Compare commits

..

2 commits

Author SHA1 Message Date
Khoa.vo
a7fcd71ed6 Update README for v2.3 2025-12-30 21:50:42 +07:00
Khoa.vo
fe2a3179a8 Update v2.3: Multi-image, Mobile Cookies, Img2Vid, Cleanup 2025-12-30 21:49:26 +07:00
24 changed files with 366 additions and 3480 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);
}

View file

@ -1,33 +1,56 @@
# aPix Image Workspace # aPix Image Workspace (v2.3)
## Tiếng Việt aPix is a lightweight Flask interface for generating high-quality images using **Google Gemini Image 3 Pro** (Nano Banana Pro) and **Google ImageFX (Whisk)**.
### Giới thiệu
aPix Image Workspace là một giao diện Flask nhẹ giúp bạn tạo hình ảnh bằng API Model Gemini Image 3 Pro (Nano Banana Pro). Bạn có thể gửi prompt, upload tài liệu tham khảo và điều chỉnh tỷ lệ khung hình/độ phân giải.
![Preview](./preview.jpeg) ## Features
### Người tạo - **Dual Models**: Support for `Gemini Image 3 Pro` and `Google ImageFX (Whisk)`.
- Người tạo: [Phạm Hưng](https://www.facebook.com/phamhungd/) - **Multi-Image Generation**: Generate up to 4 variations at once with Whisk.
- Group: [SDVN - Cộng đồng AI Art](https://www.facebook.com/groups/stablediffusion.vn/) - **Reference Image Support**: Upload reference images for style transfer and composition control.
- Website: [sdvn.vn](https://www.sdvn.vn) - **Smart Prompting**: Support for placeholders `{text}` and queue processing (`cat|dog|bird`).
- Donate: [sdvn.vn/donate](https://stablediffusion.vn/donate/) - **Zero-Config Persistence**: Built for NAS/Docker with automatic config initialization and data persistence.
- **Mobile Friendly**: Guides for retrieving cookies on Android/iOS included.
### Khởi chạy nhanh bằng `run_app` ## Quick Start (Docker / Synology NAS)
1. Nháy đúp vào `run_app.command` trên macOS, `run_app.sh` trên Linux, hoặc `run_app.bat` trên Windows để tự động tìm Python, tạo `.venv`, cài `requirements.txt` và khởi động `app.py`.
2. Mở `http://127.0.0.1:8888`, nhập prompt/tùy chọn rồi nhấn Generate.
3. Hình ảnh mới nằm trong `static/generated/`; `/gallery` thể hiện lịch sử.
### Sử dụng We recommend running aPix via Docker for the best experience.
1. Đặt biến môi trường `GOOGLE_API_KEY` với API key của Google GenAI hoặc nhập trực tiếp trong giao diện.
2. Mở trình duyệt tới `http://127.0.0.1:8888`, nhập prompt, chọn tùy chọn và nhấn Generate.
3. Hình ảnh: `static/generated` lưu nội dung mới nhất, còn `/gallery` trả về URL cho phần lịch sử.
### Cú pháp đặc biệt 1. **Create `docker-compose.yml`**:
Ứng dụng hỗ trợ cú pháp placeholder để tạo nhiều biến thể ảnh hoặc thay thế nội dung linh hoạt: ```yaml
version: '3.8'
services:
app:
image: git.khoavo.myds.me/vndangkhoa/apix:v2.3
container_name: sdvn-apix-python
restart: unless-stopped
ports:
- "8558:8888"
environment:
- PYTHONUNBUFFERED=1
volumes:
- ./config:/app/config
- ./data/generated:/app/static/generated
- ./data/uploads:/app/static/uploads
```
* **Placeholder:** Sử dụng `{text}` hoặc `[text]` trong prompt. Ví dụ: `A photo of a {animal} in the style of {style}`. 2. **Run**:
* **Trường Note:** Nội dung trong trường Note sẽ thay thế cho placeholder: ```bash
* **Thay thế đơn:** Nếu Note là `cat`, prompt sẽ thành `A photo of a cat...`. docker-compose up -d
* **Hàng đợi (Queue):** Nếu Note chứa ký tự `|` (ví dụ: `cat|dog|bird`), ứng dụng sẽ tự động tạo 3 ảnh lần lượt với `cat`, `dog`, và `bird`. ```
* **Nhiều dòng:** Nếu Note có nhiều dòng, mỗi dòng sẽ ứng với một lần tạo ảnh.
* **Mặc định:** Nếu Note để trống, placeholder sẽ giữ nguyên hoặc dùng giá trị mặc định nếu có (ví dụ `{cat|dog}` sẽ tạo 2 ảnh nếu Note trống). 3. **Access**: Open `http://your-nas-ip:8558`.
## Configuration
- **API Key**: Enter your Google Gemini API Key in the settings (optional if using Whisk).
- **Whisk Cookies**: For ImageFX, paste your `Cookie` string from `labs.google`.
- *Tip*: Instructions for getting cookies on Mobile/PC are available in the app settings.
## Credits
- **Creator**: [Phạm Hưng](https://www.facebook.com/phamhungd/)
- **Group**: [SDVN - Cộng đồng AI Art](https://www.facebook.com/groups/stablediffusion.vn/)
- **Website**: [sdvn.vn](https://www.sdvn.vn)
---
*Version 2.3 | Dockerized for optimal performance.*

Binary file not shown.

Binary file not shown.

780
app.py
View file

@ -586,10 +586,13 @@ def generate_image():
reference_image_path = ref_url reference_image_path = ref_url
# Call the client # Call the client
image_count = int(data.get('image_count', 4)) if not multipart else int(form.get('image_count', 4))
try: try:
whisk_result = whisk_client.generate_image_whisk( whisk_result = whisk_client.generate_image_whisk(
prompt=api_prompt, prompt=api_prompt,
cookie_str=cookie_str, cookie_str=cookie_str,
image_count=image_count,
aspect_ratio=aspect_ratio, aspect_ratio=aspect_ratio,
resolution=resolution, resolution=resolution,
reference_image_path=reference_image_path reference_image_path=reference_image_path
@ -598,25 +601,27 @@ def generate_image():
# Re-raise to be caught by the outer block # Re-raise to be caught by the outer block
raise e raise e
# Process result - whisk_client returns raw bytes # Process result - whisk_client returns List[bytes] or bytes (in case of fallback/legacy)
image_bytes = None image_bytes_list = []
if isinstance(whisk_result, bytes): if isinstance(whisk_result, list):
image_bytes = whisk_result image_bytes_list = whisk_result
elif isinstance(whisk_result, bytes):
image_bytes_list = [whisk_result]
elif isinstance(whisk_result, dict): elif isinstance(whisk_result, dict):
# Fallback if I ever change the client to return dict # Fallback if I ever change the client to return dict
if 'image_data' in whisk_result: 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: elif 'image_url' in whisk_result:
import requests import requests
img_resp = requests.get(whisk_result['image_url']) 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.") raise ValueError("No image data returned from Whisk.")
# Save and process image (Reuse existing logic) # Process all images
image = Image.open(BytesIO(image_bytes)) saved_urls = []
png_info = PngImagePlugin.PngInfo() saved_b64s = []
date_str = datetime.now().strftime("%Y%m%d") date_str = datetime.now().strftime("%Y%m%d")
search_pattern = os.path.join(GENERATED_DIR, f"whisk_{date_str}_*.png") search_pattern = os.path.join(GENERATED_DIR, f"whisk_{date_str}_*.png")
@ -626,15 +631,25 @@ def generate_image():
try: try:
basename = os.path.basename(f) basename = os.path.basename(f)
name_without_ext = os.path.splitext(basename)[0] name_without_ext = os.path.splitext(basename)[0]
id_part = name_without_ext.split('_')[-1] parts = name_without_ext.split('_')
# Check for batch_ID part
if len(parts) >= 3:
id_part = parts[2]
id_num = int(id_part) id_num = int(id_part)
if id_num > max_id: if id_num > max_id:
max_id = id_num max_id = id_num
except ValueError: elif len(parts) == 2:
pass
except (ValueError, IndexError):
continue continue
next_id = max_id + 1 next_batch_id = max_id + 1
filename = f"whisk_{date_str}_{next_id}.png"
for idx, img_bytes in enumerate(image_bytes_list):
image = Image.open(BytesIO(img_bytes))
png_info = PngImagePlugin.PngInfo()
filename = f"whisk_{date_str}_{next_batch_id}_{idx}.png"
filepath = os.path.join(GENERATED_DIR, filename) filepath = os.path.join(GENERATED_DIR, filename)
rel_path = os.path.join('generated', filename) rel_path = os.path.join('generated', filename)
image_url = url_for('static', filename=rel_path) image_url = url_for('static', filename=rel_path)
@ -646,7 +661,9 @@ def generate_image():
'aspect_ratio': aspect_ratio or 'Auto', 'aspect_ratio': aspect_ratio or 'Auto',
'resolution': resolution, 'resolution': resolution,
'reference_images': final_reference_paths, 'reference_images': final_reference_paths,
'model': 'whisk' 'model': 'whisk',
'batch_id': next_batch_id,
'batch_index': idx
} }
png_info.add_text('sdvn_meta', json.dumps(metadata)) png_info.add_text('sdvn_meta', json.dumps(metadata))
@ -657,10 +674,15 @@ def generate_image():
with open(filepath, 'wb') as f: with open(filepath, 'wb') as f:
f.write(final_bytes) f.write(final_bytes)
image_data = base64.b64encode(final_bytes).decode('utf-8') b64_str = base64.b64encode(final_bytes).decode('utf-8')
saved_urls.append(image_url)
saved_b64s.append(b64_str)
return jsonify({ return jsonify({
'image': image_url, 'image': saved_urls[0], # Legacy support
'image_data': image_data, 'images': saved_urls, # New support
'image_data': saved_b64s[0], # Legacy
'image_datas': saved_b64s, # New
'metadata': metadata, 'metadata': metadata,
}) })
@ -798,660 +820,134 @@ def get_prompts():
prompts_path = get_config_path('prompts.json') prompts_path = get_config_path('prompts.json')
if os.path.exists(prompts_path): if os.path.exists(prompts_path):
with open(prompts_path, 'r', encoding='utf-8') as f: with open(prompts_path, 'r', encoding='utf-8') as f:
try: core_data = json.load(f)
builtin_prompts = json.load(f) if isinstance(core_data, list):
if isinstance(builtin_prompts, list): all_prompts.extend(core_data)
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 # Read user_prompts.json file
user_prompts_path = get_config_path('user_prompts.json') user_prompts_path = get_config_path('user_prompts.json')
if os.path.exists(user_prompts_path): if os.path.exists(user_prompts_path):
try:
with open(user_prompts_path, 'r', encoding='utf-8') as f: with open(user_prompts_path, 'r', encoding='utf-8') as f:
user_prompts = json.load(f) user_data = json.load(f)
if isinstance(user_prompts, list): if isinstance(user_data, list):
# Mark each user template and add index for editing all_prompts.extend(user_data)
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
# Filter by category if specified # Filter by category if provided
if category: 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() return jsonify(all_prompts)
response = jsonify({'prompts': all_prompts, 'favorites': favorites})
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return response
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 print(f"Error reading prompts: {e}")
return jsonify([])
@app.route('/save_prompt', methods=['POST'])
@app.route('/template_favorite', methods=['POST']) def save_prompt():
def template_favorite(): data = request.get_json()
data = request.get_json() or {} new_prompt = {
key = data.get('key') 'act': data.get('act'),
favorite = data.get('favorite') 'prompt': data.get('prompt'),
'category': 'User Saved',
if not key or not isinstance(favorite, bool): 'desc': data.get('desc', '')
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():
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 = get_config_path('user_prompts.json')
user_prompts_path = os.path.join(os.path.dirname(__file__), 'user_prompts.json') try:
user_prompts = [] existing_prompts = []
if os.path.exists(user_prompts_path): if os.path.exists(user_prompts_path):
try:
with open(user_prompts_path, 'r', encoding='utf-8') as f: with open(user_prompts_path, 'r', encoding='utf-8') as f:
content = f.read() existing_prompts = json.load(f)
if content.strip():
user_prompts = json.loads(content)
except json.JSONDecodeError:
pass
user_prompts.append(new_template) existing_prompts.append(new_prompt)
with open(user_prompts_path, 'w', encoding='utf-8') as f: 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, '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)
return jsonify({'success': True}) return jsonify({'success': True})
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@app.route('/refine_prompt', methods=['POST']) @app.route('/save_template_favorite', methods=['POST'])
def refine_prompt(): def save_template_fav():
data = request.get_json() data = request.get_json()
current_prompt = data.get('current_prompt') template_name = data.get('template')
instruction = data.get('instruction') if not template_name:
api_key = data.get('api_key') or os.environ.get('GOOGLE_API_KEY') return jsonify({'error': 'Template name required'}), 400
if not api_key: favorites = load_template_favorites()
return jsonify({'error': 'API Key is required.'}), 401 if template_name not in favorites:
favorites.insert(0, template_name)
save_template_favorites(favorites)
if not instruction: return jsonify({'success': True, 'favorites': favorites})
return jsonify({'error': 'Instruction is required'}), 400
@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
favorites = load_template_favorites()
if template_name in favorites:
favorites.remove(template_name)
save_template_favorites(favorites)
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: try:
client = genai.Client(api_key=api_key) subprocess.run(['open', url])
except:
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." pass
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
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__': if __name__ == '__main__':
# Use ANSI green text so the startup banner stands out in terminals port_sever = 8888
print("\033[32m" + "aPix Image Workspace running at:" + "\033[0m", flush=True) # browser_thread = threading.Thread(target=open_browser, args=(f"http://127.0.0.1:{port_sever}",))
print("\033[32m" + f"http://localhost:{port_sever}" + " " + "\033[0m", flush=True) # browser_thread.start()
print("\033[32m" + f"http://127.0.0.1:{port_sever}" + "\033[0m", flush=True)
print("----------------------------------------------------------------") print("----------------------------------------------------------------")
print(" aPix v2.1 - STARTED") print(" aPix v2.1 - STARTED")
print("----------------------------------------------------------------") print("----------------------------------------------------------------")
initialize_config_files() # Listen on all interfaces
app.run(debug=True, host='0.0.0.0', port=port_sever) 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' version: '3.8'
services: services:
app: app:
build: . image: git.khoavo.myds.me/vndangkhoa/apix:v2.3
platform: linux/amd64 container_name: sdvn-apix-python
restart: unless-stopped
ports: ports:
- "8558:8888" - "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: environment:
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-} # Optional for Whisk - PYTHONUNBUFFERED=1
- WHISK_COOKIES=${WHISK_COOKIES:-} volumes:
restart: unless-stopped - ./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", "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", "preview": "https://linux.do/uploads/default/optimized/4X/1/5/1/1518d978c948fb70ab03c11537db1e1f5136249e_2_1000x1000.jpeg",

View file

@ -3,7 +3,7 @@
# Configuration # Configuration
REGISTRY="git.khoavo.myds.me" REGISTRY="git.khoavo.myds.me"
IMAGE_NAME="vndangkhoa/apix" IMAGE_NAME="vndangkhoa/apix"
TAG="v4" TAG="v2.3"
FULL_IMAGE="$REGISTRY/$IMAGE_NAME:$TAG" FULL_IMAGE="$REGISTRY/$IMAGE_NAME:$TAG"
echo "=== Building Docker Image for Linux/AMD64 ===" 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)); 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; return formData;
} }
@ -682,7 +688,12 @@ document.addEventListener('DOMContentLoaded', () => {
} }
if (data.image) { if (data.image) {
// 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); displayImage(data.image, data.image_data);
}
gallery.load(); gallery.load();
} else if (data.queue && data.prompts && Array.isArray(data.prompts)) { } else if (data.queue && data.prompts && Array.isArray(data.prompts)) {
// Backend returned more items - add them to queue // Backend returned more items - add them to queue
@ -1840,7 +1851,7 @@ document.addEventListener('DOMContentLoaded', () => {
setViewState('error'); setViewState('error');
} }
function displayImage(imageUrl, imageData) { function displayImage(imageUrl, imageData, allUrls = [], allDatas = []) {
let cacheBustedUrl = imageUrl; let cacheBustedUrl = imageUrl;
if (!imageUrl.startsWith('blob:') && !imageUrl.startsWith('data:')) { if (!imageUrl.startsWith('blob:') && !imageUrl.startsWith('data:')) {
cacheBustedUrl = withCacheBuster(imageUrl); cacheBustedUrl = withCacheBuster(imageUrl);
@ -1863,6 +1874,9 @@ document.addEventListener('DOMContentLoaded', () => {
hasGeneratedImage = true; // Mark that we have an image hasGeneratedImage = true; // Mark that we have an image
setViewState('result'); setViewState('result');
// Render variations if available
renderVariations(allUrls, allDatas);
// Persist image URL // Persist image URL
try { try {
localStorage.setItem('gemini-app-last-image', imageUrl); 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) { async function handleCanvasDropUrl(imageUrl) {
const cleanedUrl = imageUrl; const cleanedUrl = imageUrl;
displayImage(cleanedUrl); displayImage(cleanedUrl);

View file

@ -19,7 +19,7 @@
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<div class="brand"> <div class="brand">
<h1>aPix <span class="badge">v2.1</span></h1> <h1>aPix <span class="badge">v2.3</span></h1>
</div> </div>
<div class="sidebar-header-actions"> <div class="sidebar-header-actions">
<button type="button" class="toolbar-info-btn info-icon-btn" data-popup-target="help" <button type="button" class="toolbar-info-btn info-icon-btn" data-popup-target="help"
@ -172,6 +172,16 @@
</div> </div>
</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"> <div class="input-group">
<label for="aspect-ratio">Aspect Ratio</label> <label for="aspect-ratio">Aspect Ratio</label>
<select id="aspect-ratio"> <select id="aspect-ratio">
@ -203,6 +213,10 @@
<span>Generate</span> <span>Generate</span>
<div class="btn-shine"></div> <div class="btn-shine"></div>
</button> </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>
</div> </div>
</aside> </aside>
@ -497,9 +511,20 @@
<label for="whisk-cookies">Whisk Cookies (dành cho ImageFX)</label> <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..." <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> 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. <details class="cookie-guide"
</p> 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>
<div class="input-group api-settings-input-group"> <div class="input-group api-settings-input-group">
<label for="api-model">Model</label> <label for="api-model">Model</label>

View file

@ -130,7 +130,7 @@ def upload_reference_image(image_path, cookies):
logger.error(f"Error uploading image: {e}") logger.error(f"Error uploading image: {e}")
raise 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) cookies = parse_cookies(cookie_str)
if not cookies: if not cookies:
raise WhiskClientError("No valid cookies found") 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) media_generation_id = upload_reference_image(reference_image_path, cookies)
except Exception as e: except Exception as e:
logger.error(f"Failed to upload reference image: {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 pass
aspect_ratio_map = { aspect_ratio_map = {
@ -169,6 +166,10 @@ def generate_image_whisk(prompt, cookie_str, **kwargs):
# BRANCH: Use Recipe Endpoint if Reference Image exists # BRANCH: Use Recipe Endpoint if Reference Image exists
if media_generation_id: if media_generation_id:
target_endpoint = RECIPE_ENDPOINT 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 = { payload = {
"clientContext": { "clientContext": {
"workflowId": str(uuid.uuid4()), "workflowId": str(uuid.uuid4()),
@ -190,27 +191,25 @@ def generate_image_whisk(prompt, cookie_str, **kwargs):
} }
else: else:
# BRANCH: Use Generate Endpoint for Text-to-Image # 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 target_endpoint = GENERATE_ENDPOINT
payload = { payload = {
"userInput": { "userInput": {
"candidatesCount": 2, "candidatesCount": image_count,
"prompts": [prompt], "prompts": [prompt],
"seed": seed "seed": seed
}, },
"clientContext": { "clientContext": {
"workflowId": str(uuid.uuid4()), "workflowId": str(uuid.uuid4()),
"tool": "IMAGE_FX", # Usually ImageFX for T2I "tool": "IMAGE_FX",
"sessionId": str(int(time.time() * 1000)) "sessionId": str(int(time.time() * 1000))
}, },
"modelInput": { "modelInput": {
"modelNameType": "IMAGEN_3_5", # Usually Imagen 3 for ImageFX "modelNameType": "IMAGEN_3_5",
"aspectRatio": aspect_ratio_enum "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: try:
response = requests.post( response = requests.post(
@ -230,8 +229,6 @@ def generate_image_whisk(prompt, cookie_str, **kwargs):
except (json.JSONDecodeError, WhiskClientError) as e: except (json.JSONDecodeError, WhiskClientError) as e:
if isinstance(e, WhiskClientError): raise 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}") raise WhiskClientError(f"Generation failed ({response.status_code}): {error_text}")
# Parse Response # Parse Response
@ -250,15 +247,14 @@ def generate_image_whisk(prompt, cookie_str, **kwargs):
import json import json
logger.error(f"WHISK DEBUG - Full Response: {json.dumps(json_resp)}") logger.error(f"WHISK DEBUG - Full Response: {json.dumps(json_resp)}")
debug_info = json.dumps(json_resp) debug_info = json.dumps(json_resp)
# check for common non-standard errors
if 'error' in json_resp: if 'error' in json_resp:
err_msg = json_resp['error'] err_msg = json_resp['error']
raise WhiskClientError(f"Whisk Error: {err_msg} | Valid Cookies? Check logs.") 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}") 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: except requests.exceptions.Timeout:
raise WhiskClientError("Timout connecting to Google Whisk.") raise WhiskClientError("Timout connecting to Google Whisk.")