update beta4

This commit is contained in:
phamhungd 2025-11-29 21:47:32 +07:00
parent 931b3642ea
commit 06cf2a6954
18 changed files with 6190 additions and 2 deletions

BIN
.DS_Store vendored

Binary file not shown.

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,26 @@
export const ICONS = {
crop: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6.13 1L6 16a2 2 0 0 0 2 2h15"></path><path d="M1 6.13L16 6a2 2 0 0 1 2 2v15"></path></svg>`,
adjust: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>`,
undo: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7v6h6"></path><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"></path></svg>`,
redo: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 7v6h-6"></path><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 2.7"></path></svg>`,
reset: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"></polyline><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path></svg>`,
chevronDown: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>`,
close: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`,
flipH: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 7 3 3 7 3"></polyline><line x1="3" y1="3" x2="10" y2="10"></line><polyline points="21 17 21 21 17 21"></polyline><line x1="21" y1="21" x2="14" y2="14"></line></svg>`,
flipV: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="7 21 3 21 3 17"></polyline><line x1="3" y1="21" x2="10" y2="14"></line><polyline points="17 3 21 3 21 7"></polyline><line x1="21" y1="3" x2="14" y2="10"></line></svg>`,
rotate: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M11.5 20.5C6.80558 20.5 3 16.6944 3 12C3 7.30558 6.80558 3.5 11.5 3.5C16.1944 3.5 20 7.30558 20 12C20 13.5433 19.5887 14.9905 18.8698 16.238M22.5 15L18.8698 16.238M17.1747 12.3832L18.5289 16.3542L18.8698 16.238" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
brush: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M21.8098 3.93814C20.4998 7.20814 17.5098 11.4781 14.6598 14.2681C14.2498 11.6881 12.1898 9.66814 9.58984 9.30814C12.3898 6.44814 16.6898 3.41814 19.9698 2.09814C20.5498 1.87814 21.1298 2.04814 21.4898 2.40814C21.8698 2.78814 22.0498 3.35814 21.8098 3.93814Z" fill="currentColor"/><path d="M13.7791 15.0909C13.5791 15.2609 13.3791 15.4309 13.1791 15.5909L11.3891 17.0209C11.3891 16.9909 11.3791 16.9509 11.3791 16.9109C11.2391 15.8409 10.7391 14.8509 9.92914 14.0409C9.10914 13.2209 8.08914 12.7209 6.96914 12.5809C6.93914 12.5809 6.89914 12.5709 6.86914 12.5709L8.31914 10.7409C8.45914 10.5609 8.60914 10.3909 8.76914 10.2109L9.44914 10.3009C11.5991 10.6009 13.3291 12.2909 13.6691 14.4309L13.7791 15.0909Z" fill="currentColor"/><path d="M10.4298 17.6208C10.4298 18.7208 10.0098 19.7708 9.20976 20.5608C8.59976 21.1808 7.77977 21.6008 6.77977 21.7208L4.32976 21.9908C2.98976 22.1408 1.83976 20.9908 1.98976 19.6408L2.25976 17.1808C2.49976 14.9908 4.32976 13.5908 6.26976 13.5508C6.45976 13.5408 6.66976 13.5508 6.86976 13.5708C7.71976 13.6808 8.53976 14.0708 9.22976 14.7508C9.89976 15.4208 10.2798 16.2108 10.3898 17.0408C10.4098 17.2408 10.4298 17.4308 10.4298 17.6208Z" fill="currentColor"/></svg>`,
pen: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 19l7-7 3 3-7 7-3-3z"></path><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"></path><path d="M2 2l7.586 7.586"></path><circle cx="11" cy="11" r="2"></circle></svg>`
};
export const HSL_COLORS = [
{ id: "red", label: "Red", color: "#ff4b4b", center: 0 / 360, width: 0.12 },
{ id: "orange", label: "Orange", color: "#ff884d", center: 30 / 360, width: 0.12 },
{ id: "yellow", label: "Yellow", color: "#ffd84d", center: 50 / 360, width: 0.12 },
{ id: "green", label: "Green", color: "#45d98e", center: 120 / 360, width: 0.12 },
{ id: "cyan", label: "Cyan", color: "#30c4ff", center: 180 / 360, width: 0.12 },
{ id: "blue", label: "Blue", color: "#2f7bff", center: 220 / 360, width: 0.12 },
{ id: "magenta", label: "Magenta", color: "#c95bff", center: 300 / 360, width: 0.12 }
];
export const IMAGE_EDITOR_SUBFOLDER = "image_editor";

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

@ -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;
@ -7,6 +9,9 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) {
const imageSlotState = [];
let cachedReferenceImages = [];
// Inject image editor styles once
injectImageEditorStyles();
function initialize(initialCached = []) {
cachedReferenceImages = Array.isArray(initialCached) ? initialCached : [];
const requiredSlots = Math.min(
@ -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) {

View file

@ -55,7 +55,7 @@ a:hover {
/* Sidebar */
.sidebar {
width: 320px;
width: 450px;
background: var(--panel-backdrop);
background-image: radial-gradient(circle at 20% -20%, rgba(251, 191, 36, 0.15), transparent 45%);
/* border-right: 1px solid var(--border-color); */
@ -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;