Compare commits

..

No commits in common. "63ad7cc21feeec4a375039f0a8d32cd266adc3a4" and "5e0b3d5dba5d0a30a3cf3d5c489cc2f2895607b1" have entirely different histories.

30 changed files with 160 additions and 7106 deletions

BIN
.DS_Store vendored

Binary file not shown.

6
.gitignore vendored
View file

@ -1,7 +1,11 @@
/static/generated /static/generated
*.DS_Store .DS_Store
.DS_Store
.DS_Store
.DS_Store
/.venv /.venv
/static/uploads /static/uploads
.DS_Store
user_prompts.json user_prompts.json
/static/preview /static/preview
template_favorites.json template_favorites.json

View file

@ -1,21 +0,0 @@
FROM python:3.10-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Install python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Expose port
EXPOSE 8888
# Run the application
CMD ["python", "app.py"]

View file

@ -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);

View file

@ -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;
}

View file

@ -1,24 +0,0 @@
export const ICONS = {
crop: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6.13 1L6 16a2 2 0 0 0 2 2h15"></path><path d="M1 6.13L16 6a2 2 0 0 1 2 2v15"></path></svg>`,
adjust: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>`,
undo: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7v6h6"></path><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"></path></svg>`,
redo: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 7v6h-6"></path><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 2.7"></path></svg>`,
reset: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"></polyline><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path></svg>`,
chevronDown: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>`,
close: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`,
flipH: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 7 3 3 7 3"></polyline><line x1="3" y1="3" x2="10" y2="10"></line><polyline points="21 17 21 21 17 21"></polyline><line x1="21" y1="21" x2="14" y2="14"></line></svg>`,
flipV: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="7 21 3 21 3 17"></polyline><line x1="3" y1="21" x2="10" y2="14"></line><polyline points="17 3 21 3 21 7"></polyline><line x1="21" y1="3" x2="14" y2="10"></line></svg>`,
rotate: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15A9 9 0 1 1 23 10"></path></svg>`
};
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";

View file

@ -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();
});
}
}

File diff suppressed because it is too large Load diff

View file

@ -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);
};
},
});
}

View file

@ -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);
}
}
}

View file

@ -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);
}

Binary file not shown.

278
app.py
View file

