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