Update v2.3: Multi-image, Mobile Cookies, Img2Vid, Cleanup

This commit is contained in:
Khoa.vo 2025-12-30 21:49:26 +07:00
parent 628dfebe78
commit fe2a3179a8
23 changed files with 317 additions and 3454 deletions

View file

@ -1,7 +0,0 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import { injectImageEditorStyles } from "./image_editor_modules/styles.js";
import { registerImageEditorExtension } from "./image_editor_modules/extension.js";
injectImageEditorStyles();
registerImageEditorExtension(app, api);

View file

@ -1,71 +0,0 @@
export function clamp01(value) {
return Math.min(1, Math.max(0, value));
}
export function clamp255(value) {
return Math.min(255, Math.max(0, value));
}
export function rgbToHsl(r, g, b) {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h;
let s;
const l = (max + min) / 2;
if (max === min) {
h = 0;
s = 0;
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
default:
h = (r - g) / d + 4;
}
h /= 6;
}
return { h, s, l };
}
export function hslToRgb(h, s, l) {
let r;
let g;
let b;
if (s === 0) {
r = g = b = l;
} else {
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
return {
r: Math.round(r * 255),
g: Math.round(g * 255),
b: Math.round(b * 255)
};
}
export function hueDistance(a, b) {
let diff = Math.abs(a - b);
diff = Math.min(diff, 1 - diff);
return diff;
}

View file

@ -1,24 +0,0 @@
export const ICONS = {
crop: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6.13 1L6 16a2 2 0 0 0 2 2h15"></path><path d="M1 6.13L16 6a2 2 0 0 1 2 2v15"></path></svg>`,
adjust: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>`,
undo: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7v6h6"></path><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"></path></svg>`,
redo: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 7v6h-6"></path><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 2.7"></path></svg>`,
reset: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"></polyline><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path></svg>`,
chevronDown: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>`,
close: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`,
flipH: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 7 3 3 7 3"></polyline><line x1="3" y1="3" x2="10" y2="10"></line><polyline points="21 17 21 21 17 21"></polyline><line x1="21" y1="21" x2="14" y2="14"></line></svg>`,
flipV: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="7 21 3 21 3 17"></polyline><line x1="3" y1="21" x2="10" y2="14"></line><polyline points="17 3 21 3 21 7"></polyline><line x1="21" y1="3" x2="14" y2="10"></line></svg>`,
rotate: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15A9 9 0 1 1 23 10"></path></svg>`
};
export const HSL_COLORS = [
{ id: "red", label: "Red", color: "#ff4b4b", center: 0 / 360, width: 0.12 },
{ id: "orange", label: "Orange", color: "#ff884d", center: 30 / 360, width: 0.12 },
{ id: "yellow", label: "Yellow", color: "#ffd84d", center: 50 / 360, width: 0.12 },
{ id: "green", label: "Green", color: "#45d98e", center: 120 / 360, width: 0.12 },
{ id: "cyan", label: "Cyan", color: "#30c4ff", center: 180 / 360, width: 0.12 },
{ id: "blue", label: "Blue", color: "#2f7bff", center: 220 / 360, width: 0.12 },
{ id: "magenta", label: "Magenta", color: "#c95bff", center: 300 / 360, width: 0.12 }
];
export const IMAGE_EDITOR_SUBFOLDER = "image_editor";

View file

@ -1,493 +0,0 @@
import { clamp01 } from "./color.js";
const CHANNEL_COLORS = {
rgb: "#ffffff",
r: "#ff7070",
g: "#70ffa0",
b: "#72a0ff"
};
export class CurveEditor {
constructor({ canvas, channelButtons = [], resetButton, onChange, onCommit }) {
this.canvas = canvas;
this.ctx = canvas.getContext("2d");
this.channelButtons = channelButtons;
this.resetButton = resetButton;
this.onChange = onChange;
this.onCommit = onCommit;
this.channels = ["rgb", "r", "g", "b"];
this.activeChannel = "rgb";
this.curves = this.createDefaultCurves();
this.curveTangents = {};
this.channels.forEach(channel => (this.curveTangents[channel] = []));
this.luts = this.buildAllLUTs();
this.isDragging = false;
this.dragIndex = null;
this.curveDirty = false;
this.displayWidth = this.canvas.clientWidth || 240;
this.displayHeight = this.canvas.clientHeight || 240;
this.resizeObserver = null;
this.handleResize = this.handleResize.bind(this);
this.onPointerDown = this.onPointerDown.bind(this);
this.onPointerMove = this.onPointerMove.bind(this);
this.onPointerUp = this.onPointerUp.bind(this);
this.onDoubleClick = this.onDoubleClick.bind(this);
window.addEventListener("resize", this.handleResize);
this.canvas.addEventListener("mousedown", this.onPointerDown);
window.addEventListener("mousemove", this.onPointerMove);
window.addEventListener("mouseup", this.onPointerUp);
this.canvas.addEventListener("dblclick", this.onDoubleClick);
this.attachChannelButtons();
this.attachResetButton();
this.handleResize();
if (window.ResizeObserver) {
this.resizeObserver = new ResizeObserver(() => this.handleResize());
this.resizeObserver.observe(this.canvas);
}
this.draw();
}
destroy() {
this.resizeObserver?.disconnect();
window.removeEventListener("resize", this.handleResize);
this.canvas.removeEventListener("mousedown", this.onPointerDown);
window.removeEventListener("mousemove", this.onPointerMove);
window.removeEventListener("mouseup", this.onPointerUp);
this.canvas.removeEventListener("dblclick", this.onDoubleClick);
}
attachChannelButtons() {
this.channelButtons.forEach(btn => {
btn.addEventListener("click", () => {
const channel = btn.dataset.curveChannel;
if (channel && this.channels.includes(channel)) {
this.activeChannel = channel;
this.updateChannelButtons();
this.draw();
}
});
});
this.updateChannelButtons();
}
attachResetButton() {
if (!this.resetButton) return;
this.resetButton.addEventListener("click", () => {
this.resetChannel(this.activeChannel);
this.notifyChange();
this.notifyCommit();
});
}
updateChannelButtons() {
this.channelButtons.forEach(btn => {
const channel = btn.dataset.curveChannel;
btn.classList.toggle("active", channel === this.activeChannel);
});
}
handleResize() {
const rect = this.canvas.getBoundingClientRect();
const width = Math.max(1, rect.width || 240);
const height = Math.max(1, rect.height || 240);
this.displayWidth = width;
this.displayHeight = height;
const dpr = window.devicePixelRatio || 1;
this.canvas.width = width * dpr;
this.canvas.height = height * dpr;
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
this.ctx.scale(dpr, dpr);
this.draw();
}
createDefaultCurve() {
return [
{ x: 0, y: 0 },
{ x: 1, y: 1 }
];
}
createDefaultCurves() {
return {
rgb: this.createDefaultCurve().map(p => ({ ...p })),
r: this.createDefaultCurve().map(p => ({ ...p })),
g: this.createDefaultCurve().map(p => ({ ...p })),
b: this.createDefaultCurve().map(p => ({ ...p }))
};
}
cloneCurves(source = this.curves) {
const clone = {};
this.channels.forEach(channel => {
clone[channel] = (source[channel] || this.createDefaultCurve()).map(p => ({ x: p.x, y: p.y }));
});
return clone;
}
setState(state) {
if (!state) return;
const incoming = this.cloneCurves(state.curves || {});
this.curves = incoming;
if (state.activeChannel && this.channels.includes(state.activeChannel)) {
this.activeChannel = state.activeChannel;
}
this.rebuildAllLUTs();
this.updateChannelButtons();
this.draw();
}
getState() {
return {
curves: this.cloneCurves(),
activeChannel: this.activeChannel
};
}
resetChannel(channel, emit = false) {
if (!this.channels.includes(channel)) return;
this.curves[channel] = this.createDefaultCurve().map(p => ({ ...p }));
this.rebuildChannelLUT(channel);
this.draw();
if (emit) {
this.notifyChange();
this.notifyCommit();
this.curveDirty = false;
}
}
resetAll(emit = true) {
this.channels.forEach(channel => {
this.curves[channel] = this.createDefaultCurve().map(p => ({ ...p }));
});
this.rebuildAllLUTs();
this.draw();
if (emit) {
this.notifyChange();
this.notifyCommit();
this.curveDirty = false;
}
}
hasAdjustments() {
return this.channels.some(channel => !this.isDefaultCurve(this.curves[channel]));
}
isDefaultCurve(curve) {
if (!curve || curve.length !== 2) return false;
const [start, end] = curve;
const epsilon = 0.0001;
return Math.abs(start.x) < epsilon && Math.abs(start.y) < epsilon &&
Math.abs(end.x - 1) < epsilon && Math.abs(end.y - 1) < epsilon;
}
notifyChange() {
this.onChange?.();
}
notifyCommit() {
this.onCommit?.();
}
getLUTPack() {
return {
rgb: this.isDefaultCurve(this.curves.rgb) ? null : this.luts.rgb,
r: this.isDefaultCurve(this.curves.r) ? null : this.luts.r,
g: this.isDefaultCurve(this.curves.g) ? null : this.luts.g,
b: this.isDefaultCurve(this.curves.b) ? null : this.luts.b,
hasAdjustments: this.hasAdjustments()
};
}
buildAllLUTs() {
const result = {};
this.channels.forEach(channel => {
const curve = this.curves[channel];
const tangents = this.computeTangents(curve);
this.curveTangents[channel] = tangents;
result[channel] = this.buildCurveLUT(curve, tangents);
});
return result;
}
rebuildAllLUTs() {
this.luts = this.buildAllLUTs();
}
rebuildChannelLUT(channel) {
const curve = this.curves[channel];
const tangents = this.computeTangents(curve);
this.curveTangents[channel] = tangents;
this.luts[channel] = this.buildCurveLUT(curve, tangents);
}
buildCurveLUT(curve, tangents = null) {
const curveTangents = tangents || this.computeTangents(curve);
const lut = new Uint8ClampedArray(256);
for (let i = 0; i < 256; i++) {
const pos = i / 255;
lut[i] = Math.round(clamp01(this.sampleSmoothCurve(curve, pos, curveTangents)) * 255);
}
return lut;
}
computeTangents(curve) {
const n = curve.length;
if (n < 2) return new Array(n).fill(0);
const tangents = new Array(n).fill(0);
const delta = new Array(n - 1).fill(0);
const dx = new Array(n - 1).fill(0);
for (let i = 0; i < n - 1; i++) {
dx[i] = Math.max(1e-6, curve[i + 1].x - curve[i].x);
delta[i] = (curve[i + 1].y - curve[i].y) / dx[i];
}
tangents[0] = delta[0];
tangents[n - 1] = delta[n - 2];
for (let i = 1; i < n - 1; i++) {
if (delta[i - 1] * delta[i] <= 0) {
tangents[i] = 0;
} else {
const w1 = 2 * dx[i] + dx[i - 1];
const w2 = dx[i] + 2 * dx[i - 1];
tangents[i] = (w1 + w2) / (w1 / delta[i - 1] + w2 / delta[i]);
}
}
for (let i = 0; i < n - 1; i++) {
if (Math.abs(delta[i]) < 1e-6) {
tangents[i] = 0;
tangents[i + 1] = 0;
} else {
let alpha = tangents[i] / delta[i];
let beta = tangents[i + 1] / delta[i];
const sum = alpha * alpha + beta * beta;
if (sum > 9) {
const tau = 3 / Math.sqrt(sum);
alpha *= tau;
beta *= tau;
tangents[i] = alpha * delta[i];
tangents[i + 1] = beta * delta[i];
}
}
}
return tangents;
}
sampleSmoothCurve(curve, t, tangents) {
if (!curve || curve.length === 0) return t;
const n = curve.length;
if (!tangents || tangents.length !== n) {
tangents = this.computeTangents(curve);
}
if (t <= curve[0].x) return curve[0].y;
if (t >= curve[n - 1].x) return curve[n - 1].y;
let idx = 1;
for (; idx < n; idx++) {
if (t <= curve[idx].x) break;
}
const p0 = curve[idx - 1];
const p1 = curve[idx];
const m0 = tangents[idx - 1] ?? 0;
const m1 = tangents[idx] ?? 0;
const span = p1.x - p0.x || 1e-6;
const u = (t - p0.x) / span;
const h00 = (2 * u ** 3) - (3 * u ** 2) + 1;
const h10 = u ** 3 - 2 * u ** 2 + u;
const h01 = (-2 * u ** 3) + (3 * u ** 2);
const h11 = u ** 3 - u ** 2;
const value = h00 * p0.y + h10 * span * m0 + h01 * p1.y + h11 * span * m1;
return clamp01(value);
}
getActiveCurve() {
return this.curves[this.activeChannel];
}
addPoint(x, y) {
const points = this.getActiveCurve();
let insertIndex = points.findIndex(point => x < point.x);
if (insertIndex === -1) {
points.push({ x, y });
insertIndex = points.length - 1;
} else {
points.splice(insertIndex, 0, { x, y });
}
this.rebuildChannelLUT(this.activeChannel);
this.draw();
this.curveDirty = true;
this.notifyChange();
return insertIndex;
}
updatePoint(index, x, y) {
const points = this.getActiveCurve();
const point = points[index];
if (!point) return;
const originalX = point.x;
const originalY = point.y;
if (index === 0) {
point.x = 0;
point.y = clamp01(y);
} else if (index === points.length - 1) {
point.x = 1;
point.y = clamp01(y);
} else {
const minX = points[index - 1].x + 0.01;
const maxX = points[index + 1].x - 0.01;
point.x = clamp01(Math.min(Math.max(x, minX), maxX));
point.y = clamp01(y);
}
if (Math.abs(originalX - point.x) < 0.0001 && Math.abs(originalY - point.y) < 0.0001) {
return;
}
this.rebuildChannelLUT(this.activeChannel);
this.draw();
this.curveDirty = true;
this.notifyChange();
}
removePoint(index) {
const points = this.getActiveCurve();
if (index <= 0 || index >= points.length - 1) return;
points.splice(index, 1);
this.rebuildChannelLUT(this.activeChannel);
this.draw();
this.notifyChange();
this.notifyCommit();
this.curveDirty = false;
}
getPointerPosition(event) {
const rect = this.canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return null;
const x = clamp01((event.clientX - rect.left) / rect.width);
const y = clamp01(1 - (event.clientY - rect.top) / rect.height);
return { x, y };
}
findPointIndex(pos, threshold = 10) {
if (!pos) return -1;
const points = this.getActiveCurve();
const targetX = pos.x * this.displayWidth;
const targetY = (1 - pos.y) * this.displayHeight;
for (let i = 0; i < points.length; i++) {
const pt = points[i];
const px = pt.x * this.displayWidth;
const py = (1 - pt.y) * this.displayHeight;
const dist = Math.hypot(px - targetX, py - targetY);
if (dist <= threshold) return i;
}
return -1;
}
onPointerDown(event) {
if (event.button !== 0) return;
const pos = this.getPointerPosition(event);
if (!pos) return;
event.preventDefault();
let idx = this.findPointIndex(pos);
if (idx === -1) {
idx = this.addPoint(pos.x, pos.y);
}
this.dragIndex = idx;
this.isDragging = true;
this.updatePoint(idx, pos.x, pos.y);
}
onPointerMove(event) {
if (!this.isDragging || this.dragIndex === null) return;
const pos = this.getPointerPosition(event);
if (!pos) return;
event.preventDefault();
this.updatePoint(this.dragIndex, pos.x, pos.y);
}
onPointerUp() {
if (!this.isDragging) return;
this.isDragging = false;
this.dragIndex = null;
if (this.curveDirty) {
this.curveDirty = false;
this.notifyCommit();
}
}
onDoubleClick(event) {
const pos = this.getPointerPosition(event);
if (!pos) return;
const idx = this.findPointIndex(pos, 8);
if (idx > 0 && idx < this.getActiveCurve().length - 1) {
this.removePoint(idx);
}
}
getChannelColor() {
return CHANNEL_COLORS[this.activeChannel] || "#ffffff";
}
draw() {
if (!this.ctx) return;
const ctx = this.ctx;
const w = this.displayWidth;
const h = this.displayHeight;
ctx.clearRect(0, 0, w, h);
this.drawGrid(ctx, w, h);
this.drawCurve(ctx, w, h);
this.drawPoints(ctx, w, h);
}
drawGrid(ctx, w, h) {
ctx.fillStyle = "rgba(0,0,0,0.5)";
ctx.fillRect(0, 0, w, h);
ctx.strokeStyle = "rgba(255,255,255,0.08)";
ctx.lineWidth = 1;
for (let i = 1; i < 4; i++) {
const x = (w / 4) * i;
const y = (h / 4) * i;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, h);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
}
drawCurve(ctx, w, h) {
const points = this.getActiveCurve();
if (!points?.length) return;
const tangents = this.curveTangents[this.activeChannel] || this.computeTangents(points);
ctx.strokeStyle = this.getChannelColor();
ctx.lineWidth = 2;
ctx.beginPath();
const steps = 128;
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const value = this.sampleSmoothCurve(points, t, tangents);
const x = t * w;
const y = (1 - value) * h;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
drawPoints(ctx, w, h) {
const points = this.getActiveCurve();
ctx.fillStyle = "#000";
ctx.lineWidth = 2;
ctx.strokeStyle = this.getChannelColor();
points.forEach(pt => {
const x = pt.x * w;
const y = (1 - pt.y) * h;
ctx.beginPath();
ctx.arc(x, y, 5, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
});
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,132 +0,0 @@
import { ImageEditor } from "./editor.js";
import { IMAGE_EDITOR_SUBFOLDER } from "./constants.js";
import {
parseImageWidgetValue,
extractFilenameFromSrc,
buildEditorFilename,
buildImageReference,
updateWidgetWithRef,
createImageURLFromRef,
setImageSource,
refreshComboLists,
} from "./reference.js";
export function registerImageEditorExtension(app, api) {
app.registerExtension({
name: "SDVN.ImageEditor",
async beforeRegisterNodeDef(nodeType) {
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
if (this.imgs && this.imgs.length > 0) {
options.push({
content: "🎨 Image Editor",
callback: () => {
const img = this.imgs[this.imgs.length - 1];
let src = null;
if (img && img.src) src = img.src;
else if (img && img.image) src = img.image.src;
if (src) {
new ImageEditor(src, async (blob) => {
const formData = new FormData();
const inferredName = extractFilenameFromSrc(src);
const editorName = buildEditorFilename(inferredName);
formData.append("image", blob, editorName);
formData.append("overwrite", "false");
formData.append("type", "input");
formData.append("subfolder", IMAGE_EDITOR_SUBFOLDER);
try {
const resp = await api.fetchApi("/upload/image", {
method: "POST",
body: formData,
});
const data = await resp.json();
const ref = buildImageReference(data, {
type: "input",
subfolder: IMAGE_EDITOR_SUBFOLDER,
filename: editorName,
});
const imageWidget = this.widgets?.find?.(
(w) => w.name === "image" || w.type === "image"
);
if (imageWidget) {
updateWidgetWithRef(this, imageWidget, ref);
}
const newSrc = createImageURLFromRef(api, ref);
if (newSrc) {
setImageSource(img, newSrc);
app.graph.setDirtyCanvas(true);
}
await refreshComboLists(app);
console.info("[SDVN.ImageEditor] Image saved to input folder:", data?.name || editorName);
} catch (e) {
console.error("[SDVN.ImageEditor] Upload failed", e);
}
});
}
},
});
} else if (this.widgets) {
const imageWidget = this.widgets.find((w) => w.name === "image" || w.type === "image");
if (imageWidget && imageWidget.value) {
options.push({
content: "🎨 Image Editor",
callback: () => {
const parsed = parseImageWidgetValue(imageWidget.value);
if (!parsed.filename) {
console.warn("[SDVN.ImageEditor] Image not available for editing.");
return;
}
const src = api.apiURL(
`/view?filename=${encodeURIComponent(parsed.filename)}&type=${parsed.type}&subfolder=${encodeURIComponent(
parsed.subfolder
)}`
);
new ImageEditor(src, async (blob) => {
const formData = new FormData();
const newName = buildEditorFilename(parsed.filename);
formData.append("image", blob, newName);
formData.append("overwrite", "false");
formData.append("type", "input");
formData.append("subfolder", IMAGE_EDITOR_SUBFOLDER);
try {
const resp = await api.fetchApi("/upload/image", {
method: "POST",
body: formData,
});
const data = await resp.json();
const ref = buildImageReference(data, {
type: "input",
subfolder: IMAGE_EDITOR_SUBFOLDER,
filename: newName,
});
if (imageWidget) {
updateWidgetWithRef(this, imageWidget, ref);
}
const newSrc = createImageURLFromRef(api, ref);
if (this.imgs && this.imgs.length > 0) {
this.imgs.forEach((img) => setImageSource(img, newSrc));
}
this.setDirtyCanvas?.(true, true);
app.graph.setDirtyCanvas(true, true);
await refreshComboLists(app);
} catch (e) {
console.error("[SDVN.ImageEditor] Upload failed", e);
}
});
},
});
}
}
return getExtraMenuOptions?.apply(this, arguments);
};
},
});
}

View file

@ -1,149 +0,0 @@
export function buildImageReference(data, fallback = {}) {
const ref = {
filename: data?.name || data?.filename || fallback.filename,
subfolder: data?.subfolder ?? fallback.subfolder ?? "",
type: data?.type || fallback.type || "input",
};
if (!ref.filename) {
return null;
}
return ref;
}
export function buildAnnotatedLabel(ref) {
if (!ref?.filename) return "";
const path = ref.subfolder ? `${ref.subfolder}/${ref.filename}` : ref.filename;
return `${path} [${ref.type || "input"}]`;
}
export function parseImageWidgetValue(value) {
const defaults = { filename: null, subfolder: "", type: "input" };
if (!value) return defaults;
if (typeof value === "object") {
return {
filename: value.filename || null,
subfolder: value.subfolder || "",
type: value.type || "input",
};
}
const raw = value.toString().trim();
let type = "input";
let path = raw;
const match = raw.match(/\[([^\]]+)\]\s*$/);
if (match) {
type = match[1].trim() || "input";
path = raw.slice(0, match.index).trim();
}
path = path.replace(/^[\\/]+/, "");
const parts = path.split(/[\\/]/).filter(Boolean);
const filename = parts.pop() || null;
const subfolder = parts.join("/") || "";
return { filename, subfolder, type };
}
export function sanitizeFilenamePart(part) {
return (part || "")
.replace(/[\\/]/g, "_")
.replace(/[<>:"|?*\x00-\x1F]/g, "_")
.replace(/\s+/g, "_");
}
export function buildEditorFilename(sourceName) {
let name = sourceName ? sourceName.toString() : "";
name = name.split(/[\\/]/).pop() || "";
name = name.replace(/\.[^.]+$/, "");
name = sanitizeFilenamePart(name);
if (!name) name = `image_${Date.now()}`;
return `${name}.png`;
}
export function extractFilenameFromSrc(src) {
if (!src) return null;
try {
const url = new URL(src, window.location.origin);
return url.searchParams.get("filename");
} catch {
return null;
}
}
export function formatWidgetValueFromRef(ref, currentValue) {
if (currentValue && typeof currentValue === "object") {
return {
...currentValue,
filename: ref.filename,
subfolder: ref.subfolder,
type: ref.type,
};
}
return buildAnnotatedLabel(ref);
}
export function updateWidgetWithRef(node, widget, ref) {
if (!node || !widget || !ref) return;
const annotatedLabel = buildAnnotatedLabel(ref);
const storedValue = formatWidgetValueFromRef(ref, widget.value);
widget.value = storedValue;
widget.callback?.(storedValue);
if (widget.inputEl) {
widget.inputEl.value = annotatedLabel;
}
if (Array.isArray(node.widgets_values)) {
const idx = node.widgets?.indexOf?.(widget) ?? -1;
if (idx >= 0) {
node.widgets_values[idx] = annotatedLabel;
}
}
if (Array.isArray(node.inputs)) {
node.inputs.forEach(input => {
if (!input?.widget) return;
if (input.widget === widget || (widget.name && input.widget.name === widget.name)) {
input.widget.value = annotatedLabel;
if (input.widget.inputEl) {
input.widget.inputEl.value = annotatedLabel;
}
}
});
}
if (typeof annotatedLabel === "string" && widget.options?.values) {
const values = widget.options.values;
if (Array.isArray(values) && !values.includes(annotatedLabel)) {
values.push(annotatedLabel);
}
}
}
export function createImageURLFromRef(api, ref) {
if (!ref?.filename) return null;
const params = new URLSearchParams();
params.set("filename", ref.filename);
params.set("type", ref.type || "input");
params.set("subfolder", ref.subfolder || "");
params.set("t", Date.now().toString());
return api.apiURL(`/view?${params.toString()}`);
}
export function setImageSource(target, newSrc) {
if (!target || !newSrc) return;
if (target instanceof Image) {
target.src = newSrc;
} else if (target.image instanceof Image) {
target.image.src = newSrc;
} else if (target.img instanceof Image) {
target.img.src = newSrc;
}
}
export async function refreshComboLists(app) {
if (typeof app.refreshComboInNodes === "function") {
try {
await app.refreshComboInNodes();
} catch (err) {
console.warn("SDVN.ImageEditor: refreshComboInNodes failed", err);
}
}
}

View file

@ -1,435 +0,0 @@
const STYLE_ID = "sdvn-image-editor-style";
const IMAGE_EDITOR_CSS = `
:root {
--apix-bg: #0f0f0f;
--apix-panel: #1a1a1a;
--apix-border: #2a2a2a;
--apix-text: #e0e0e0;
--apix-text-dim: #888;
--apix-accent: #f5c518; /* Yellow accent from apix */
--apix-accent-hover: #ffd54f;
--apix-danger: #ff4444;
}
.apix-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: var(--apix-bg);
z-index: 10000;
display: flex;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
color: var(--apix-text);
overflow: hidden;
user-select: none;
}
/* Left Sidebar (Tools) */
.apix-sidebar-left {
width: 60px;
background: var(--apix-panel);
border-right: 1px solid var(--apix-border);
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 0;
gap: 15px;
z-index: 10;
}
/* Main Canvas Area */
.apix-main-area {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
background: #000;
overflow: hidden;
}
.apix-header {
height: 50px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
background: var(--apix-panel);
border-bottom: 1px solid var(--apix-border);
}
.apix-header-title {
font-weight: 700;
color: var(--apix-accent);
font-size: 18px;
display: flex;
align-items: center;
gap: 10px;
}
.apix-canvas-container {
flex: 1;
position: relative;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
cursor: grab;
}
.apix-canvas-container:active {
cursor: grabbing;
}
/* Bottom Bar (Zoom) */
.apix-bottom-bar {
height: 40px;
background: var(--apix-panel);
border-top: 1px solid var(--apix-border);
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
font-size: 12px;
}
/* Right Sidebar (Adjustments) */
.apix-sidebar-right {
width: 320px;
background: var(--apix-panel);
border-left: 1px solid var(--apix-border);
display: flex;
flex-direction: column;
z-index: 10;
height: 100vh;
max-height: 100vh;
overflow: hidden;
}
.apix-sidebar-scroll {
flex: 1;
overflow-y: auto;
padding-bottom: 20px;
scrollbar-width: thin;
scrollbar-color: var(--apix-accent) transparent;
}
.apix-sidebar-scroll::-webkit-scrollbar {
width: 6px;
}
.apix-sidebar-scroll::-webkit-scrollbar-thumb {
background: var(--apix-accent);
border-radius: 3px;
}
.apix-sidebar-scroll::-webkit-scrollbar-track {
background: transparent;
}
/* UI Components */
.apix-tool-btn {
width: 40px;
height: 40px;
border-radius: 8px;
border: 1px solid transparent;
background: transparent;
color: var(--apix-text-dim);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.apix-tool-btn:hover {
color: var(--apix-text);
background: rgba(255,255,255,0.05);
}
.apix-tool-btn.active {
color: #000;
background: var(--apix-accent);
}
.apix-tool-btn.icon-only svg {
width: 18px;
height: 18px;
}
.apix-sidebar-divider {
width: 24px;
height: 1px;
background: var(--apix-border);
margin: 12px 0;
}
.apix-panel-section {
border-bottom: 1px solid var(--apix-border);
}
.apix-panel-header {
padding: 15px;
font-weight: 600;
font-size: 13px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(255,255,255,0.02);
user-select: none;
}
.apix-panel-header span:first-child {
color: #8d8d8d;
font-weight: 700;
letter-spacing: 0.3px;
}
.apix-panel-header:hover {
background: rgba(255,255,255,0.05);
}
.apix-panel-content {
padding: 15px;
display: flex;
flex-direction: column;
gap: 15px;
}
.apix-panel-content.hidden {
display: none;
}
.apix-control-row {
display: flex;
flex-direction: column;
gap: 8px;
}
.apix-control-label {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--apix-text-dim);
letter-spacing: 0.2px;
font-weight: 600;
}
.apix-slider-meta {
display: flex;
align-items: center;
justify-content: flex-end;
}
.apix-slider-meta span {
min-width: 36px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.apix-slider-wrapper {
position: relative;
width: 100%;
padding-right: 26px;
}
.apix-slider-reset {
border: none;
background: transparent;
color: var(--apix-text-dim);
cursor: pointer;
width: 22px;
height: 22px;
position: absolute;
right: 0;
top: 56%;
transform: translateY(-50%);
opacity: 0.4;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.2s, color 0.2s;
}
.apix-slider-reset:hover {
opacity: 1;
color: var(--apix-accent);
}
.apix-slider-reset svg {
width: 12px;
height: 12px;
pointer-events: none;
}
.apix-curve-panel {
display: flex;
flex-direction: column;
gap: 10px;
}
.apix-curve-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 11px;
color: var(--apix-text-dim);
gap: 8px;
}
.apix-curve-channel-buttons {
display: flex;
gap: 6px;
}
.apix-curve-channel-btn {
border: 1px solid var(--apix-border);
background: transparent;
color: var(--apix-text-dim);
font-size: 10px;
padding: 2px 8px;
border-radius: 999px;
cursor: pointer;
transition: all 0.2s;
}
.apix-curve-channel-btn.active {
background: var(--apix-accent);
color: #000;
border-color: var(--apix-accent);
}
.apix-curve-reset {
border: none;
background: transparent;
color: var(--apix-accent);
font-size: 11px;
cursor: pointer;
padding: 0 4px;
}
.apix-curve-stage {
width: 100%;
height: 240px;
border: 1px solid var(--apix-border);
border-radius: 8px;
background: linear-gradient(180deg, rgba(255,255,255,0.05) 0%, rgba(0,0,0,0.25) 100%);
position: relative;
overflow: hidden;
}
.apix-curve-stage canvas {
width: 100%;
height: 100%;
display: block;
}
.apix-slider {
-webkit-appearance: none;
width: 100%;
height: 4px;
background: #333;
border-radius: 2px;
outline: none;
}
.apix-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--apix-accent);
cursor: pointer;
border: 2px solid #1a1a1a;
transition: transform 0.1s;
}
.apix-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
.apix-btn {
padding: 8px 16px;
border-radius: 6px;
border: none;
font-weight: 600;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}
.apix-btn-primary {
background: var(--apix-accent);
color: #000;
}
.apix-btn-primary:hover {
background: var(--apix-accent-hover);
}
.apix-btn-secondary {
background: #333;
color: #fff;
}
.apix-btn-secondary:hover {
background: #444;
}
.apix-btn-toggle.active {
background: var(--apix-accent);
color: #000;
}
.apix-hsl-swatches {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.apix-hsl-chip {
width: 26px;
height: 26px;
border-radius: 50%;
border: 2px solid transparent;
background: var(--chip-color, #fff);
cursor: pointer;
transition: transform 0.2s, border 0.2s;
}
.apix-hsl-chip.active {
border-color: var(--apix-accent);
transform: scale(1.05);
}
.apix-hsl-slider .apix-slider-meta span {
font-size: 11px;
color: var(--apix-text-dim);
}
.apix-hsl-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
align-items: center;
font-size: 11px;
color: var(--apix-text-dim);
}
.apix-hsl-reset {
border: none;
background: transparent;
color: var(--apix-accent);
cursor: pointer;
font-size: 11px;
}
.apix-sidebar-right {
position: relative;
}
.apix-footer {
padding: 20px;
border-top: 1px solid var(--apix-border);
display: flex;
justify-content: flex-end;
gap: 10px;
background: var(--apix-panel);
}
/* Crop Overlay */
.apix-crop-overlay {
position: absolute;
border: 1px solid rgba(255, 255, 255, 0.5);
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.7);
pointer-events: none;
display: none;
}
.apix-crop-handle {
position: absolute;
width: 12px;
height: 12px;
background: var(--apix-accent);
border: 1px solid #000;
pointer-events: auto;
z-index: 100;
}
/* Handle positions */
.handle-tl { top: -6px; left: -6px; cursor: nw-resize; }
.handle-tr { top: -6px; right: -6px; cursor: ne-resize; }
.handle-bl { bottom: -6px; left: -6px; cursor: sw-resize; }
.handle-br { bottom: -6px; right: -6px; cursor: se-resize; }
/* Edges */
.handle-t { top: -6px; left: 50%; transform: translateX(-50%); cursor: n-resize; }
.handle-b { bottom: -6px; left: 50%; transform: translateX(-50%); cursor: s-resize; }
.handle-l { left: -6px; top: 50%; transform: translateY(-50%); cursor: w-resize; }
.handle-r { right: -6px; top: 50%; transform: translateY(-50%); cursor: e-resize; }
`;
export function injectImageEditorStyles() {
if (document.getElementById(STYLE_ID)) {
return;
}
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = IMAGE_EDITOR_CSS;
document.head.appendChild(style);
}

Binary file not shown.

Binary file not shown.

832
app.py
View file

@ -586,10 +586,13 @@ def generate_image():
reference_image_path = ref_url
# Call the client
image_count = int(data.get('image_count', 4)) if not multipart else int(form.get('image_count', 4))
try:
whisk_result = whisk_client.generate_image_whisk(
prompt=api_prompt,
cookie_str=cookie_str,
image_count=image_count,
aspect_ratio=aspect_ratio,
resolution=resolution,
reference_image_path=reference_image_path
@ -598,26 +601,28 @@ def generate_image():
# Re-raise to be caught by the outer block
raise e
# Process result - whisk_client returns raw bytes
image_bytes = None
if isinstance(whisk_result, bytes):
image_bytes = whisk_result
# Process result - whisk_client returns List[bytes] or bytes (in case of fallback/legacy)
image_bytes_list = []
if isinstance(whisk_result, list):
image_bytes_list = whisk_result
elif isinstance(whisk_result, bytes):
image_bytes_list = [whisk_result]
elif isinstance(whisk_result, dict):
# Fallback if I ever change the client to return dict
if 'image_data' in whisk_result:
image_bytes = whisk_result['image_data']
image_bytes_list = [whisk_result['image_data']]
elif 'image_url' in whisk_result:
import requests
img_resp = requests.get(whisk_result['image_url'])
image_bytes = img_resp.content
image_bytes_list = [img_resp.content]
if not image_bytes:
if not image_bytes_list:
raise ValueError("No image data returned from Whisk.")
# Save and process image (Reuse existing logic)
image = Image.open(BytesIO(image_bytes))
png_info = PngImagePlugin.PngInfo()
# Process all images
saved_urls = []
saved_b64s = []
date_str = datetime.now().strftime("%Y%m%d")
search_pattern = os.path.join(GENERATED_DIR, f"whisk_{date_str}_*.png")
existing_files = glob.glob(search_pattern)
@ -626,41 +631,58 @@ def generate_image():
try:
basename = os.path.basename(f)
name_without_ext = os.path.splitext(basename)[0]
id_part = name_without_ext.split('_')[-1]
id_num = int(id_part)
if id_num > max_id:
max_id = id_num
except ValueError:
parts = name_without_ext.split('_')
# Check for batch_ID part
if len(parts) >= 3:
id_part = parts[2]
id_num = int(id_part)
if id_num > max_id:
max_id = id_num
elif len(parts) == 2:
pass
except (ValueError, IndexError):
continue
next_id = max_id + 1
filename = f"whisk_{date_str}_{next_id}.png"
filepath = os.path.join(GENERATED_DIR, filename)
rel_path = os.path.join('generated', filename)
image_url = url_for('static', filename=rel_path)
next_batch_id = max_id + 1
metadata = {
'prompt': prompt,
'note': note,
'processed_prompt': api_prompt,
'aspect_ratio': aspect_ratio or 'Auto',
'resolution': resolution,
'reference_images': final_reference_paths,
'model': 'whisk'
}
png_info.add_text('sdvn_meta', json.dumps(metadata))
for idx, img_bytes in enumerate(image_bytes_list):
image = Image.open(BytesIO(img_bytes))
png_info = PngImagePlugin.PngInfo()
buffer = BytesIO()
image.save(buffer, format='PNG', pnginfo=png_info)
final_bytes = buffer.getvalue()
filename = f"whisk_{date_str}_{next_batch_id}_{idx}.png"
filepath = os.path.join(GENERATED_DIR, filename)
rel_path = os.path.join('generated', filename)
image_url = url_for('static', filename=rel_path)
with open(filepath, 'wb') as f:
f.write(final_bytes)
metadata = {
'prompt': prompt,
'note': note,
'processed_prompt': api_prompt,
'aspect_ratio': aspect_ratio or 'Auto',
'resolution': resolution,
'reference_images': final_reference_paths,
'model': 'whisk',
'batch_id': next_batch_id,
'batch_index': idx
}
png_info.add_text('sdvn_meta', json.dumps(metadata))
buffer = BytesIO()
image.save(buffer, format='PNG', pnginfo=png_info)
final_bytes = buffer.getvalue()
with open(filepath, 'wb') as f:
f.write(final_bytes)
b64_str = base64.b64encode(final_bytes).decode('utf-8')
saved_urls.append(image_url)
saved_b64s.append(b64_str)
image_data = base64.b64encode(final_bytes).decode('utf-8')
return jsonify({
'image': image_url,
'image_data': image_data,
'image': saved_urls[0], # Legacy support
'images': saved_urls, # New support
'image_data': saved_b64s[0], # Legacy
'image_datas': saved_b64s, # New
'metadata': metadata,
})
@ -797,661 +819,135 @@ def get_prompts():
# Read prompts.json file
prompts_path = get_config_path('prompts.json')
if os.path.exists(prompts_path):
with open(prompts_path, 'r', encoding='utf-8') as f:
try:
builtin_prompts = json.load(f)
if isinstance(builtin_prompts, list):
for idx, prompt in enumerate(builtin_prompts):
prompt['builtinTemplateIndex'] = idx
prompt['tags'] = parse_tags_field(prompt.get('tags'))
all_prompts.extend(builtin_prompts)
except json.JSONDecodeError:
pass
# Read user_prompts.json file and mark as user templates
with open(prompts_path, 'r', encoding='utf-8') as f:
core_data = json.load(f)
if isinstance(core_data, list):
all_prompts.extend(core_data)
# Read user_prompts.json file
user_prompts_path = get_config_path('user_prompts.json')
if os.path.exists(user_prompts_path):
try:
with open(user_prompts_path, 'r', encoding='utf-8') as f:
user_prompts = json.load(f)
if isinstance(user_prompts, list):
# Mark each user template and add index for editing
for idx, template in enumerate(user_prompts):
template['isUserTemplate'] = True
template['userTemplateIndex'] = idx
template['tags'] = parse_tags_field(template.get('tags'))
all_prompts.extend(user_prompts)
except json.JSONDecodeError:
pass # Ignore if empty or invalid
with open(user_prompts_path, 'r', encoding='utf-8') as f:
user_data = json.load(f)
if isinstance(user_data, list):
all_prompts.extend(user_data)
# Filter by category if specified
# Filter by category if provided
if category:
all_prompts = [p for p in all_prompts if p.get('category') == category]
filtered_prompts = [p for p in all_prompts if p.get('category') == category]
return jsonify(filtered_prompts)
favorites = load_template_favorites()
response = jsonify({'prompts': all_prompts, 'favorites': favorites})
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return response
return jsonify(all_prompts)
except Exception as e:
return jsonify({'error': str(e)}), 500
print(f"Error reading prompts: {e}")
return jsonify([])
@app.route('/template_favorite', methods=['POST'])
def template_favorite():
data = request.get_json() or {}
key = data.get('key')
favorite = data.get('favorite')
if not key or not isinstance(favorite, bool):
return jsonify({'error': 'Invalid favorite payload'}), 400
favorites = load_template_favorites()
if favorite:
if key not in favorites:
favorites.append(key)
else:
favorites = [item for item in favorites if item != key]
save_template_favorites(favorites)
return jsonify({'favorites': favorites})
@app.route('/gallery_favorites', methods=['GET'])
def get_gallery_favorites():
favorites = load_gallery_favorites()
return jsonify({'favorites': favorites})
@app.route('/toggle_gallery_favorite', methods=['POST'])
def toggle_gallery_favorite():
data = request.get_json() or {}
filename = data.get('filename')
source = data.get('source')
rel_path = data.get('path') or data.get('relative_path')
resolved_source, _, storage_key = resolve_gallery_target(source, filename, rel_path)
if not storage_key:
return jsonify({'error': 'Filename is required'}), 400
favorites = load_gallery_favorites()
legacy_key = os.path.basename(storage_key)
if storage_key in favorites or legacy_key in favorites:
favorites = [item for item in favorites if item not in (storage_key, legacy_key)]
is_favorite = False
else:
favorites.append(storage_key)
is_favorite = True
save_gallery_favorites(favorites)
return jsonify({'favorites': favorites, 'is_favorite': is_favorite, 'source': resolved_source})
@app.route('/save_template', methods=['POST'])
def save_template():
@app.route('/save_prompt', methods=['POST'])
def save_prompt():
data = request.get_json()
new_prompt = {
'act': data.get('act'),
'prompt': data.get('prompt'),
'category': 'User Saved',
'desc': data.get('desc', '')
}
user_prompts_path = get_config_path('user_prompts.json')
try:
import requests
from urllib.parse import urlparse
# Handle multipart form data
title = request.form.get('title')
prompt = request.form.get('prompt')
mode = request.form.get('mode', 'generate')
note = request.form.get('note', '')
category = request.form.get('category', 'User')
tags_field = request.form.get('tags')
tags = parse_tags_field(tags_field)
if not title or not prompt:
return jsonify({'error': 'Title and prompt are required'}), 400
# Handle preview image
preview_path = None
preview_dir = os.path.join(app.static_folder, 'preview')
os.makedirs(preview_dir, exist_ok=True)
# Check if file was uploaded
if 'preview' in request.files:
file = request.files['preview']
if file.filename:
ext = os.path.splitext(file.filename)[1] or '.png'
file.stream.seek(0)
file_bytes = file.read()
preview_filename = save_preview_image(
preview_dir=preview_dir,
extension=ext,
source_bytes=file_bytes
)
if preview_filename:
preview_path = url_for('static', filename=f'preview/{preview_filename}')
# If no file uploaded, check if URL/path provided
if not preview_path:
preview_url = request.form.get('preview_path')
if preview_url:
try:
# Check if it's a URL or local path
if preview_url.startswith('http://') or preview_url.startswith('https://'):
# Download from URL
response = requests.get(preview_url, timeout=10)
response.raise_for_status()
# Determine extension from content-type or URL
content_type = response.headers.get('content-type', '')
if 'image/png' in content_type:
ext = '.png'
elif 'image/jpeg' in content_type or 'image/jpg' in content_type:
ext = '.jpg'
elif 'image/webp' in content_type:
ext = '.webp'
else:
# Try to get from URL
parsed = urlparse(preview_url)
ext = os.path.splitext(parsed.path)[1] or '.png'
preview_filename = save_preview_image(
preview_dir=preview_dir,
extension=ext,
source_bytes=response.content
)
if preview_filename:
preview_path = url_for('static', filename=f'preview/{preview_filename}')
else:
preview_path = preview_url
elif preview_url.startswith('/static/'):
# Local path - copy to preview folder
rel_path = preview_url.split('/static/')[1]
source_path = os.path.join(app.static_folder, rel_path)
if os.path.exists(source_path):
ext = os.path.splitext(source_path)[1] or '.png'
preview_filename = save_preview_image(
preview_dir=preview_dir,
extension=ext,
source_path=source_path
)
if preview_filename:
preview_path = url_for('static', filename=f'preview/{preview_filename}')
else:
preview_path = preview_url
else:
# File doesn't exist, use original path
preview_path = preview_url
else:
# Use as-is if it's already a valid path
preview_path = preview_url
except Exception as e:
print(f"Error processing preview image URL: {e}")
# Use the original URL if processing fails
preview_path = preview_url
new_template = {
'title': title,
'prompt': prompt,
'note': note,
'mode': mode,
'category': category,
'preview': preview_path,
'tags': tags
}
# Save to user_prompts.json
user_prompts_path = os.path.join(os.path.dirname(__file__), 'user_prompts.json')
user_prompts = []
existing_prompts = []
if os.path.exists(user_prompts_path):
try:
with open(user_prompts_path, 'r', encoding='utf-8') as f:
content = f.read()
if content.strip():
user_prompts = json.loads(content)
except json.JSONDecodeError:
pass
user_prompts.append(new_template)
with open(user_prompts_path, 'r', encoding='utf-8') as f:
existing_prompts = json.load(f)
existing_prompts.append(new_prompt)
with open(user_prompts_path, 'w', encoding='utf-8') as f:
json.dump(user_prompts, f, indent=4, ensure_ascii=False)
return jsonify({'success': True, 'template': new_template})
except Exception as e:
print(f"Error saving template: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/update_template', methods=['POST'])
def update_template():
try:
import requests
from urllib.parse import urlparse
template_index = request.form.get('template_index')
builtin_index_raw = request.form.get('builtin_index')
builtin_index = None
try:
if builtin_index_raw:
builtin_index = int(builtin_index_raw)
except ValueError:
return jsonify({'error': 'Invalid builtin template index'}), 400
if template_index is None and builtin_index is None:
return jsonify({'error': 'Template index or builtin index is required'}), 400
if template_index is not None:
try:
template_index = int(template_index)
except ValueError:
return jsonify({'error': 'Invalid template index'}), 400
title = request.form.get('title')
prompt = request.form.get('prompt')
mode = request.form.get('mode', 'generate')
note = request.form.get('note', '')
category = request.form.get('category', 'User')
tags_field = request.form.get('tags')
tags = parse_tags_field(tags_field)
if not title or not prompt:
return jsonify({'error': 'Title and prompt are required'}), 400
preview_path = None
preview_dir = os.path.join(app.static_folder, 'preview')
os.makedirs(preview_dir, exist_ok=True)
if 'preview' in request.files:
file = request.files['preview']
if file.filename:
ext = os.path.splitext(file.filename)[1] or '.png'
file.stream.seek(0)
file_bytes = file.read()
preview_filename = save_preview_image(
preview_dir=preview_dir,
extension=ext,
source_bytes=file_bytes
)
if preview_filename:
preview_path = url_for('static', filename=f'preview/{preview_filename}')
if not preview_path:
preview_url = request.form.get('preview_path')
if preview_url:
try:
if preview_url.startswith('http://') or preview_url.startswith('https://'):
response = requests.get(preview_url, timeout=10)
response.raise_for_status()
content_type = response.headers.get('content-type', '')
if 'image/png' in content_type:
ext = '.png'
elif 'image/jpeg' in content_type or 'image/jpg' in content_type:
ext = '.jpg'
elif 'image/webp' in content_type:
ext = '.webp'
else:
parsed = urlparse(preview_url)
ext = os.path.splitext(parsed.path)[1] or '.png'
preview_filename = save_preview_image(
preview_dir=preview_dir,
extension=ext,
source_bytes=response.content
)
if preview_filename:
preview_path = url_for('static', filename=f'preview/{preview_filename}')
else:
preview_path = preview_url
elif preview_url.startswith('/static/'):
rel_path = preview_url.split('/static/')[1]
source_path = os.path.join(app.static_folder, rel_path)
if os.path.exists(source_path):
ext = os.path.splitext(source_path)[1] or '.png'
preview_filename = save_preview_image(
preview_dir=preview_dir,
extension=ext,
source_path=source_path
)
if preview_filename:
preview_path = url_for('static', filename=f'preview/{preview_filename}')
else:
preview_path = preview_url
else:
preview_path = preview_url
else:
preview_path = preview_url
except Exception as e:
print(f"Error processing preview image URL: {e}")
preview_path = preview_url
if builtin_index is not None:
prompts_path = os.path.join(os.path.dirname(__file__), 'prompts.json')
if not os.path.exists(prompts_path):
return jsonify({'error': 'Prompts file not found'}), 404
try:
with open(prompts_path, 'r', encoding='utf-8') as f:
builtin_prompts = json.load(f)
except json.JSONDecodeError:
return jsonify({'error': 'Unable to read prompts.json'}), 500
if not isinstance(builtin_prompts, list) or builtin_index < 0 or builtin_index >= len(builtin_prompts):
return jsonify({'error': 'Invalid builtin template index'}), 400
existing_template = builtin_prompts[builtin_index]
old_preview = existing_template.get('preview', '')
if preview_path and old_preview and '/preview/' in old_preview:
try:
old_filename = old_preview.split('/preview/')[-1]
old_filepath = os.path.join(preview_dir, old_filename)
if os.path.exists(old_filepath):
os.remove(old_filepath)
except Exception as e:
print(f"Error deleting old preview image: {e}")
existing_template['title'] = title
existing_template['prompt'] = prompt
existing_template['note'] = note
existing_template['mode'] = mode
existing_template['category'] = category
if preview_path:
existing_template['preview'] = preview_path
existing_template['tags'] = tags
builtin_prompts[builtin_index] = existing_template
with open(prompts_path, 'w', encoding='utf-8') as f:
json.dump(builtin_prompts, f, indent=4, ensure_ascii=False)
existing_template['builtinTemplateIndex'] = builtin_index
return jsonify({'success': True, 'template': existing_template})
# Fallback to user template update
user_prompts_path = os.path.join(os.path.dirname(__file__), 'user_prompts.json')
user_prompts = []
if os.path.exists(user_prompts_path):
try:
with open(user_prompts_path, 'r', encoding='utf-8') as f:
content = f.read()
if content.strip():
user_prompts = json.loads(content)
except json.JSONDecodeError:
pass
if template_index < 0 or template_index >= len(user_prompts):
return jsonify({'error': 'Invalid template index'}), 400
old_template = user_prompts[template_index]
old_preview = old_template.get('preview', '')
if preview_path and old_preview and '/preview/' in old_preview:
try:
old_filename = old_preview.split('/preview/')[-1]
old_filepath = os.path.join(preview_dir, old_filename)
if os.path.exists(old_filepath):
os.remove(old_filepath)
except Exception as e:
print(f"Error deleting old preview image: {e}")
user_prompts[template_index] = {
'title': title,
'prompt': prompt,
'note': note,
'mode': mode,
'category': category,
'preview': preview_path,
'tags': tags
}
with open(user_prompts_path, 'w', encoding='utf-8') as f:
json.dump(user_prompts, f, indent=4, ensure_ascii=False)
user_prompts[template_index]['isUserTemplate'] = True
user_prompts[template_index]['userTemplateIndex'] = template_index
return jsonify({'success': True, 'template': user_prompts[template_index]})
except Exception as e:
print(f"Error updating template: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/delete_template', methods=['POST'])
def delete_template():
try:
template_index = request.form.get('template_index')
if template_index is None:
return jsonify({'error': 'Template index is required'}), 400
try:
template_index = int(template_index)
except ValueError:
return jsonify({'error': 'Invalid template index'}), 400
user_prompts_path = os.path.join(os.path.dirname(__file__), 'user_prompts.json')
if not os.path.exists(user_prompts_path):
return jsonify({'error': 'User prompts file not found'}), 404
with open(user_prompts_path, 'r', encoding='utf-8') as f:
user_prompts = json.load(f)
if template_index < 0 or template_index >= len(user_prompts):
return jsonify({'error': 'Template not found'}), 404
template_to_delete = user_prompts[template_index]
# Delete preview image if it exists and is local
preview_path = template_to_delete.get('preview')
if preview_path and '/static/preview/' in preview_path:
# Extract filename
try:
filename = preview_path.split('/static/preview/')[1]
preview_dir = os.path.join(app.static_folder, 'preview')
filepath = os.path.join(preview_dir, filename)
if os.path.exists(filepath):
os.remove(filepath)
except Exception as e:
print(f"Error deleting preview image: {e}")
# Remove from list
del user_prompts[template_index]
# Save back
with open(user_prompts_path, 'w', encoding='utf-8') as f:
json.dump(user_prompts, f, indent=4, ensure_ascii=False)
json.dump(existing_prompts, f, ensure_ascii=False, indent=4)
return jsonify({'success': True})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/refine_prompt', methods=['POST'])
def refine_prompt():
@app.route('/save_template_favorite', methods=['POST'])
def save_template_fav():
data = request.get_json()
current_prompt = data.get('current_prompt')
instruction = data.get('instruction')
api_key = data.get('api_key') or os.environ.get('GOOGLE_API_KEY')
template_name = data.get('template')
if not template_name:
return jsonify({'error': 'Template name required'}), 400
if not api_key:
return jsonify({'error': 'API Key is required.'}), 401
favorites = load_template_favorites()
if template_name not in favorites:
favorites.insert(0, template_name)
save_template_favorites(favorites)
if not instruction:
return jsonify({'error': 'Instruction is required'}), 400
return jsonify({'success': True, 'favorites': favorites})
try:
client = genai.Client(api_key=api_key)
system_instruction = "You are an expert prompt engineer for image generation AI. Rewrite the prompt to incorporate the user's instruction while maintaining the original intent and improving quality. Return ONLY the new prompt text, no explanations."
prompt_content = f"Current prompt: {current_prompt}\nUser instruction: {instruction}\nNew prompt:"
print(f"Refining prompt with instruction: {instruction}")
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=[prompt_content],
config=types.GenerateContentConfig(
system_instruction=system_instruction,
temperature=0.7,
)
)
if response.text:
return jsonify({'refined_prompt': response.text.strip()})
else:
return jsonify({'error': 'No response from AI'}), 500
@app.route('/remove_template_favorite', methods=['POST'])
def remove_template_fav():
data = request.get_json()
template_name = data.get('template')
if not template_name:
return jsonify({'error': 'Template name required'}), 400
except Exception as e:
return jsonify({'error': str(e)}), 500
#Tun sever
@app.route('/download_image', methods=['POST'])
def download_image():
import requests
from urllib.parse import urlparse
data = request.get_json() or {}
url = data.get('url')
favorites = load_template_favorites()
if template_name in favorites:
favorites.remove(template_name)
save_template_favorites(favorites)
if not url:
return jsonify({'error': 'URL is required'}), 400
return jsonify({'success': True, 'favorites': favorites})
@app.route('/get_template_favorites')
def get_template_favs():
return jsonify(load_template_favorites())
@app.route('/save_gallery_favorite', methods=['POST'])
def save_gallery_fav():
data = request.get_json()
image_url = data.get('url')
if not image_url:
return jsonify({'error': 'URL required'}), 400
favorites = load_gallery_favorites()
if image_url not in favorites:
favorites.insert(0, image_url)
save_gallery_favorites(favorites)
return jsonify({'success': True, 'favorites': favorites})
@app.route('/remove_gallery_favorite', methods=['POST'])
def remove_gallery_fav():
data = request.get_json()
image_url = data.get('url')
if not image_url:
return jsonify({'error': 'URL required'}), 400
favorites = load_gallery_favorites()
if image_url in favorites:
favorites.remove(image_url)
save_gallery_favorites(favorites)
return jsonify({'success': True, 'favorites': favorites})
@app.route('/get_gallery_favorites')
def get_gallery_favs():
return jsonify(load_gallery_favorites())
def open_browser(url):
time.sleep(1.5)
print(f"Opening browser at {url}")
try:
download_url = url
# Check if it's a URL (http/https)
if url.startswith('http://') or url.startswith('https://'):
# Try to use gallery-dl to extract the image URL
try:
# -g: get URLs, -q: quiet
cmd = ['gallery-dl', '-g', '-q', url]
# Timeout to prevent hanging on slow sites
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
if result.returncode == 0:
urls = result.stdout.strip().split('\n')
if urls and urls[0] and urls[0].startswith('http'):
download_url = urls[0]
except Exception as e:
print(f"gallery-dl extraction failed (using direct URL): {e}")
# Fallback to using the original URL directly
# Download logic (for both direct URL and extracted URL)
if download_url.startswith('http://') or download_url.startswith('https://'):
response = requests.get(download_url, timeout=30)
response.raise_for_status()
content_type = response.headers.get('content-type', '')
ext = '.png'
if 'image/jpeg' in content_type: ext = '.jpg'
elif 'image/webp' in content_type: ext = '.webp'
elif 'image/gif' in content_type: ext = '.gif'
else:
parsed = urlparse(download_url)
ext = os.path.splitext(parsed.path)[1] or '.png'
filename = f"{uuid.uuid4()}{ext}"
filepath = os.path.join(UPLOADS_DIR, filename)
with open(filepath, 'wb') as f:
f.write(response.content)
rel_path = f"uploads/{filename}"
final_url = url_for('static', filename=rel_path)
return jsonify({'path': final_url, 'local_path': filepath})
else:
# Handle local file path
# Remove quotes if present
clean_path = url.strip('"\'')
if os.path.exists(clean_path):
ext = os.path.splitext(clean_path)[1] or '.png'
filename = f"{uuid.uuid4()}{ext}"
filepath = os.path.join(UPLOADS_DIR, filename)
shutil.copy2(clean_path, filepath)
rel_path = f"uploads/{filename}"
final_url = url_for('static', filename=rel_path)
return jsonify({'path': final_url, 'local_path': filepath})
else:
return jsonify({'error': 'File path not found on server'}), 404
except Exception as e:
print(f"Error downloading image: {e}")
return jsonify({'error': str(e)}), 500
def pinggy_thread(port,pinggy):
server = {
"Auto": "",
"USA": "us.",
"Europe": "eu.",
"Asia": "ap.",
"South America": "br.",
"Australia": "au."
}
sv = server[Sever_Pinggy]
import socket
while True:
time.sleep(0.5)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
result = sock.connect_ex(('127.0.0.1', port))
if result == 0:
break
sock.close()
try:
if pinggy != None:
if ":" in pinggy:
pinggy, ac, ps = pinggy.split(":")
cmd = ["ssh", "-p", "443", f"-R0:localhost:{port}", "-o", "StrictHostKeyChecking=no", "-o", "ServerAliveInterval=30", f"{pinggy}@{sv}pro.pinggy.io", f'\"b:{ac}:{ps}\"']
else:
cmd = ["ssh", "-p", "443", f"-R0:localhost:{port}", "-o", "StrictHostKeyChecking=no", "-o", "ServerAliveInterval=30", f"{pinggy}@{sv}pro.pinggy.io"]
else:
cmd = ["ssh", "-p", "443", "-L4300:localhost:4300", "-o", "StrictHostKeyChecking=no", "-o", "ServerAliveInterval=30", f"-R0:localhost:{port}", "free.pinggy.io"]
process = subprocess.Popen(cmd,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
for line in iter(process.stdout.readline, ''):
match = re.search(r'(https?://[^\s]+)', line)
if match:
url = match.group(1)
# Bỏ qua các link dashboard
if "dashboard.pinggy.io" in url:
continue
print(f"\033[92m🔗 Link online để sử dụng:\033[0m {url}")
if pinggy == None:
html="<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"
subprocess.run(['open', url])
except:
pass
if __name__ == '__main__':
# Use ANSI green text so the startup banner stands out in terminals
print("\033[32m" + "aPix Image Workspace running at:" + "\033[0m", flush=True)
print("\033[32m" + f"http://localhost:{port_sever}" + " " + "\033[0m", flush=True)
print("\033[32m" + f"http://127.0.0.1:{port_sever}" + "\033[0m", flush=True)
port_sever = 8888
# browser_thread = threading.Thread(target=open_browser, args=(f"http://127.0.0.1:{port_sever}",))
# browser_thread.start()
print("----------------------------------------------------------------")
print(" aPix v2.1 - STARTED")
print("----------------------------------------------------------------")
initialize_config_files()
app.run(debug=True, host='0.0.0.0', port=port_sever)
# Listen on all interfaces
app.run(host='0.0.0.0', port=port_sever, debug=True)

View file

@ -1,18 +0,0 @@
version: '3.8'
services:
app:
image: git.khoavo.myds.me/vndangkhoa/apix:v2
container_name: apix_container
ports:
- "8558:8888"
volumes:
- ./static/generated:/app/static/generated
- ./static/uploads:/app/static/uploads
- ./config:/app/config
environment:
- CONFIG_DIR=/app/config
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- WHISK_COOKIES=${WHISK_COOKIES:-}
restart: unless-stopped
pull_policy: always

View file

@ -1,17 +1,14 @@
version: '3.8'
services:
app:
build: .
platform: linux/amd64
image: git.khoavo.myds.me/vndangkhoa/apix:v2.3
container_name: sdvn-apix-python
restart: unless-stopped
ports:
- "8558:8888"
volumes:
- ./static:/app/static
- ./prompts.json:/app/prompts.json
- ./user_prompts.json:/app/user_prompts.json
- ./gallery_favorites.json:/app/gallery_favorites.json
environment:
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-} # Optional for Whisk
- WHISK_COOKIES=${WHISK_COOKIES:-}
restart: unless-stopped
- PYTHONUNBUFFERED=1
volumes:
- ./config:/app/config
- ./data/generated:/app/static/generated
- ./data/uploads:/app/static/uploads

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

View file

@ -1,4 +1,13 @@
[
{
"title": "Live Action Studio",
"preview": "https://images.unsplash.com/photo-1598550476439-c923097980d6?ixlib=rb-4.0.3&auto=format&fit=crop&w=500&q=60",
"prompt": "Tạo ảnh live action người thật nhân vật trong hình, sau đó làm cho nhân vật đang như đứng ở phim trường quay live action, nhân vật đang tạo dáng trong khi các nhân viên xung quanh đang chỉnh trang phục, ảnh toàn thân nhân vật, ảnh chụp bằng máy ảnh chất lượng cao, focus vào nhân vật chính, tiền cảnh mờ có camera và các thiết bị như đang chụp nén",
"author": "System Default",
"link": "",
"mode": "generate",
"category": "Cinematic"
},
{
"title": "Giải bài toán bằng ảnh chụp",
"preview": "https://linux.do/uploads/default/optimized/4X/1/5/1/1518d978c948fb70ab03c11537db1e1f5136249e_2_1000x1000.jpeg",

View file

@ -3,7 +3,7 @@
# Configuration
REGISTRY="git.khoavo.myds.me"
IMAGE_NAME="vndangkhoa/apix"
TAG="v4"
TAG="v2.3"
FULL_IMAGE="$REGISTRY/$IMAGE_NAME:$TAG"
echo "=== Building Docker Image for Linux/AMD64 ==="

View file

@ -1,65 +0,0 @@
@echo off
setlocal
cd /d "%~dp0"
if defined PYTHON_BIN goto :found_python
for /f "delims=" %%P in ('where python3 2^>nul') do (
set "PYTHON_BIN=%%~P"
goto :found_python
)
for /f "delims=" %%P in ('where python 2^>nul') do (
set "PYTHON_BIN=%%~P"
goto :found_python
)
for /f "delims=" %%P in ('py -3 -c "import sys; print(sys.executable)" 2^>nul') do (
set "PYTHON_BIN=%%~P"
goto :found_python
)
echo Error: Python not found.
echo Please install Python from https://www.python.org/downloads/
echo or install it via the Microsoft Store.
echo IMPORTANT: When installing, make sure to check "Add Python to PATH".
pause
exit /b 1
:found_python
if not exist ".venv" (
echo Creating virtual environment...
"%PYTHON_BIN%" -m venv .venv
if errorlevel 1 (
echo Error: Failed to create virtual environment.
pause
exit /b 1
)
)
echo Activating virtual environment...
call .venv\Scripts\activate.bat
if errorlevel 1 (
echo Error: Failed to activate virtual environment.
pause
exit /b 1
)
echo Installing dependencies...
pip install -r requirements.txt
if errorlevel 1 (
echo Error: Failed to install dependencies.
pause
exit /b 1
)
echo Starting application...
call .venv\Scripts\python.exe app.py
if errorlevel 1 (
echo Error: Application crashed or exited with an error.
pause
exit /b 1
)
echo Application finished successfully.
pause
endlocal

View file

@ -1,39 +0,0 @@
#!/bin/zsh
cd "$(dirname "$0")"
# Prefer python3 but fall back to python; allow overriding via env
PYTHON_BIN="${PYTHON_BIN:-$(command -v python3 || command -v python)}"
if [[ -z "$PYTHON_BIN" ]]; then
echo "Error: Python not found."
echo "Please install Python 3."
echo " - On macOS: brew install python3"
echo " - Or download from https://www.python.org/downloads/"
read -k 1 "key?Press any key to exit..."
exit 1
fi
# Create a virtual environment if missing, then activate it
# Create a virtual environment if missing, then activate it
if [[ ! -d ".venv" ]]; then
echo "Creating virtual environment..."
"$PYTHON_BIN" -m venv .venv || { echo "Error: Failed to create virtual environment."; read -k 1 "key?Press any key to exit..."; exit 1; }
fi
echo "Activating virtual environment..."
source .venv/bin/activate || { echo "Error: Failed to activate virtual environment."; read -k 1 "key?Press any key to exit..."; exit 1; }
# Ensure dependencies are available (skip reinstall if up-to-date)
echo "Installing dependencies..."
pip install -r requirements.txt || { echo "Error: Failed to install dependencies."; read -k 1 "key?Press any key to exit..."; exit 1; }
# Start the Flask app on port 8888
echo "Starting application..."
.venv/bin/python app.py
if [[ $? -ne 0 ]]; then
echo "Error: Application crashed or exited with an error."
read -k 1 "key?Press any key to exit..."
exit 1
fi
echo "Application finished successfully."
read -k 1 "key?Press any key to exit..."

View file

@ -1,33 +0,0 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")"
# Prefer python3 but fall back to python; allow override via environment
PYTHON_BIN="${PYTHON_BIN:-$(command -v python3 || command -v python)}"
if [[ -z "$PYTHON_BIN" ]]; then
echo "Error: Python not found."
echo "Please install Python 3."
echo " - On macOS: brew install python3"
echo " - On Linux: sudo apt install python3 (or equivalent for your distro)"
echo " - Or download from https://www.python.org/downloads/"
exit 1
fi
# Create a virtual environment if missing, then activate it
# Create a virtual environment if missing, then activate it
if [[ ! -d ".venv" ]]; then
echo "Creating virtual environment..."
"$PYTHON_BIN" -m venv .venv || { echo "Error: Failed to create virtual environment."; exit 1; }
fi
echo "Activating virtual environment..."
source .venv/bin/activate || { echo "Error: Failed to activate virtual environment."; exit 1; }
# Ensure dependencies are available
echo "Installing dependencies..."
pip install -r requirements.txt || { echo "Error: Failed to install dependencies."; exit 1; }
# Start the Flask app on port 8888
echo "Starting application..."
exec .venv/bin/python app.py || { echo "Error: Application exited with an error."; exit 1; }

View file

@ -237,6 +237,12 @@ document.addEventListener('DOMContentLoaded', () => {
formData.append('reference_image_paths', JSON.stringify(referencePaths));
}
// Add Image Count for Whisk
const imageCountInput = document.getElementById('image-count');
if (imageCountInput && apiModelSelect && apiModelSelect.value === 'whisk') {
formData.append('image_count', imageCountInput.value);
}
return formData;
}
@ -682,7 +688,12 @@ document.addEventListener('DOMContentLoaded', () => {
}
if (data.image) {
displayImage(data.image, data.image_data);
// Check if multiple images
if (data.images && data.images.length > 1) {
displayImage(data.images[0], data.image_datas ? data.image_datas[0] : null, data.images, data.image_datas);
} else {
displayImage(data.image, data.image_data);
}
gallery.load();
} else if (data.queue && data.prompts && Array.isArray(data.prompts)) {
// Backend returned more items - add them to queue
@ -1840,7 +1851,7 @@ document.addEventListener('DOMContentLoaded', () => {
setViewState('error');
}
function displayImage(imageUrl, imageData) {
function displayImage(imageUrl, imageData, allUrls = [], allDatas = []) {
let cacheBustedUrl = imageUrl;
if (!imageUrl.startsWith('blob:') && !imageUrl.startsWith('data:')) {
cacheBustedUrl = withCacheBuster(imageUrl);
@ -1863,6 +1874,9 @@ document.addEventListener('DOMContentLoaded', () => {
hasGeneratedImage = true; // Mark that we have an image
setViewState('result');
// Render variations if available
renderVariations(allUrls, allDatas);
// Persist image URL
try {
localStorage.setItem('gemini-app-last-image', imageUrl);
@ -1871,6 +1885,85 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
// Create container for variations
const variationsContainer = document.createElement('div');
variationsContainer.className = 'variations-container';
variationsContainer.style.cssText = `
display: flex;
gap: 8px;
padding: 10px;
justify-content: center;
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 100;
background: rgba(0,0,0,0.5);
border-radius: 12px;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
`;
// Insert into result state
const resultStateEl = document.getElementById('result-state');
if (resultStateEl) {
resultStateEl.appendChild(variationsContainer);
// Show on hover
resultStateEl.addEventListener('mouseenter', () => variationsContainer.style.opacity = '1');
resultStateEl.addEventListener('mouseleave', () => variationsContainer.style.opacity = '0');
variationsContainer.addEventListener('mouseenter', () => variationsContainer.style.opacity = '1'); // Keep visible when hovering container
variationsContainer.style.pointerEvents = 'auto';
}
function renderVariations(urls, datas) {
variationsContainer.innerHTML = '';
if (!urls || urls.length <= 1) {
variationsContainer.style.display = 'none';
return;
}
variationsContainer.style.display = 'flex';
variationsContainer.style.opacity = '1'; // Auto show when new batch arrives
urls.forEach((url, index) => {
const thumb = document.createElement('img');
thumb.src = datas && datas[index] ? `data:image/png;base64,${datas[index]}` : withCacheBuster(url);
thumb.className = 'variation-thumb';
thumb.style.cssText = `
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 6px;
cursor: pointer;
border: 2px solid transparent;
transition: transform 0.2s, border-color 0.2s;
`;
thumb.addEventListener('click', (e) => {
e.stopPropagation();
displayImage(url, datas ? datas[index] : null, urls, datas);
// Highlight active
variationsContainer.querySelectorAll('img').forEach(img => img.style.borderColor = 'transparent');
thumb.style.borderColor = 'var(--accent-color)';
});
// Highlight current
if (generatedImage.src.includes(datas[index]) || generatedImage.src.includes(url.split('?')[0])) {
thumb.style.borderColor = 'var(--accent-color)';
}
variationsContainer.appendChild(thumb);
});
// Auto hide after 5 seconds if not interacting
setTimeout(() => {
if (!resultStateEl.matches(':hover')) {
variationsContainer.style.opacity = '0';
}
}, 5000);
}
async function handleCanvasDropUrl(imageUrl) {
const cleanedUrl = imageUrl;
displayImage(cleanedUrl);

View file

@ -19,7 +19,7 @@
<aside class="sidebar">
<div class="sidebar-header">
<div class="brand">
<h1>aPix <span class="badge">v2.1</span></h1>
<h1>aPix <span class="badge">v2.3</span></h1>
</div>
<div class="sidebar-header-actions">
<button type="button" class="toolbar-info-btn info-icon-btn" data-popup-target="help"
@ -172,6 +172,16 @@
</div>
</div>
<div class="input-group" id="image-count-group">
<label for="image-count">Image Count (Whisk)</label>
<select id="image-count">
<option value="1">1 Image</option>
<option value="2">2 Images</option>
<option value="3">3 Images</option>
<option value="4" selected>4 Images</option>
</select>
</div>
<div class="input-group">
<label for="aspect-ratio">Aspect Ratio</label>
<select id="aspect-ratio">
@ -203,6 +213,10 @@
<span>Generate</span>
<div class="btn-shine"></div>
</button>
<button id="img2vid-btn" class="secondary-btn" style="margin-top: 0.5rem; opacity: 0.7;"
title="Coming Soon">
<span>Img2Vid (Coming Soon)</span>
</button>
</div>
</div>
</aside>
@ -497,9 +511,20 @@
<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>
<details class="cookie-guide"
style="margin-top: 0.5rem; font-size: 0.85rem; color: var(--text-secondary);">
<summary style="cursor: pointer; user-select: none;">Hướng dẫn lấy Cookie (Mobile & PC)
</summary>
<ul style="padding-left: 1.2rem; margin-top: 0.5rem; line-height: 1.4;">
<li><strong>PC:</strong> F12 > Network > F5 > Click request bất kỳ (vd: generate) > Copy
Header "Cookie".</li>
<li><strong>Android:</strong> Dùng <em>Kiwi Browser</em> > Cài đặt > Developer Tools >
Network.</li>
<li><strong>iOS:</strong> Dùng ứng dụng "Web Inspector" hoặc kết nối với Mac (Safari
Developer Mode).</li>
</ul>
</details>
</div>
<div class="input-group api-settings-input-group">
<label for="api-model">Model</label>

View file

@ -130,7 +130,7 @@ def upload_reference_image(image_path, cookies):
logger.error(f"Error uploading image: {e}")
raise e
def generate_image_whisk(prompt, cookie_str, **kwargs):
def generate_image_whisk(prompt, cookie_str, image_count=4, **kwargs):
cookies = parse_cookies(cookie_str)
if not cookies:
raise WhiskClientError("No valid cookies found")
@ -144,9 +144,6 @@ def generate_image_whisk(prompt, cookie_str, **kwargs):
media_generation_id = upload_reference_image(reference_image_path, cookies)
except Exception as e:
logger.error(f"Failed to upload reference image: {e}")
# Fallback to Text-to-Image? Or fail?
# If user wants reference, we should probably fail or warn.
# For now, let's log and continue (media_generation_id will be None -> T2I)
pass
aspect_ratio_map = {
@ -169,6 +166,10 @@ def generate_image_whisk(prompt, cookie_str, **kwargs):
# BRANCH: Use Recipe Endpoint if Reference Image exists
if media_generation_id:
target_endpoint = RECIPE_ENDPOINT
# Recipe endpoint doesn't strictly support candidatesCount in the same way,
# but the backend often generates 4 by default for Recipe too?
# Actually standard ImageFX recipes produce 4.
# We will assume it produces multiple and we collect them.
payload = {
"clientContext": {
"workflowId": str(uuid.uuid4()),
@ -190,27 +191,25 @@ def generate_image_whisk(prompt, cookie_str, **kwargs):
}
else:
# BRANCH: Use Generate Endpoint for Text-to-Image
# NOTE: Payload for generateImage is inferred to be userInput based.
# If this fails, we might need further inspection, but Recipe flow is the priority.
target_endpoint = GENERATE_ENDPOINT
payload = {
"userInput": {
"candidatesCount": 2,
"candidatesCount": image_count,
"prompts": [prompt],
"seed": seed
},
"clientContext": {
"workflowId": str(uuid.uuid4()),
"tool": "IMAGE_FX", # Usually ImageFX for T2I
"tool": "IMAGE_FX",
"sessionId": str(int(time.time() * 1000))
},
"modelInput": {
"modelNameType": "IMAGEN_3_5", # Usually Imagen 3 for ImageFX
"modelNameType": "IMAGEN_3_5",
"aspectRatio": aspect_ratio_enum
}
}
logger.info(f"Generating image. Endpoint: {target_endpoint}, Prompt: {prompt}")
logger.info(f"Generating image. Endpoint: {target_endpoint}, Prompt: {prompt}, Count: {image_count}")
try:
response = requests.post(
@ -230,8 +229,6 @@ def generate_image_whisk(prompt, cookie_str, **kwargs):
except (json.JSONDecodeError, WhiskClientError) as e:
if isinstance(e, WhiskClientError): raise e
# Additional T2I Fallback: If generateImage fails 400, try Recipe with empty media?
# Not implementing strictly to avoid loops, but helpful mental note.
raise WhiskClientError(f"Generation failed ({response.status_code}): {error_text}")
# Parse Response
@ -250,15 +247,14 @@ def generate_image_whisk(prompt, cookie_str, **kwargs):
import json
logger.error(f"WHISK DEBUG - Full Response: {json.dumps(json_resp)}")
debug_info = json.dumps(json_resp)
# check for common non-standard errors
if 'error' in json_resp:
err_msg = json_resp['error']
raise WhiskClientError(f"Whisk Error: {err_msg} | Valid Cookies? Check logs.")
# Return the full structure in the error so user can see it in UI
raise WhiskClientError(f"Whisk API returned NO IMAGES. Google says: {debug_info}")
return base64.b64decode(images[0])
# Return LIST of bytes
return [base64.b64decode(img_str) for img_str in images]
except requests.exceptions.Timeout:
raise WhiskClientError("Timout connecting to Google Whisk.")