Compare commits
10 commits
5e0b3d5dba
...
63ad7cc21f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63ad7cc21f | ||
|
|
9e0f3b80b2 | ||
|
|
ac7303588f | ||
|
|
06cf2a6954 | ||
|
|
931b3642ea | ||
|
|
4a9f6764d9 | ||
|
|
0485681c76 | ||
|
|
b62da7f34b | ||
|
|
414052adb9 | ||
|
|
8f68bf431f |
30 changed files with 7106 additions and 160 deletions
BIN
.DS_Store
vendored
BIN
.DS_Store
vendored
Binary file not shown.
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -1,11 +1,7 @@
|
|||
/static/generated
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
*.DS_Store
|
||||
/.venv
|
||||
/static/uploads
|
||||
.DS_Store
|
||||
user_prompts.json
|
||||
/static/preview
|
||||
template_favorites.json
|
||||
|
|
|
|||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
FROM python:3.10-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8888
|
||||
|
||||
# Run the application
|
||||
CMD ["python", "app.py"]
|
||||
7
Image_editor_example/image_editor.js
Normal file
7
Image_editor_example/image_editor.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { injectImageEditorStyles } from "./image_editor_modules/styles.js";
|
||||
import { registerImageEditorExtension } from "./image_editor_modules/extension.js";
|
||||
|
||||
injectImageEditorStyles();
|
||||
registerImageEditorExtension(app, api);
|
||||
71
Image_editor_example/image_editor_modules/color.js
Normal file
71
Image_editor_example/image_editor_modules/color.js
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
export function clamp01(value) {
|
||||
return Math.min(1, Math.max(0, value));
|
||||
}
|
||||
|
||||
export function clamp255(value) {
|
||||
return Math.min(255, Math.max(0, value));
|
||||
}
|
||||
|
||||
export function rgbToHsl(r, g, b) {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h;
|
||||
let s;
|
||||
const l = (max + min) / 2;
|
||||
if (max === min) {
|
||||
h = 0;
|
||||
s = 0;
|
||||
} else {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0);
|
||||
break;
|
||||
case g:
|
||||
h = (b - r) / d + 2;
|
||||
break;
|
||||
default:
|
||||
h = (r - g) / d + 4;
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
return { h, s, l };
|
||||
}
|
||||
|
||||
export function hslToRgb(h, s, l) {
|
||||
let r;
|
||||
let g;
|
||||
let b;
|
||||
if (s === 0) {
|
||||
r = g = b = l;
|
||||
} else {
|
||||
const hue2rgb = (p, q, t) => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r = hue2rgb(p, q, h + 1 / 3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1 / 3);
|
||||
}
|
||||
return {
|
||||
r: Math.round(r * 255),
|
||||
g: Math.round(g * 255),
|
||||
b: Math.round(b * 255)
|
||||
};
|
||||
}
|
||||
|
||||
export function hueDistance(a, b) {
|
||||
let diff = Math.abs(a - b);
|
||||
diff = Math.min(diff, 1 - diff);
|
||||
return diff;
|
||||
}
|
||||
24
Image_editor_example/image_editor_modules/constants.js
Normal file
24
Image_editor_example/image_editor_modules/constants.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
export const ICONS = {
|
||||
crop: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6.13 1L6 16a2 2 0 0 0 2 2h15"></path><path d="M1 6.13L16 6a2 2 0 0 1 2 2v15"></path></svg>`,
|
||||
adjust: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>`,
|
||||
undo: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7v6h6"></path><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"></path></svg>`,
|
||||
redo: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 7v6h-6"></path><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 2.7"></path></svg>`,
|
||||
reset: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"></polyline><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path></svg>`,
|
||||
chevronDown: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>`,
|
||||
close: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`,
|
||||
flipH: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 7 3 3 7 3"></polyline><line x1="3" y1="3" x2="10" y2="10"></line><polyline points="21 17 21 21 17 21"></polyline><line x1="21" y1="21" x2="14" y2="14"></line></svg>`,
|
||||
flipV: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="7 21 3 21 3 17"></polyline><line x1="3" y1="21" x2="10" y2="14"></line><polyline points="17 3 21 3 21 7"></polyline><line x1="21" y1="3" x2="14" y2="10"></line></svg>`,
|
||||
rotate: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15A9 9 0 1 1 23 10"></path></svg>`
|
||||
};
|
||||
|
||||
export const HSL_COLORS = [
|
||||
{ id: "red", label: "Red", color: "#ff4b4b", center: 0 / 360, width: 0.12 },
|
||||
{ id: "orange", label: "Orange", color: "#ff884d", center: 30 / 360, width: 0.12 },
|
||||
{ id: "yellow", label: "Yellow", color: "#ffd84d", center: 50 / 360, width: 0.12 },
|
||||
{ id: "green", label: "Green", color: "#45d98e", center: 120 / 360, width: 0.12 },
|
||||
{ id: "cyan", label: "Cyan", color: "#30c4ff", center: 180 / 360, width: 0.12 },
|
||||
{ id: "blue", label: "Blue", color: "#2f7bff", center: 220 / 360, width: 0.12 },
|
||||
{ id: "magenta", label: "Magenta", color: "#c95bff", center: 300 / 360, width: 0.12 }
|
||||
];
|
||||
|
||||
export const IMAGE_EDITOR_SUBFOLDER = "image_editor";
|
||||
493
Image_editor_example/image_editor_modules/curve.js
Normal file
493
Image_editor_example/image_editor_modules/curve.js
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
import { clamp01 } from "./color.js";
|
||||
|
||||
const CHANNEL_COLORS = {
|
||||
rgb: "#ffffff",
|
||||
r: "#ff7070",
|
||||
g: "#70ffa0",
|
||||
b: "#72a0ff"
|
||||
};
|
||||
|
||||
export class CurveEditor {
|
||||
constructor({ canvas, channelButtons = [], resetButton, onChange, onCommit }) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext("2d");
|
||||
this.channelButtons = channelButtons;
|
||||
this.resetButton = resetButton;
|
||||
this.onChange = onChange;
|
||||
this.onCommit = onCommit;
|
||||
|
||||
this.channels = ["rgb", "r", "g", "b"];
|
||||
this.activeChannel = "rgb";
|
||||
this.curves = this.createDefaultCurves();
|
||||
this.curveTangents = {};
|
||||
this.channels.forEach(channel => (this.curveTangents[channel] = []));
|
||||
this.luts = this.buildAllLUTs();
|
||||
this.isDragging = false;
|
||||
this.dragIndex = null;
|
||||
this.curveDirty = false;
|
||||
this.displayWidth = this.canvas.clientWidth || 240;
|
||||
this.displayHeight = this.canvas.clientHeight || 240;
|
||||
|
||||
this.resizeObserver = null;
|
||||
this.handleResize = this.handleResize.bind(this);
|
||||
this.onPointerDown = this.onPointerDown.bind(this);
|
||||
this.onPointerMove = this.onPointerMove.bind(this);
|
||||
this.onPointerUp = this.onPointerUp.bind(this);
|
||||
this.onDoubleClick = this.onDoubleClick.bind(this);
|
||||
|
||||
window.addEventListener("resize", this.handleResize);
|
||||
this.canvas.addEventListener("mousedown", this.onPointerDown);
|
||||
window.addEventListener("mousemove", this.onPointerMove);
|
||||
window.addEventListener("mouseup", this.onPointerUp);
|
||||
this.canvas.addEventListener("dblclick", this.onDoubleClick);
|
||||
|
||||
this.attachChannelButtons();
|
||||
this.attachResetButton();
|
||||
this.handleResize();
|
||||
if (window.ResizeObserver) {
|
||||
this.resizeObserver = new ResizeObserver(() => this.handleResize());
|
||||
this.resizeObserver.observe(this.canvas);
|
||||
}
|
||||
this.draw();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.resizeObserver?.disconnect();
|
||||
window.removeEventListener("resize", this.handleResize);
|
||||
this.canvas.removeEventListener("mousedown", this.onPointerDown);
|
||||
window.removeEventListener("mousemove", this.onPointerMove);
|
||||
window.removeEventListener("mouseup", this.onPointerUp);
|
||||
this.canvas.removeEventListener("dblclick", this.onDoubleClick);
|
||||
}
|
||||
|
||||
attachChannelButtons() {
|
||||
this.channelButtons.forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const channel = btn.dataset.curveChannel;
|
||||
if (channel && this.channels.includes(channel)) {
|
||||
this.activeChannel = channel;
|
||||
this.updateChannelButtons();
|
||||
this.draw();
|
||||
}
|
||||
});
|
||||
});
|
||||
this.updateChannelButtons();
|
||||
}
|
||||
|
||||
attachResetButton() {
|
||||
if (!this.resetButton) return;
|
||||
this.resetButton.addEventListener("click", () => {
|
||||
this.resetChannel(this.activeChannel);
|
||||
this.notifyChange();
|
||||
this.notifyCommit();
|
||||
});
|
||||
}
|
||||
|
||||
updateChannelButtons() {
|
||||
this.channelButtons.forEach(btn => {
|
||||
const channel = btn.dataset.curveChannel;
|
||||
btn.classList.toggle("active", channel === this.activeChannel);
|
||||
});
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const width = Math.max(1, rect.width || 240);
|
||||
const height = Math.max(1, rect.height || 240);
|
||||
this.displayWidth = width;
|
||||
this.displayHeight = height;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
this.canvas.width = width * dpr;
|
||||
this.canvas.height = height * dpr;
|
||||
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
this.ctx.scale(dpr, dpr);
|
||||
this.draw();
|
||||
}
|
||||
|
||||
createDefaultCurve() {
|
||||
return [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 1 }
|
||||
];
|
||||
}
|
||||
|
||||
createDefaultCurves() {
|
||||
return {
|
||||
rgb: this.createDefaultCurve().map(p => ({ ...p })),
|
||||
r: this.createDefaultCurve().map(p => ({ ...p })),
|
||||
g: this.createDefaultCurve().map(p => ({ ...p })),
|
||||
b: this.createDefaultCurve().map(p => ({ ...p }))
|
||||
};
|
||||
}
|
||||
|
||||
cloneCurves(source = this.curves) {
|
||||
const clone = {};
|
||||
this.channels.forEach(channel => {
|
||||
clone[channel] = (source[channel] || this.createDefaultCurve()).map(p => ({ x: p.x, y: p.y }));
|
||||
});
|
||||
return clone;
|
||||
}
|
||||
|
||||
setState(state) {
|
||||
if (!state) return;
|
||||
const incoming = this.cloneCurves(state.curves || {});
|
||||
this.curves = incoming;
|
||||
if (state.activeChannel && this.channels.includes(state.activeChannel)) {
|
||||
this.activeChannel = state.activeChannel;
|
||||
}
|
||||
this.rebuildAllLUTs();
|
||||
this.updateChannelButtons();
|
||||
this.draw();
|
||||
}
|
||||
|
||||
getState() {
|
||||
return {
|
||||
curves: this.cloneCurves(),
|
||||
activeChannel: this.activeChannel
|
||||
};
|
||||
}
|
||||
|
||||
resetChannel(channel, emit = false) {
|
||||
if (!this.channels.includes(channel)) return;
|
||||
this.curves[channel] = this.createDefaultCurve().map(p => ({ ...p }));
|
||||
this.rebuildChannelLUT(channel);
|
||||
this.draw();
|
||||
if (emit) {
|
||||
this.notifyChange();
|
||||
this.notifyCommit();
|
||||
this.curveDirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
resetAll(emit = true) {
|
||||
this.channels.forEach(channel => {
|
||||
this.curves[channel] = this.createDefaultCurve().map(p => ({ ...p }));
|
||||
});
|
||||
this.rebuildAllLUTs();
|
||||
this.draw();
|
||||
if (emit) {
|
||||
this.notifyChange();
|
||||
this.notifyCommit();
|
||||
this.curveDirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
hasAdjustments() {
|
||||
return this.channels.some(channel => !this.isDefaultCurve(this.curves[channel]));
|
||||
}
|
||||
|
||||
isDefaultCurve(curve) {
|
||||
if (!curve || curve.length !== 2) return false;
|
||||
const [start, end] = curve;
|
||||
const epsilon = 0.0001;
|
||||
return Math.abs(start.x) < epsilon && Math.abs(start.y) < epsilon &&
|
||||
Math.abs(end.x - 1) < epsilon && Math.abs(end.y - 1) < epsilon;
|
||||
}
|
||||
|
||||
notifyChange() {
|
||||
this.onChange?.();
|
||||
}
|
||||
|
||||
notifyCommit() {
|
||||
this.onCommit?.();
|
||||
}
|
||||
|
||||
getLUTPack() {
|
||||
return {
|
||||
rgb: this.isDefaultCurve(this.curves.rgb) ? null : this.luts.rgb,
|
||||
r: this.isDefaultCurve(this.curves.r) ? null : this.luts.r,
|
||||
g: this.isDefaultCurve(this.curves.g) ? null : this.luts.g,
|
||||
b: this.isDefaultCurve(this.curves.b) ? null : this.luts.b,
|
||||
hasAdjustments: this.hasAdjustments()
|
||||
};
|
||||
}
|
||||
|
||||
buildAllLUTs() {
|
||||
const result = {};
|
||||
this.channels.forEach(channel => {
|
||||
const curve = this.curves[channel];
|
||||
const tangents = this.computeTangents(curve);
|
||||
this.curveTangents[channel] = tangents;
|
||||
result[channel] = this.buildCurveLUT(curve, tangents);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
rebuildAllLUTs() {
|
||||
this.luts = this.buildAllLUTs();
|
||||
}
|
||||
|
||||
rebuildChannelLUT(channel) {
|
||||
const curve = this.curves[channel];
|
||||
const tangents = this.computeTangents(curve);
|
||||
this.curveTangents[channel] = tangents;
|
||||
this.luts[channel] = this.buildCurveLUT(curve, tangents);
|
||||
}
|
||||
|
||||
buildCurveLUT(curve, tangents = null) {
|
||||
const curveTangents = tangents || this.computeTangents(curve);
|
||||
const lut = new Uint8ClampedArray(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const pos = i / 255;
|
||||
lut[i] = Math.round(clamp01(this.sampleSmoothCurve(curve, pos, curveTangents)) * 255);
|
||||
}
|
||||
return lut;
|
||||
}
|
||||
|
||||
computeTangents(curve) {
|
||||
const n = curve.length;
|
||||
if (n < 2) return new Array(n).fill(0);
|
||||
const tangents = new Array(n).fill(0);
|
||||
const delta = new Array(n - 1).fill(0);
|
||||
const dx = new Array(n - 1).fill(0);
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
dx[i] = Math.max(1e-6, curve[i + 1].x - curve[i].x);
|
||||
delta[i] = (curve[i + 1].y - curve[i].y) / dx[i];
|
||||
}
|
||||
tangents[0] = delta[0];
|
||||
tangents[n - 1] = delta[n - 2];
|
||||
for (let i = 1; i < n - 1; i++) {
|
||||
if (delta[i - 1] * delta[i] <= 0) {
|
||||
tangents[i] = 0;
|
||||
} else {
|
||||
const w1 = 2 * dx[i] + dx[i - 1];
|
||||
const w2 = dx[i] + 2 * dx[i - 1];
|
||||
tangents[i] = (w1 + w2) / (w1 / delta[i - 1] + w2 / delta[i]);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
if (Math.abs(delta[i]) < 1e-6) {
|
||||
tangents[i] = 0;
|
||||
tangents[i + 1] = 0;
|
||||
} else {
|
||||
let alpha = tangents[i] / delta[i];
|
||||
let beta = tangents[i + 1] / delta[i];
|
||||
const sum = alpha * alpha + beta * beta;
|
||||
if (sum > 9) {
|
||||
const tau = 3 / Math.sqrt(sum);
|
||||
alpha *= tau;
|
||||
beta *= tau;
|
||||
tangents[i] = alpha * delta[i];
|
||||
tangents[i + 1] = beta * delta[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return tangents;
|
||||
}
|
||||
|
||||
sampleSmoothCurve(curve, t, tangents) {
|
||||
if (!curve || curve.length === 0) return t;
|
||||
const n = curve.length;
|
||||
if (!tangents || tangents.length !== n) {
|
||||
tangents = this.computeTangents(curve);
|
||||
}
|
||||
if (t <= curve[0].x) return curve[0].y;
|
||||
if (t >= curve[n - 1].x) return curve[n - 1].y;
|
||||
let idx = 1;
|
||||
for (; idx < n; idx++) {
|
||||
if (t <= curve[idx].x) break;
|
||||
}
|
||||
const p0 = curve[idx - 1];
|
||||
const p1 = curve[idx];
|
||||
const m0 = tangents[idx - 1] ?? 0;
|
||||
const m1 = tangents[idx] ?? 0;
|
||||
const span = p1.x - p0.x || 1e-6;
|
||||
const u = (t - p0.x) / span;
|
||||
const h00 = (2 * u ** 3) - (3 * u ** 2) + 1;
|
||||
const h10 = u ** 3 - 2 * u ** 2 + u;
|
||||
const h01 = (-2 * u ** 3) + (3 * u ** 2);
|
||||
const h11 = u ** 3 - u ** 2;
|
||||
const value = h00 * p0.y + h10 * span * m0 + h01 * p1.y + h11 * span * m1;
|
||||
return clamp01(value);
|
||||
}
|
||||
|
||||
getActiveCurve() {
|
||||
return this.curves[this.activeChannel];
|
||||
}
|
||||
|
||||
addPoint(x, y) {
|
||||
const points = this.getActiveCurve();
|
||||
let insertIndex = points.findIndex(point => x < point.x);
|
||||
if (insertIndex === -1) {
|
||||
points.push({ x, y });
|
||||
insertIndex = points.length - 1;
|
||||
} else {
|
||||
points.splice(insertIndex, 0, { x, y });
|
||||
}
|
||||
this.rebuildChannelLUT(this.activeChannel);
|
||||
this.draw();
|
||||
this.curveDirty = true;
|
||||
this.notifyChange();
|
||||
return insertIndex;
|
||||
}
|
||||
|
||||
updatePoint(index, x, y) {
|
||||
const points = this.getActiveCurve();
|
||||
const point = points[index];
|
||||
if (!point) return;
|
||||
const originalX = point.x;
|
||||
const originalY = point.y;
|
||||
if (index === 0) {
|
||||
point.x = 0;
|
||||
point.y = clamp01(y);
|
||||
} else if (index === points.length - 1) {
|
||||
point.x = 1;
|
||||
point.y = clamp01(y);
|
||||
} else {
|
||||
const minX = points[index - 1].x + 0.01;
|
||||
const maxX = points[index + 1].x - 0.01;
|
||||
point.x = clamp01(Math.min(Math.max(x, minX), maxX));
|
||||
point.y = clamp01(y);
|
||||
}
|
||||
if (Math.abs(originalX - point.x) < 0.0001 && Math.abs(originalY - point.y) < 0.0001) {
|
||||
return;
|
||||
}
|
||||
this.rebuildChannelLUT(this.activeChannel);
|
||||
this.draw();
|
||||
this.curveDirty = true;
|
||||
this.notifyChange();
|
||||
}
|
||||
|
||||
removePoint(index) {
|
||||
const points = this.getActiveCurve();
|
||||
if (index <= 0 || index >= points.length - 1) return;
|
||||
points.splice(index, 1);
|
||||
this.rebuildChannelLUT(this.activeChannel);
|
||||
this.draw();
|
||||
this.notifyChange();
|
||||
this.notifyCommit();
|
||||
this.curveDirty = false;
|
||||
}
|
||||
|
||||
getPointerPosition(event) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return null;
|
||||
const x = clamp01((event.clientX - rect.left) / rect.width);
|
||||
const y = clamp01(1 - (event.clientY - rect.top) / rect.height);
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
findPointIndex(pos, threshold = 10) {
|
||||
if (!pos) return -1;
|
||||
const points = this.getActiveCurve();
|
||||
const targetX = pos.x * this.displayWidth;
|
||||
const targetY = (1 - pos.y) * this.displayHeight;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const pt = points[i];
|
||||
const px = pt.x * this.displayWidth;
|
||||
const py = (1 - pt.y) * this.displayHeight;
|
||||
const dist = Math.hypot(px - targetX, py - targetY);
|
||||
if (dist <= threshold) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
onPointerDown(event) {
|
||||
if (event.button !== 0) return;
|
||||
const pos = this.getPointerPosition(event);
|
||||
if (!pos) return;
|
||||
event.preventDefault();
|
||||
let idx = this.findPointIndex(pos);
|
||||
if (idx === -1) {
|
||||
idx = this.addPoint(pos.x, pos.y);
|
||||
}
|
||||
this.dragIndex = idx;
|
||||
this.isDragging = true;
|
||||
this.updatePoint(idx, pos.x, pos.y);
|
||||
}
|
||||
|
||||
onPointerMove(event) {
|
||||
if (!this.isDragging || this.dragIndex === null) return;
|
||||
const pos = this.getPointerPosition(event);
|
||||
if (!pos) return;
|
||||
event.preventDefault();
|
||||
this.updatePoint(this.dragIndex, pos.x, pos.y);
|
||||
}
|
||||
|
||||
onPointerUp() {
|
||||
if (!this.isDragging) return;
|
||||
this.isDragging = false;
|
||||
this.dragIndex = null;
|
||||
if (this.curveDirty) {
|
||||
this.curveDirty = false;
|
||||
this.notifyCommit();
|
||||
}
|
||||
}
|
||||
|
||||
onDoubleClick(event) {
|
||||
const pos = this.getPointerPosition(event);
|
||||
if (!pos) return;
|
||||
const idx = this.findPointIndex(pos, 8);
|
||||
if (idx > 0 && idx < this.getActiveCurve().length - 1) {
|
||||
this.removePoint(idx);
|
||||
}
|
||||
}
|
||||
|
||||
getChannelColor() {
|
||||
return CHANNEL_COLORS[this.activeChannel] || "#ffffff";
|
||||
}
|
||||
|
||||
draw() {
|
||||
if (!this.ctx) return;
|
||||
const ctx = this.ctx;
|
||||
const w = this.displayWidth;
|
||||
const h = this.displayHeight;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
this.drawGrid(ctx, w, h);
|
||||
this.drawCurve(ctx, w, h);
|
||||
this.drawPoints(ctx, w, h);
|
||||
}
|
||||
|
||||
drawGrid(ctx, w, h) {
|
||||
ctx.fillStyle = "rgba(0,0,0,0.5)";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.08)";
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 1; i < 4; i++) {
|
||||
const x = (w / 4) * i;
|
||||
const y = (h / 4) * i;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, h);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(w, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
drawCurve(ctx, w, h) {
|
||||
const points = this.getActiveCurve();
|
||||
if (!points?.length) return;
|
||||
const tangents = this.curveTangents[this.activeChannel] || this.computeTangents(points);
|
||||
ctx.strokeStyle = this.getChannelColor();
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
const steps = 128;
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
const value = this.sampleSmoothCurve(points, t, tangents);
|
||||
const x = t * w;
|
||||
const y = (1 - value) * h;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
drawPoints(ctx, w, h) {
|
||||
const points = this.getActiveCurve();
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = this.getChannelColor();
|
||||
points.forEach(pt => {
|
||||
const x = pt.x * w;
|
||||
const y = (1 - pt.y) * h;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
});
|
||||
}
|
||||
}
|
||||
1287
Image_editor_example/image_editor_modules/editor.js
Normal file
1287
Image_editor_example/image_editor_modules/editor.js
Normal file
File diff suppressed because it is too large
Load diff
132
Image_editor_example/image_editor_modules/extension.js
Normal file
132
Image_editor_example/image_editor_modules/extension.js
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { ImageEditor } from "./editor.js";
|
||||
import { IMAGE_EDITOR_SUBFOLDER } from "./constants.js";
|
||||
import {
|
||||
parseImageWidgetValue,
|
||||
extractFilenameFromSrc,
|
||||
buildEditorFilename,
|
||||
buildImageReference,
|
||||
updateWidgetWithRef,
|
||||
createImageURLFromRef,
|
||||
setImageSource,
|
||||
refreshComboLists,
|
||||
} from "./reference.js";
|
||||
|
||||
export function registerImageEditorExtension(app, api) {
|
||||
app.registerExtension({
|
||||
name: "SDVN.ImageEditor",
|
||||
async beforeRegisterNodeDef(nodeType) {
|
||||
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
||||
nodeType.prototype.getExtraMenuOptions = function (_, options) {
|
||||
if (this.imgs && this.imgs.length > 0) {
|
||||
options.push({
|
||||
content: "🎨 Image Editor",
|
||||
callback: () => {
|
||||
const img = this.imgs[this.imgs.length - 1];
|
||||
let src = null;
|
||||
if (img && img.src) src = img.src;
|
||||
else if (img && img.image) src = img.image.src;
|
||||
|
||||
if (src) {
|
||||
new ImageEditor(src, async (blob) => {
|
||||
const formData = new FormData();
|
||||
const inferredName = extractFilenameFromSrc(src);
|
||||
const editorName = buildEditorFilename(inferredName);
|
||||
formData.append("image", blob, editorName);
|
||||
formData.append("overwrite", "false");
|
||||
formData.append("type", "input");
|
||||
formData.append("subfolder", IMAGE_EDITOR_SUBFOLDER);
|
||||
|
||||
try {
|
||||
const resp = await api.fetchApi("/upload/image", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const data = await resp.json();
|
||||
const ref = buildImageReference(data, {
|
||||
type: "input",
|
||||
subfolder: IMAGE_EDITOR_SUBFOLDER,
|
||||
filename: editorName,
|
||||
});
|
||||
const imageWidget = this.widgets?.find?.(
|
||||
(w) => w.name === "image" || w.type === "image"
|
||||
);
|
||||
if (imageWidget) {
|
||||
updateWidgetWithRef(this, imageWidget, ref);
|
||||
}
|
||||
const newSrc = createImageURLFromRef(api, ref);
|
||||
if (newSrc) {
|
||||
setImageSource(img, newSrc);
|
||||
app.graph.setDirtyCanvas(true);
|
||||
}
|
||||
await refreshComboLists(app);
|
||||
console.info("[SDVN.ImageEditor] Image saved to input folder:", data?.name || editorName);
|
||||
} catch (e) {
|
||||
console.error("[SDVN.ImageEditor] Upload failed", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
} else if (this.widgets) {
|
||||
const imageWidget = this.widgets.find((w) => w.name === "image" || w.type === "image");
|
||||
if (imageWidget && imageWidget.value) {
|
||||
options.push({
|
||||
content: "🎨 Image Editor",
|
||||
callback: () => {
|
||||
const parsed = parseImageWidgetValue(imageWidget.value);
|
||||
if (!parsed.filename) {
|
||||
console.warn("[SDVN.ImageEditor] Image not available for editing.");
|
||||
return;
|
||||
}
|
||||
const src = api.apiURL(
|
||||
`/view?filename=${encodeURIComponent(parsed.filename)}&type=${parsed.type}&subfolder=${encodeURIComponent(
|
||||
parsed.subfolder
|
||||
)}`
|
||||
);
|
||||
|
||||
new ImageEditor(src, async (blob) => {
|
||||
const formData = new FormData();
|
||||
const newName = buildEditorFilename(parsed.filename);
|
||||
formData.append("image", blob, newName);
|
||||
formData.append("overwrite", "false");
|
||||
formData.append("type", "input");
|
||||
formData.append("subfolder", IMAGE_EDITOR_SUBFOLDER);
|
||||
|
||||
try {
|
||||
const resp = await api.fetchApi("/upload/image", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const data = await resp.json();
|
||||
const ref = buildImageReference(data, {
|
||||
type: "input",
|
||||
subfolder: IMAGE_EDITOR_SUBFOLDER,
|
||||
filename: newName,
|
||||
});
|
||||
|
||||
if (imageWidget) {
|
||||
updateWidgetWithRef(this, imageWidget, ref);
|
||||
}
|
||||
|
||||
const newSrc = createImageURLFromRef(api, ref);
|
||||
|
||||
if (this.imgs && this.imgs.length > 0) {
|
||||
this.imgs.forEach((img) => setImageSource(img, newSrc));
|
||||
}
|
||||
|
||||
this.setDirtyCanvas?.(true, true);
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
await refreshComboLists(app);
|
||||
} catch (e) {
|
||||
console.error("[SDVN.ImageEditor] Upload failed", e);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return getExtraMenuOptions?.apply(this, arguments);
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
149
Image_editor_example/image_editor_modules/reference.js
Normal file
149
Image_editor_example/image_editor_modules/reference.js
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
export function buildImageReference(data, fallback = {}) {
|
||||
const ref = {
|
||||
filename: data?.name || data?.filename || fallback.filename,
|
||||
subfolder: data?.subfolder ?? fallback.subfolder ?? "",
|
||||
type: data?.type || fallback.type || "input",
|
||||
};
|
||||
if (!ref.filename) {
|
||||
return null;
|
||||
}
|
||||
return ref;
|
||||
}
|
||||
|
||||
export function buildAnnotatedLabel(ref) {
|
||||
if (!ref?.filename) return "";
|
||||
const path = ref.subfolder ? `${ref.subfolder}/${ref.filename}` : ref.filename;
|
||||
return `${path} [${ref.type || "input"}]`;
|
||||
}
|
||||
|
||||
export function parseImageWidgetValue(value) {
|
||||
const defaults = { filename: null, subfolder: "", type: "input" };
|
||||
if (!value) return defaults;
|
||||
if (typeof value === "object") {
|
||||
return {
|
||||
filename: value.filename || null,
|
||||
subfolder: value.subfolder || "",
|
||||
type: value.type || "input",
|
||||
};
|
||||
}
|
||||
|
||||
const raw = value.toString().trim();
|
||||
let type = "input";
|
||||
let path = raw;
|
||||
const match = raw.match(/\[([^\]]+)\]\s*$/);
|
||||
if (match) {
|
||||
type = match[1].trim() || "input";
|
||||
path = raw.slice(0, match.index).trim();
|
||||
}
|
||||
path = path.replace(/^[\\/]+/, "");
|
||||
const parts = path.split(/[\\/]/).filter(Boolean);
|
||||
const filename = parts.pop() || null;
|
||||
const subfolder = parts.join("/") || "";
|
||||
return { filename, subfolder, type };
|
||||
}
|
||||
|
||||
export function sanitizeFilenamePart(part) {
|
||||
return (part || "")
|
||||
.replace(/[\\/]/g, "_")
|
||||
.replace(/[<>:"|?*\x00-\x1F]/g, "_")
|
||||
.replace(/\s+/g, "_");
|
||||
}
|
||||
|
||||
export function buildEditorFilename(sourceName) {
|
||||
let name = sourceName ? sourceName.toString() : "";
|
||||
name = name.split(/[\\/]/).pop() || "";
|
||||
name = name.replace(/\.[^.]+$/, "");
|
||||
name = sanitizeFilenamePart(name);
|
||||
if (!name) name = `image_${Date.now()}`;
|
||||
return `${name}.png`;
|
||||
}
|
||||
|
||||
export function extractFilenameFromSrc(src) {
|
||||
if (!src) return null;
|
||||
try {
|
||||
const url = new URL(src, window.location.origin);
|
||||
return url.searchParams.get("filename");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatWidgetValueFromRef(ref, currentValue) {
|
||||
if (currentValue && typeof currentValue === "object") {
|
||||
return {
|
||||
...currentValue,
|
||||
filename: ref.filename,
|
||||
subfolder: ref.subfolder,
|
||||
type: ref.type,
|
||||
};
|
||||
}
|
||||
return buildAnnotatedLabel(ref);
|
||||
}
|
||||
|
||||
export function updateWidgetWithRef(node, widget, ref) {
|
||||
if (!node || !widget || !ref) return;
|
||||
const annotatedLabel = buildAnnotatedLabel(ref);
|
||||
const storedValue = formatWidgetValueFromRef(ref, widget.value);
|
||||
widget.value = storedValue;
|
||||
widget.callback?.(storedValue);
|
||||
if (widget.inputEl) {
|
||||
widget.inputEl.value = annotatedLabel;
|
||||
}
|
||||
|
||||
if (Array.isArray(node.widgets_values)) {
|
||||
const idx = node.widgets?.indexOf?.(widget) ?? -1;
|
||||
if (idx >= 0) {
|
||||
node.widgets_values[idx] = annotatedLabel;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(node.inputs)) {
|
||||
node.inputs.forEach(input => {
|
||||
if (!input?.widget) return;
|
||||
if (input.widget === widget || (widget.name && input.widget.name === widget.name)) {
|
||||
input.widget.value = annotatedLabel;
|
||||
if (input.widget.inputEl) {
|
||||
input.widget.inputEl.value = annotatedLabel;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof annotatedLabel === "string" && widget.options?.values) {
|
||||
const values = widget.options.values;
|
||||
if (Array.isArray(values) && !values.includes(annotatedLabel)) {
|
||||
values.push(annotatedLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createImageURLFromRef(api, ref) {
|
||||
if (!ref?.filename) return null;
|
||||
const params = new URLSearchParams();
|
||||
params.set("filename", ref.filename);
|
||||
params.set("type", ref.type || "input");
|
||||
params.set("subfolder", ref.subfolder || "");
|
||||
params.set("t", Date.now().toString());
|
||||
return api.apiURL(`/view?${params.toString()}`);
|
||||
}
|
||||
|
||||
export function setImageSource(target, newSrc) {
|
||||
if (!target || !newSrc) return;
|
||||
if (target instanceof Image) {
|
||||
target.src = newSrc;
|
||||
} else if (target.image instanceof Image) {
|
||||
target.image.src = newSrc;
|
||||
} else if (target.img instanceof Image) {
|
||||
target.img.src = newSrc;
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshComboLists(app) {
|
||||
if (typeof app.refreshComboInNodes === "function") {
|
||||
try {
|
||||
await app.refreshComboInNodes();
|
||||
} catch (err) {
|
||||
console.warn("SDVN.ImageEditor: refreshComboInNodes failed", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
435
Image_editor_example/image_editor_modules/styles.js
Normal file
435
Image_editor_example/image_editor_modules/styles.js
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
const STYLE_ID = "sdvn-image-editor-style";
|
||||
|
||||
const IMAGE_EDITOR_CSS = `
|
||||
:root {
|
||||
--apix-bg: #0f0f0f;
|
||||
--apix-panel: #1a1a1a;
|
||||
--apix-border: #2a2a2a;
|
||||
--apix-text: #e0e0e0;
|
||||
--apix-text-dim: #888;
|
||||
--apix-accent: #f5c518; /* Yellow accent from apix */
|
||||
--apix-accent-hover: #ffd54f;
|
||||
--apix-danger: #ff4444;
|
||||
}
|
||||
.apix-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: var(--apix-bg);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
color: var(--apix-text);
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Left Sidebar (Tools) */
|
||||
.apix-sidebar-left {
|
||||
width: 60px;
|
||||
background: var(--apix-panel);
|
||||
border-right: 1px solid var(--apix-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
gap: 15px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Main Canvas Area */
|
||||
.apix-main-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
.apix-header {
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
background: var(--apix-panel);
|
||||
border-bottom: 1px solid var(--apix-border);
|
||||
}
|
||||
.apix-header-title {
|
||||
font-weight: 700;
|
||||
color: var(--apix-accent);
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.apix-canvas-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: grab;
|
||||
}
|
||||
.apix-canvas-container:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Bottom Bar (Zoom) */
|
||||
.apix-bottom-bar {
|
||||
height: 40px;
|
||||
background: var(--apix-panel);
|
||||
border-top: 1px solid var(--apix-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Right Sidebar (Adjustments) */
|
||||
.apix-sidebar-right {
|
||||
width: 320px;
|
||||
background: var(--apix-panel);
|
||||
border-left: 1px solid var(--apix-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 10;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
.apix-sidebar-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 20px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--apix-accent) transparent;
|
||||
}
|
||||
.apix-sidebar-scroll::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.apix-sidebar-scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--apix-accent);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.apix-sidebar-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* UI Components */
|
||||
.apix-tool-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--apix-text-dim);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.apix-tool-btn:hover {
|
||||
color: var(--apix-text);
|
||||
background: rgba(255,255,255,0.05);
|
||||
}
|
||||
.apix-tool-btn.active {
|
||||
color: #000;
|
||||
background: var(--apix-accent);
|
||||
}
|
||||
.apix-tool-btn.icon-only svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.apix-sidebar-divider {
|
||||
width: 24px;
|
||||
height: 1px;
|
||||
background: var(--apix-border);
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.apix-panel-section {
|
||||
border-bottom: 1px solid var(--apix-border);
|
||||
}
|
||||
.apix-panel-header {
|
||||
padding: 15px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: rgba(255,255,255,0.02);
|
||||
user-select: none;
|
||||
}
|
||||
.apix-panel-header span:first-child {
|
||||
color: #8d8d8d;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.apix-panel-header:hover {
|
||||
background: rgba(255,255,255,0.05);
|
||||
}
|
||||
.apix-panel-content {
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
.apix-panel-content.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.apix-control-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.apix-control-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: var(--apix-text-dim);
|
||||
letter-spacing: 0.2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.apix-slider-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.apix-slider-meta span {
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.apix-slider-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-right: 26px;
|
||||
}
|
||||
.apix-slider-reset {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--apix-text-dim);
|
||||
cursor: pointer;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 56%;
|
||||
transform: translateY(-50%);
|
||||
opacity: 0.4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.2s, color 0.2s;
|
||||
}
|
||||
.apix-slider-reset:hover {
|
||||
opacity: 1;
|
||||
color: var(--apix-accent);
|
||||
}
|
||||
.apix-slider-reset svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.apix-curve-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.apix-curve-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: var(--apix-text-dim);
|
||||
gap: 8px;
|
||||
}
|
||||
.apix-curve-channel-buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.apix-curve-channel-btn {
|
||||
border: 1px solid var(--apix-border);
|
||||
background: transparent;
|
||||
color: var(--apix-text-dim);
|
||||
font-size: 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.apix-curve-channel-btn.active {
|
||||
background: var(--apix-accent);
|
||||
color: #000;
|
||||
border-color: var(--apix-accent);
|
||||
}
|
||||
.apix-curve-reset {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--apix-accent);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.apix-curve-stage {
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
border: 1px solid var(--apix-border);
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.05) 0%, rgba(0,0,0,0.25) 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.apix-curve-stage canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.apix-slider {
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: #333;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
.apix-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--apix-accent);
|
||||
cursor: pointer;
|
||||
border: 2px solid #1a1a1a;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
.apix-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.apix-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.apix-btn-primary {
|
||||
background: var(--apix-accent);
|
||||
color: #000;
|
||||
}
|
||||
.apix-btn-primary:hover {
|
||||
background: var(--apix-accent-hover);
|
||||
}
|
||||
.apix-btn-secondary {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
}
|
||||
.apix-btn-secondary:hover {
|
||||
background: #444;
|
||||
}
|
||||
.apix-btn-toggle.active {
|
||||
background: var(--apix-accent);
|
||||
color: #000;
|
||||
}
|
||||
.apix-hsl-swatches {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.apix-hsl-chip {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
background: var(--chip-color, #fff);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, border 0.2s;
|
||||
}
|
||||
.apix-hsl-chip.active {
|
||||
border-color: var(--apix-accent);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.apix-hsl-slider .apix-slider-meta span {
|
||||
font-size: 11px;
|
||||
color: var(--apix-text-dim);
|
||||
}
|
||||
.apix-hsl-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
color: var(--apix-text-dim);
|
||||
}
|
||||
.apix-hsl-reset {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--apix-accent);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.apix-sidebar-right {
|
||||
position: relative;
|
||||
}
|
||||
.apix-footer {
|
||||
padding: 20px;
|
||||
border-top: 1px solid var(--apix-border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
background: var(--apix-panel);
|
||||
}
|
||||
|
||||
/* Crop Overlay */
|
||||
.apix-crop-overlay {
|
||||
position: absolute;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.7);
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
}
|
||||
.apix-crop-handle {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--apix-accent);
|
||||
border: 1px solid #000;
|
||||
pointer-events: auto;
|
||||
z-index: 100;
|
||||
}
|
||||
/* Handle positions */
|
||||
.handle-tl { top: -6px; left: -6px; cursor: nw-resize; }
|
||||
.handle-tr { top: -6px; right: -6px; cursor: ne-resize; }
|
||||
.handle-bl { bottom: -6px; left: -6px; cursor: sw-resize; }
|
||||
.handle-br { bottom: -6px; right: -6px; cursor: se-resize; }
|
||||
/* Edges */
|
||||
.handle-t { top: -6px; left: 50%; transform: translateX(-50%); cursor: n-resize; }
|
||||
.handle-b { bottom: -6px; left: 50%; transform: translateX(-50%); cursor: s-resize; }
|
||||
.handle-l { left: -6px; top: 50%; transform: translateY(-50%); cursor: w-resize; }
|
||||
.handle-r { right: -6px; top: 50%; transform: translateY(-50%); cursor: e-resize; }
|
||||
`;
|
||||
|
||||
export function injectImageEditorStyles() {
|
||||
if (document.getElementById(STYLE_ID)) {
|
||||
return;
|
||||
}
|
||||
const style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = IMAGE_EDITOR_CSS;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
BIN
__pycache__/app.cpython-314.pyc
Normal file
BIN
__pycache__/app.cpython-314.pyc
Normal file
Binary file not shown.
BIN
__pycache__/whisk_client.cpython-314.pyc
Normal file
BIN
__pycache__/whisk_client.cpython-314.pyc
Normal file
Binary file not shown.
278
app.py
278
app.py
|
|
@ -11,6 +11,9 @@ from flask import Flask, render_template, request, jsonify, url_for
|
|||
from google import genai
|
||||
from google.genai import types
|
||||
from PIL import Image, PngImagePlugin
|
||||
import threading, time, subprocess, re
|
||||
import whisk_client
|
||||
|
||||
|
||||
import logging
|
||||
|
||||
|
|
@ -391,12 +394,15 @@ def generate_image():
|
|||
if not prompt:
|
||||
return jsonify({'error': 'Prompt is required'}), 400
|
||||
|
||||
if not api_key:
|
||||
return jsonify({'error': 'API Key is required.'}), 401
|
||||
# Determine if this is a Whisk request
|
||||
is_whisk = 'whisk' in model.lower() or 'imagefx' in model.lower()
|
||||
|
||||
if not is_whisk and not api_key:
|
||||
return jsonify({'error': 'API Key is required for Gemini models.'}), 401
|
||||
|
||||
try:
|
||||
print("Đang gửi lệnh...", flush=True)
|
||||
client = genai.Client(api_key=api_key)
|
||||
# client initialization moved to Gemini block
|
||||
|
||||
image_config_args = {}
|
||||
|
||||
|
|
@ -512,6 +518,133 @@ def generate_image():
|
|||
continue
|
||||
|
||||
model_name = model
|
||||
|
||||
# ==================================================================================
|
||||
# WHISK (IMAGEFX) HANDLING
|
||||
# ==================================================================================
|
||||
if is_whisk:
|
||||
print(f"Detected Whisk/ImageFX model request: {model_name}", flush=True)
|
||||
|
||||
# Extract cookies from request headers or form data
|
||||
# Priority: Form Data 'cookies' > Request Header 'x-whisk-cookies' > Environment Variable
|
||||
cookie_str = request.form.get('cookies') or request.headers.get('x-whisk-cookies') or os.environ.get('WHISK_COOKIES')
|
||||
|
||||
if not cookie_str:
|
||||
return jsonify({'error': 'Whisk cookies are required. Please provide them in the "cookies" form field or configuration.'}), 400
|
||||
|
||||
print("Sending request to Whisk...", flush=True)
|
||||
try:
|
||||
# Check for reference images
|
||||
reference_image_path = None
|
||||
|
||||
# final_reference_paths (populated above) contains URLs/paths to reference images.
|
||||
# Can be new uploads or history items.
|
||||
if final_reference_paths:
|
||||
# Use the first one
|
||||
ref_url = final_reference_paths[0]
|
||||
|
||||
# Convert URL/Path to absolute local path
|
||||
# ref_url might be "http://.../static/..." or "/static/..."
|
||||
if '/static/' in ref_url:
|
||||
rel_path = ref_url.split('/static/')[1]
|
||||
possible_path = os.path.join(app.static_folder, rel_path)
|
||||
if os.path.exists(possible_path):
|
||||
reference_image_path = possible_path
|
||||
print(f"Whisk: Using reference image at {reference_image_path}", flush=True)
|
||||
elif os.path.exists(ref_url):
|
||||
# It's already a path?
|
||||
reference_image_path = ref_url
|
||||
|
||||
# Call the client
|
||||
try:
|
||||
whisk_result = whisk_client.generate_image_whisk(
|
||||
prompt=api_prompt,
|
||||
cookie_str=cookie_str,
|
||||
aspect_ratio=aspect_ratio,
|
||||
resolution=resolution,
|
||||
reference_image_path=reference_image_path
|
||||
)
|
||||
except Exception as e:
|
||||
# Re-raise to be caught by the outer block
|
||||
raise e
|
||||
|
||||
# Process result - whisk_client returns raw bytes
|
||||
image_bytes = None
|
||||
if isinstance(whisk_result, bytes):
|
||||
image_bytes = whisk_result
|
||||
elif isinstance(whisk_result, dict):
|
||||
# Fallback if I ever change the client to return dict
|
||||
if 'image_data' in whisk_result:
|
||||
image_bytes = whisk_result['image_data']
|
||||
elif 'image_url' in whisk_result:
|
||||
import requests
|
||||
img_resp = requests.get(whisk_result['image_url'])
|
||||
image_bytes = img_resp.content
|
||||
|
||||
if not image_bytes:
|
||||
raise ValueError("No image data returned from Whisk.")
|
||||
|
||||
# Save and process image (Reuse existing logic)
|
||||
image = Image.open(BytesIO(image_bytes))
|
||||
png_info = PngImagePlugin.PngInfo()
|
||||
|
||||
date_str = datetime.now().strftime("%Y%m%d")
|
||||
search_pattern = os.path.join(GENERATED_DIR, f"whisk_{date_str}_*.png")
|
||||
existing_files = glob.glob(search_pattern)
|
||||
max_id = 0
|
||||
for f in existing_files:
|
||||
try:
|
||||
basename = os.path.basename(f)
|
||||
name_without_ext = os.path.splitext(basename)[0]
|
||||
id_part = name_without_ext.split('_')[-1]
|
||||
id_num = int(id_part)
|
||||
if id_num > max_id:
|
||||
max_id = id_num
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
next_id = max_id + 1
|
||||
filename = f"whisk_{date_str}_{next_id}.png"
|
||||
filepath = os.path.join(GENERATED_DIR, filename)
|
||||
rel_path = os.path.join('generated', filename)
|
||||
image_url = url_for('static', filename=rel_path)
|
||||
|
||||
metadata = {
|
||||
'prompt': prompt,
|
||||
'note': note,
|
||||
'processed_prompt': api_prompt,
|
||||
'aspect_ratio': aspect_ratio or 'Auto',
|
||||
'resolution': resolution,
|
||||
'reference_images': final_reference_paths,
|
||||
'model': 'whisk'
|
||||
}
|
||||
png_info.add_text('sdvn_meta', json.dumps(metadata))
|
||||
|
||||
buffer = BytesIO()
|
||||
image.save(buffer, format='PNG', pnginfo=png_info)
|
||||
final_bytes = buffer.getvalue()
|
||||
|
||||
with open(filepath, 'wb') as f:
|
||||
f.write(final_bytes)
|
||||
|
||||
image_data = base64.b64encode(final_bytes).decode('utf-8')
|
||||
return jsonify({
|
||||
'image': image_url,
|
||||
'image_data': image_data,
|
||||
'metadata': metadata,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Whisk error: {e}")
|
||||
return jsonify({'error': f"Whisk Generation Error: {str(e)}"}), 500
|
||||
|
||||
# ==================================================================================
|
||||
# STANDARD GEMINI HANDLING
|
||||
# ==================================================================================
|
||||
|
||||
# Initialize Client here, since API Key is required
|
||||
client = genai.Client(api_key=api_key)
|
||||
|
||||
print(f"Đang tạo với model {model_name}...", flush=True)
|
||||
response = client.models.generate_content(
|
||||
model=model_name,
|
||||
|
|
@ -742,6 +875,7 @@ def save_template():
|
|||
title = request.form.get('title')
|
||||
prompt = request.form.get('prompt')
|
||||
mode = request.form.get('mode', 'generate')
|
||||
note = request.form.get('note', '')
|
||||
category = request.form.get('category', 'User')
|
||||
tags_field = request.form.get('tags')
|
||||
tags = parse_tags_field(tags_field)
|
||||
|
|
@ -837,6 +971,7 @@ def save_template():
|
|||
new_template = {
|
||||
'title': title,
|
||||
'prompt': prompt,
|
||||
'note': note,
|
||||
'mode': mode,
|
||||
'category': category,
|
||||
'preview': preview_path,
|
||||
|
|
@ -895,6 +1030,7 @@ def update_template():
|
|||
title = request.form.get('title')
|
||||
prompt = request.form.get('prompt')
|
||||
mode = request.form.get('mode', 'generate')
|
||||
note = request.form.get('note', '')
|
||||
category = request.form.get('category', 'User')
|
||||
tags_field = request.form.get('tags')
|
||||
tags = parse_tags_field(tags_field)
|
||||
|
|
@ -1004,6 +1140,7 @@ def update_template():
|
|||
|
||||
existing_template['title'] = title
|
||||
existing_template['prompt'] = prompt
|
||||
existing_template['note'] = note
|
||||
existing_template['mode'] = mode
|
||||
existing_template['category'] = category
|
||||
if preview_path:
|
||||
|
|
@ -1047,6 +1184,7 @@ def update_template():
|
|||
user_prompts[template_index] = {
|
||||
'title': title,
|
||||
'prompt': prompt,
|
||||
'note': note,
|
||||
'mode': mode,
|
||||
'category': category,
|
||||
'preview': preview_path,
|
||||
|
|
@ -1152,10 +1290,144 @@ def refine_prompt():
|
|||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
#Tun sever
|
||||
|
||||
@app.route('/download_image', methods=['POST'])
|
||||
def download_image():
|
||||
import requests
|
||||
from urllib.parse import urlparse
|
||||
|
||||
data = request.get_json() or {}
|
||||
url = data.get('url')
|
||||
|
||||
if not url:
|
||||
return jsonify({'error': 'URL is required'}), 400
|
||||
|
||||
try:
|
||||
download_url = url
|
||||
|
||||
# Check if it's a URL (http/https)
|
||||
if url.startswith('http://') or url.startswith('https://'):
|
||||
# Try to use gallery-dl to extract the image URL
|
||||
try:
|
||||
# -g: get URLs, -q: quiet
|
||||
cmd = ['gallery-dl', '-g', '-q', url]
|
||||
# Timeout to prevent hanging on slow sites
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||
|
||||
if result.returncode == 0:
|
||||
urls = result.stdout.strip().split('\n')
|
||||
if urls and urls[0] and urls[0].startswith('http'):
|
||||
download_url = urls[0]
|
||||
except Exception as e:
|
||||
print(f"gallery-dl extraction failed (using direct URL): {e}")
|
||||
# Fallback to using the original URL directly
|
||||
|
||||
# Download logic (for both direct URL and extracted URL)
|
||||
if download_url.startswith('http://') or download_url.startswith('https://'):
|
||||
response = requests.get(download_url, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
content_type = response.headers.get('content-type', '')
|
||||
ext = '.png'
|
||||
if 'image/jpeg' in content_type: ext = '.jpg'
|
||||
elif 'image/webp' in content_type: ext = '.webp'
|
||||
elif 'image/gif' in content_type: ext = '.gif'
|
||||
else:
|
||||
parsed = urlparse(download_url)
|
||||
ext = os.path.splitext(parsed.path)[1] or '.png'
|
||||
|
||||
filename = f"{uuid.uuid4()}{ext}"
|
||||
filepath = os.path.join(UPLOADS_DIR, filename)
|
||||
|
||||
with open(filepath, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
rel_path = f"uploads/{filename}"
|
||||
final_url = url_for('static', filename=rel_path)
|
||||
|
||||
return jsonify({'path': final_url, 'local_path': filepath})
|
||||
|
||||
else:
|
||||
# Handle local file path
|
||||
# Remove quotes if present
|
||||
clean_path = url.strip('"\'')
|
||||
|
||||
if os.path.exists(clean_path):
|
||||
ext = os.path.splitext(clean_path)[1] or '.png'
|
||||
filename = f"{uuid.uuid4()}{ext}"
|
||||
filepath = os.path.join(UPLOADS_DIR, filename)
|
||||
shutil.copy2(clean_path, filepath)
|
||||
|
||||
rel_path = f"uploads/{filename}"
|
||||
final_url = url_for('static', filename=rel_path)
|
||||
return jsonify({'path': final_url, 'local_path': filepath})
|
||||
else:
|
||||
return jsonify({'error': 'File path not found on server'}), 404
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error downloading image: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
def pinggy_thread(port,pinggy):
|
||||
|
||||
server = {
|
||||
"Auto": "",
|
||||
"USA": "us.",
|
||||
"Europe": "eu.",
|
||||
"Asia": "ap.",
|
||||
"South America": "br.",
|
||||
"Australia": "au."
|
||||
|
||||
}
|
||||
|
||||
sv = server[Sever_Pinggy]
|
||||
|
||||
import socket
|
||||
while True:
|
||||
time.sleep(0.5)
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
result = sock.connect_ex(('127.0.0.1', port))
|
||||
if result == 0:
|
||||
break
|
||||
sock.close()
|
||||
try:
|
||||
if pinggy != None:
|
||||
if ":" in pinggy:
|
||||
pinggy, ac, ps = pinggy.split(":")
|
||||
cmd = ["ssh", "-p", "443", f"-R0:localhost:{port}", "-o", "StrictHostKeyChecking=no", "-o", "ServerAliveInterval=30", f"{pinggy}@{sv}pro.pinggy.io", f'\"b:{ac}:{ps}\"']
|
||||
else:
|
||||
cmd = ["ssh", "-p", "443", f"-R0:localhost:{port}", "-o", "StrictHostKeyChecking=no", "-o", "ServerAliveInterval=30", f"{pinggy}@{sv}pro.pinggy.io"]
|
||||
else:
|
||||
cmd = ["ssh", "-p", "443", "-L4300:localhost:4300", "-o", "StrictHostKeyChecking=no", "-o", "ServerAliveInterval=30", f"-R0:localhost:{port}", "free.pinggy.io"]
|
||||
process = subprocess.Popen(cmd,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
|
||||
for line in iter(process.stdout.readline, ''):
|
||||
match = re.search(r'(https?://[^\s]+)', line)
|
||||
if match:
|
||||
url = match.group(1)
|
||||
# Bỏ qua các link dashboard
|
||||
if "dashboard.pinggy.io" in url:
|
||||
continue
|
||||
print(f"\033[92m🔗 Link online để sử dụng:\033[0m {url}")
|
||||
if pinggy == None:
|
||||
html="<div><code style='color:yellow'>Link pinggy free hoạt động trong 60phút, khởi động lại hoặc đăng ký tại [dashboard.pinggy.io] để lấy token, nhập custom pinggy trong tệp Domain_sever.txt trên drive theo cú pháp 'pinggy-{token}'</code></div>"
|
||||
display(HTML(html))
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"❌ Lỗi: {e}")
|
||||
|
||||
def sever_flare(port, pinggy = None):
|
||||
threading.Thread(target=pinggy_thread, daemon=True, args=(port,pinggy,)).start()
|
||||
|
||||
|
||||
port_sever = 8888
|
||||
Sever_Pinggy = "Auto"
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Use ANSI green text so the startup banner stands out in terminals
|
||||
print("\033[32m" + "aPix Image Workspace running at:" + "\033[0m", flush=True)
|
||||
print("\033[32m" + f"http://localhost:{port_sever}" + " " + "\033[0m", flush=True)
|
||||
print("\033[32m" + f"http://127.0.0.1:{port_sever}" + "\033[0m", flush=True)
|
||||
|
||||
# sever_flare(port_sever, "cXPggKvHuW:sdvn:1231")
|
||||
app.run(debug=True, port=port_sever)
|
||||
|
|
|
|||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
platform: linux/amd64
|
||||
ports:
|
||||
- "8558:8888"
|
||||
volumes:
|
||||
- ./static:/app/static
|
||||
- ./prompts.json:/app/prompts.json
|
||||
- ./user_prompts.json:/app/user_prompts.json
|
||||
- ./gallery_favorites.json:/app/gallery_favorites.json
|
||||
environment:
|
||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-} # Optional for Whisk
|
||||
- WHISK_COOKIES=${WHISK_COOKIES:-}
|
||||
restart: unless-stopped
|
||||
|
|
@ -4,8 +4,10 @@
|
|||
"gemini-3-pro-image-preview_20251125_42.png",
|
||||
"gemini-3-pro-image-preview_20251125_41.png",
|
||||
"gemini-3-pro-image-preview_20251125_37.png",
|
||||
"gemini-3-pro-image-preview_20251125_26.png",
|
||||
"gemini-3-pro-image-preview_20251125_24.png",
|
||||
"generated/gemini-3-pro-image-preview_20251124_10.png",
|
||||
"generated/gemini-3-pro-image-preview_20251124_9.png"
|
||||
"generated/gemini-3-pro-image-preview_20251124_9.png",
|
||||
"generated/gemini-3-pro-image-preview_20251125_26.png",
|
||||
"generated/gemini-3-pro-image-preview_20251129_12.png",
|
||||
"generated/gemini-3-pro-image-preview_20251220_1.png"
|
||||
]
|
||||
|
|
@ -2,3 +2,5 @@ flask
|
|||
google-genai
|
||||
pillow
|
||||
Send2Trash
|
||||
gallery-dl
|
||||
requests
|
||||
|
|
|
|||
71
static/image_editor_modules/color.js
Normal file
71
static/image_editor_modules/color.js
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
export function clamp01(value) {
|
||||
return Math.min(1, Math.max(0, value));
|
||||
}
|
||||
|
||||
export function clamp255(value) {
|
||||
return Math.min(255, Math.max(0, value));
|
||||
}
|
||||
|
||||
export function rgbToHsl(r, g, b) {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h;
|
||||
let s;
|
||||
const l = (max + min) / 2;
|
||||
if (max === min) {
|
||||
h = 0;
|
||||
s = 0;
|
||||
} else {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0);
|
||||
break;
|
||||
case g:
|
||||
h = (b - r) / d + 2;
|
||||
break;
|
||||
default:
|
||||
h = (r - g) / d + 4;
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
return { h, s, l };
|
||||
}
|
||||
|
||||
export function hslToRgb(h, s, l) {
|
||||
let r;
|
||||
let g;
|
||||
let b;
|
||||
if (s === 0) {
|
||||
r = g = b = l;
|
||||
} else {
|
||||
const hue2rgb = (p, q, t) => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r = hue2rgb(p, q, h + 1 / 3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1 / 3);
|
||||
}
|
||||
return {
|
||||
r: Math.round(r * 255),
|
||||
g: Math.round(g * 255),
|
||||
b: Math.round(b * 255)
|
||||
};
|
||||
}
|
||||
|
||||
export function hueDistance(a, b) {
|
||||
let diff = Math.abs(a - b);
|
||||
diff = Math.min(diff, 1 - diff);
|
||||
return diff;
|
||||
}
|
||||
26
static/image_editor_modules/constants.js
Normal file
26
static/image_editor_modules/constants.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
export const ICONS = {
|
||||
crop: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6.13 1L6 16a2 2 0 0 0 2 2h15"></path><path d="M1 6.13L16 6a2 2 0 0 1 2 2v15"></path></svg>`,
|
||||
adjust: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>`,
|
||||
undo: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7v6h6"></path><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"></path></svg>`,
|
||||
redo: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 7v6h-6"></path><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 2.7"></path></svg>`,
|
||||
reset: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"></polyline><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path></svg>`,
|
||||
chevronDown: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>`,
|
||||
close: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`,
|
||||
flipH: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 7 3 3 7 3"></polyline><line x1="3" y1="3" x2="10" y2="10"></line><polyline points="21 17 21 21 17 21"></polyline><line x1="21" y1="21" x2="14" y2="14"></line></svg>`,
|
||||
flipV: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="7 21 3 21 3 17"></polyline><line x1="3" y1="21" x2="10" y2="14"></line><polyline points="17 3 21 3 21 7"></polyline><line x1="21" y1="3" x2="14" y2="10"></line></svg>`,
|
||||
rotate: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M11.5 20.5C6.80558 20.5 3 16.6944 3 12C3 7.30558 6.80558 3.5 11.5 3.5C16.1944 3.5 20 7.30558 20 12C20 13.5433 19.5887 14.9905 18.8698 16.238M22.5 15L18.8698 16.238M17.1747 12.3832L18.5289 16.3542L18.8698 16.238" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
|
||||
brush: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M21.8098 3.93814C20.4998 7.20814 17.5098 11.4781 14.6598 14.2681C14.2498 11.6881 12.1898 9.66814 9.58984 9.30814C12.3898 6.44814 16.6898 3.41814 19.9698 2.09814C20.5498 1.87814 21.1298 2.04814 21.4898 2.40814C21.8698 2.78814 22.0498 3.35814 21.8098 3.93814Z" fill="currentColor"/><path d="M13.7791 15.0909C13.5791 15.2609 13.3791 15.4309 13.1791 15.5909L11.3891 17.0209C11.3891 16.9909 11.3791 16.9509 11.3791 16.9109C11.2391 15.8409 10.7391 14.8509 9.92914 14.0409C9.10914 13.2209 8.08914 12.7209 6.96914 12.5809C6.93914 12.5809 6.89914 12.5709 6.86914 12.5709L8.31914 10.7409C8.45914 10.5609 8.60914 10.3909 8.76914 10.2109L9.44914 10.3009C11.5991 10.6009 13.3291 12.2909 13.6691 14.4309L13.7791 15.0909Z" fill="currentColor"/><path d="M10.4298 17.6208C10.4298 18.7208 10.0098 19.7708 9.20976 20.5608C8.59976 21.1808 7.77977 21.6008 6.77977 21.7208L4.32976 21.9908C2.98976 22.1408 1.83976 20.9908 1.98976 19.6408L2.25976 17.1808C2.49976 14.9908 4.32976 13.5908 6.26976 13.5508C6.45976 13.5408 6.66976 13.5508 6.86976 13.5708C7.71976 13.6808 8.53976 14.0708 9.22976 14.7508C9.89976 15.4208 10.2798 16.2108 10.3898 17.0408C10.4098 17.2408 10.4298 17.4308 10.4298 17.6208Z" fill="currentColor"/></svg>`,
|
||||
pen: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 19l7-7 3 3-7 7-3-3z"></path><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"></path><path d="M2 2l7.586 7.586"></path><circle cx="11" cy="11" r="2"></circle></svg>`
|
||||
};
|
||||
|
||||
export const HSL_COLORS = [
|
||||
{ id: "red", label: "Red", color: "#ff4b4b", center: 0 / 360, width: 0.12 },
|
||||
{ id: "orange", label: "Orange", color: "#ff884d", center: 30 / 360, width: 0.12 },
|
||||
{ id: "yellow", label: "Yellow", color: "#ffd84d", center: 50 / 360, width: 0.12 },
|
||||
{ id: "green", label: "Green", color: "#45d98e", center: 120 / 360, width: 0.12 },
|
||||
{ id: "cyan", label: "Cyan", color: "#30c4ff", center: 180 / 360, width: 0.12 },
|
||||
{ id: "blue", label: "Blue", color: "#2f7bff", center: 220 / 360, width: 0.12 },
|
||||
{ id: "magenta", label: "Magenta", color: "#c95bff", center: 300 / 360, width: 0.12 }
|
||||
];
|
||||
|
||||
export const IMAGE_EDITOR_SUBFOLDER = "image_editor";
|
||||
493
static/image_editor_modules/curve.js
Normal file
493
static/image_editor_modules/curve.js
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
import { clamp01 } from "./color.js";
|
||||
|
||||
const CHANNEL_COLORS = {
|
||||
rgb: "#ffffff",
|
||||
r: "#ff7070",
|
||||
g: "#70ffa0",
|
||||
b: "#72a0ff"
|
||||
};
|
||||
|
||||
export class CurveEditor {
|
||||
constructor({ canvas, channelButtons = [], resetButton, onChange, onCommit }) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext("2d");
|
||||
this.channelButtons = channelButtons;
|
||||
this.resetButton = resetButton;
|
||||
this.onChange = onChange;
|
||||
this.onCommit = onCommit;
|
||||
|
||||
this.channels = ["rgb", "r", "g", "b"];
|
||||
this.activeChannel = "rgb";
|
||||
this.curves = this.createDefaultCurves();
|
||||
this.curveTangents = {};
|
||||
this.channels.forEach(channel => (this.curveTangents[channel] = []));
|
||||
this.luts = this.buildAllLUTs();
|
||||
this.isDragging = false;
|
||||
this.dragIndex = null;
|
||||
this.curveDirty = false;
|
||||
this.displayWidth = this.canvas.clientWidth || 240;
|
||||
this.displayHeight = this.canvas.clientHeight || 240;
|
||||
|
||||
this.resizeObserver = null;
|
||||
this.handleResize = this.handleResize.bind(this);
|
||||
this.onPointerDown = this.onPointerDown.bind(this);
|
||||
this.onPointerMove = this.onPointerMove.bind(this);
|
||||
this.onPointerUp = this.onPointerUp.bind(this);
|
||||
this.onDoubleClick = this.onDoubleClick.bind(this);
|
||||
|
||||
window.addEventListener("resize", this.handleResize);
|
||||
this.canvas.addEventListener("mousedown", this.onPointerDown);
|
||||
window.addEventListener("mousemove", this.onPointerMove);
|
||||
window.addEventListener("mouseup", this.onPointerUp);
|
||||
this.canvas.addEventListener("dblclick", this.onDoubleClick);
|
||||
|
||||
this.attachChannelButtons();
|
||||
this.attachResetButton();
|
||||
this.handleResize();
|
||||
if (window.ResizeObserver) {
|
||||
this.resizeObserver = new ResizeObserver(() => this.handleResize());
|
||||
this.resizeObserver.observe(this.canvas);
|
||||
}
|
||||
this.draw();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.resizeObserver?.disconnect();
|
||||
window.removeEventListener("resize", this.handleResize);
|
||||
this.canvas.removeEventListener("mousedown", this.onPointerDown);
|
||||
window.removeEventListener("mousemove", this.onPointerMove);
|
||||
window.removeEventListener("mouseup", this.onPointerUp);
|
||||
this.canvas.removeEventListener("dblclick", this.onDoubleClick);
|
||||
}
|
||||
|
||||
attachChannelButtons() {
|
||||
this.channelButtons.forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const channel = btn.dataset.curveChannel;
|
||||
if (channel && this.channels.includes(channel)) {
|
||||
this.activeChannel = channel;
|
||||
this.updateChannelButtons();
|
||||
this.draw();
|
||||
}
|
||||
});
|
||||
});
|
||||
this.updateChannelButtons();
|
||||
}
|
||||
|
||||
attachResetButton() {
|
||||
if (!this.resetButton) return;
|
||||
this.resetButton.addEventListener("click", () => {
|
||||
this.resetChannel(this.activeChannel);
|
||||
this.notifyChange();
|
||||
this.notifyCommit();
|
||||
});
|
||||
}
|
||||
|
||||
updateChannelButtons() {
|
||||
this.channelButtons.forEach(btn => {
|
||||
const channel = btn.dataset.curveChannel;
|
||||
btn.classList.toggle("active", channel === this.activeChannel);
|
||||
});
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const width = Math.max(1, rect.width || 240);
|
||||
const height = Math.max(1, rect.height || 240);
|
||||
this.displayWidth = width;
|
||||
this.displayHeight = height;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
this.canvas.width = width * dpr;
|
||||
this.canvas.height = height * dpr;
|
||||
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
this.ctx.scale(dpr, dpr);
|
||||
this.draw();
|
||||
}
|
||||
|
||||
createDefaultCurve() {
|
||||
return [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 1 }
|
||||
];
|
||||
}
|
||||
|
||||
createDefaultCurves() {
|
||||
return {
|
||||
rgb: this.createDefaultCurve().map(p => ({ ...p })),
|
||||
r: this.createDefaultCurve().map(p => ({ ...p })),
|
||||
g: this.createDefaultCurve().map(p => ({ ...p })),
|
||||
b: this.createDefaultCurve().map(p => ({ ...p }))
|
||||
};
|
||||
}
|
||||
|
||||
cloneCurves(source = this.curves) {
|
||||
const clone = {};
|
||||
this.channels.forEach(channel => {
|
||||
clone[channel] = (source[channel] || this.createDefaultCurve()).map(p => ({ x: p.x, y: p.y }));
|
||||
});
|
||||
return clone;
|
||||
}
|
||||
|
||||
setState(state) {
|
||||
if (!state) return;
|
||||
const incoming = this.cloneCurves(state.curves || {});
|
||||
this.curves = incoming;
|
||||
if (state.activeChannel && this.channels.includes(state.activeChannel)) {
|
||||
this.activeChannel = state.activeChannel;
|
||||
}
|
||||
this.rebuildAllLUTs();
|
||||
this.updateChannelButtons();
|
||||
this.draw();
|
||||
}
|
||||
|
||||
getState() {
|
||||
return {
|
||||
curves: this.cloneCurves(),
|
||||
activeChannel: this.activeChannel
|
||||
};
|
||||
}
|
||||
|
||||
resetChannel(channel, emit = false) {
|
||||
if (!this.channels.includes(channel)) return;
|
||||
this.curves[channel] = this.createDefaultCurve().map(p => ({ ...p }));
|
||||
this.rebuildChannelLUT(channel);
|
||||
this.draw();
|
||||
if (emit) {
|
||||
this.notifyChange();
|
||||
this.notifyCommit();
|
||||
this.curveDirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
resetAll(emit = true) {
|
||||
this.channels.forEach(channel => {
|
||||
this.curves[channel] = this.createDefaultCurve().map(p => ({ ...p }));
|
||||
});
|
||||
this.rebuildAllLUTs();
|
||||
this.draw();
|
||||
if (emit) {
|
||||
this.notifyChange();
|
||||
this.notifyCommit();
|
||||
this.curveDirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
hasAdjustments() {
|
||||
return this.channels.some(channel => !this.isDefaultCurve(this.curves[channel]));
|
||||
}
|
||||
|
||||
isDefaultCurve(curve) {
|
||||
if (!curve || curve.length !== 2) return false;
|
||||
const [start, end] = curve;
|
||||
const epsilon = 0.0001;
|
||||
return Math.abs(start.x) < epsilon && Math.abs(start.y) < epsilon &&
|
||||
Math.abs(end.x - 1) < epsilon && Math.abs(end.y - 1) < epsilon;
|
||||
}
|
||||
|
||||
notifyChange() {
|
||||
this.onChange?.();
|
||||
}
|
||||
|
||||
notifyCommit() {
|
||||
this.onCommit?.();
|
||||
}
|
||||
|
||||
getLUTPack() {
|
||||
return {
|
||||
rgb: this.isDefaultCurve(this.curves.rgb) ? null : this.luts.rgb,
|
||||
r: this.isDefaultCurve(this.curves.r) ? null : this.luts.r,
|
||||
g: this.isDefaultCurve(this.curves.g) ? null : this.luts.g,
|
||||
b: this.isDefaultCurve(this.curves.b) ? null : this.luts.b,
|
||||
hasAdjustments: this.hasAdjustments()
|
||||
};
|
||||
}
|
||||
|
||||
buildAllLUTs() {
|
||||
const result = {};
|
||||
this.channels.forEach(channel => {
|
||||
const curve = this.curves[channel];
|
||||
const tangents = this.computeTangents(curve);
|
||||
this.curveTangents[channel] = tangents;
|
||||
result[channel] = this.buildCurveLUT(curve, tangents);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
rebuildAllLUTs() {
|
||||
this.luts = this.buildAllLUTs();
|
||||
}
|
||||
|
||||
rebuildChannelLUT(channel) {
|
||||
const curve = this.curves[channel];
|
||||
const tangents = this.computeTangents(curve);
|
||||
this.curveTangents[channel] = tangents;
|
||||
this.luts[channel] = this.buildCurveLUT(curve, tangents);
|
||||
}
|
||||
|
||||
buildCurveLUT(curve, tangents = null) {
|
||||
const curveTangents = tangents || this.computeTangents(curve);
|
||||
const lut = new Uint8ClampedArray(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const pos = i / 255;
|
||||
lut[i] = Math.round(clamp01(this.sampleSmoothCurve(curve, pos, curveTangents)) * 255);
|
||||
}
|
||||
return lut;
|
||||
}
|
||||
|
||||
computeTangents(curve) {
|
||||
const n = curve.length;
|
||||
if (n < 2) return new Array(n).fill(0);
|
||||
const tangents = new Array(n).fill(0);
|
||||
const delta = new Array(n - 1).fill(0);
|
||||
const dx = new Array(n - 1).fill(0);
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
dx[i] = Math.max(1e-6, curve[i + 1].x - curve[i].x);
|
||||
delta[i] = (curve[i + 1].y - curve[i].y) / dx[i];
|
||||
}
|
||||
tangents[0] = delta[0];
|
||||
tangents[n - 1] = delta[n - 2];
|
||||
for (let i = 1; i < n - 1; i++) {
|
||||
if (delta[i - 1] * delta[i] <= 0) {
|
||||
tangents[i] = 0;
|
||||
} else {
|
||||
const w1 = 2 * dx[i] + dx[i - 1];
|
||||
const w2 = dx[i] + 2 * dx[i - 1];
|
||||
tangents[i] = (w1 + w2) / (w1 / delta[i - 1] + w2 / delta[i]);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
if (Math.abs(delta[i]) < 1e-6) {
|
||||
tangents[i] = 0;
|
||||
tangents[i + 1] = 0;
|
||||
} else {
|
||||
let alpha = tangents[i] / delta[i];
|
||||
let beta = tangents[i + 1] / delta[i];
|
||||
const sum = alpha * alpha + beta * beta;
|
||||
if (sum > 9) {
|
||||
const tau = 3 / Math.sqrt(sum);
|
||||
alpha *= tau;
|
||||
beta *= tau;
|
||||
tangents[i] = alpha * delta[i];
|
||||
tangents[i + 1] = beta * delta[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return tangents;
|
||||
}
|
||||
|
||||
sampleSmoothCurve(curve, t, tangents) {
|
||||
if (!curve || curve.length === 0) return t;
|
||||
const n = curve.length;
|
||||
if (!tangents || tangents.length !== n) {
|
||||
tangents = this.computeTangents(curve);
|
||||
}
|
||||
if (t <= curve[0].x) return curve[0].y;
|
||||
if (t >= curve[n - 1].x) return curve[n - 1].y;
|
||||
let idx = 1;
|
||||
for (; idx < n; idx++) {
|
||||
if (t <= curve[idx].x) break;
|
||||
}
|
||||
const p0 = curve[idx - 1];
|
||||
const p1 = curve[idx];
|
||||
const m0 = tangents[idx - 1] ?? 0;
|
||||
const m1 = tangents[idx] ?? 0;
|
||||
const span = p1.x - p0.x || 1e-6;
|
||||
const u = (t - p0.x) / span;
|
||||
const h00 = (2 * u ** 3) - (3 * u ** 2) + 1;
|
||||
const h10 = u ** 3 - 2 * u ** 2 + u;
|
||||
const h01 = (-2 * u ** 3) + (3 * u ** 2);
|
||||
const h11 = u ** 3 - u ** 2;
|
||||
const value = h00 * p0.y + h10 * span * m0 + h01 * p1.y + h11 * span * m1;
|
||||
return clamp01(value);
|
||||
}
|
||||
|
||||
getActiveCurve() {
|
||||
return this.curves[this.activeChannel];
|
||||
}
|
||||
|
||||
addPoint(x, y) {
|
||||
const points = this.getActiveCurve();
|
||||
let insertIndex = points.findIndex(point => x < point.x);
|
||||
if (insertIndex === -1) {
|
||||
points.push({ x, y });
|
||||
insertIndex = points.length - 1;
|
||||
} else {
|
||||
points.splice(insertIndex, 0, { x, y });
|
||||
}
|
||||
this.rebuildChannelLUT(this.activeChannel);
|
||||
this.draw();
|
||||
this.curveDirty = true;
|
||||
this.notifyChange();
|
||||
return insertIndex;
|
||||
}
|
||||
|
||||
updatePoint(index, x, y) {
|
||||
const points = this.getActiveCurve();
|
||||
const point = points[index];
|
||||
if (!point) return;
|
||||
const originalX = point.x;
|
||||
const originalY = point.y;
|
||||
if (index === 0) {
|
||||
point.x = 0;
|
||||
point.y = clamp01(y);
|
||||
} else if (index === points.length - 1) {
|
||||
point.x = 1;
|
||||
point.y = clamp01(y);
|
||||
} else {
|
||||
const minX = points[index - 1].x + 0.01;
|
||||
const maxX = points[index + 1].x - 0.01;
|
||||
point.x = clamp01(Math.min(Math.max(x, minX), maxX));
|
||||
point.y = clamp01(y);
|
||||
}
|
||||
if (Math.abs(originalX - point.x) < 0.0001 && Math.abs(originalY - point.y) < 0.0001) {
|
||||
return;
|
||||
}
|
||||
this.rebuildChannelLUT(this.activeChannel);
|
||||
this.draw();
|
||||
this.curveDirty = true;
|
||||
this.notifyChange();
|
||||
}
|
||||
|
||||
removePoint(index) {
|
||||
const points = this.getActiveCurve();
|
||||
if (index <= 0 || index >= points.length - 1) return;
|
||||
points.splice(index, 1);
|
||||
this.rebuildChannelLUT(this.activeChannel);
|
||||
this.draw();
|
||||
this.notifyChange();
|
||||
this.notifyCommit();
|
||||
this.curveDirty = false;
|
||||
}
|
||||
|
||||
getPointerPosition(event) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return null;
|
||||
const x = clamp01((event.clientX - rect.left) / rect.width);
|
||||
const y = clamp01(1 - (event.clientY - rect.top) / rect.height);
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
findPointIndex(pos, threshold = 10) {
|
||||
if (!pos) return -1;
|
||||
const points = this.getActiveCurve();
|
||||
const targetX = pos.x * this.displayWidth;
|
||||
const targetY = (1 - pos.y) * this.displayHeight;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const pt = points[i];
|
||||
const px = pt.x * this.displayWidth;
|
||||
const py = (1 - pt.y) * this.displayHeight;
|
||||
const dist = Math.hypot(px - targetX, py - targetY);
|
||||
if (dist <= threshold) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
onPointerDown(event) {
|
||||
if (event.button !== 0) return;
|
||||
const pos = this.getPointerPosition(event);
|
||||
if (!pos) return;
|
||||
event.preventDefault();
|
||||
let idx = this.findPointIndex(pos);
|
||||
if (idx === -1) {
|
||||
idx = this.addPoint(pos.x, pos.y);
|
||||
}
|
||||
this.dragIndex = idx;
|
||||
this.isDragging = true;
|
||||
this.updatePoint(idx, pos.x, pos.y);
|
||||
}
|
||||
|
||||
onPointerMove(event) {
|
||||
if (!this.isDragging || this.dragIndex === null) return;
|
||||
const pos = this.getPointerPosition(event);
|
||||
if (!pos) return;
|
||||
event.preventDefault();
|
||||
this.updatePoint(this.dragIndex, pos.x, pos.y);
|
||||
}
|
||||
|
||||
onPointerUp() {
|
||||
if (!this.isDragging) return;
|
||||
this.isDragging = false;
|
||||
this.dragIndex = null;
|
||||
if (this.curveDirty) {
|
||||
this.curveDirty = false;
|
||||
this.notifyCommit();
|
||||
}
|
||||
}
|
||||
|
||||
onDoubleClick(event) {
|
||||
const pos = this.getPointerPosition(event);
|
||||
if (!pos) return;
|
||||
const idx = this.findPointIndex(pos, 8);
|
||||
if (idx > 0 && idx < this.getActiveCurve().length - 1) {
|
||||
this.removePoint(idx);
|
||||
}
|
||||
}
|
||||
|
||||
getChannelColor() {
|
||||
return CHANNEL_COLORS[this.activeChannel] || "#ffffff";
|
||||
}
|
||||
|
||||
draw() {
|
||||
if (!this.ctx) return;
|
||||
const ctx = this.ctx;
|
||||
const w = this.displayWidth;
|
||||
const h = this.displayHeight;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
this.drawGrid(ctx, w, h);
|
||||
this.drawCurve(ctx, w, h);
|
||||
this.drawPoints(ctx, w, h);
|
||||
}
|
||||
|
||||
drawGrid(ctx, w, h) {
|
||||
ctx.fillStyle = "rgba(0,0,0,0.5)";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.08)";
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 1; i < 4; i++) {
|
||||
const x = (w / 4) * i;
|
||||
const y = (h / 4) * i;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, h);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(w, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
drawCurve(ctx, w, h) {
|
||||
const points = this.getActiveCurve();
|
||||
if (!points?.length) return;
|
||||
const tangents = this.curveTangents[this.activeChannel] || this.computeTangents(points);
|
||||
ctx.strokeStyle = this.getChannelColor();
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
const steps = 128;
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
const value = this.sampleSmoothCurve(points, t, tangents);
|
||||
const x = t * w;
|
||||
const y = (1 - value) * h;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
drawPoints(ctx, w, h) {
|
||||
const points = this.getActiveCurve();
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = this.getChannelColor();
|
||||
points.forEach(pt => {
|
||||
const x = pt.x * w;
|
||||
const y = (1 - pt.y) * h;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
});
|
||||
}
|
||||
}
|
||||
2220
static/image_editor_modules/editor.js
Normal file
2220
static/image_editor_modules/editor.js
Normal file
File diff suppressed because it is too large
Load diff
132
static/image_editor_modules/extension.js
Normal file
132
static/image_editor_modules/extension.js
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { ImageEditor } from "./editor.js";
|
||||
import { IMAGE_EDITOR_SUBFOLDER } from "./constants.js";
|
||||
import {
|
||||
parseImageWidgetValue,
|
||||
extractFilenameFromSrc,
|
||||
buildEditorFilename,
|
||||
buildImageReference,
|
||||
updateWidgetWithRef,
|
||||
createImageURLFromRef,
|
||||
setImageSource,
|
||||
refreshComboLists,
|
||||
} from "./reference.js";
|
||||
|
||||
export function registerImageEditorExtension(app, api) {
|
||||
app.registerExtension({
|
||||
name: "SDVN.ImageEditor",
|
||||
async beforeRegisterNodeDef(nodeType) {
|
||||
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
||||
nodeType.prototype.getExtraMenuOptions = function (_, options) {
|
||||
if (this.imgs && this.imgs.length > 0) {
|
||||
options.push({
|
||||
content: "🎨 Image Editor",
|
||||
callback: () => {
|
||||
const img = this.imgs[this.imgs.length - 1];
|
||||
let src = null;
|
||||
if (img && img.src) src = img.src;
|
||||
else if (img && img.image) src = img.image.src;
|
||||
|
||||
if (src) {
|
||||
new ImageEditor(src, async (blob) => {
|
||||
const formData = new FormData();
|
||||
const inferredName = extractFilenameFromSrc(src);
|
||||
const editorName = buildEditorFilename(inferredName);
|
||||
formData.append("image", blob, editorName);
|
||||
formData.append("overwrite", "false");
|
||||
formData.append("type", "input");
|
||||
formData.append("subfolder", IMAGE_EDITOR_SUBFOLDER);
|
||||
|
||||
try {
|
||||
const resp = await api.fetchApi("/upload/image", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const data = await resp.json();
|
||||
const ref = buildImageReference(data, {
|
||||
type: "input",
|
||||
subfolder: IMAGE_EDITOR_SUBFOLDER,
|
||||
filename: editorName,
|
||||
});
|
||||
const imageWidget = this.widgets?.find?.(
|
||||
(w) => w.name === "image" || w.type === "image"
|
||||
);
|
||||
if (imageWidget) {
|
||||
updateWidgetWithRef(this, imageWidget, ref);
|
||||
}
|
||||
const newSrc = createImageURLFromRef(api, ref);
|
||||
if (newSrc) {
|
||||
setImageSource(img, newSrc);
|
||||
app.graph.setDirtyCanvas(true);
|
||||
}
|
||||
await refreshComboLists(app);
|
||||
console.info("[SDVN.ImageEditor] Image saved to input folder:", data?.name || editorName);
|
||||
} catch (e) {
|
||||
console.error("[SDVN.ImageEditor] Upload failed", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
} else if (this.widgets) {
|
||||
const imageWidget = this.widgets.find((w) => w.name === "image" || w.type === "image");
|
||||
if (imageWidget && imageWidget.value) {
|
||||
options.push({
|
||||
content: "🎨 Image Editor",
|
||||
callback: () => {
|
||||
const parsed = parseImageWidgetValue(imageWidget.value);
|
||||
if (!parsed.filename) {
|
||||
console.warn("[SDVN.ImageEditor] Image not available for editing.");
|
||||
return;
|
||||
}
|
||||
const src = api.apiURL(
|
||||
`/view?filename=${encodeURIComponent(parsed.filename)}&type=${parsed.type}&subfolder=${encodeURIComponent(
|
||||
parsed.subfolder
|
||||
)}`
|
||||
);
|
||||
|
||||
new ImageEditor(src, async (blob) => {
|
||||
const formData = new FormData();
|
||||
const newName = buildEditorFilename(parsed.filename);
|
||||
formData.append("image", blob, newName);
|
||||
formData.append("overwrite", "false");
|
||||
formData.append("type", "input");
|
||||
formData.append("subfolder", IMAGE_EDITOR_SUBFOLDER);
|
||||
|
||||
try {
|
||||
const resp = await api.fetchApi("/upload/image", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const data = await resp.json();
|
||||
const ref = buildImageReference(data, {
|
||||
type: "input",
|
||||
subfolder: IMAGE_EDITOR_SUBFOLDER,
|
||||
filename: newName,
|
||||
});
|
||||
|
||||
if (imageWidget) {
|
||||
updateWidgetWithRef(this, imageWidget, ref);
|
||||
}
|
||||
|
||||
const newSrc = createImageURLFromRef(api, ref);
|
||||
|
||||
if (this.imgs && this.imgs.length > 0) {
|
||||
this.imgs.forEach((img) => setImageSource(img, newSrc));
|
||||
}
|
||||
|
||||
this.setDirtyCanvas?.(true, true);
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
await refreshComboLists(app);
|
||||
} catch (e) {
|
||||
console.error("[SDVN.ImageEditor] Upload failed", e);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return getExtraMenuOptions?.apply(this, arguments);
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
149
static/image_editor_modules/reference.js
Normal file
149
static/image_editor_modules/reference.js
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
export function buildImageReference(data, fallback = {}) {
|
||||
const ref = {
|
||||
filename: data?.name || data?.filename || fallback.filename,
|
||||
subfolder: data?.subfolder ?? fallback.subfolder ?? "",
|
||||
type: data?.type || fallback.type || "input",
|
||||
};
|
||||
if (!ref.filename) {
|
||||
return null;
|
||||
}
|
||||
return ref;
|
||||
}
|
||||
|
||||
export function buildAnnotatedLabel(ref) {
|
||||
if (!ref?.filename) return "";
|
||||
const path = ref.subfolder ? `${ref.subfolder}/${ref.filename}` : ref.filename;
|
||||
return `${path} [${ref.type || "input"}]`;
|
||||
}
|
||||
|
||||
export function parseImageWidgetValue(value) {
|
||||
const defaults = { filename: null, subfolder: "", type: "input" };
|
||||
if (!value) return defaults;
|
||||
if (typeof value === "object") {
|
||||
return {
|
||||
filename: value.filename || null,
|
||||
subfolder: value.subfolder || "",
|
||||
type: value.type || "input",
|
||||
};
|
||||
}
|
||||
|
||||
const raw = value.toString().trim();
|
||||
let type = "input";
|
||||
let path = raw;
|
||||
const match = raw.match(/\[([^\]]+)\]\s*$/);
|
||||
if (match) {
|
||||
type = match[1].trim() || "input";
|
||||
path = raw.slice(0, match.index).trim();
|
||||
}
|
||||
path = path.replace(/^[\\/]+/, "");
|
||||
const parts = path.split(/[\\/]/).filter(Boolean);
|
||||
const filename = parts.pop() || null;
|
||||
const subfolder = parts.join("/") || "";
|
||||
return { filename, subfolder, type };
|
||||
}
|
||||
|
||||
export function sanitizeFilenamePart(part) {
|
||||
return (part || "")
|
||||
.replace(/[\\/]/g, "_")
|
||||
.replace(/[<>:"|?*\x00-\x1F]/g, "_")
|
||||
.replace(/\s+/g, "_");
|
||||
}
|
||||
|
||||
export function buildEditorFilename(sourceName) {
|
||||
let name = sourceName ? sourceName.toString() : "";
|
||||
name = name.split(/[\\/]/).pop() || "";
|
||||
name = name.replace(/\.[^.]+$/, "");
|
||||
name = sanitizeFilenamePart(name);
|
||||
if (!name) name = `image_${Date.now()}`;
|
||||
return `${name}.png`;
|
||||
}
|
||||
|
||||
export function extractFilenameFromSrc(src) {
|
||||
if (!src) return null;
|
||||
try {
|
||||
const url = new URL(src, window.location.origin);
|
||||
return url.searchParams.get("filename");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatWidgetValueFromRef(ref, currentValue) {
|
||||
if (currentValue && typeof currentValue === "object") {
|
||||
return {
|
||||
...currentValue,
|
||||
filename: ref.filename,
|
||||
subfolder: ref.subfolder,
|
||||
type: ref.type,
|
||||
};
|
||||
}
|
||||
return buildAnnotatedLabel(ref);
|
||||
}
|
||||
|
||||
export function updateWidgetWithRef(node, widget, ref) {
|
||||
if (!node || !widget || !ref) return;
|
||||
const annotatedLabel = buildAnnotatedLabel(ref);
|
||||
const storedValue = formatWidgetValueFromRef(ref, widget.value);
|
||||
widget.value = storedValue;
|
||||
widget.callback?.(storedValue);
|
||||
if (widget.inputEl) {
|
||||
widget.inputEl.value = annotatedLabel;
|
||||
}
|
||||
|
||||
if (Array.isArray(node.widgets_values)) {
|
||||
const idx = node.widgets?.indexOf?.(widget) ?? -1;
|
||||
if (idx >= 0) {
|
||||
node.widgets_values[idx] = annotatedLabel;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(node.inputs)) {
|
||||
node.inputs.forEach(input => {
|
||||
if (!input?.widget) return;
|
||||
if (input.widget === widget || (widget.name && input.widget.name === widget.name)) {
|
||||
input.widget.value = annotatedLabel;
|
||||
if (input.widget.inputEl) {
|
||||
input.widget.inputEl.value = annotatedLabel;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof annotatedLabel === "string" && widget.options?.values) {
|
||||
const values = widget.options.values;
|
||||
if (Array.isArray(values) && !values.includes(annotatedLabel)) {
|
||||
values.push(annotatedLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createImageURLFromRef(api, ref) {
|
||||
if (!ref?.filename) return null;
|
||||
const params = new URLSearchParams();
|
||||
params.set("filename", ref.filename);
|
||||
params.set("type", ref.type || "input");
|
||||
params.set("subfolder", ref.subfolder || "");
|
||||
params.set("t", Date.now().toString());
|
||||
return api.apiURL(`/view?${params.toString()}`);
|
||||
}
|
||||
|
||||
export function setImageSource(target, newSrc) {
|
||||
if (!target || !newSrc) return;
|
||||
if (target instanceof Image) {
|
||||
target.src = newSrc;
|
||||
} else if (target.image instanceof Image) {
|
||||
target.image.src = newSrc;
|
||||
} else if (target.img instanceof Image) {
|
||||
target.img.src = newSrc;
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshComboLists(app) {
|
||||
if (typeof app.refreshComboInNodes === "function") {
|
||||
try {
|
||||
await app.refreshComboInNodes();
|
||||
} catch (err) {
|
||||
console.warn("SDVN.ImageEditor: refreshComboInNodes failed", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
435
static/image_editor_modules/styles.js
Normal file
435
static/image_editor_modules/styles.js
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
const STYLE_ID = "sdvn-image-editor-style";
|
||||
|
||||
const IMAGE_EDITOR_CSS = `
|
||||
:root {
|
||||
--apix-bg: #0f0f0f;
|
||||
--apix-panel: #1a1a1a;
|
||||
--apix-border: #2a2a2a;
|
||||
--apix-text: #e0e0e0;
|
||||
--apix-text-dim: #888;
|
||||
--apix-accent: #f5c518; /* Yellow accent from apix */
|
||||
--apix-accent-hover: #ffd54f;
|
||||
--apix-danger: #ff4444;
|
||||
}
|
||||
.apix-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: var(--apix-bg);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
color: var(--apix-text);
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Left Sidebar (Tools) */
|
||||
.apix-sidebar-left {
|
||||
width: 60px;
|
||||
background: var(--apix-panel);
|
||||
border-right: 1px solid var(--apix-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
gap: 15px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Main Canvas Area */
|
||||
.apix-main-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
.apix-header {
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
background: var(--apix-panel);
|
||||
border-bottom: 1px solid var(--apix-border);
|
||||
}
|
||||
.apix-header-title {
|
||||
font-weight: 700;
|
||||
color: var(--apix-accent);
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.apix-canvas-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: grab;
|
||||
}
|
||||
.apix-canvas-container:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Bottom Bar (Zoom) */
|
||||
.apix-bottom-bar {
|
||||
height: 40px;
|
||||
background: var(--apix-panel);
|
||||
border-top: 1px solid var(--apix-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Right Sidebar (Adjustments) */
|
||||
.apix-sidebar-right {
|
||||
width: 320px;
|
||||
background: var(--apix-panel);
|
||||
border-left: 1px solid var(--apix-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 10;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
.apix-sidebar-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 20px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--apix-accent) transparent;
|
||||
}
|
||||
.apix-sidebar-scroll::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.apix-sidebar-scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--apix-accent);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.apix-sidebar-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* UI Components */
|
||||
.apix-tool-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--apix-text-dim);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.apix-tool-btn:hover {
|
||||
color: var(--apix-text);
|
||||
background: rgba(255,255,255,0.05);
|
||||
}
|
||||
.apix-tool-btn.active {
|
||||
color: #000;
|
||||
background: var(--apix-accent);
|
||||
}
|
||||
.apix-tool-btn.icon-only svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.apix-sidebar-divider {
|
||||
width: 24px;
|
||||
height: 1px;
|
||||
background: var(--apix-border);
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.apix-panel-section {
|
||||
border-bottom: 1px solid var(--apix-border);
|
||||
}
|
||||
.apix-panel-header {
|
||||
padding: 15px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: rgba(255,255,255,0.02);
|
||||
user-select: none;
|
||||
}
|
||||
.apix-panel-header span:first-child {
|
||||
color: #8d8d8d;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.apix-panel-header:hover {
|
||||
background: rgba(255,255,255,0.05);
|
||||
}
|
||||
.apix-panel-content {
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
.apix-panel-content.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.apix-control-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.apix-control-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: var(--apix-text-dim);
|
||||
letter-spacing: 0.2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.apix-slider-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.apix-slider-meta span {
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.apix-slider-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-right: 26px;
|
||||
}
|
||||
.apix-slider-reset {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--apix-text-dim);
|
||||
cursor: pointer;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 56%;
|
||||
transform: translateY(-50%);
|
||||
opacity: 0.4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.2s, color 0.2s;
|
||||
}
|
||||
.apix-slider-reset:hover {
|
||||
opacity: 1;
|
||||
color: var(--apix-accent);
|
||||
}
|
||||
.apix-slider-reset svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.apix-curve-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.apix-curve-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: var(--apix-text-dim);
|
||||
gap: 8px;
|
||||
}
|
||||
.apix-curve-channel-buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.apix-curve-channel-btn {
|
||||
border: 1px solid var(--apix-border);
|
||||
background: transparent;
|
||||
color: var(--apix-text-dim);
|
||||
font-size: 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.apix-curve-channel-btn.active {
|
||||
background: var(--apix-accent);
|
||||
color: #000;
|
||||
border-color: var(--apix-accent);
|
||||
}
|
||||
.apix-curve-reset {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--apix-accent);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.apix-curve-stage {
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
border: 1px solid var(--apix-border);
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.05) 0%, rgba(0,0,0,0.25) 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.apix-curve-stage canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.apix-slider {
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: #333;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
.apix-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--apix-accent);
|
||||
cursor: pointer;
|
||||
border: 2px solid #1a1a1a;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
.apix-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.apix-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.apix-btn-primary {
|
||||
background: var(--apix-accent);
|
||||
color: #000;
|
||||
}
|
||||
.apix-btn-primary:hover {
|
||||
background: var(--apix-accent-hover);
|
||||
}
|
||||
.apix-btn-secondary {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
}
|
||||
.apix-btn-secondary:hover {
|
||||
background: #444;
|
||||
}
|
||||
.apix-btn-toggle.active {
|
||||
background: var(--apix-accent);
|
||||
color: #000;
|
||||
}
|
||||
.apix-hsl-swatches {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.apix-hsl-chip {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
background: var(--chip-color, #fff);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, border 0.2s;
|
||||
}
|
||||
.apix-hsl-chip.active {
|
||||
border-color: var(--apix-accent);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.apix-hsl-slider .apix-slider-meta span {
|
||||
font-size: 11px;
|
||||
color: var(--apix-text-dim);
|
||||
}
|
||||
.apix-hsl-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
color: var(--apix-text-dim);
|
||||
}
|
||||
.apix-hsl-reset {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--apix-accent);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.apix-sidebar-right {
|
||||
position: relative;
|
||||
}
|
||||
.apix-footer {
|
||||
padding: 20px;
|
||||
border-top: 1px solid var(--apix-border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
background: var(--apix-panel);
|
||||
}
|
||||
|
||||
/* Crop Overlay */
|
||||
.apix-crop-overlay {
|
||||
position: absolute;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.7);
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
}
|
||||
.apix-crop-handle {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--apix-accent);
|
||||
border: 1px solid #000;
|
||||
pointer-events: auto;
|
||||
z-index: 100;
|
||||
}
|
||||
/* Handle positions */
|
||||
.handle-tl { top: -6px; left: -6px; cursor: nw-resize; }
|
||||
.handle-tr { top: -6px; right: -6px; cursor: ne-resize; }
|
||||
.handle-bl { bottom: -6px; left: -6px; cursor: sw-resize; }
|
||||
.handle-br { bottom: -6px; right: -6px; cursor: se-resize; }
|
||||
/* Edges */
|
||||
.handle-t { top: -6px; left: 50%; transform: translateX(-50%); cursor: n-resize; }
|
||||
.handle-b { bottom: -6px; left: 50%; transform: translateX(-50%); cursor: s-resize; }
|
||||
.handle-l { left: -6px; top: 50%; transform: translateY(-50%); cursor: w-resize; }
|
||||
.handle-r { right: -6px; top: 50%; transform: translateY(-50%); cursor: e-resize; }
|
||||
`;
|
||||
|
||||
export function injectImageEditorStyles() {
|
||||
if (document.getElementById(STYLE_ID)) {
|
||||
return;
|
||||
}
|
||||
const style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = IMAGE_EDITOR_CSS;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
|
@ -3,33 +3,97 @@ import { extractMetadataFromBlob } from './metadata.js';
|
|||
|
||||
const FILTER_STORAGE_KEY = 'gemini-app-history-filter';
|
||||
const SEARCH_STORAGE_KEY = 'gemini-app-history-search';
|
||||
const FAVORITES_STORAGE_KEY = 'gemini-app-history-favorites';
|
||||
const SOURCE_STORAGE_KEY = 'gemini-app-history-source';
|
||||
const VALID_SOURCES = ['generated', 'uploads'];
|
||||
const DEFAULT_STATE = {
|
||||
filter: 'all',
|
||||
search: '',
|
||||
favorites: false,
|
||||
};
|
||||
|
||||
function createStateMap(fallback) {
|
||||
return {
|
||||
generated: fallback,
|
||||
uploads: fallback,
|
||||
};
|
||||
}
|
||||
|
||||
function parseStoredMap(key, fallback) {
|
||||
const defaultMap = createStateMap(fallback);
|
||||
let raw;
|
||||
try {
|
||||
raw = localStorage.getItem(key);
|
||||
if (!raw) return defaultMap;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return {
|
||||
generated: parsed.generated ?? parsed.default ?? fallback,
|
||||
uploads: parsed.uploads ?? parsed.default ?? fallback,
|
||||
};
|
||||
}
|
||||
// Backward compatibility: single value string or number
|
||||
return createStateMap(raw);
|
||||
} catch (e) {
|
||||
// If parsing failed but raw exists, treat it as a primitive single value
|
||||
if (raw) {
|
||||
return createStateMap(raw);
|
||||
}
|
||||
console.warn('Failed to load history state', e);
|
||||
return defaultMap;
|
||||
}
|
||||
}
|
||||
|
||||
function persistStateMap(key, map) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(map));
|
||||
} catch (e) {
|
||||
console.warn('Failed to save history state', e);
|
||||
}
|
||||
}
|
||||
|
||||
export function createGallery({ galleryGrid, onSelect }) {
|
||||
let currentFilter = 'all';
|
||||
let searchQuery = '';
|
||||
let currentFilter = DEFAULT_STATE.filter;
|
||||
let searchQuery = DEFAULT_STATE.search;
|
||||
let currentSource = 'generated';
|
||||
let allImages = [];
|
||||
let favorites = [];
|
||||
let showOnlyFavorites = false; // New toggle state
|
||||
let showOnlyFavorites = DEFAULT_STATE.favorites;
|
||||
const stateBySource = {
|
||||
generated: { ...DEFAULT_STATE },
|
||||
uploads: { ...DEFAULT_STATE },
|
||||
};
|
||||
|
||||
// Load saved filter, search and favorites from localStorage (per source)
|
||||
const savedFilters = parseStoredMap(FILTER_STORAGE_KEY, DEFAULT_STATE.filter);
|
||||
const savedSearches = parseStoredMap(SEARCH_STORAGE_KEY, DEFAULT_STATE.search);
|
||||
const savedFavorites = parseStoredMap(FAVORITES_STORAGE_KEY, DEFAULT_STATE.favorites);
|
||||
|
||||
stateBySource.generated.filter = savedFilters.generated;
|
||||
stateBySource.uploads.filter = savedFilters.uploads;
|
||||
stateBySource.generated.search = savedSearches.generated;
|
||||
stateBySource.uploads.search = savedSearches.uploads;
|
||||
stateBySource.generated.favorites = savedFavorites.generated;
|
||||
stateBySource.uploads.favorites = savedFavorites.uploads;
|
||||
|
||||
// Load saved filter and search from localStorage
|
||||
try {
|
||||
const savedFilter = localStorage.getItem(FILTER_STORAGE_KEY);
|
||||
if (savedFilter) currentFilter = savedFilter;
|
||||
|
||||
const savedSearch = localStorage.getItem(SEARCH_STORAGE_KEY);
|
||||
if (savedSearch) searchQuery = savedSearch;
|
||||
|
||||
const savedSource = localStorage.getItem(SOURCE_STORAGE_KEY);
|
||||
if (savedSource && VALID_SOURCES.includes(savedSource)) {
|
||||
currentSource = savedSource;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load history filter/search', e);
|
||||
console.warn('Failed to load history source', e);
|
||||
}
|
||||
|
||||
function applySourceState(source) {
|
||||
const state = stateBySource[source] || DEFAULT_STATE;
|
||||
currentFilter = state.filter ?? DEFAULT_STATE.filter;
|
||||
searchQuery = state.search ?? DEFAULT_STATE.search;
|
||||
showOnlyFavorites = state.favorites ?? DEFAULT_STATE.favorites;
|
||||
}
|
||||
|
||||
applySourceState(currentSource);
|
||||
|
||||
// Load favorites from backend
|
||||
async function loadFavorites() {
|
||||
try {
|
||||
|
|
@ -306,32 +370,35 @@ export function createGallery({ galleryGrid, onSelect }) {
|
|||
function setFilter(filterType) {
|
||||
if (currentFilter === filterType) return;
|
||||
currentFilter = filterType;
|
||||
stateBySource[currentSource].filter = filterType;
|
||||
|
||||
// Save to localStorage
|
||||
try {
|
||||
localStorage.setItem(FILTER_STORAGE_KEY, filterType);
|
||||
} catch (e) {
|
||||
console.warn('Failed to save history filter', e);
|
||||
}
|
||||
persistStateMap(FILTER_STORAGE_KEY, {
|
||||
generated: stateBySource.generated.filter,
|
||||
uploads: stateBySource.uploads.filter,
|
||||
});
|
||||
|
||||
renderGallery();
|
||||
}
|
||||
|
||||
function setSearch(query) {
|
||||
searchQuery = query || '';
|
||||
stateBySource[currentSource].search = searchQuery;
|
||||
|
||||
// Save to localStorage
|
||||
try {
|
||||
localStorage.setItem(SEARCH_STORAGE_KEY, searchQuery);
|
||||
} catch (e) {
|
||||
console.warn('Failed to save history search', e);
|
||||
}
|
||||
persistStateMap(SEARCH_STORAGE_KEY, {
|
||||
generated: stateBySource.generated.search,
|
||||
uploads: stateBySource.uploads.search,
|
||||
});
|
||||
|
||||
renderGallery();
|
||||
}
|
||||
|
||||
function toggleFavorites() {
|
||||
showOnlyFavorites = !showOnlyFavorites;
|
||||
stateBySource[currentSource].favorites = showOnlyFavorites;
|
||||
persistStateMap(FAVORITES_STORAGE_KEY, {
|
||||
generated: stateBySource.generated.favorites,
|
||||
uploads: stateBySource.uploads.favorites,
|
||||
});
|
||||
renderGallery();
|
||||
return showOnlyFavorites;
|
||||
}
|
||||
|
|
@ -345,16 +412,21 @@ export function createGallery({ galleryGrid, onSelect }) {
|
|||
console.warn('Failed to save history source', e);
|
||||
}
|
||||
if (resetFilters) {
|
||||
currentFilter = 'all';
|
||||
showOnlyFavorites = false;
|
||||
searchQuery = '';
|
||||
try {
|
||||
localStorage.setItem(FILTER_STORAGE_KEY, currentFilter);
|
||||
localStorage.setItem(SEARCH_STORAGE_KEY, searchQuery);
|
||||
} catch (e) {
|
||||
console.warn('Failed to reset history filters', e);
|
||||
}
|
||||
stateBySource[currentSource] = { ...DEFAULT_STATE };
|
||||
persistStateMap(FILTER_STORAGE_KEY, {
|
||||
generated: stateBySource.generated.filter,
|
||||
uploads: stateBySource.uploads.filter,
|
||||
});
|
||||
persistStateMap(SEARCH_STORAGE_KEY, {
|
||||
generated: stateBySource.generated.search,
|
||||
uploads: stateBySource.uploads.search,
|
||||
});
|
||||
persistStateMap(FAVORITES_STORAGE_KEY, {
|
||||
generated: stateBySource.generated.favorites,
|
||||
uploads: stateBySource.uploads.favorites,
|
||||
});
|
||||
}
|
||||
applySourceState(currentSource);
|
||||
return load();
|
||||
}
|
||||
|
||||
|
|
@ -376,6 +448,11 @@ export function createGallery({ galleryGrid, onSelect }) {
|
|||
|
||||
function setFavoritesActive(active) {
|
||||
showOnlyFavorites = Boolean(active);
|
||||
stateBySource[currentSource].favorites = showOnlyFavorites;
|
||||
persistStateMap(FAVORITES_STORAGE_KEY, {
|
||||
generated: stateBySource.generated.favorites,
|
||||
uploads: stateBySource.uploads.favorites,
|
||||
});
|
||||
renderGallery();
|
||||
return showOnlyFavorites;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { dataUrlToBlob, withCacheBuster } from './utils.js';
|
||||
import { ImageEditor } from '../image_editor_modules/editor.js';
|
||||
import { injectImageEditorStyles } from '../image_editor_modules/styles.js';
|
||||
|
||||
export function createReferenceSlotManager(imageInputGrid, options = {}) {
|
||||
const MAX_IMAGE_SLOTS = 16;
|
||||
|
|
@ -6,6 +8,9 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) {
|
|||
const onChange = options.onChange;
|
||||
const imageSlotState = [];
|
||||
let cachedReferenceImages = [];
|
||||
|
||||
// Inject image editor styles once
|
||||
injectImageEditorStyles();
|
||||
|
||||
function initialize(initialCached = []) {
|
||||
cachedReferenceImages = Array.isArray(initialCached) ? initialCached : [];
|
||||
|
|
@ -75,6 +80,13 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) {
|
|||
preview.alt = 'Uploaded reference';
|
||||
slot.appendChild(preview);
|
||||
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.type = 'button';
|
||||
editBtn.className = 'slot-edit hidden';
|
||||
editBtn.setAttribute('aria-label', 'Edit image');
|
||||
editBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M2 8H13M22 8H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M22 16H11M2 16H5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="16" cy="8" r="3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle r="3" transform="matrix(-1 0 0 1 8 16)" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
slot.appendChild(editBtn);
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.className = 'slot-remove hidden';
|
||||
|
|
@ -89,7 +101,7 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) {
|
|||
slot.appendChild(input);
|
||||
|
||||
slot.addEventListener('click', event => {
|
||||
if (event.target === removeBtn) return;
|
||||
if (event.target === removeBtn || event.target === editBtn) return;
|
||||
input.click();
|
||||
});
|
||||
|
||||
|
|
@ -129,6 +141,11 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) {
|
|||
}
|
||||
});
|
||||
|
||||
editBtn.addEventListener('click', event => {
|
||||
event.stopPropagation();
|
||||
handleEditImage(index);
|
||||
});
|
||||
|
||||
removeBtn.addEventListener('click', event => {
|
||||
event.stopPropagation();
|
||||
clearSlot(index);
|
||||
|
|
@ -201,12 +218,14 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) {
|
|||
const slot = slotRecord.slot;
|
||||
const placeholder = slot.querySelector('.slot-placeholder');
|
||||
const preview = slot.querySelector('.slot-preview');
|
||||
const editBtn = slot.querySelector('.slot-edit');
|
||||
const removeBtn = slot.querySelector('.slot-remove');
|
||||
|
||||
if (slotRecord.data && slotRecord.data.preview) {
|
||||
preview.src = slotRecord.data.preview;
|
||||
preview.classList.remove('hidden');
|
||||
placeholder.classList.add('hidden');
|
||||
editBtn.classList.remove('hidden');
|
||||
removeBtn.classList.remove('hidden');
|
||||
slot.classList.add('filled');
|
||||
slot.classList.remove('empty');
|
||||
|
|
@ -214,6 +233,7 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) {
|
|||
preview.src = '';
|
||||
preview.classList.add('hidden');
|
||||
placeholder.classList.remove('hidden');
|
||||
editBtn.classList.add('hidden');
|
||||
removeBtn.classList.add('hidden');
|
||||
slot.classList.add('empty');
|
||||
slot.classList.remove('filled');
|
||||
|
|
@ -230,6 +250,23 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) {
|
|||
onChange?.();
|
||||
}
|
||||
|
||||
function handleEditImage(index) {
|
||||
const slotRecord = imageSlotState[index];
|
||||
if (!slotRecord || !slotRecord.data || !slotRecord.data.preview) return;
|
||||
|
||||
const imageSrc = slotRecord.data.preview;
|
||||
|
||||
new ImageEditor(imageSrc, async (blob) => {
|
||||
// Convert blob to file
|
||||
const fileName = slotRecord.data.file?.name || slotRecord.data.cached?.name || `edited-${index + 1}.png`;
|
||||
const file = new File([blob], fileName, { type: blob.type || 'image/png' });
|
||||
|
||||
// Update the slot with the edited image
|
||||
// Treat edited images as new uploads so they are sent as files (not paths)
|
||||
handleSlotFile(index, file, null);
|
||||
});
|
||||
}
|
||||
|
||||
function maybeAddSlot() {
|
||||
const hasEmpty = imageSlotState.some(record => !record.data);
|
||||
if (!hasEmpty && imageSlotState.length < MAX_IMAGE_SLOTS) {
|
||||
|
|
@ -303,11 +340,29 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) {
|
|||
return null;
|
||||
}
|
||||
|
||||
async function addReferenceFromUrl(url) {
|
||||
// Find first empty slot
|
||||
let emptyIndex = imageSlotState.findIndex(record => !record.data);
|
||||
|
||||
if (emptyIndex === -1) {
|
||||
if (imageSlotState.length < MAX_IMAGE_SLOTS) {
|
||||
addImageSlot();
|
||||
emptyIndex = imageSlotState.length - 1;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
await handleSlotDropFromHistory(emptyIndex, url);
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
initialize,
|
||||
getReferenceFiles,
|
||||
getReferencePaths,
|
||||
serializeReferenceImages,
|
||||
setReferenceImages,
|
||||
addReferenceFromUrl,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
266
static/script.js
266
static/script.js
|
|
@ -11,7 +11,7 @@ const ZOOM_STEP = 0.1;
|
|||
const MIN_ZOOM = 0.4;
|
||||
const MAX_ZOOM = 4;
|
||||
const SIDEBAR_MIN_WIDTH = 260;
|
||||
const SIDEBAR_MAX_WIDTH = 520;
|
||||
const SIDEBAR_MAX_WIDTH = 1000;
|
||||
|
||||
const infoContent = {
|
||||
title: 'Thông tin',
|
||||
|
|
@ -102,6 +102,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
const downloadLink = document.getElementById('download-link');
|
||||
const galleryGrid = document.getElementById('gallery-grid');
|
||||
const imageInputGrid = document.getElementById('image-input-grid');
|
||||
const referenceUrlInput = document.getElementById('reference-url-input');
|
||||
const imageDisplayArea = document.querySelector('.image-display-area');
|
||||
const canvasToolbar = document.querySelector('.canvas-toolbar');
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
|
|
@ -131,10 +132,28 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
if (apiModelSelect) {
|
||||
apiModelSelect.addEventListener('change', () => {
|
||||
toggleResolutionVisibility();
|
||||
toggleCookiesVisibility();
|
||||
persistSettings();
|
||||
});
|
||||
}
|
||||
|
||||
const whiskCookiesGroup = document.getElementById('whisk-cookies-group');
|
||||
const whiskCookiesInput = document.getElementById('whisk-cookies');
|
||||
|
||||
function toggleCookiesVisibility() {
|
||||
if (whiskCookiesGroup && apiModelSelect) {
|
||||
if (apiModelSelect.value === 'whisk') {
|
||||
whiskCookiesGroup.classList.remove('hidden');
|
||||
} else {
|
||||
whiskCookiesGroup.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (whiskCookiesInput) {
|
||||
whiskCookiesInput.addEventListener('input', persistSettings);
|
||||
}
|
||||
|
||||
// Load Settings
|
||||
function loadSettings() {
|
||||
try {
|
||||
|
|
@ -155,6 +174,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
if (bodyFontSelect && settings.bodyFont) {
|
||||
bodyFontSelect.value = settings.bodyFont;
|
||||
}
|
||||
if (whiskCookiesInput && settings.whiskCookies) {
|
||||
whiskCookiesInput.value = settings.whiskCookies;
|
||||
}
|
||||
toggleCookiesVisibility();
|
||||
return settings;
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -168,7 +191,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
const referenceImages = (typeof slotManager !== 'undefined' && typeof slotManager.serializeReferenceImages === 'function')
|
||||
? slotManager.serializeReferenceImages()
|
||||
: [];
|
||||
|
||||
|
||||
const settings = {
|
||||
apiKey: apiKeyInput.value,
|
||||
prompt: promptInput.value,
|
||||
|
|
@ -179,6 +202,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
referenceImages,
|
||||
theme: currentTheme || DEFAULT_THEME,
|
||||
bodyFont: bodyFontSelect ? bodyFontSelect.value : DEFAULT_BODY_FONT,
|
||||
whiskCookies: whiskCookiesInput ? whiskCookiesInput.value : '',
|
||||
};
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
|
||||
|
|
@ -198,12 +222,16 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
const selectedModel = model || (apiModelSelect ? apiModelSelect.value : 'gemini-3-pro-image-preview');
|
||||
formData.append('model', selectedModel);
|
||||
|
||||
if (whiskCookiesInput && whiskCookiesInput.value) {
|
||||
formData.append('cookies', whiskCookiesInput.value);
|
||||
}
|
||||
|
||||
// Add reference images using correct slotManager methods
|
||||
const referenceFiles = slotManager.getReferenceFiles();
|
||||
referenceFiles.forEach(file => {
|
||||
formData.append('reference_images', file);
|
||||
});
|
||||
|
||||
|
||||
const referencePaths = slotManager.getReferencePaths();
|
||||
if (referencePaths && referencePaths.length > 0) {
|
||||
formData.append('reference_image_paths', JSON.stringify(referencePaths));
|
||||
|
|
@ -452,6 +480,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
persistSettings();
|
||||
refreshPromptHighlight();
|
||||
}
|
||||
// Fill note (default empty when absent)
|
||||
promptNoteInput.value = template.note !== undefined ? (i18n.getText(template.note) || '') : '';
|
||||
refreshNoteHighlight();
|
||||
persistSettings();
|
||||
// Stay in template gallery view - don't auto-switch
|
||||
// User will switch view by selecting image from history or generating
|
||||
}
|
||||
|
|
@ -587,14 +619,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
// 2. Item currently being processed (isProcessingQueue)
|
||||
// 3. Items waiting for backend response (pendingRequests)
|
||||
const count = generationQueue.length + (isProcessingQueue ? 1 : 0) + pendingRequests;
|
||||
|
||||
console.log('Queue counter update:', {
|
||||
queue: generationQueue.length,
|
||||
processing: isProcessingQueue,
|
||||
|
||||
console.log('Queue counter update:', {
|
||||
queue: generationQueue.length,
|
||||
processing: isProcessingQueue,
|
||||
pending: pendingRequests,
|
||||
total: count
|
||||
total: count
|
||||
});
|
||||
|
||||
|
||||
if (count > 0) {
|
||||
if (queueCounter) {
|
||||
queueCounter.classList.remove('hidden');
|
||||
|
|
@ -618,10 +650,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
const task = generationQueue.shift();
|
||||
isProcessingQueue = true;
|
||||
updateQueueCounter(); // Show counter immediately
|
||||
|
||||
|
||||
try {
|
||||
setViewState('loading');
|
||||
|
||||
|
||||
// Check if this task already has a result (immediate generation)
|
||||
if (task.immediateResult) {
|
||||
// Display the already-generated image
|
||||
|
|
@ -725,7 +757,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
// Mark fetch as completed and decrement pending
|
||||
// We do this BEFORE adding to queue to avoid double counting
|
||||
fetchCompleted = true;
|
||||
|
|
@ -780,12 +812,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
}
|
||||
} catch (error) {
|
||||
console.error('Error in addToQueue:', error);
|
||||
|
||||
|
||||
// If fetch failed (didn't complete), we need to decrement pendingRequests
|
||||
if (!fetchCompleted) {
|
||||
pendingRequests--;
|
||||
}
|
||||
|
||||
|
||||
updateQueueCounter();
|
||||
showError(error.message);
|
||||
}
|
||||
|
|
@ -811,7 +843,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
const blobUrl = window.URL.createObjectURL(blob);
|
||||
|
||||
|
||||
const tempLink = document.createElement('a');
|
||||
tempLink.href = blobUrl;
|
||||
tempLink.download = filename;
|
||||
|
|
@ -829,7 +861,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
if (imageDisplayArea) {
|
||||
imageDisplayArea.addEventListener('wheel', handleCanvasWheel, { passive: false });
|
||||
imageDisplayArea.addEventListener('pointerdown', handleCanvasPointerDown);
|
||||
|
||||
|
||||
// Drag and drop support
|
||||
imageDisplayArea.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -844,7 +876,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
imageDisplayArea.addEventListener('drop', async (e) => {
|
||||
e.preventDefault();
|
||||
imageDisplayArea.classList.remove('drag-over');
|
||||
|
||||
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
|
|
@ -853,7 +885,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
// Display image immediately
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
displayImage(objectUrl);
|
||||
|
||||
|
||||
// Extract and apply metadata
|
||||
const metadata = await extractMetadataFromBlob(file);
|
||||
if (metadata) {
|
||||
|
|
@ -960,9 +992,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
const createTemplateModal = document.getElementById('create-template-modal');
|
||||
const closeTemplateModalBtn = document.getElementById('close-template-modal');
|
||||
const saveTemplateBtn = document.getElementById('save-template-btn');
|
||||
|
||||
|
||||
const templateTitleInput = document.getElementById('template-title');
|
||||
const templatePromptInput = document.getElementById('template-prompt');
|
||||
const templateNoteInput = document.getElementById('template-note');
|
||||
const templateModeSelect = document.getElementById('template-mode');
|
||||
const templateCategorySelect = document.getElementById('template-category-select');
|
||||
const templateCategoryInput = document.getElementById('template-category-input');
|
||||
|
|
@ -1183,14 +1216,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
}
|
||||
|
||||
// Global function for opening edit modal (called from templateGallery.js)
|
||||
window.openEditTemplateModal = async function(template) {
|
||||
window.openEditTemplateModal = async function (template) {
|
||||
editingTemplate = template;
|
||||
editingTemplateSource = template.isUserTemplate ? 'user' : 'builtin';
|
||||
editingBuiltinIndex = editingTemplateSource === 'builtin' ? template.builtinTemplateIndex : null;
|
||||
|
||||
|
||||
// Pre-fill with template data
|
||||
templateTitleInput.value = template.title || '';
|
||||
templatePromptInput.value = template.prompt || '';
|
||||
templateNoteInput.value = i18n.getText(template.note) || '';
|
||||
templateModeSelect.value = template.mode || 'generate';
|
||||
templateCategoryInput.classList.add('hidden');
|
||||
templateCategoryInput.value = '';
|
||||
|
|
@ -1199,18 +1233,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
try {
|
||||
const response = await fetch('/prompts');
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (data.prompts) {
|
||||
const categories = new Set();
|
||||
data.prompts.forEach(t => {
|
||||
if (t.category) {
|
||||
const categoryText = typeof t.category === 'string'
|
||||
? t.category
|
||||
const categoryText = typeof t.category === 'string'
|
||||
? t.category
|
||||
: (t.category.vi || t.category.en || '');
|
||||
if (categoryText) categories.add(categoryText);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
templateCategorySelect.innerHTML = '';
|
||||
const sortedCategories = Array.from(categories).sort();
|
||||
sortedCategories.forEach(cat => {
|
||||
|
|
@ -1219,15 +1253,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
option.textContent = cat;
|
||||
templateCategorySelect.appendChild(option);
|
||||
});
|
||||
|
||||
|
||||
const newOption = document.createElement('option');
|
||||
newOption.value = 'new';
|
||||
newOption.textContent = '+ New Category';
|
||||
templateCategorySelect.appendChild(newOption);
|
||||
|
||||
|
||||
// Set to template's category
|
||||
const templateCategory = typeof template.category === 'string'
|
||||
? template.category
|
||||
const templateCategory = typeof template.category === 'string'
|
||||
? template.category
|
||||
: (template.category.vi || template.category.en || '');
|
||||
templateCategorySelect.value = templateCategory || 'User';
|
||||
}
|
||||
|
|
@ -1256,16 +1290,16 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
|
||||
// Update button text
|
||||
saveTemplateBtn.innerHTML = '<span>Update Template</span><div class="btn-shine"></div>';
|
||||
|
||||
|
||||
createTemplateModal.classList.remove('hidden');
|
||||
};
|
||||
|
||||
// Global function for opening create modal with empty values (called from templateGallery.js)
|
||||
window.openCreateTemplateModal = async function() {
|
||||
window.openCreateTemplateModal = async function () {
|
||||
editingTemplate = null;
|
||||
editingTemplateSource = 'user';
|
||||
editingBuiltinIndex = null;
|
||||
|
||||
|
||||
setTemplateTags([]);
|
||||
if (templateTagInput) {
|
||||
templateTagInput.value = '';
|
||||
|
|
@ -1274,6 +1308,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
// Clear all fields
|
||||
templateTitleInput.value = '';
|
||||
templatePromptInput.value = '';
|
||||
templateNoteInput.value = promptNoteInput.value || '';
|
||||
templateModeSelect.value = 'generate';
|
||||
templateCategoryInput.classList.add('hidden');
|
||||
templateCategoryInput.value = '';
|
||||
|
|
@ -1282,18 +1317,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
try {
|
||||
const response = await fetch('/prompts');
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (data.prompts) {
|
||||
const categories = new Set();
|
||||
data.prompts.forEach(t => {
|
||||
if (t.category) {
|
||||
const categoryText = typeof t.category === 'string'
|
||||
? t.category
|
||||
const categoryText = typeof t.category === 'string'
|
||||
? t.category
|
||||
: (t.category.vi || t.category.en || '');
|
||||
if (categoryText) categories.add(categoryText);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
templateCategorySelect.innerHTML = '';
|
||||
const sortedCategories = Array.from(categories).sort();
|
||||
sortedCategories.forEach(cat => {
|
||||
|
|
@ -1302,12 +1337,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
option.textContent = cat;
|
||||
templateCategorySelect.appendChild(option);
|
||||
});
|
||||
|
||||
|
||||
const newOption = document.createElement('option');
|
||||
newOption.value = 'new';
|
||||
newOption.textContent = '+ New Category';
|
||||
templateCategorySelect.appendChild(newOption);
|
||||
|
||||
|
||||
if (sortedCategories.includes('User')) {
|
||||
templateCategorySelect.value = 'User';
|
||||
} else if (sortedCategories.length > 0) {
|
||||
|
|
@ -1327,7 +1362,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
|
||||
// Update button text
|
||||
saveTemplateBtn.innerHTML = '<span>Save Template</span><div class="btn-shine"></div>';
|
||||
|
||||
|
||||
createTemplateModal.classList.remove('hidden');
|
||||
};
|
||||
|
||||
|
|
@ -1337,10 +1372,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
editingTemplate = null;
|
||||
editingTemplateSource = 'user';
|
||||
editingBuiltinIndex = null;
|
||||
|
||||
|
||||
// Pre-fill data
|
||||
templateTitleInput.value = '';
|
||||
templatePromptInput.value = promptInput.value;
|
||||
templateNoteInput.value = promptNoteInput.value || '';
|
||||
templateModeSelect.value = 'generate';
|
||||
templateCategoryInput.classList.add('hidden');
|
||||
templateCategoryInput.value = '';
|
||||
|
|
@ -1349,25 +1385,25 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
try {
|
||||
const response = await fetch('/prompts');
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (data.prompts) {
|
||||
// Extract unique categories
|
||||
const categories = new Set();
|
||||
data.prompts.forEach(template => {
|
||||
if (template.category) {
|
||||
// Handle both string and object categories
|
||||
const categoryText = typeof template.category === 'string'
|
||||
? template.category
|
||||
const categoryText = typeof template.category === 'string'
|
||||
? template.category
|
||||
: (template.category.vi || template.category.en || '');
|
||||
if (categoryText) {
|
||||
categories.add(categoryText);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Clear existing options except "new"
|
||||
templateCategorySelect.innerHTML = '';
|
||||
|
||||
|
||||
// Add sorted categories
|
||||
const sortedCategories = Array.from(categories).sort();
|
||||
sortedCategories.forEach(cat => {
|
||||
|
|
@ -1376,13 +1412,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
option.textContent = cat;
|
||||
templateCategorySelect.appendChild(option);
|
||||
});
|
||||
|
||||
|
||||
// Add "new category" option at the end
|
||||
const newOption = document.createElement('option');
|
||||
newOption.value = 'new';
|
||||
newOption.textContent = '+ New Category';
|
||||
templateCategorySelect.appendChild(newOption);
|
||||
|
||||
|
||||
// Set default to first category or "User" if it exists
|
||||
if (sortedCategories.includes('User')) {
|
||||
templateCategorySelect.value = 'User';
|
||||
|
|
@ -1456,7 +1492,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
templatePreviewDropzone.addEventListener('click', (e) => {
|
||||
// Don't toggle if clicking on the input itself
|
||||
if (e.target === templatePreviewUrlInput) return;
|
||||
|
||||
|
||||
if (!isUrlInputMode) {
|
||||
// Switch to URL input mode
|
||||
isUrlInputMode = true;
|
||||
|
|
@ -1511,7 +1547,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
templatePreviewDropzone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
templatePreviewDropzone.classList.add('drag-over');
|
||||
|
|
@ -1525,7 +1561,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
templatePreviewDropzone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
templatePreviewDropzone.classList.remove('drag-over');
|
||||
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
const file = files[0];
|
||||
|
|
@ -1547,9 +1583,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
saveTemplateBtn.addEventListener('click', async () => {
|
||||
const title = templateTitleInput.value.trim();
|
||||
const prompt = templatePromptInput.value.trim();
|
||||
const note = templateNoteInput.value.trim();
|
||||
const mode = templateModeSelect.value;
|
||||
let category = templateCategorySelect.value;
|
||||
|
||||
|
||||
if (category === 'new') {
|
||||
category = templateCategoryInput.value.trim();
|
||||
}
|
||||
|
|
@ -1574,6 +1611,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
const formData = new FormData();
|
||||
formData.append('title', title);
|
||||
formData.append('prompt', prompt);
|
||||
formData.append('note', note);
|
||||
formData.append('mode', mode);
|
||||
formData.append('category', category);
|
||||
formData.append('tags', JSON.stringify(templateTags));
|
||||
|
|
@ -1608,10 +1646,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
|
||||
// Success
|
||||
createTemplateModal.classList.add('hidden');
|
||||
|
||||
|
||||
// Reload template gallery
|
||||
await templateGallery.load();
|
||||
|
||||
|
||||
// Reset editing state
|
||||
editingTemplate = null;
|
||||
editingTemplateSource = null;
|
||||
|
|
@ -1655,7 +1693,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
loadGallery();
|
||||
loadTemplateGallery();
|
||||
initializeSidebarResizer(sidebar, resizeHandle);
|
||||
|
||||
|
||||
// Restore last image if available
|
||||
try {
|
||||
const lastImage = localStorage.getItem('gemini-app-last-image');
|
||||
|
|
@ -1665,13 +1703,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
} catch (e) {
|
||||
console.warn('Failed to restore last image', e);
|
||||
}
|
||||
|
||||
|
||||
// Setup canvas language toggle
|
||||
const canvasLangInput = document.getElementById('canvas-lang-input');
|
||||
if (canvasLangInput) {
|
||||
// Set initial state
|
||||
canvasLangInput.checked = i18n.currentLang === 'en';
|
||||
|
||||
|
||||
canvasLangInput.addEventListener('change', (e) => {
|
||||
i18n.setLanguage(e.target.checked ? 'en' : 'vi');
|
||||
// Update visual state
|
||||
|
|
@ -1691,6 +1729,24 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
const historySourceBtns = document.querySelectorAll('.history-source-btn');
|
||||
const initialSource = gallery.getCurrentSource ? gallery.getCurrentSource() : 'generated';
|
||||
|
||||
function syncHistoryControlsFromGallery() {
|
||||
const activeFilter = gallery.getCurrentFilter ? gallery.getCurrentFilter() : 'all';
|
||||
historyFilterBtns.forEach(btn => {
|
||||
if (btn.classList.contains('history-favorites-btn')) return;
|
||||
const isActive = btn.dataset.filter === activeFilter;
|
||||
btn.classList.toggle('active', isActive);
|
||||
});
|
||||
|
||||
if (historyFavoritesBtn && gallery.isFavoritesActive) {
|
||||
historyFavoritesBtn.classList.toggle('active', gallery.isFavoritesActive());
|
||||
}
|
||||
|
||||
const historySearchInputEl = document.getElementById('history-search-input');
|
||||
if (historySearchInputEl && gallery.getSearchQuery) {
|
||||
historySearchInputEl.value = gallery.getSearchQuery();
|
||||
}
|
||||
}
|
||||
|
||||
historySourceBtns.forEach(btn => {
|
||||
const isActive = btn.dataset.source === initialSource;
|
||||
btn.classList.toggle('active', isActive);
|
||||
|
|
@ -1703,41 +1759,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
b.classList.toggle('active', active);
|
||||
b.setAttribute('aria-pressed', String(active));
|
||||
});
|
||||
await gallery.setSource(targetSource, { resetFilters: true });
|
||||
|
||||
// Reset filters UI to show all when switching source
|
||||
historyFilterBtns.forEach(b => {
|
||||
if (!b.classList.contains('history-favorites-btn')) {
|
||||
b.classList.toggle('active', b.dataset.filter === 'all');
|
||||
}
|
||||
});
|
||||
|
||||
// Disable favorites toggle on source change
|
||||
if (historyFavoritesBtn) {
|
||||
historyFavoritesBtn.classList.remove('active');
|
||||
}
|
||||
if (gallery.setFavoritesActive) {
|
||||
gallery.setFavoritesActive(false);
|
||||
}
|
||||
|
||||
// Clear search box
|
||||
const historySearchInputEl = document.getElementById('history-search-input');
|
||||
if (historySearchInputEl) {
|
||||
historySearchInputEl.value = '';
|
||||
}
|
||||
if (gallery.setSearchQuery) {
|
||||
gallery.setSearchQuery('');
|
||||
}
|
||||
await gallery.setSource(targetSource, { resetFilters: false });
|
||||
syncHistoryControlsFromGallery();
|
||||
});
|
||||
});
|
||||
|
||||
// Set initial active state based on saved filter
|
||||
const currentFilter = gallery.getCurrentFilter();
|
||||
historyFilterBtns.forEach(btn => {
|
||||
if (btn.dataset.filter === currentFilter && !btn.classList.contains('history-favorites-btn')) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
syncHistoryControlsFromGallery();
|
||||
|
||||
// Handle favorites button as toggle
|
||||
if (historyFavoritesBtn) {
|
||||
|
|
@ -1752,7 +1780,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
if (!btn.classList.contains('history-favorites-btn')) {
|
||||
btn.addEventListener('click', () => {
|
||||
const filterType = btn.dataset.filter;
|
||||
|
||||
|
||||
// Remove active from all date filter buttons (not favorites)
|
||||
historyFilterBtns.forEach(b => {
|
||||
if (!b.classList.contains('history-favorites-btn')) {
|
||||
|
|
@ -1833,7 +1861,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
|
||||
hasGeneratedImage = true; // Mark that we have an image
|
||||
setViewState('result');
|
||||
|
||||
|
||||
// Persist image URL
|
||||
try {
|
||||
localStorage.setItem('gemini-app-last-image', imageUrl);
|
||||
|
|
@ -1863,7 +1891,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
promptInput.value = metadata.prompt;
|
||||
refreshPromptHighlight();
|
||||
}
|
||||
|
||||
|
||||
// If metadata doesn't have 'note' field, set to empty string instead of keeping current value
|
||||
if (metadata.hasOwnProperty('note')) {
|
||||
promptNoteInput.value = metadata.note || '';
|
||||
|
|
@ -1871,14 +1899,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
promptNoteInput.value = '';
|
||||
}
|
||||
refreshNoteHighlight();
|
||||
|
||||
|
||||
if (metadata.aspect_ratio) aspectRatioInput.value = metadata.aspect_ratio;
|
||||
if (metadata.resolution) resolutionInput.value = metadata.resolution;
|
||||
|
||||
|
||||
if (metadata.reference_images && Array.isArray(metadata.reference_images)) {
|
||||
slotManager.setReferenceImages(metadata.reference_images);
|
||||
}
|
||||
|
||||
|
||||
persistSettings();
|
||||
}
|
||||
|
||||
|
|
@ -1967,9 +1995,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
const targetTag = event.target?.tagName;
|
||||
if (targetTag && ['INPUT', 'TEXTAREA', 'SELECT'].includes(targetTag)) return;
|
||||
if (event.target?.isContentEditable) return;
|
||||
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
|
||||
// Toggle template gallery
|
||||
if (templateGalleryState.classList.contains('hidden')) {
|
||||
setViewState('template-gallery');
|
||||
|
|
@ -2121,4 +2149,52 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
};
|
||||
}
|
||||
|
||||
// Reference URL Input Logic
|
||||
if (referenceUrlInput && typeof slotManager !== 'undefined') {
|
||||
referenceUrlInput.addEventListener('keydown', async (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
const url = referenceUrlInput.value.trim();
|
||||
if (!url) return;
|
||||
|
||||
referenceUrlInput.disabled = true;
|
||||
const originalPlaceholder = referenceUrlInput.getAttribute('placeholder');
|
||||
referenceUrlInput.setAttribute('placeholder', 'Đang tải...');
|
||||
|
||||
try {
|
||||
const response = await fetch('/download_image', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to download image');
|
||||
}
|
||||
|
||||
if (data.path) {
|
||||
const success = await slotManager.addReferenceFromUrl(data.path);
|
||||
if (success) {
|
||||
referenceUrlInput.value = '';
|
||||
} else {
|
||||
alert('Không còn slot trống cho ảnh tham chiếu.');
|
||||
}
|
||||
} else {
|
||||
throw new Error('No image path returned');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Download error:', error);
|
||||
alert(`Lỗi tải ảnh: ${error.message}`);
|
||||
} finally {
|
||||
referenceUrlInput.disabled = false;
|
||||
referenceUrlInput.setAttribute('placeholder', originalPlaceholder);
|
||||
referenceUrlInput.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ a:hover {
|
|||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: 320px;
|
||||
width: 450px;
|
||||
background: var(--panel-backdrop);
|
||||
background-image: radial-gradient(circle at 20% -20%, rgba(251, 191, 36, 0.15), transparent 45%);
|
||||
/* border-right: 1px solid var(--border-color); */
|
||||
|
|
@ -399,7 +399,7 @@ select:focus {
|
|||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
/* Theme overrides driven from index.css gradients */
|
||||
|
|
@ -786,11 +786,38 @@ body.theme-amin { --bd-bg: linear-gradient(to right, #4A00E0, #8E2DE2); }
|
|||
}
|
||||
|
||||
.slot-preview.hidden,
|
||||
.slot-edit.hidden,
|
||||
.slot-remove.hidden,
|
||||
.slot-placeholder.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.slot-edit {
|
||||
position: absolute;
|
||||
top: 0.35rem;
|
||||
right: 2.25rem;
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
color: var(--text-primary);
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.4);
|
||||
z-index: 3;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.slot-edit:hover {
|
||||
background: rgba(251, 191, 36, 0.25);
|
||||
color: var(--accent-color);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.slot-remove {
|
||||
position: absolute;
|
||||
top: 0.35rem;
|
||||
|
|
@ -1924,7 +1951,7 @@ button#generate-btn:disabled {
|
|||
|
||||
.template-preview-dropzone {
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
height: 150px;
|
||||
border: 2px dashed rgba(255, 255, 255, 0.15);
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
|
|
@ -2069,6 +2096,16 @@ button#generate-btn:disabled {
|
|||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5);
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#create-template-modal .popup-body {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.25rem;
|
||||
margin-right: -0.25rem; /* allow scrollbar without shifting content */
|
||||
}
|
||||
|
||||
#save-template-btn {
|
||||
|
|
|
|||
|
|
@ -46,18 +46,16 @@
|
|||
<div class="field-action-buttons" data-target="prompt" aria-label="Prompt actions">
|
||||
<button type="button" class="field-action-btn" data-action="copy" title="Copy prompt"
|
||||
aria-label="Copy prompt">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none"
|
||||
stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor"
|
||||
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2.5" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="field-action-btn" data-action="paste" title="Paste"
|
||||
aria-label="Paste vào prompt">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none"
|
||||
stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor"
|
||||
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 4h8" />
|
||||
<path d="M9 2h6a2 2 0 0 1 2 2v1H7V4a2 2 0 0 1 2-2z" />
|
||||
<rect x="5" y="5" width="14" height="16" rx="2" />
|
||||
|
|
@ -67,9 +65,8 @@
|
|||
</button>
|
||||
<button type="button" class="field-action-btn" data-action="clear" title="Clear prompt"
|
||||
aria-label="Xoá prompt">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none"
|
||||
stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor"
|
||||
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v12a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
|
||||
<path d="M10 11v6" />
|
||||
|
|
@ -132,18 +129,16 @@
|
|||
<div class="field-action-buttons" data-target="note" aria-label="Note actions">
|
||||
<button type="button" class="field-action-btn" data-action="copy" title="Copy note"
|
||||
aria-label="Copy note">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none"
|
||||
stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor"
|
||||
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2.5" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="field-action-btn" data-action="paste" title="Paste"
|
||||
aria-label="Paste vào note">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none"
|
||||
stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor"
|
||||
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 4h8" />
|
||||
<path d="M9 2h6a2 2 0 0 1 2 2v1H7V4a2 2 0 0 1 2-2z" />
|
||||
<rect x="5" y="5" width="14" height="16" rx="2" />
|
||||
|
|
@ -153,9 +148,8 @@
|
|||
</button>
|
||||
<button type="button" class="field-action-btn" data-action="clear" title="Clear note"
|
||||
aria-label="Xoá note">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none"
|
||||
stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor"
|
||||
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v12a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
|
||||
<path d="M10 11v6" />
|
||||
|
|
@ -172,6 +166,10 @@
|
|||
<label>Reference Images</label>
|
||||
</div>
|
||||
<div id="image-input-grid" class="image-input-grid" aria-live="polite"></div>
|
||||
<div class="image-url-input-wrapper" style="margin-top: 0.5rem;">
|
||||
<input type="text" id="reference-url-input" placeholder="Nhập URL hoặc đường dẫn ảnh..."
|
||||
style="width: 100%; padding: 0.5rem; border-radius: 4px; border: 1px solid var(--border-color); background: rgba(0,0,0,0.2); color: var(--text-primary); font-size: 0.85rem;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
|
|
@ -410,6 +408,11 @@
|
|||
<textarea id="template-prompt" rows="3" placeholder="Template Prompt"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="template-note">Note</label>
|
||||
<textarea id="template-note" rows="2" placeholder="Template Note (optional)"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="template-mode">Mode</label>
|
||||
|
|
@ -489,6 +492,15 @@
|
|||
rel="noreferrer">aistudio.google.com/api-keys</a>
|
||||
</p>
|
||||
</div>
|
||||
<!-- Whisk Cookies Input -->
|
||||
<div class="input-group api-settings-input-group hidden" id="whisk-cookies-group">
|
||||
<label for="whisk-cookies">Whisk Cookies (dành cho ImageFX)</label>
|
||||
<textarea id="whisk-cookies" rows="3" placeholder="Paste toàn bộ cookie string từ labs.google..."
|
||||
style="width: 100%; padding: 0.5rem; background: rgba(0,0,0,0.2); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 0.8rem;"></textarea>
|
||||
<p class="input-hint">
|
||||
F12 trên labs.google > Network > Request bất kỳ > Copy Request Headers > Cookie.
|
||||
</p>
|
||||
</div>
|
||||
<div class="input-group api-settings-input-group">
|
||||
<label for="api-model">Model</label>
|
||||
<div class="select-wrapper">
|
||||
|
|
@ -496,6 +508,7 @@
|
|||
style="width: 100%; padding: 0.75rem; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 0.5rem; color: var(--text-primary); font-size: 0.9rem;">
|
||||
<option value="gemini-3-pro-image-preview">Gemini 3 Pro (Image Preview)</option>
|
||||
<option value="gemini-2.5-flash-image">Gemini 2.5 Flash Image</option>
|
||||
<option value="whisk">Whisk (ImageFX) [Experimental]</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -553,4 +566,4 @@
|
|||
<script type="module" src="{{ url_for('static', filename='script.js') }}"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
254
whisk_client.py
Normal file
254
whisk_client.py
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import requests
|
||||
import json
|
||||
import time
|
||||
import base64
|
||||
import os
|
||||
import uuid
|
||||
import logging
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(levelname)s:%(name)s:%(message)s')
|
||||
logger = logging.getLogger("whisk_client")
|
||||
|
||||
# Constants from reverse engineering
|
||||
AUTH_ENDPOINT = "https://labs.google/fx/api/auth/session"
|
||||
UPLOAD_ENDPOINT = "https://labs.google/fx/api/trpc/backbone.uploadImage"
|
||||
|
||||
# Endpoint 1: Text-to-Image
|
||||
# (Captured in Step 405)
|
||||
GENERATE_ENDPOINT = "https://aisandbox-pa.googleapis.com/v1/whisk:generateImage"
|
||||
|
||||
# Endpoint 2: Reference Image (Recipe)
|
||||
# (Captured in Step 424)
|
||||
RECIPE_ENDPOINT = "https://aisandbox-pa.googleapis.com/v1/whisk:runImageRecipe"
|
||||
|
||||
DEFAULT_HEADERS = {
|
||||
"Origin": "https://labs.google",
|
||||
"Content-Type": "application/json",
|
||||
"Referer": "https://labs.google/fx/tools/image-fx",
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
}
|
||||
|
||||
class WhiskClientError(Exception):
|
||||
pass
|
||||
|
||||
def parse_cookies(cookie_input):
|
||||
if not cookie_input:
|
||||
return {}
|
||||
|
||||
cookies = {}
|
||||
cookie_input = cookie_input.strip()
|
||||
|
||||
if cookie_input.startswith('[') and cookie_input.endswith(']'):
|
||||
try:
|
||||
cookie_list = json.loads(cookie_input)
|
||||
for c in cookie_list:
|
||||
name = c.get('name')
|
||||
value = c.get('value')
|
||||
if name and value:
|
||||
cookies[name] = value
|
||||
return cookies
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
for item in cookie_input.split(';'):
|
||||
if '=' in item:
|
||||
name, value = item.split('=', 1)
|
||||
cookies[name.strip()] = value.strip()
|
||||
return cookies
|
||||
|
||||
def get_session_token(cookies):
|
||||
logger.info("Fetching session token from labs.google...")
|
||||
try:
|
||||
response = requests.get(
|
||||
AUTH_ENDPOINT,
|
||||
headers={**DEFAULT_HEADERS},
|
||||
cookies=cookies,
|
||||
timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
if not data.get('access_token'):
|
||||
raise WhiskClientError("Session response missing access_token")
|
||||
|
||||
return data['access_token']
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch session token: {e}")
|
||||
raise WhiskClientError(f"Authentication failed: {str(e)}")
|
||||
|
||||
def upload_reference_image(image_path, cookies):
|
||||
if not image_path or not os.path.exists(image_path):
|
||||
return None
|
||||
|
||||
logger.info(f"Uploading reference image: {image_path}")
|
||||
|
||||
try:
|
||||
with open(image_path, "rb") as img_file:
|
||||
import mimetypes
|
||||
mime_type, _ = mimetypes.guess_type(image_path)
|
||||
if not mime_type: mime_type = "image/png"
|
||||
|
||||
b64_data = base64.b64encode(img_file.read()).decode('utf-8')
|
||||
data_uri = f"data:{mime_type};base64,{b64_data}"
|
||||
|
||||
payload = {
|
||||
"json": {
|
||||
"clientContext": {
|
||||
"workflowId": str(uuid.uuid4()),
|
||||
"sessionId": str(int(time.time() * 1000))
|
||||
},
|
||||
"uploadMediaInput": {
|
||||
"mediaCategory": "MEDIA_CATEGORY_SUBJECT",
|
||||
"rawBytes": data_uri,
|
||||
"caption": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
UPLOAD_ENDPOINT,
|
||||
headers=DEFAULT_HEADERS,
|
||||
cookies=cookies,
|
||||
json=payload,
|
||||
timeout=60
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
raise WhiskClientError(f"Image upload failed: {response.text}")
|
||||
|
||||
data = response.json()
|
||||
try:
|
||||
media_id = data['result']['data']['json']['result']['uploadMediaGenerationId']
|
||||
except (KeyError, TypeError):
|
||||
raise WhiskClientError("Failed to retrieve uploadMediaGenerationId")
|
||||
|
||||
logger.info(f"Image uploaded successfully. ID: {media_id}")
|
||||
return media_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error uploading image: {e}")
|
||||
raise e
|
||||
|
||||
def generate_image_whisk(prompt, cookie_str, **kwargs):
|
||||
cookies = parse_cookies(cookie_str)
|
||||
if not cookies:
|
||||
raise WhiskClientError("No valid cookies found")
|
||||
|
||||
access_token = get_session_token(cookies)
|
||||
|
||||
ref_image_path = kwargs.get('reference_image_path')
|
||||
media_generation_id = None
|
||||
|
||||
if ref_image_path:
|
||||
try:
|
||||
media_generation_id = upload_reference_image(ref_image_path, cookies)
|
||||
except Exception as e:
|
||||
logger.warning(f"Skipping reference image due to upload error: {e}")
|
||||
|
||||
aspect_ratio_map = {
|
||||
"1:1": "IMAGE_ASPECT_RATIO_SQUARE",
|
||||
"9:16": "IMAGE_ASPECT_RATIO_PORTRAIT",
|
||||
"16:9": "IMAGE_ASPECT_RATIO_LANDSCAPE",
|
||||
"4:3": "IMAGE_ASPECT_RATIO_LANDSCAPE_FOUR_THREE",
|
||||
"3:4": "IMAGE_ASPECT_RATIO_PORTRAIT",
|
||||
"Auto": "IMAGE_ASPECT_RATIO_SQUARE"
|
||||
}
|
||||
aspect_ratio_key = kwargs.get('aspect_ratio', 'Auto')
|
||||
aspect_ratio_enum = aspect_ratio_map.get(aspect_ratio_key, "IMAGE_ASPECT_RATIO_SQUARE")
|
||||
|
||||
seed = kwargs.get('seed', int(time.time()))
|
||||
headers = {
|
||||
**DEFAULT_HEADERS,
|
||||
"Authorization": f"Bearer {access_token}"
|
||||
}
|
||||
|
||||
# BRANCH: Use Recipe Endpoint if Reference Image exists
|
||||
if media_generation_id:
|
||||
target_endpoint = RECIPE_ENDPOINT
|
||||
payload = {
|
||||
"clientContext": {
|
||||
"workflowId": str(uuid.uuid4()),
|
||||
"tool": "BACKBONE",
|
||||
"sessionId": str(int(time.time() * 1000))
|
||||
},
|
||||
"seed": seed,
|
||||
"imageModelSettings": {
|
||||
"imageModel": "GEM_PIX",
|
||||
"aspectRatio": aspect_ratio_enum
|
||||
},
|
||||
"userInstruction": prompt,
|
||||
"recipeMediaInputs": [{
|
||||
"mediaInput": {
|
||||
"mediaCategory": "MEDIA_CATEGORY_SUBJECT",
|
||||
"mediaGenerationId": media_generation_id
|
||||
}
|
||||
}]
|
||||
}
|
||||
else:
|
||||
# BRANCH: Use Generate Endpoint for Text-to-Image
|
||||
# NOTE: Payload for generateImage is inferred to be userInput based.
|
||||
# If this fails, we might need further inspection, but Recipe flow is the priority.
|
||||
target_endpoint = GENERATE_ENDPOINT
|
||||
payload = {
|
||||
"userInput": {
|
||||
"candidatesCount": 2,
|
||||
"prompts": [prompt],
|
||||
"seed": seed
|
||||
},
|
||||
"clientContext": {
|
||||
"workflowId": str(uuid.uuid4()),
|
||||
"tool": "IMAGE_FX", # Usually ImageFX for T2I
|
||||
"sessionId": str(int(time.time() * 1000))
|
||||
},
|
||||
"modelInput": {
|
||||
"modelNameType": "IMAGEN_3_5", # Usually Imagen 3 for ImageFX
|
||||
"aspectRatio": aspect_ratio_enum
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(f"Generating image. Endpoint: {target_endpoint}, Prompt: {prompt}")
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
target_endpoint,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=120
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
error_text = response.text
|
||||
try:
|
||||
err_json = response.json()
|
||||
details = err_json.get('error', {}).get('details', [])
|
||||
if any(d.get('reason') in ['PUBLIC_ERROR_UNSAFE_GENERATION', 'PUBLIC_ERROR_SEXUAL'] for d in details):
|
||||
raise WhiskClientError("⚠️ Google Safety Filter Triggered. Prompt bị từ chối do nội dung không an toàn.")
|
||||
except (json.JSONDecodeError, WhiskClientError) as e:
|
||||
if isinstance(e, WhiskClientError): raise e
|
||||
|
||||
# Additional T2I Fallback: If generateImage fails 400, try Recipe with empty media?
|
||||
# Not implementing strictly to avoid loops, but helpful mental note.
|
||||
raise WhiskClientError(f"Generation failed ({response.status_code}): {error_text}")
|
||||
|
||||
# Parse Response
|
||||
json_resp = response.json()
|
||||
|
||||
images = []
|
||||
if 'imagePanels' in json_resp:
|
||||
for panel in json_resp['imagePanels']:
|
||||
for img in panel.get('generatedImages', []):
|
||||
if 'encodedImage' in img:
|
||||
images.append(img['encodedImage'])
|
||||
|
||||
if not images:
|
||||
logger.error(f"Unexpected response structure: {json_resp.keys()}")
|
||||
raise WhiskClientError("No images found in response")
|
||||
|
||||
return base64.b64decode(images[0])
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
raise WhiskClientError("Timout connecting to Google Whisk.")
|
||||
except Exception as e:
|
||||
logger.error(f"Whisk Generation Error: {e}")
|
||||
raise WhiskClientError(str(e))
|
||||
Loading…
Reference in a new issue