Compare commits

..

10 commits

Author SHA1 Message Date
Khoa.vo
63ad7cc21f feat: Add Whisk integration and Docker support 2025-12-30 19:09:21 +07:00
phamhungd
9e0f3b80b2 update 2025-12-30 16:20:57 +07:00
phamhungd
ac7303588f fillter history fix 2025-11-29 22:27:10 +07:00
phamhungd
06cf2a6954 update beta4 2025-11-29 22:08:42 +07:00
phamhungd
931b3642ea Revert "fix"
This reverts commit d9ac3c2cc51586e48520c9a5c343a069aef836aa.
2025-11-29 22:08:42 +07:00
phamhungd
4a9f6764d9 fix 2025-11-29 22:08:24 +07:00
phamhungd
0485681c76 Update .gitignore 2025-11-29 14:51:54 +07:00
phamhungd
b62da7f34b up 2025-11-29 00:04:15 +07:00
phamhungd
414052adb9 up 2025-11-28 16:42:13 +07:00
phamhungd
8f68bf431f update code 2025-11-28 16:30:40 +07:00
30 changed files with 7106 additions and 160 deletions

BIN
.DS_Store vendored

Binary file not shown.

6
.gitignore vendored
View file

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

21
Dockerfile Normal file
View file

@ -0,0 +1,21 @@
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

@ -0,0 +1,7 @@
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

@ -0,0 +1,71 @@
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

@ -0,0 +1,24 @@
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

@ -0,0 +1,493 @@
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

@ -0,0 +1,132 @@
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

@ -0,0 +1,149 @@
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

@ -0,0 +1,435 @@
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.

Binary file not shown.

278
app.py
View file