@ -11,9 +11,6 @@ from flask import Flask, render_template, request, jsonify, url_for
from google import genai from google import genai
from google.genai import types from google.genai import types
from PIL import Image, PngImagePlugin from PIL import Image, PngImagePlugin
import threading, time, subprocess, re
import whisk_client
import logging import logging
@ -394,15 +391,12 @@ def generate_image():
if not prompt: if not prompt:
return jsonify({'error': 'Prompt is required'}), 400 return jsonify({'error': 'Prompt is required'}), 400
# Determine if this is a Whisk request if not api_key:
is_whisk = 'whisk' in model.lower() or 'imagefx' in model.lower() return jsonify({'error': 'API Key is required.'}), 401
if not is_whisk and not api_key:
return jsonify({'error': 'API Key is required for Gemini models.'}), 401
try: try:
print("Đang gửi lệnh...", flush=True) print("Đang gửi lệnh...", flush=True)
# client initialization moved to Gemini block client = genai.Client(api_key=api_key)
image_config_args = {} image_config_args = {}
@ -518,133 +512,6 @@ def generate_image():
continue continue
model_name = model model_name = model
# ==================================================================================
# WHISK (IMAGEFX) HANDLING
# ==================================================================================
if is_whisk:
print(f"Detected Whisk/ImageFX model request: {model_name}", flush=True)
# Extract cookies from request headers or form data
# Priority: Form Data 'cookies' > Request Header 'x-whisk-cookies' > Environment Variable
cookie_str = request.form.get('cookies') or request.headers.get('x-whisk-cookies') or os.environ.get('WHISK_COOKIES')
if not cookie_str:
return jsonify({'error': 'Whisk cookies are required. Please provide them in the "cookies" form field or configuration.'}), 400
print("Sending request to Whisk...", flush=True)
try:
# Check for reference images
reference_image_path = None
# final_reference_paths (populated above) contains URLs/paths to reference images.
# Can be new uploads or history items.
if final_reference_paths:
# Use the first one
ref_url = final_reference_paths[0]
# Convert URL/Path to absolute local path
# ref_url might be "http://.../static/..." or "/static/..."
if '/static/' in ref_url:
rel_path = ref_url.split('/static/')[1]
possible_path = os.path.join(app.static_folder, rel_path)
if os.path.exists(possible_path):
reference_image_path = possible_path
print(f"Whisk: Using reference image at {reference_image_path}", flush=True)
elif os.path.exists(ref_url):
# It's already a path?
reference_image_path = ref_url
# Call the client
try:
whisk_result = whisk_client.generate_image_whisk(
prompt=api_prompt,
cookie_str=cookie_str,
aspect_ratio=aspect_ratio,
resolution=resolution,
reference_image_path=reference_image_path
)
except Exception as e:
# 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
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']
elif 'image_url' in whisk_result:
import requests
img_resp = requests.get(whisk_result['image_url'])
image_bytes = img_resp.content
if not image_bytes:
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()
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)
max_id = 0
for f in existing_files:
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:
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)
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))
buffer = BytesIO()
image.save(buffer, format='PNG', pnginfo=png_info)
final_bytes = buffer.getvalue()
with open(filepath, 'wb') as f:
f.write(final_bytes)
image_data = base64.b64encode(final_bytes).decode('utf-8')
return jsonify({
'image': image_url,
'image_data': image_data,
'metadata': metadata,
})
except Exception as e:
print(f"Whisk error: {e}")
return jsonify({'error': f"Whisk Generation Error: {str(e)}"}), 500
# ==================================================================================
# STANDARD GEMINI HANDLING
# ==================================================================================
# Initialize Client here, since API Key is required
client = genai.Client(api_key=api_key)
print(f"Đang tạo với model {model_name}...", flush=True) print(f"Đang tạo với model {model_name}...", flush=True)
response = client.models.generate_content( response = client.models.generate_content(
model=model_name, model=model_name,
@ -875,7 +742,6 @@ def save_template():
title = request.form.get('title') title = request.form.get('title')
prompt = request.form.get('prompt') prompt = request.form.get('prompt')
mode = request.form.get('mode', 'generate') mode = request.form.get('mode', 'generate')
note = request.form.get('note', '')
category = request.form.get('category', 'User') category = request.form.get('category', 'User')
tags_field = request.form.get('tags') tags_field = request.form.get('tags')
tags = parse_tags_field(tags_field) tags = parse_tags_field(tags_field)
@ -971,7 +837,6 @@ def save_template():
new_template = { new_template = {
'title': title, 'title': title,
'prompt': prompt, 'prompt': prompt,
'note': note,
'mode': mode, 'mode': mode,
'category': category, 'category': category,
'preview': preview_path, 'preview': preview_path,
@ -1030,7 +895,6 @@ def update_template():
title = request.form.get('title') title = request.form.get('title')
prompt = request.form.get('prompt') prompt = request.form.get('prompt')
mode = request.form.get('mode', 'generate') mode = request.form.get('mode', 'generate')
note = request.form.get('note', '')
category = request.form.get('category', 'User') category = request.form.get('category', 'User')
tags_field = request.form.get('tags') tags_field = request.form.get('tags')
tags = parse_tags_field(tags_field) tags = parse_tags_field(tags_field)
@ -1140,7 +1004,6 @@ def update_template():
existing_template['title'] = title existing_template['title'] = title
existing_template['prompt'] = prompt existing_template['prompt'] = prompt
existing_template['note'] = note
existing_template['mode'] = mode existing_template['mode'] = mode
existing_template['category'] = category existing_template['category'] = category
if preview_path: if preview_path:
@ -1184,7 +1047,6 @@ def update_template():
user_prompts[template_index] = { user_prompts[template_index] = {
'title': title, 'title': title,
'prompt': prompt, 'prompt': prompt,
'note': note,
'mode': mode, 'mode': mode,
'category': category, 'category': category,
'preview': preview_path, 'preview': preview_path,
@ -1290,144 +1152,10 @@ def refine_prompt():
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 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')
if not url:
return jsonify({'error': 'URL is required'}), 400
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="<div><code style='color:yellow'>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}'</code></div>"
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 port_sever = 8888
Sever_Pinggy = "Auto"
if __name__ == '__main__': if __name__ == '__main__':
# Use ANSI green text so the startup banner stands out in terminals # 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" + "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://localhost:{port_sever}" + " " + "\033[0m", flush=True)
print("\033[32m" + f"http://127.0.0.1:{port_sever}" + "\033[0m", flush=True) print("\033[32m" + f"http://127.0.0.1:{port_sever}" + "\033[0m", flush=True)
# sever_flare(port_sever, "cXPggKvHuW:sdvn:1231")
app.run(debug=True, port=port_sever) app.run(debug=True, port=port_sever)

View file

@ -1,17 +0,0 @@
version: '3.8'
services:
app:
build: .
platform: linux/amd64
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

View file

@ -4,10 +4,8 @@
"gemini-3-pro-image-preview_20251125_42.png", "gemini-3-pro-image-preview_20251125_42.png",
"gemini-3-pro-image-preview_20251125_41.png", "gemini-3-pro-image-preview_20251125_41.png",
"gemini-3-pro-image-preview_20251125_37.png", "gemini-3-pro-image-preview_20251125_37.png",
"gemini-3-pro-image-preview_20251125_26.png",
"gemini-3-pro-image-preview_20251125_24.png", "gemini-3-pro-image-preview_20251125_24.png",
"generated/gemini-3-pro-image-preview_20251124_10.png", "generated/gemini-3-pro-image-preview_20251124_10.png",
"generated/gemini-3-pro-image-preview_20251124_9.png", "generated/gemini-3-pro-image-preview_20251124_9.png"
"generated/gemini-3-pro-image-preview_20251125_26.png",
"generated/gemini-3-pro-image-preview_20251129_12.png",
"generated/gemini-3-pro-image-preview_20251220_1.png"
] ]

View file

@ -2,5 +2,3 @@ flask
google-genai google-genai
pillow pillow
Send2Trash Send2Trash
gallery-dl
requests

View file

@ -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;
}

View file

@ -1,26 +0,0 @@
export const ICONS = {
crop: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6.13 1L6 16a2 2 0 0 0 2 2h15"></path><path d="M1 6.13L16 6a2 2 0 0 1 2 2v15"></path></svg>`,
adjust: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>`,
undo: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7v6h6"></path><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"></path></svg>`,
redo: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 7v6h-6"></path><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 2.7"></path></svg>`,
reset: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"></polyline><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path></svg>`,
chevronDown: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>`,
close: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`,
flipH: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 7 3 3 7 3"></polyline><line x1="3" y1="3" x2="10" y2="10"></line><polyline points="21 17 21 21 17 21"></polyline><line x1="21" y1="21" x2="14" y2="14"></line></svg>`,
flipV: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="7 21 3 21 3 17"></polyline><line x1="3" y1="21" x2="10" y2="14"></line><polyline points="17 3 21 3 21 7"></polyline><line x1="21" y1="3" x2="14" y2="10"></line></svg>`,
rotate: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M11.5 20.5C6.80558 20.5 3 16.6944 3 12C3 7.30558 6.80558 3.5 11.5 3.5C16.1944 3.5 20 7.30558 20 12C20 13.5433 19.5887 14.9905 18.8698 16.238M22.5 15L18.8698 16.238M17.1747 12.3832L18.5289 16.3542L18.8698 16.238" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
brush: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M21.8098 3.93814C20.4998 7.20814 17.5098 11.4781 14.6598 14.2681C14.2498 11.6881 12.1898 9.66814 9.58984 9.30814C12.3898 6.44814 16.6898 3.41814 19.9698 2.09814C20.5498 1.87814 21.1298 2.04814 21.4898 2.40814C21.8698 2.78814 22.0498 3.35814 21.8098 3.93814Z" fill="currentColor"/><path d="M13.7791 15.0909C13.5791 15.2609 13.3791 15.4309 13.1791 15.5909L11.3891 17.0209C11.3891 16.9909 11.3791 16.9509 11.3791 16.9109C11.2391 15.8409 10.7391 14.8509 9.92914 14.0409C9.10914 13.2209 8.08914 12.7209 6.96914 12.5809C6.93914 12.5809 6.89914 12.5709 6.86914 12.5709L8.31914 10.7409C8.45914 10.5609 8.60914 10.3909 8.76914 10.2109L9.44914 10.3009C11.5991 10.6009 13.3291 12.2909 13.6691 14.4309L13.7791 15.0909Z" fill="currentColor"/><path d="M10.4298 17.6208C10.4298 18.7208 10.0098 19.7708 9.20976 20.5608C8.59976 21.1808 7.77977 21.6008 6.77977 21.7208L4.32976 21.9908C2.98976 22.1408 1.83976 20.9908 1.98976 19.6408L2.25976 17.1808C2.49976 14.9908 4.32976 13.5908 6.26976 13.5508C6.45976 13.5408 6.66976 13.5508 6.86976 13.5708C7.71976 13.6808 8.53976 14.0708 9.22976 14.7508C9.89976 15.4208 10.2798 16.2108 10.3898 17.0408C10.4098 17.2408 10.4298 17.4308 10.4298 17.6208Z" fill="currentColor"/></svg>`,
pen: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 19l7-7 3 3-7 7-3-3z"></path><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"></path><path d="M2 2l7.586 7.586"></path><circle cx="11" cy="11" r="2"></circle></svg>`
};
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";

View file

@ -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();
});
}
}

File diff suppressed because it is too large Load diff

View file

@ -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);
};
},
});
}

View file

@ -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);
}
}
}

View file

@ -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);
}

View file

@ -3,97 +3,33 @@ import { extractMetadataFromBlob } from './metadata.js';
const FILTER_STORAGE_KEY = 'gemini-app-history-filter'; const FILTER_STORAGE_KEY = 'gemini-app-history-filter';
const SEARCH_STORAGE_KEY = 'gemini-app-history-search'; const SEARCH_STORAGE_KEY = 'gemini-app-history-search';
const FAVORITES_STORAGE_KEY = 'gemini-app-history-favorites';
const SOURCE_STORAGE_KEY = 'gemini-app-history-source'; const SOURCE_STORAGE_KEY = 'gemini-app-history-source';
const VALID_SOURCES = ['generated', 'uploads']; const VALID_SOURCES = ['generated', 'uploads'];
const DEFAULT_STATE = {
filter: 'all',
search: '',
favorites: false,
};
function createStateMap(fallback) {
return {
generated: fallback,
uploads: fallback,
};
}
function parseStoredMap(key, fallback) {
const defaultMap = createStateMap(fallback);
let raw;
try {
raw = localStorage.getItem(key);
if (!raw) return defaultMap;
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
return {
generated: parsed.generated ?? parsed.default ?? fallback,
uploads: parsed.uploads ?? parsed.default ?? fallback,
};
}
// Backward compatibility: single value string or number
return createStateMap(raw);
} catch (e) {
// If parsing failed but raw exists, treat it as a primitive single value
if (raw) {
return createStateMap(raw);
}
console.warn('Failed to load history state', e);
return defaultMap;
}
}
function persistStateMap(key, map) {
try {
localStorage.setItem(key, JSON.stringify(map));
} catch (e) {
console.warn('Failed to save history state', e);
}
}
export function createGallery({ galleryGrid, onSelect }) { export function createGallery({ galleryGrid, onSelect }) {
let currentFilter = DEFAULT_STATE.filter; let currentFilter = 'all';
let searchQuery = DEFAULT_STATE.search; let searchQuery = '';
let currentSource = 'generated'; let currentSource = 'generated';
let allImages = []; let allImages = [];
let favorites = []; let favorites = [];
let showOnlyFavorites = DEFAULT_STATE.favorites; let showOnlyFavorites = false; // New toggle state
const stateBySource = {
generated: { ...DEFAULT_STATE },
uploads: { ...DEFAULT_STATE },
};
// Load saved filter, search and favorites from localStorage (per source)
const savedFilters = parseStoredMap(FILTER_STORAGE_KEY, DEFAULT_STATE.filter);
const savedSearches = parseStoredMap(SEARCH_STORAGE_KEY, DEFAULT_STATE.search);
const savedFavorites = parseStoredMap(FAVORITES_STORAGE_KEY, DEFAULT_STATE.favorites);
stateBySource.generated.filter = savedFilters.generated;
stateBySource.uploads.filter = savedFilters.uploads;
stateBySource.generated.search = savedSearches.generated;
stateBySource.uploads.search = savedSearches.uploads;
stateBySource.generated.favorites = savedFavorites.generated;
stateBySource.uploads.favorites = savedFavorites.uploads;
// Load saved filter and search from localStorage
try { try {
const savedFilter = localStorage.getItem(FILTER_STORAGE_KEY);
if (savedFilter) currentFilter = savedFilter;
const savedSearch = localStorage.getItem(SEARCH_STORAGE_KEY);
if (savedSearch) searchQuery = savedSearch;
const savedSource = localStorage.getItem(SOURCE_STORAGE_KEY); const savedSource = localStorage.getItem(SOURCE_STORAGE_KEY);
if (savedSource && VALID_SOURCES.includes(savedSource)) { if (savedSource && VALID_SOURCES.includes(savedSource)) {
currentSource = savedSource; currentSource = savedSource;
} }
} catch (e) { } catch (e) {
console.warn('Failed to load history source', e); console.warn('Failed to load history filter/search', e);
} }
function applySourceState(source) {
const state = stateBySource[source] || DEFAULT_STATE;
currentFilter = state.filter ?? DEFAULT_STATE.filter;
searchQuery = state.search ?? DEFAULT_STATE.search;
showOnlyFavorites = state.favorites ?? DEFAULT_STATE.favorites;
}
applySourceState(currentSource);
// Load favorites from backend // Load favorites from backend
async function loadFavorites() { async function loadFavorites() {
try { try {
@ -370,35 +306,32 @@ export function createGallery({ galleryGrid, onSelect }) {
function setFilter(filterType) { function setFilter(filterType) {
if (currentFilter === filterType) return; if (currentFilter === filterType) return;
currentFilter = filterType; currentFilter = filterType;
stateBySource[currentSource].filter = filterType;
persistStateMap(FILTER_STORAGE_KEY, { // Save to localStorage
generated: stateBySource.generated.filter, try {
uploads: stateBySource.uploads.filter, localStorage.setItem(FILTER_STORAGE_KEY, filterType);
}); } catch (e) {
console.warn('Failed to save history filter', e);
}
renderGallery(); renderGallery();
} }
function setSearch(query) { function setSearch(query) {
searchQuery = query || ''; searchQuery = query || '';
stateBySource[currentSource].search = searchQuery;
persistStateMap(SEARCH_STORAGE_KEY, { // Save to localStorage
generated: stateBySource.generated.search, try {
uploads: stateBySource.uploads.search, localStorage.setItem(SEARCH_STORAGE_KEY, searchQuery);
}); } catch (e) {
console.warn('Failed to save history search', e);
}
renderGallery(); renderGallery();
} }
function toggleFavorites() { function toggleFavorites() {
showOnlyFavorites = !showOnlyFavorites; showOnlyFavorites = !showOnlyFavorites;
stateBySource[currentSource].favorites = showOnlyFavorites;
persistStateMap(FAVORITES_STORAGE_KEY, {
generated: stateBySource.generated.favorites,
uploads: stateBySource.uploads.favorites,
});
renderGallery(); renderGallery();
return showOnlyFavorites; return showOnlyFavorites;
} }
@ -412,21 +345,16 @@ export function createGallery({ galleryGrid, onSelect }) {
console.warn('Failed to save history source', e); console.warn('Failed to save history source', e);
} }
if (resetFilters) { if (resetFilters) {
stateBySource[currentSource] = { ...DEFAULT_STATE }; currentFilter = 'all';
persistStateMap(FILTER_STORAGE_KEY, { showOnlyFavorites = false;
generated: stateBySource.generated.filter, searchQuery = '';
uploads: stateBySource.uploads.filter, try {
}); localStorage.setItem(FILTER_STORAGE_KEY, currentFilter);
persistStateMap(SEARCH_STORAGE_KEY, { localStorage.setItem(SEARCH_STORAGE_KEY, searchQuery);
generated: stateBySource.generated.search, } catch (e) {
uploads: stateBySource.uploads.search, console.warn('Failed to reset history filters', e);
}); }
persistStateMap(FAVORITES_STORAGE_KEY, {
generated: stateBySource.generated.favorites,
uploads: stateBySource.uploads.favorites,
});
} }
applySourceState(currentSource);
return load(); return load();
} }
@ -448,11 +376,6 @@ export function createGallery({ galleryGrid, onSelect }) {
function setFavoritesActive(active) { function setFavoritesActive(active) {
showOnlyFavorites = Boolean(active); showOnlyFavorites = Boolean(active);
stateBySource[currentSource].favorites = showOnlyFavorites;
persistStateMap(FAVORITES_STORAGE_KEY, {
generated: stateBySource.generated.favorites,
uploads: stateBySource.uploads.favorites,
});
renderGallery(); renderGallery();
return showOnlyFavorites; return showOnlyFavorites;
} }

View file

@ -1,6 +1,4 @@
import { dataUrlToBlob, withCacheBuster } from './utils.js'; import { dataUrlToBlob, withCacheBuster } from './utils.js';
import { ImageEditor } from '../image_editor_modules/editor.js';
import { injectImageEditorStyles } from '../image_editor_modules/styles.js';
export function createReferenceSlotManager(imageInputGrid, options = {}) { export function createReferenceSlotManager(imageInputGrid, options = {}) {
const MAX_IMAGE_SLOTS = 16; const MAX_IMAGE_SLOTS = 16;
@ -9,9 +7,6 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) {
const imageSlotState = []; const imageSlotState = [];
let cachedReferenceImages = []; let cachedReferenceImages = [];
// Inject image editor styles once
injectImageEditorStyles();
function initialize(initialCached = []) { function initialize(initialCached = []) {
cachedReferenceImages = Array.isArray(initialCached) ? initialCached : []; cachedReferenceImages = Array.isArray(initialCached) ? initialCached : [];
const requiredSlots = Math.min( const requiredSlots = Math.min(
@ -80,13 +75,6 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) {
preview.alt = 'Uploaded reference'; preview.alt = 'Uploaded reference';
slot.appendChild(preview); slot.appendChild(preview);
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'slot-edit hidden';
editBtn.setAttribute('aria-label', 'Edit image');
editBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M2 8H13M22 8H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M22 16H11M2 16H5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="16" cy="8" r="3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle r="3" transform="matrix(-1 0 0 1 8 16)" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
slot.appendChild(editBtn);
const removeBtn = document.createElement('button'); const removeBtn = document.createElement('button');
removeBtn.type = 'button'; removeBtn.type = 'button';
removeBtn.className = 'slot-remove hidden'; removeBtn.className = 'slot-remove hidden';
@ -101,7 +89,7 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) {
slot.appendChild(input); slot.appendChild(input);
slot.addEventListener('click', event => { slot.addEventListener('click', event => {
if (event.target === removeBtn || event.target === editBtn) return; if (event.target === removeBtn) return;
input.click(); input.click();
}); });
@ -141,11 +129,6 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) {
} }
}); });
editBtn.addEventListener('click', event => {
event.stopPropagation();
handleEditImage(index);
});
removeBtn.addEventListener('click', event => { removeBtn.addEventListener('click', event => {
event.stopPropagation(); event.stopPropagation();
clearSlot(index); clearSlot(index);
@ -218,14 +201,12 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) {
const slot = slotRecord.slot; const slot = slotRecord.slot;
const placeholder = slot.querySelector('.slot-placeholder'); const placeholder = slot.querySelector('.slot-placeholder');
const preview = slot.querySelector('.slot-preview'); const preview = slot.querySelector('.slot-preview');
const editBtn = slot.querySelector('.slot-edit');
const removeBtn = slot.querySelector('.slot-remove'); const removeBtn = slot.querySelector('.slot-remove');
if (slotRecord.data && slotRecord.data.preview) { if (slotRecord.data && slotRecord.data.preview) {
preview.src = slotRecord.data.preview; preview.src = slotRecord.data.preview;
preview.classList.remove('hidden'); preview.classList.remove('hidden');
placeholder.classList.add('hidden'); placeholder.classList.add('hidden');
editBtn.classList.remove('hidden');
removeBtn.classList.remove('hidden'); removeBtn.classList.remove('hidden');
slot.classList.add('filled'); slot.classList.add('filled');
slot.classList.remove('empty'); slot.classList.remove('empty');
@ -233,7 +214,6 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) {
preview.src = ''; preview.src = '';
preview.classList.add('hidden'); preview.classList.add('hidden');
placeholder.classList.remove('hidden'); placeholder.classList.remove('hidden');
editBtn.classList.add('hidden');
removeBtn.classList.add('hidden'); removeBtn.classList.add('hidden');
slot.classList.add('empty'); slot.classList.add('empty');
slot.classList.remove('filled'); slot.classList.remove('filled');
@ -250,23 +230,6 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) {
onChange?.(); onChange?.();
} }
function handleEditImage(index) {
const slotRecord = imageSlotState[index];
if (!slotRecord || !slotRecord.data || !slotRecord.data.preview) return;
const imageSrc = slotRecord.data.preview;
new ImageEditor(imageSrc, async (blob) => {
// Convert blob to file
const fileName = slotRecord.data.file?.name || slotRecord.data.cached?.name || `edited-${index + 1}.png`;
const file = new File([blob], fileName, { type: blob.type || 'image/png' });
// Update the slot with the edited image
// Treat edited images as new uploads so they are sent as files (not paths)
handleSlotFile(index, file, null);
});
}
function maybeAddSlot() { function maybeAddSlot() {
const hasEmpty = imageSlotState.some(record => !record.data); const hasEmpty = imageSlotState.some(record => !record.data);
if (!hasEmpty && imageSlotState.length < MAX_IMAGE_SLOTS) { if (!hasEmpty && imageSlotState.length < MAX_IMAGE_SLOTS) {
@ -340,29 +303,11 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) {
return null; return null;
} }
async function addReferenceFromUrl(url) {
// Find first empty slot
let emptyIndex = imageSlotState.findIndex(record => !record.data);
if (emptyIndex === -1) {
if (imageSlotState.length < MAX_IMAGE_SLOTS) {
addImageSlot();
emptyIndex = imageSlotState.length - 1;
} else {
return false;
}
}
await handleSlotDropFromHistory(emptyIndex, url);
return true;
}
return { return {
initialize, initialize,
getReferenceFiles, getReferenceFiles,
getReferencePaths, getReferencePaths,
serializeReferenceImages, serializeReferenceImages,
setReferenceImages, setReferenceImages,
addReferenceFromUrl,
}; };
} }

View file

@ -11,7 +11,7 @@ const ZOOM_STEP = 0.1;
const MIN_ZOOM = 0.4; const MIN_ZOOM = 0.4;
const MAX_ZOOM = 4; const MAX_ZOOM = 4;
const SIDEBAR_MIN_WIDTH = 260; const SIDEBAR_MIN_WIDTH = 260;
const SIDEBAR_MAX_WIDTH = 1000; const SIDEBAR_MAX_WIDTH = 520;
const infoContent = { const infoContent = {
title: 'Thông tin', title: 'Thông tin',
@ -102,7 +102,6 @@ document.addEventListener('DOMContentLoaded', () => {
const downloadLink = document.getElementById('download-link'); const downloadLink = document.getElementById('download-link');
const galleryGrid = document.getElementById('gallery-grid'); const galleryGrid = document.getElementById('gallery-grid');
const imageInputGrid = document.getElementById('image-input-grid'); const imageInputGrid = document.getElementById('image-input-grid');
const referenceUrlInput = document.getElementById('reference-url-input');
const imageDisplayArea = document.querySelector('.image-display-area'); const imageDisplayArea = document.querySelector('.image-display-area');
const canvasToolbar = document.querySelector('.canvas-toolbar'); const canvasToolbar = document.querySelector('.canvas-toolbar');
const sidebar = document.querySelector('.sidebar'); const sidebar = document.querySelector('.sidebar');
@ -132,28 +131,10 @@ document.addEventListener('DOMContentLoaded', () => {
if (apiModelSelect) { if (apiModelSelect) {
apiModelSelect.addEventListener('change', () => { apiModelSelect.addEventListener('change', () => {
toggleResolutionVisibility(); toggleResolutionVisibility();
toggleCookiesVisibility();
persistSettings(); persistSettings();
}); });
} }
const whiskCookiesGroup = document.getElementById('whisk-cookies-group');
const whiskCookiesInput = document.getElementById('whisk-cookies');
function toggleCookiesVisibility() {
if (whiskCookiesGroup && apiModelSelect) {
if (apiModelSelect.value === 'whisk') {
whiskCookiesGroup.classList.remove('hidden');
} else {
whiskCookiesGroup.classList.add('hidden');
}
}
}
if (whiskCookiesInput) {
whiskCookiesInput.addEventListener('input', persistSettings);
}
// Load Settings // Load Settings
function loadSettings() { function loadSettings() {
try { try {
@ -174,10 +155,6 @@ document.addEventListener('DOMContentLoaded', () => {
if (bodyFontSelect && settings.bodyFont) { if (bodyFontSelect && settings.bodyFont) {
bodyFontSelect.value = settings.bodyFont; bodyFontSelect.value = settings.bodyFont;
} }
if (whiskCookiesInput && settings.whiskCookies) {
whiskCookiesInput.value = settings.whiskCookies;
}
toggleCookiesVisibility();
return settings; return settings;
} }
} catch (e) { } catch (e) {
@ -202,7 +179,6 @@ document.addEventListener('DOMContentLoaded', () => {
referenceImages, referenceImages,
theme: currentTheme || DEFAULT_THEME, theme: currentTheme || DEFAULT_THEME,
bodyFont: bodyFontSelect ? bodyFontSelect.value : DEFAULT_BODY_FONT, bodyFont: bodyFontSelect ? bodyFontSelect.value : DEFAULT_BODY_FONT,
whiskCookies: whiskCookiesInput ? whiskCookiesInput.value : '',
}; };
try { try {
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings)); localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
@ -222,10 +198,6 @@ document.addEventListener('DOMContentLoaded', () => {
const selectedModel = model || (apiModelSelect ? apiModelSelect.value : 'gemini-3-pro-image-preview'); const selectedModel = model || (apiModelSelect ? apiModelSelect.value : 'gemini-3-pro-image-preview');
formData.append('model', selectedModel); formData.append('model', selectedModel);
if (whiskCookiesInput && whiskCookiesInput.value) {
formData.append('cookies', whiskCookiesInput.value);
}
// Add reference images using correct slotManager methods // Add reference images using correct slotManager methods
const referenceFiles = slotManager.getReferenceFiles(); const referenceFiles = slotManager.getReferenceFiles();
referenceFiles.forEach(file => { referenceFiles.forEach(file => {
@ -480,10 +452,6 @@ document.addEventListener('DOMContentLoaded', () => {
persistSettings(); persistSettings();
refreshPromptHighlight(); refreshPromptHighlight();
} }
// Fill note (default empty when absent)
promptNoteInput.value = template.note !== undefined ? (i18n.getText(template.note) || '') : '';
refreshNoteHighlight();
persistSettings();
// Stay in template gallery view - don't auto-switch // Stay in template gallery view - don't auto-switch
// User will switch view by selecting image from history or generating // User will switch view by selecting image from history or generating
} }
@ -995,7 +963,6 @@ document.addEventListener('DOMContentLoaded', () => {
const templateTitleInput = document.getElementById('template-title'); const templateTitleInput = document.getElementById('template-title');
const templatePromptInput = document.getElementById('template-prompt'); const templatePromptInput = document.getElementById('template-prompt');
const templateNoteInput = document.getElementById('template-note');
const templateModeSelect = document.getElementById('template-mode'); const templateModeSelect = document.getElementById('template-mode');
const templateCategorySelect = document.getElementById('template-category-select'); const templateCategorySelect = document.getElementById('template-category-select');
const templateCategoryInput = document.getElementById('template-category-input'); const templateCategoryInput = document.getElementById('template-category-input');
@ -1224,7 +1191,6 @@ document.addEventListener('DOMContentLoaded', () => {
// Pre-fill with template data // Pre-fill with template data
templateTitleInput.value = template.title || ''; templateTitleInput.value = template.title || '';
templatePromptInput.value = template.prompt || ''; templatePromptInput.value = template.prompt || '';
templateNoteInput.value = i18n.getText(template.note) || '';
templateModeSelect.value = template.mode || 'generate'; templateModeSelect.value = template.mode || 'generate';
templateCategoryInput.classList.add('hidden'); templateCategoryInput.classList.add('hidden');
templateCategoryInput.value = ''; templateCategoryInput.value = '';
@ -1308,7 +1274,6 @@ document.addEventListener('DOMContentLoaded', () => {
// Clear all fields // Clear all fields
templateTitleInput.value = ''; templateTitleInput.value = '';
templatePromptInput.value = ''; templatePromptInput.value = '';
templateNoteInput.value = promptNoteInput.value || '';
templateModeSelect.value = 'generate'; templateModeSelect.value = 'generate';
templateCategoryInput.classList.add('hidden'); templateCategoryInput.classList.add('hidden');
templateCategoryInput.value = ''; templateCategoryInput.value = '';
@ -1376,7 +1341,6 @@ document.addEventListener('DOMContentLoaded', () => {
// Pre-fill data // Pre-fill data
templateTitleInput.value = ''; templateTitleInput.value = '';
templatePromptInput.value = promptInput.value; templatePromptInput.value = promptInput.value;
templateNoteInput.value = promptNoteInput.value || '';
templateModeSelect.value = 'generate'; templateModeSelect.value = 'generate';
templateCategoryInput.classList.add('hidden'); templateCategoryInput.classList.add('hidden');
templateCategoryInput.value = ''; templateCategoryInput.value = '';
@ -1583,7 +1547,6 @@ document.addEventListener('DOMContentLoaded', () => {
saveTemplateBtn.addEventListener('click', async () => { saveTemplateBtn.addEventListener('click', async () => {
const title = templateTitleInput.value.trim(); const title = templateTitleInput.value.trim();
const prompt = templatePromptInput.value.trim(); const prompt = templatePromptInput.value.trim();
const note = templateNoteInput.value.trim();
const mode = templateModeSelect.value; const mode = templateModeSelect.value;
let category = templateCategorySelect.value; let category = templateCategorySelect.value;
@ -1611,7 +1574,6 @@ document.addEventListener('DOMContentLoaded', () => {
const formData = new FormData(); const formData = new FormData();
formData.append('title', title); formData.append('title', title);
formData.append('prompt', prompt); formData.append('prompt', prompt);
formData.append('note', note);
formData.append('mode', mode); formData.append('mode', mode);
formData.append('category', category); formData.append('category', category);
formData.append('tags', JSON.stringify(templateTags)); formData.append('tags', JSON.stringify(templateTags));
@ -1729,24 +1691,6 @@ document.addEventListener('DOMContentLoaded', () => {
const historySourceBtns = document.querySelectorAll('.history-source-btn'); const historySourceBtns = document.querySelectorAll('.history-source-btn');
const initialSource = gallery.getCurrentSource ? gallery.getCurrentSource() : 'generated'; const initialSource = gallery.getCurrentSource ? gallery.getCurrentSource() : 'generated';
function syncHistoryControlsFromGallery() {
const activeFilter = gallery.getCurrentFilter ? gallery.getCurrentFilter() : 'all';
historyFilterBtns.forEach(btn => {
if (btn.classList.contains('history-favorites-btn')) return;
const isActive = btn.dataset.filter === activeFilter;
btn.classList.toggle('active', isActive);
});
if (historyFavoritesBtn && gallery.isFavoritesActive) {
historyFavoritesBtn.classList.toggle('active', gallery.isFavoritesActive());
}
const historySearchInputEl = document.getElementById('history-search-input');
if (historySearchInputEl && gallery.getSearchQuery) {
historySearchInputEl.value = gallery.getSearchQuery();
}
}
historySourceBtns.forEach(btn => { historySourceBtns.forEach(btn => {
const isActive = btn.dataset.source === initialSource; const isActive = btn.dataset.source === initialSource;
btn.classList.toggle('active', isActive); btn.classList.toggle('active', isActive);
@ -1759,13 +1703,41 @@ document.addEventListener('DOMContentLoaded', () => {
b.classList.toggle('active', active); b.classList.toggle('active', active);
b.setAttribute('aria-pressed', String(active)); b.setAttribute('aria-pressed', String(active));
}); });
await gallery.setSource(targetSource, { resetFilters: false }); await gallery.setSource(targetSource, { resetFilters: true });
syncHistoryControlsFromGallery();
// Reset filters UI to show all when switching source
historyFilterBtns.forEach(b => {
if (!b.classList.contains('history-favorites-btn')) {
b.classList.toggle('active', b.dataset.filter === 'all');
}
});
// Disable favorites toggle on source change
if (historyFavoritesBtn) {
historyFavoritesBtn.classList.remove('active');
}
if (gallery.setFavoritesActive) {
gallery.setFavoritesActive(false);
}
// Clear search box
const historySearchInputEl = document.getElementById('history-search-input');
if (historySearchInputEl) {
historySearchInputEl.value = '';
}
if (gallery.setSearchQuery) {
gallery.setSearchQuery('');
}
}); });
}); });
// Set initial active state based on saved filter // Set initial active state based on saved filter
syncHistoryControlsFromGallery(); const currentFilter = gallery.getCurrentFilter();
historyFilterBtns.forEach(btn => {
if (btn.dataset.filter === currentFilter && !btn.classList.contains('history-favorites-btn')) {
btn.classList.add('active');
}
});
// Handle favorites button as toggle // Handle favorites button as toggle
if (historyFavoritesBtn) { if (historyFavoritesBtn) {
@ -2149,52 +2121,4 @@ document.addEventListener('DOMContentLoaded', () => {
}; };
} }
// Reference URL Input Logic
if (referenceUrlInput && typeof slotManager !== 'undefined') {
referenceUrlInput.addEventListener('keydown', async (event) => {
if (event.key === 'Enter') {
event.preventDefault();
const url = referenceUrlInput.value.trim();
if (!url) return;
referenceUrlInput.disabled = true;
const originalPlaceholder = referenceUrlInput.getAttribute('placeholder');
referenceUrlInput.setAttribute('placeholder', 'Đang tải...');
try {
const response = await fetch('/download_image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to download image');
}
if (data.path) {
const success = await slotManager.addReferenceFromUrl(data.path);
if (success) {
referenceUrlInput.value = '';
} else {
alert('Không còn slot trống cho ảnh tham chiếu.');
}
} else {
throw new Error('No image path returned');
}
} catch (error) {
console.error('Download error:', error);
alert(`Lỗi tải ảnh: ${error.message}`);
} finally {
referenceUrlInput.disabled = false;
referenceUrlInput.setAttribute('placeholder', originalPlaceholder);
referenceUrlInput.focus();
}
}
});
}
}); });

View file

@ -55,7 +55,7 @@ a:hover {
/* Sidebar */ /* Sidebar */
.sidebar { .sidebar {
width: 450px; width: 320px;
background: var(--panel-backdrop); background: var(--panel-backdrop);
background-image: radial-gradient(circle at 20% -20%, rgba(251, 191, 36, 0.15), transparent 45%); background-image: radial-gradient(circle at 20% -20%, rgba(251, 191, 36, 0.15), transparent 45%);
/* border-right: 1px solid var(--border-color); */ /* border-right: 1px solid var(--border-color); */
@ -399,7 +399,7 @@ select:focus {
textarea { textarea {
resize: vertical; resize: vertical;
min-height: 30px; min-height: 100px;
} }
/* Theme overrides driven from index.css gradients */ /* Theme overrides driven from index.css gradients */
@ -786,38 +786,11 @@ body.theme-amin { --bd-bg: linear-gradient(to right, #4A00E0, #8E2DE2); }
} }
.slot-preview.hidden, .slot-preview.hidden,
.slot-edit.hidden,
.slot-remove.hidden, .slot-remove.hidden,
.slot-placeholder.hidden { .slot-placeholder.hidden {
display: none; display: none;
} }
.slot-edit {
position: absolute;
top: 0.35rem;
right: 2.25rem;
background: rgba(15, 23, 42, 0.85);
border: none;
border-radius: 999px;
color: var(--text-primary);
width: 1.75rem;
height: 1.75rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.4);
z-index: 3;
font-size: 0.875rem;
transition: all 0.2s ease;
}
.slot-edit:hover {
background: rgba(251, 191, 36, 0.25);
color: var(--accent-color);
transform: scale(1.05);
}
.slot-remove { .slot-remove {
position: absolute; position: absolute;
top: 0.35rem; top: 0.35rem;
@ -1951,7 +1924,7 @@ button#generate-btn:disabled {
.template-preview-dropzone { .template-preview-dropzone {
width: 100%; width: 100%;
height: 150px; height: 220px;
border: 2px dashed rgba(255, 255, 255, 0.15); border: 2px dashed rgba(255, 255, 255, 0.15);
border-radius: 0.75rem; border-radius: 0.75rem;
background: rgba(0, 0, 0, 0.2); background: rgba(0, 0, 0, 0.2);
@ -2096,16 +2069,6 @@ button#generate-btn:disabled {
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5); box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5);
max-height: 90vh;
display: flex;
flex-direction: column;
}
#create-template-modal .popup-body {
flex: 1 1 auto;
overflow-y: auto;
padding-right: 0.25rem;
margin-right: -0.25rem; /* allow scrollbar without shifting content */
} }
#save-template-btn { #save-template-btn {

View file

@ -46,16 +46,18 @@
<div class="field-action-buttons" data-target="prompt" aria-label="Prompt actions"> <div class="field-action-buttons" data-target="prompt" aria-label="Prompt actions">
<button type="button" class="field-action-btn" data-action="copy" title="Copy prompt" <button type="button" class="field-action-btn" data-action="copy" title="Copy prompt"
aria-label="Copy prompt"> aria-label="Copy prompt">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" <svg viewBox="0 0 24 24" width="16" height="16" fill="none"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"> stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2.5" /> <rect x="9" y="9" width="13" height="13" rx="2.5" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /> <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg> </svg>
</button> </button>
<button type="button" class="field-action-btn" data-action="paste" title="Paste" <button type="button" class="field-action-btn" data-action="paste" title="Paste"
aria-label="Paste vào prompt"> aria-label="Paste vào prompt">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" <svg viewBox="0 0 24 24" width="16" height="16" fill="none"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"> stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
stroke-linejoin="round">
<path d="M8 4h8" /> <path d="M8 4h8" />
<path d="M9 2h6a2 2 0 0 1 2 2v1H7V4a2 2 0 0 1 2-2z" /> <path d="M9 2h6a2 2 0 0 1 2 2v1H7V4a2 2 0 0 1 2-2z" />
<rect x="5" y="5" width="14" height="16" rx="2" /> <rect x="5" y="5" width="14" height="16" rx="2" />
@ -65,8 +67,9 @@
</button> </button>
<button type="button" class="field-action-btn" data-action="clear" title="Clear prompt" <button type="button" class="field-action-btn" data-action="clear" title="Clear prompt"
aria-label="Xoá prompt"> aria-label="Xoá prompt">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" <svg viewBox="0 0 24 24" width="16" height="16" fill="none"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"> stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
stroke-linejoin="round">
<path d="M3 6h18" /> <path d="M3 6h18" />
<path d="M19 6v12a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /> <path d="M19 6v12a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
<path d="M10 11v6" /> <path d="M10 11v6" />
@ -129,16 +132,18 @@
<div class="field-action-buttons" data-target="note" aria-label="Note actions"> <div class="field-action-buttons" data-target="note" aria-label="Note actions">
<button type="button" class="field-action-btn" data-action="copy" title="Copy note" <button type="button" class="field-action-btn" data-action="copy" title="Copy note"
aria-label="Copy note"> aria-label="Copy note">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" <svg viewBox="0 0 24 24" width="16" height="16" fill="none"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"> stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2.5" /> <rect x="9" y="9" width="13" height="13" rx="2.5" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /> <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg> </svg>
</button> </button>
<button type="button" class="field-action-btn" data-action="paste" title="Paste" <button type="button" class="field-action-btn" data-action="paste" title="Paste"
aria-label="Paste vào note"> aria-label="Paste vào note">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" <svg viewBox="0 0 24 24" width="16" height="16" fill="none"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"> stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
stroke-linejoin="round">
<path d="M8 4h8" /> <path d="M8 4h8" />
<path d="M9 2h6a2 2 0 0 1 2 2v1H7V4a2 2 0 0 1 2-2z" /> <path d="M9 2h6a2 2 0 0 1 2 2v1H7V4a2 2 0 0 1 2-2z" />
<rect x="5" y="5" width="14" height="16" rx="2" /> <rect x="5" y="5" width="14" height="16" rx="2" />
@ -148,8 +153,9 @@
</button> </button>
<button type="button" class="field-action-btn" data-action="clear" title="Clear note" <button type="button" class="field-action-btn" data-action="clear" title="Clear note"
aria-label="Xoá note"> aria-label="Xoá note">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" <svg viewBox="0 0 24 24" width="16" height="16" fill="none"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"> stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
stroke-linejoin="round">
<path d="M3 6h18" /> <path d="M3 6h18" />
<path d="M19 6v12a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /> <path d="M19 6v12a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
<path d="M10 11v6" /> <path d="M10 11v6" />
@ -166,10 +172,6 @@
<label>Reference Images</label> <label>Reference Images</label>
</div> </div>
<div id="image-input-grid" class="image-input-grid" aria-live="polite"></div> <div id="image-input-grid" class="image-input-grid" aria-live="polite"></div>
<div class="image-url-input-wrapper" style="margin-top: 0.5rem;">
<input type="text" id="reference-url-input" placeholder="Nhập URL hoặc đường dẫn ảnh..."
style="width: 100%; padding: 0.5rem; border-radius: 4px; border: 1px solid var(--border-color); background: rgba(0,0,0,0.2); color: var(--text-primary); font-size: 0.85rem;">
</div>
</div> </div>
<div class="input-group"> <div class="input-group">
@ -408,11 +410,6 @@
<textarea id="template-prompt" rows="3" placeholder="Template Prompt"></textarea> <textarea id="template-prompt" rows="3" placeholder="Template Prompt"></textarea>
</div> </div>
<div class="form-group">
<label for="template-note">Note</label>
<textarea id="template-note" rows="2" placeholder="Template Note (optional)"></textarea>
</div>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="template-mode">Mode</label> <label for="template-mode">Mode</label>
@ -492,15 +489,6 @@
rel="noreferrer">aistudio.google.com/api-keys</a> rel="noreferrer">aistudio.google.com/api-keys</a>
</p> </p>
</div> </div>
<!-- Whisk Cookies Input -->
<div class="input-group api-settings-input-group hidden" id="whisk-cookies-group">
<label for="whisk-cookies">Whisk Cookies (dành cho ImageFX)</label>
<textarea id="whisk-cookies" rows="3" placeholder="Paste toàn bộ cookie string từ labs.google..."
style="width: 100%; padding: 0.5rem; background: rgba(0,0,0,0.2); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 0.8rem;"></textarea>
<p class="input-hint">
F12 trên labs.google > Network > Request bất kỳ > Copy Request Headers > Cookie.
</p>
</div>
<div class="input-group api-settings-input-group"> <div class="input-group api-settings-input-group">
<label for="api-model">Model</label> <label for="api-model">Model</label>
<div class="select-wrapper"> <div class="select-wrapper">
@ -508,7 +496,6 @@
style="width: 100%; padding: 0.75rem; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 0.5rem; color: var(--text-primary); font-size: 0.9rem;"> style="width: 100%; padding: 0.75rem; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 0.5rem; color: var(--text-primary); font-size: 0.9rem;">
<option value="gemini-3-pro-image-preview">Gemini 3 Pro (Image Preview)</option> <option value="gemini-3-pro-image-preview">Gemini 3 Pro (Image Preview)</option>
<option value="gemini-2.5-flash-image">Gemini 2.5 Flash Image</option> <option value="gemini-2.5-flash-image">Gemini 2.5 Flash Image</option>
<option value="whisk">Whisk (ImageFX) [Experimental]</option>
</select> </select>
</div> </div>
</div> </div>

View file

@ -1,254 +0,0 @@
import requests
import json
import time
import base64
import os
import uuid
import logging
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(levelname)s:%(name)s:%(message)s')
logger = logging.getLogger("whisk_client")
# Constants from reverse engineering
AUTH_ENDPOINT = "https://labs.google/fx/api/auth/session"
UPLOAD_ENDPOINT = "https://labs.google/fx/api/trpc/backbone.uploadImage"
# Endpoint 1: Text-to-Image
# (Captured in Step 405)
GENERATE_ENDPOINT = "https://aisandbox-pa.googleapis.com/v1/whisk:generateImage"
# Endpoint 2: Reference Image (Recipe)
# (Captured in Step 424)
RECIPE_ENDPOINT = "https://aisandbox-pa.googleapis.com/v1/whisk:runImageRecipe"
DEFAULT_HEADERS = {
"Origin": "https://labs.google",
"Content-Type": "application/json",
"Referer": "https://labs.google/fx/tools/image-fx",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
}
class WhiskClientError(Exception):
pass
def parse_cookies(cookie_input):
if not cookie_input:
return {}
cookies = {}
cookie_input = cookie_input.strip()
if cookie_input.startswith('[') and cookie_input.endswith(']'):
try:
cookie_list = json.loads(cookie_input)
for c in cookie_list:
name = c.get('name')
value = c.get('value')
if name and value:
cookies[name] = value
return cookies
except json.JSONDecodeError:
pass
for item in cookie_input.split(';'):
if '=' in item:
name, value = item.split('=', 1)
cookies[name.strip()] = value.strip()
return cookies
def get_session_token(cookies):
logger.info("Fetching session token from labs.google...")
try:
response = requests.get(
AUTH_ENDPOINT,
headers={**DEFAULT_HEADERS},
cookies=cookies,
timeout=30
)
response.raise_for_status()
data = response.json()
if not data.get('access_token'):
raise WhiskClientError("Session response missing access_token")
return data['access_token']
except Exception as e:
logger.error(f"Failed to fetch session token: {e}")
raise WhiskClientError(f"Authentication failed: {str(e)}")
def upload_reference_image(image_path, cookies):
if not image_path or not os.path.exists(image_path):
return None
logger.info(f"Uploading reference image: {image_path}")
try:
with open(image_path, "rb") as img_file:
import mimetypes
mime_type, _ = mimetypes.guess_type(image_path)
if not mime_type: mime_type = "image/png"
b64_data = base64.b64encode(img_file.read()).decode('utf-8')
data_uri = f"data:{mime_type};base64,{b64_data}"
payload = {
"json": {
"clientContext": {
"workflowId": str(uuid.uuid4()),
"sessionId": str(int(time.time() * 1000))
},
"uploadMediaInput": {
"mediaCategory": "MEDIA_CATEGORY_SUBJECT",
"rawBytes": data_uri,
"caption": ""
}
}
}
response = requests.post(
UPLOAD_ENDPOINT,
headers=DEFAULT_HEADERS,
cookies=cookies,
json=payload,
timeout=60
)
if not response.ok:
raise WhiskClientError(f"Image upload failed: {response.text}")
data = response.json()
try:
media_id = data['result']['data']['json']['result']['uploadMediaGenerationId']
except (KeyError, TypeError):
raise WhiskClientError("Failed to retrieve uploadMediaGenerationId")
logger.info(f"Image uploaded successfully. ID: {media_id}")
return media_id
except Exception as e:
logger.error(f"Error uploading image: {e}")
raise e
def generate_image_whisk(prompt, cookie_str, **kwargs):
cookies = parse_cookies(cookie_str)
if not cookies:
raise WhiskClientError("No valid cookies found")
access_token = get_session_token(cookies)
ref_image_path = kwargs.get('reference_image_path')
media_generation_id = None
if ref_image_path:
try:
media_generation_id = upload_reference_image(ref_image_path, cookies)
except Exception as e:
logger.warning(f"Skipping reference image due to upload error: {e}")
aspect_ratio_map = {
"1:1": "IMAGE_ASPECT_RATIO_SQUARE",
"9:16": "IMAGE_ASPECT_RATIO_PORTRAIT",
"16:9": "IMAGE_ASPECT_RATIO_LANDSCAPE",
"4:3": "IMAGE_ASPECT_RATIO_LANDSCAPE_FOUR_THREE",
"3:4": "IMAGE_ASPECT_RATIO_PORTRAIT",
"Auto": "IMAGE_ASPECT_RATIO_SQUARE"
}
aspect_ratio_key = kwargs.get('aspect_ratio', 'Auto')
aspect_ratio_enum = aspect_ratio_map.get(aspect_ratio_key, "IMAGE_ASPECT_RATIO_SQUARE")
seed = kwargs.get('seed', int(time.time()))
headers = {
**DEFAULT_HEADERS,
"Authorization": f"Bearer {access_token}"
}
# BRANCH: Use Recipe Endpoint if Reference Image exists
if media_generation_id:
target_endpoint = RECIPE_ENDPOINT
payload = {
"clientContext": {
"workflowId": str(uuid.uuid4()),
"tool": "BACKBONE",
"sessionId": str(int(time.time() * 1000))
},
"seed": seed,
"imageModelSettings": {
"imageModel": "GEM_PIX",
"aspectRatio": aspect_ratio_enum
},
"userInstruction": prompt,
"recipeMediaInputs": [{
"mediaInput": {
"mediaCategory": "MEDIA_CATEGORY_SUBJECT",
"mediaGenerationId": media_generation_id
}
}]
}
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,
"prompts": [prompt],
"seed": seed
},
"clientContext": {
"workflowId": str(uuid.uuid4()),
"tool": "IMAGE_FX", # Usually ImageFX for T2I
"sessionId": str(int(time.time() * 1000))
},
"modelInput": {
"modelNameType": "IMAGEN_3_5", # Usually Imagen 3 for ImageFX
"aspectRatio": aspect_ratio_enum
}
}
logger.info(f"Generating image. Endpoint: {target_endpoint}, Prompt: {prompt}")
try:
response = requests.post(
target_endpoint,
headers=headers,
json=payload,
timeout=120
)
if not response.ok:
error_text = response.text
try:
err_json = response.json()
details = err_json.get('error', {}).get('details', [])
if any(d.get('reason') in ['PUBLIC_ERROR_UNSAFE_GENERATION', 'PUBLIC_ERROR_SEXUAL'] for d in details):
raise WhiskClientError("⚠️ Google Safety Filter Triggered. Prompt bị từ chối do nội dung không an toàn.")
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
json_resp = response.json()
images = []
if 'imagePanels' in json_resp:
for panel in json_resp['imagePanels']:
for img in panel.get('generatedImages', []):
if 'encodedImage' in img:
images.append(img['encodedImage'])
if not images:
logger.error(f"Unexpected response structure: {json_resp.keys()}")
raise WhiskClientError("No images found in response")
return base64.b64decode(images[0])
except requests.exceptions.Timeout:
raise WhiskClientError("Timout connecting to Google Whisk.")
except Exception as e:
logger.error(f"Whisk Generation Error: {e}")
raise WhiskClientError(str(e))