@ -11,6 +11,9 @@ from flask import Flask, render_template, request, jsonify, url_for
from google import genai
from google.genai import types
from PIL import Image, PngImagePlugin
import threading, time, subprocess, re
import whisk_client
import logging
@ -391,12 +394,15 @@ def generate_image():
if not prompt:
return jsonify({'error': 'Prompt is required'}), 400
if not api_key:
return jsonify({'error': 'API Key is required.'}), 401
# Determine if this is a Whisk request
is_whisk = 'whisk' in model.lower() or 'imagefx' in model.lower()
if not is_whisk and not api_key:
return jsonify({'error': 'API Key is required for Gemini models.'}), 401
try:
print("Đang gửi lệnh...", flush=True)
client = genai.Client(api_key=api_key)
# client initialization moved to Gemini block
image_config_args = {}
@ -512,6 +518,133 @@ def generate_image():
continue
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)
response = client.models.generate_content(
model=model_name,
@ -742,6 +875,7 @@ def save_template():
title = request.form.get('title')
prompt = request.form.get('prompt')
mode = request.form.get('mode', 'generate')
note = request.form.get('note', '')
category = request.form.get('category', 'User')
tags_field = request.form.get('tags')
tags = parse_tags_field(tags_field)
@ -837,6 +971,7 @@ def save_template():
new_template = {
'title': title,
'prompt': prompt,
'note': note,
'mode': mode,
'category': category,
'preview': preview_path,
@ -895,6 +1030,7 @@ def update_template():
title = request.form.get('title')
prompt = request.form.get('prompt')
mode = request.form.get('mode', 'generate')
note = request.form.get('note', '')
category = request.form.get('category', 'User')
tags_field = request.form.get('tags')
tags = parse_tags_field(tags_field)
@ -1004,6 +1140,7 @@ def update_template():
existing_template['title'] = title
existing_template['prompt'] = prompt
existing_template['note'] = note
existing_template['mode'] = mode
existing_template['category'] = category
if preview_path:
@ -1047,6 +1184,7 @@ def update_template():
user_prompts[template_index] = {
'title': title,
'prompt': prompt,
'note': note,
'mode': mode,
'category': category,
'preview': preview_path,
@ -1152,10 +1290,144 @@ def refine_prompt():
except Exception as e:
return jsonify({'error': str(e)}), 500
#Tun sever
@app.route('/download_image', methods=['POST'])
def download_image():
import requests
from urllib.parse import urlparse
data = request.get_json() or {}
url = data.get('url')
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
Sever_Pinggy = "Auto"
if __name__ == '__main__':
# Use ANSI green text so the startup banner stands out in terminals
print("\033[32m" + "aPix Image Workspace running at:" + "\033[0m", flush=True)
print("\033[32m" + f"http://localhost:{port_sever}" + " " + "\033[0m", flush=True)
print("\033[32m" + f"http://127.0.0.1:{port_sever}" + "\033[0m", flush=True)
# sever_flare(port_sever, "cXPggKvHuW:sdvn:1231")
app.run(debug=True, port=port_sever)

17
docker-compose.yml Normal file
View file

@ -0,0 +1,17 @@
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,8 +4,10 @@
"gemini-3-pro-image-preview_20251125_42.png",
"gemini-3-pro-image-preview_20251125_41.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",
"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,3 +2,5 @@ flask
google-genai
pillow
Send2Trash
gallery-dl
requests

View file

@ -0,0 +1,71 @@
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

@ -0,0 +1,26 @@
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

@ -0,0 +1,493 @@
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

@ -0,0 +1,132 @@
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

@ -0,0 +1,149 @@
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

@ -0,0 +1,435 @@
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,33 +3,97 @@ import { extractMetadataFromBlob } from './metadata.js';
const FILTER_STORAGE_KEY = 'gemini-app-history-filter';
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 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 }) {
let currentFilter = 'all';
let searchQuery = '';
let currentFilter = DEFAULT_STATE.filter;
let searchQuery = DEFAULT_STATE.search;
let currentSource = 'generated';
let allImages = [];
let favorites = [];
let showOnlyFavorites = false; // New toggle state
let showOnlyFavorites = DEFAULT_STATE.favorites;
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 {
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);
if (savedSource && VALID_SOURCES.includes(savedSource)) {
currentSource = savedSource;
}
} catch (e) {
console.warn('Failed to load history filter/search', e);
console.warn('Failed to load history source', 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
async function loadFavorites() {
try {
@ -306,32 +370,35 @@ export function createGallery({ galleryGrid, onSelect }) {
function setFilter(filterType) {
if (currentFilter === filterType) return;
currentFilter = filterType;
stateBySource[currentSource].filter = filterType;
// Save to localStorage
try {
localStorage.setItem(FILTER_STORAGE_KEY, filterType);
} catch (e) {
console.warn('Failed to save history filter', e);
}
persistStateMap(FILTER_STORAGE_KEY, {
generated: stateBySource.generated.filter,
uploads: stateBySource.uploads.filter,
});
renderGallery();
}
function setSearch(query) {
searchQuery = query || '';
stateBySource[currentSource].search = searchQuery;
// Save to localStorage
try {
localStorage.setItem(SEARCH_STORAGE_KEY, searchQuery);
} catch (e) {
console.warn('Failed to save history search', e);
}
persistStateMap(SEARCH_STORAGE_KEY, {
generated: stateBySource.generated.search,
uploads: stateBySource.uploads.search,
});
renderGallery();
}
function toggleFavorites() {
showOnlyFavorites = !showOnlyFavorites;
stateBySource[currentSource].favorites = showOnlyFavorites;
persistStateMap(FAVORITES_STORAGE_KEY, {
generated: stateBySource.generated.favorites,
uploads: stateBySource.uploads.favorites,
});
renderGallery();
return showOnlyFavorites;
}
@ -345,16 +412,21 @@ export function createGallery({ galleryGrid, onSelect }) {
console.warn('Failed to save history source', e);
}
if (resetFilters) {
currentFilter = 'all';
showOnlyFavorites = false;
searchQuery = '';
try {
localStorage.setItem(FILTER_STORAGE_KEY, currentFilter);
localStorage.setItem(SEARCH_STORAGE_KEY, searchQuery);
} catch (e) {
console.warn('Failed to reset history filters', e);
}
stateBySource[currentSource] = { ...DEFAULT_STATE };
persistStateMap(FILTER_STORAGE_KEY, {
generated: stateBySource.generated.filter,
uploads: stateBySource.uploads.filter,
});
persistStateMap(SEARCH_STORAGE_KEY, {
generated: stateBySource.generated.search,
uploads: stateBySource.uploads.search,
});
persistStateMap(FAVORITES_STORAGE_KEY, {
generated: stateBySource.generated.favorites,
uploads: stateBySource.uploads.favorites,
});
}
applySourceState(currentSource);
return load();
}
@ -376,6 +448,11 @@ export function createGallery({ galleryGrid, onSelect }) {
function setFavoritesActive(active) {
showOnlyFavorites = Boolean(active);
stateBySource[currentSource].favorites = showOnlyFavorites;
persistStateMap(FAVORITES_STORAGE_KEY, {
generated: stateBySource.generated.favorites,
uploads: stateBySource.uploads.favorites,
});
renderGallery();
return showOnlyFavorites;
}

View file

@ -1,4 +1,6 @@
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 = {}) {
const MAX_IMAGE_SLOTS = 16;
@ -6,6 +8,9 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) {
const onChange = options.onChange;
const imageSlotState = [];
let cachedReferenceImages = [];
// Inject image editor styles once
injectImageEditorStyles();
function initialize(initialCached = []) {
cachedReferenceImages = Array.isArray(initialCached) ? initialCached : [];
@ -75,6 +80,13 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) {
preview.alt = 'Uploaded reference';
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');
removeBtn.type = 'button';
removeBtn.className = 'slot-remove hidden';
@ -89,7 +101,7 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) {
slot.appendChild(input);
slot.addEventListener('click', event => {
if (event.target === removeBtn) return;
if (event.target === removeBtn || event.target === editBtn) return;
input.click();
});
@ -129,6 +141,11 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) {
}
});
editBtn.addEventListener('click', event => {
event.stopPropagation();
handleEditImage(index);
});
removeBtn.addEventListener('click', event => {
event.stopPropagation();
clearSlot(index);
@ -201,12 +218,14 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) {
const slot = slotRecord.slot;
const placeholder = slot.querySelector('.slot-placeholder');
const preview = slot.querySelector('.slot-preview');
const editBtn = slot.querySelector('.slot-edit');
const removeBtn = slot.querySelector('.slot-remove');
if (slotRecord.data && slotRecord.data.preview) {
preview.src = slotRecord.data.preview;
preview.classList.remove('hidden');
placeholder.classList.add('hidden');
editBtn.classList.remove('hidden');
removeBtn.classList.remove('hidden');
slot.classList.add('filled');
slot.classList.remove('empty');
@ -214,6 +233,7 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) {
preview.src = '';
preview.classList.add('hidden');
placeholder.classList.remove('hidden');
editBtn.classList.add('hidden');
removeBtn.classList.add('hidden');
slot.classList.add('empty');
slot.classList.remove('filled');
@ -230,6 +250,23 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) {
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() {
const hasEmpty = imageSlotState.some(record => !record.data);
if (!hasEmpty && imageSlotState.length < MAX_IMAGE_SLOTS) {
@ -303,11 +340,29 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) {
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 {
initialize,
getReferenceFiles,
getReferencePaths,
serializeReferenceImages,
setReferenceImages,
addReferenceFromUrl,
};
}

View file

@ -11,7 +11,7 @@ const ZOOM_STEP = 0.1;
const MIN_ZOOM = 0.4;
const MAX_ZOOM = 4;
const SIDEBAR_MIN_WIDTH = 260;
const SIDEBAR_MAX_WIDTH = 520;
const SIDEBAR_MAX_WIDTH = 1000;
const infoContent = {
title: 'Thông tin',
@ -102,6 +102,7 @@ document.addEventListener('DOMContentLoaded', () => {
const downloadLink = document.getElementById('download-link');
const galleryGrid = document.getElementById('gallery-grid');
const imageInputGrid = document.getElementById('image-input-grid');
const referenceUrlInput = document.getElementById('reference-url-input');
const imageDisplayArea = document.querySelector('.image-display-area');
const canvasToolbar = document.querySelector('.canvas-toolbar');
const sidebar = document.querySelector('.sidebar');
@ -131,10 +132,28 @@ document.addEventListener('DOMContentLoaded', () => {
if (apiModelSelect) {
apiModelSelect.addEventListener('change', () => {
toggleResolutionVisibility();
toggleCookiesVisibility();
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
function loadSettings() {
try {
@ -155,6 +174,10 @@ document.addEventListener('DOMContentLoaded', () => {
if (bodyFontSelect && settings.bodyFont) {
bodyFontSelect.value = settings.bodyFont;
}
if (whiskCookiesInput && settings.whiskCookies) {
whiskCookiesInput.value = settings.whiskCookies;
}
toggleCookiesVisibility();
return settings;
}
} catch (e) {
@ -168,7 +191,7 @@ document.addEventListener('DOMContentLoaded', () => {
const referenceImages = (typeof slotManager !== 'undefined' && typeof slotManager.serializeReferenceImages === 'function')
? slotManager.serializeReferenceImages()
: [];
const settings = {
apiKey: apiKeyInput.value,
prompt: promptInput.value,
@ -179,6 +202,7 @@ document.addEventListener('DOMContentLoaded', () => {
referenceImages,
theme: currentTheme || DEFAULT_THEME,
bodyFont: bodyFontSelect ? bodyFontSelect.value : DEFAULT_BODY_FONT,
whiskCookies: whiskCookiesInput ? whiskCookiesInput.value : '',
};
try {
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
@ -198,12 +222,16 @@ document.addEventListener('DOMContentLoaded', () => {
const selectedModel = model || (apiModelSelect ? apiModelSelect.value : 'gemini-3-pro-image-preview');
formData.append('model', selectedModel);
if (whiskCookiesInput && whiskCookiesInput.value) {
formData.append('cookies', whiskCookiesInput.value);
}
// Add reference images using correct slotManager methods
const referenceFiles = slotManager.getReferenceFiles();
referenceFiles.forEach(file => {
formData.append('reference_images', file);
});
const referencePaths = slotManager.getReferencePaths();
if (referencePaths && referencePaths.length > 0) {
formData.append('reference_image_paths', JSON.stringify(referencePaths));
@ -452,6 +480,10 @@ document.addEventListener('DOMContentLoaded', () => {
persistSettings();
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
// User will switch view by selecting image from history or generating
}
@ -587,14 +619,14 @@ document.addEventListener('DOMContentLoaded', () => {
// 2. Item currently being processed (isProcessingQueue)
// 3. Items waiting for backend response (pendingRequests)
const count = generationQueue.length + (isProcessingQueue ? 1 : 0) + pendingRequests;
console.log('Queue counter update:', {
queue: generationQueue.length,
processing: isProcessingQueue,
console.log('Queue counter update:', {
queue: generationQueue.length,
processing: isProcessingQueue,
pending: pendingRequests,
total: count
total: count
});
if (count > 0) {
if (queueCounter) {
queueCounter.classList.remove('hidden');
@ -618,10 +650,10 @@ document.addEventListener('DOMContentLoaded', () => {
const task = generationQueue.shift();
isProcessingQueue = true;
updateQueueCounter(); // Show counter immediately
try {
setViewState('loading');
// Check if this task already has a result (immediate generation)
if (task.immediateResult) {
// Display the already-generated image
@ -725,7 +757,7 @@ document.addEventListener('DOMContentLoaded', () => {
});
const data = await response.json();
// Mark fetch as completed and decrement pending
// We do this BEFORE adding to queue to avoid double counting
fetchCompleted = true;
@ -780,12 +812,12 @@ document.addEventListener('DOMContentLoaded', () => {
}
} catch (error) {
console.error('Error in addToQueue:', error);
// If fetch failed (didn't complete), we need to decrement pendingRequests
if (!fetchCompleted) {
pendingRequests--;
}
updateQueueCounter();
showError(error.message);
}
@ -811,7 +843,7 @@ document.addEventListener('DOMContentLoaded', () => {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const tempLink = document.createElement('a');
tempLink.href = blobUrl;
tempLink.download = filename;
@ -829,7 +861,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (imageDisplayArea) {
imageDisplayArea.addEventListener('wheel', handleCanvasWheel, { passive: false });
imageDisplayArea.addEventListener('pointerdown', handleCanvasPointerDown);
// Drag and drop support
imageDisplayArea.addEventListener('dragover', (e) => {
e.preventDefault();
@ -844,7 +876,7 @@ document.addEventListener('DOMContentLoaded', () => {
imageDisplayArea.addEventListener('drop', async (e) => {
e.preventDefault();
imageDisplayArea.classList.remove('drag-over');
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
const file = files[0];
@ -853,7 +885,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Display image immediately
const objectUrl = URL.createObjectURL(file);
displayImage(objectUrl);
// Extract and apply metadata
const metadata = await extractMetadataFromBlob(file);
if (metadata) {
@ -960,9 +992,10 @@ document.addEventListener('DOMContentLoaded', () => {
const createTemplateModal = document.getElementById('create-template-modal');
const closeTemplateModalBtn = document.getElementById('close-template-modal');
const saveTemplateBtn = document.getElementById('save-template-btn');
const templateTitleInput = document.getElementById('template-title');
const templatePromptInput = document.getElementById('template-prompt');
const templateNoteInput = document.getElementById('template-note');
const templateModeSelect = document.getElementById('template-mode');
const templateCategorySelect = document.getElementById('template-category-select');
const templateCategoryInput = document.getElementById('template-category-input');
@ -1183,14 +1216,15 @@ document.addEventListener('DOMContentLoaded', () => {
}
// Global function for opening edit modal (called from templateGallery.js)
window.openEditTemplateModal = async function(template) {
window.openEditTemplateModal = async function (template) {
editingTemplate = template;
editingTemplateSource = template.isUserTemplate ? 'user' : 'builtin';
editingBuiltinIndex = editingTemplateSource === 'builtin' ? template.builtinTemplateIndex : null;
// Pre-fill with template data
templateTitleInput.value = template.title || '';
templatePromptInput.value = template.prompt || '';
templateNoteInput.value = i18n.getText(template.note) || '';
templateModeSelect.value = template.mode || 'generate';
templateCategoryInput.classList.add('hidden');
templateCategoryInput.value = '';
@ -1199,18 +1233,18 @@ document.addEventListener('DOMContentLoaded', () => {
try {
const response = await fetch('/prompts');
const data = await response.json();
if (data.prompts) {
const categories = new Set();
data.prompts.forEach(t => {
if (t.category) {
const categoryText = typeof t.category === 'string'
? t.category
const categoryText = typeof t.category === 'string'
? t.category
: (t.category.vi || t.category.en || '');
if (categoryText) categories.add(categoryText);
}
});
templateCategorySelect.innerHTML = '';
const sortedCategories = Array.from(categories).sort();
sortedCategories.forEach(cat => {
@ -1219,15 +1253,15 @@ document.addEventListener('DOMContentLoaded', () => {
option.textContent = cat;
templateCategorySelect.appendChild(option);
});
const newOption = document.createElement('option');
newOption.value = 'new';
newOption.textContent = '+ New Category';
templateCategorySelect.appendChild(newOption);
// Set to template's category
const templateCategory = typeof template.category === 'string'
? template.category
const templateCategory = typeof template.category === 'string'
? template.category
: (template.category.vi || template.category.en || '');
templateCategorySelect.value = templateCategory || 'User';
}
@ -1256,16 +1290,16 @@ document.addEventListener('DOMContentLoaded', () => {
// Update button text
saveTemplateBtn.innerHTML = '<span>Update Template</span><div class="btn-shine"></div>';
createTemplateModal.classList.remove('hidden');
};
// Global function for opening create modal with empty values (called from templateGallery.js)
window.openCreateTemplateModal = async function() {
window.openCreateTemplateModal = async function () {
editingTemplate = null;
editingTemplateSource = 'user';
editingBuiltinIndex = null;
setTemplateTags([]);
if (templateTagInput) {
templateTagInput.value = '';
@ -1274,6 +1308,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Clear all fields
templateTitleInput.value = '';
templatePromptInput.value = '';
templateNoteInput.value = promptNoteInput.value || '';
templateModeSelect.value = 'generate';
templateCategoryInput.classList.add('hidden');
templateCategoryInput.value = '';
@ -1282,18 +1317,18 @@ document.addEventListener('DOMContentLoaded', () => {
try {
const response = await fetch('/prompts');
const data = await response.json();
if (data.prompts) {
const categories = new Set();
data.prompts.forEach(t => {
if (t.category) {
const categoryText = typeof t.category === 'string'
? t.category
const categoryText = typeof t.category === 'string'
? t.category
: (t.category.vi || t.category.en || '');
if (categoryText) categories.add(categoryText);
}
});
templateCategorySelect.innerHTML = '';
const sortedCategories = Array.from(categories).sort();
sortedCategories.forEach(cat => {
@ -1302,12 +1337,12 @@ document.addEventListener('DOMContentLoaded', () => {
option.textContent = cat;
templateCategorySelect.appendChild(option);
});
const newOption = document.createElement('option');
newOption.value = 'new';
newOption.textContent = '+ New Category';
templateCategorySelect.appendChild(newOption);
if (sortedCategories.includes('User')) {
templateCategorySelect.value = 'User';
} else if (sortedCategories.length > 0) {
@ -1327,7 +1362,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Update button text
saveTemplateBtn.innerHTML = '<span>Save Template</span><div class="btn-shine"></div>';
createTemplateModal.classList.remove('hidden');
};
@ -1337,10 +1372,11 @@ document.addEventListener('DOMContentLoaded', () => {
editingTemplate = null;
editingTemplateSource = 'user';
editingBuiltinIndex = null;
// Pre-fill data
templateTitleInput.value = '';
templatePromptInput.value = promptInput.value;
templateNoteInput.value = promptNoteInput.value || '';
templateModeSelect.value = 'generate';
templateCategoryInput.classList.add('hidden');
templateCategoryInput.value = '';
@ -1349,25 +1385,25 @@ document.addEventListener('DOMContentLoaded', () => {
try {
const response = await fetch('/prompts');
const data = await response.json();
if (data.prompts) {
// Extract unique categories
const categories = new Set();
data.prompts.forEach(template => {
if (template.category) {
// Handle both string and object categories
const categoryText = typeof template.category === 'string'
? template.category
const categoryText = typeof template.category === 'string'
? template.category
: (template.category.vi || template.category.en || '');
if (categoryText) {
categories.add(categoryText);
}
}
});
// Clear existing options except "new"
templateCategorySelect.innerHTML = '';
// Add sorted categories
const sortedCategories = Array.from(categories).sort();
sortedCategories.forEach(cat => {
@ -1376,13 +1412,13 @@ document.addEventListener('DOMContentLoaded', () => {
option.textContent = cat;
templateCategorySelect.appendChild(option);
});
// Add "new category" option at the end
const newOption = document.createElement('option');
newOption.value = 'new';
newOption.textContent = '+ New Category';
templateCategorySelect.appendChild(newOption);
// Set default to first category or "User" if it exists
if (sortedCategories.includes('User')) {
templateCategorySelect.value = 'User';
@ -1456,7 +1492,7 @@ document.addEventListener('DOMContentLoaded', () => {
templatePreviewDropzone.addEventListener('click', (e) => {
// Don't toggle if clicking on the input itself
if (e.target === templatePreviewUrlInput) return;
if (!isUrlInputMode) {
// Switch to URL input mode
isUrlInputMode = true;
@ -1511,7 +1547,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
}
templatePreviewDropzone.addEventListener('dragover', (e) => {
e.preventDefault();
templatePreviewDropzone.classList.add('drag-over');
@ -1525,7 +1561,7 @@ document.addEventListener('DOMContentLoaded', () => {
templatePreviewDropzone.addEventListener('drop', (e) => {
e.preventDefault();
templatePreviewDropzone.classList.remove('drag-over');
const files = e.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
@ -1547,9 +1583,10 @@ document.addEventListener('DOMContentLoaded', () => {
saveTemplateBtn.addEventListener('click', async () => {
const title = templateTitleInput.value.trim();
const prompt = templatePromptInput.value.trim();
const note = templateNoteInput.value.trim();
const mode = templateModeSelect.value;
let category = templateCategorySelect.value;
if (category === 'new') {
category = templateCategoryInput.value.trim();
}
@ -1574,6 +1611,7 @@ document.addEventListener('DOMContentLoaded', () => {
const formData = new FormData();
formData.append('title', title);
formData.append('prompt', prompt);
formData.append('note', note);
formData.append('mode', mode);
formData.append('category', category);
formData.append('tags', JSON.stringify(templateTags));
@ -1608,10 +1646,10 @@ document.addEventListener('DOMContentLoaded', () => {
// Success
createTemplateModal.classList.add('hidden');
// Reload template gallery
await templateGallery.load();
// Reset editing state
editingTemplate = null;
editingTemplateSource = null;
@ -1655,7 +1693,7 @@ document.addEventListener('DOMContentLoaded', () => {
loadGallery();
loadTemplateGallery();
initializeSidebarResizer(sidebar, resizeHandle);
// Restore last image if available
try {
const lastImage = localStorage.getItem('gemini-app-last-image');
@ -1665,13 +1703,13 @@ document.addEventListener('DOMContentLoaded', () => {
} catch (e) {
console.warn('Failed to restore last image', e);
}
// Setup canvas language toggle
const canvasLangInput = document.getElementById('canvas-lang-input');
if (canvasLangInput) {
// Set initial state
canvasLangInput.checked = i18n.currentLang === 'en';
canvasLangInput.addEventListener('change', (e) => {
i18n.setLanguage(e.target.checked ? 'en' : 'vi');
// Update visual state
@ -1691,6 +1729,24 @@ document.addEventListener('DOMContentLoaded', () => {
const historySourceBtns = document.querySelectorAll('.history-source-btn');
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 => {
const isActive = btn.dataset.source === initialSource;
btn.classList.toggle('active', isActive);
@ -1703,41 +1759,13 @@ document.addEventListener('DOMContentLoaded', () => {
b.classList.toggle('active', active);
b.setAttribute('aria-pressed', String(active));
});
await gallery.setSource(targetSource, { resetFilters: true });
// 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('');
}
await gallery.setSource(targetSource, { resetFilters: false });
syncHistoryControlsFromGallery();
});
});
// Set initial active state based on saved filter
const currentFilter = gallery.getCurrentFilter();
historyFilterBtns.forEach(btn => {
if (btn.dataset.filter === currentFilter && !btn.classList.contains('history-favorites-btn')) {
btn.classList.add('active');
}
});
syncHistoryControlsFromGallery();
// Handle favorites button as toggle
if (historyFavoritesBtn) {
@ -1752,7 +1780,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (!btn.classList.contains('history-favorites-btn')) {
btn.addEventListener('click', () => {
const filterType = btn.dataset.filter;
// Remove active from all date filter buttons (not favorites)
historyFilterBtns.forEach(b => {
if (!b.classList.contains('history-favorites-btn')) {
@ -1833,7 +1861,7 @@ document.addEventListener('DOMContentLoaded', () => {
hasGeneratedImage = true; // Mark that we have an image
setViewState('result');
// Persist image URL
try {
localStorage.setItem('gemini-app-last-image', imageUrl);
@ -1863,7 +1891,7 @@ document.addEventListener('DOMContentLoaded', () => {
promptInput.value = metadata.prompt;
refreshPromptHighlight();
}
// If metadata doesn't have 'note' field, set to empty string instead of keeping current value
if (metadata.hasOwnProperty('note')) {
promptNoteInput.value = metadata.note || '';
@ -1871,14 +1899,14 @@ document.addEventListener('DOMContentLoaded', () => {
promptNoteInput.value = '';
}
refreshNoteHighlight();
if (metadata.aspect_ratio) aspectRatioInput.value = metadata.aspect_ratio;
if (metadata.resolution) resolutionInput.value = metadata.resolution;
if (metadata.reference_images && Array.isArray(metadata.reference_images)) {
slotManager.setReferenceImages(metadata.reference_images);
}
persistSettings();
}
@ -1967,9 +1995,9 @@ document.addEventListener('DOMContentLoaded', () => {
const targetTag = event.target?.tagName;
if (targetTag && ['INPUT', 'TEXTAREA', 'SELECT'].includes(targetTag)) return;
if (event.target?.isContentEditable) return;
event.preventDefault();
// Toggle template gallery
if (templateGalleryState.classList.contains('hidden')) {
setViewState('template-gallery');
@ -2121,4 +2149,52 @@ 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 {
width: 320px;
width: 450px;
background: var(--panel-backdrop);
background-image: radial-gradient(circle at 20% -20%, rgba(251, 191, 36, 0.15), transparent 45%);
/* border-right: 1px solid var(--border-color); */
@ -399,7 +399,7 @@ select:focus {
textarea {
resize: vertical;
min-height: 100px;
min-height: 30px;
}
/* Theme overrides driven from index.css gradients */
@ -786,11 +786,38 @@ body.theme-amin { --bd-bg: linear-gradient(to right, #4A00E0, #8E2DE2); }
}
.slot-preview.hidden,
.slot-edit.hidden,
.slot-remove.hidden,
.slot-placeholder.hidden {
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 {
position: absolute;
top: 0.35rem;
@ -1924,7 +1951,7 @@ button#generate-btn:disabled {
.template-preview-dropzone {
width: 100%;
height: 220px;
height: 150px;
border: 2px dashed rgba(255, 255, 255, 0.15);
border-radius: 0.75rem;
background: rgba(0, 0, 0, 0.2);
@ -2069,6 +2096,16 @@ button#generate-btn:disabled {
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
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 {

View file

@ -46,18 +46,16 @@
<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"
aria-label="Copy prompt">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none"
stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
stroke-linejoin="round">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<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" />
</svg>
</button>
<button type="button" class="field-action-btn" data-action="paste" title="Paste"
aria-label="Paste vào prompt">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none"
stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
stroke-linejoin="round">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 4h8" />
<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" />
@ -67,9 +65,8 @@
</button>
<button type="button" class="field-action-btn" data-action="clear" title="Clear prompt"
aria-label="Xoá prompt">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none"
stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
stroke-linejoin="round">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18" />
<path d="M19 6v12a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
<path d="M10 11v6" />
@ -132,18 +129,16 @@
<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"
aria-label="Copy note">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none"
stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
stroke-linejoin="round">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<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" />
</svg>
</button>
<button type="button" class="field-action-btn" data-action="paste" title="Paste"
aria-label="Paste vào note">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none"
stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
stroke-linejoin="round">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 4h8" />
<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" />
@ -153,9 +148,8 @@
</button>
<button type="button" class="field-action-btn" data-action="clear" title="Clear note"
aria-label="Xoá note">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none"
stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
stroke-linejoin="round">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18" />
<path d="M19 6v12a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
<path d="M10 11v6" />
@ -172,6 +166,10 @@
<label>Reference Images</label>
</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 class="input-group">
@ -410,6 +408,11 @@
<textarea id="template-prompt" rows="3" placeholder="Template Prompt"></textarea>
</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-group">
<label for="template-mode">Mode</label>
@ -489,6 +492,15 @@
rel="noreferrer">aistudio.google.com/api-keys</a>
</p>
</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">
<label for="api-model">Model</label>
<div class="select-wrapper">
@ -496,6 +508,7 @@
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-2.5-flash-image">Gemini 2.5 Flash Image</option>
<option value="whisk">Whisk (ImageFX) [Experimental]</option>
</select>
</div>
</div>
@ -553,4 +566,4 @@
<script type="module" src="{{ url_for('static', filename='script.js') }}"></script>
</body>
</html>
</html>

254
whisk_client.py Normal file
View file

@ -0,0 +1,254 @@
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))