a quick try at a tool for painting

From yesterday night, just finished some final touches; should be
enough for some cool things.

Signed-off-by: Victor Seiji Hariki <victorseijih@gmail.com>
This commit is contained in:
Victor Seiji Hariki 2022-12-01 18:10:30 -03:00
parent 9dcef66c21
commit c63003e1cf
No known key found for this signature in database
GPG key ID: F369E3EA50A0DEEE
12 changed files with 370 additions and 57 deletions

View file

@ -210,16 +210,19 @@
<!-- Content --> <!-- Content -->
<script src="js/index.js" type="text/javascript"></script> <script src="js/index.js" type="text/javascript"></script>
<script src="js/shortcuts.js" type="text/javascript"></script>
<script src="js/ui/floating/history.js" type="text/javascript"></script> <script src="js/ui/floating/history.js" type="text/javascript"></script>
<!-- Load Tools --> <!-- Load Tools -->
<script src="js/ui/tool/dream.js" type="text/javascript"></script> <script src="js/ui/tool/dream.js" type="text/javascript"></script>
<script src="js/ui/tool/maskbrush.js" type="text/javascript"></script> <script src="js/ui/tool/maskbrush.js" type="text/javascript"></script>
<script src="js/ui/tool/colorbrush.js" type="text/javascript"></script>
<script src="js/ui/tool/select.js" type="text/javascript"></script> <script src="js/ui/tool/select.js" type="text/javascript"></script>
<script src="js/ui/tool/stamp.js" type="text/javascript"></script> <script src="js/ui/tool/stamp.js" type="text/javascript"></script>
<!-- Initialize --> <!-- Initialize -->
<script
src="js/initalize/shortcuts.populate.js"
type="text/javascript"></script>
<script <script
src="js/initalize/toolbar.populate.js" src="js/initalize/toolbar.populate.js"
type="text/javascript"></script> type="text/javascript"></script>

View file

@ -48,6 +48,7 @@ var stableDiffusionData = {
}; };
// stuff things use // stuff things use
let debug = false;
var returnedImages; var returnedImages;
var imageIndex = 0; var imageIndex = 0;
var tmpImgXYWH = {}; var tmpImgXYWH = {};
@ -126,6 +127,7 @@ function testHostConfiguration() {
* Check host configuration * Check host configuration
*/ */
const hostEl = document.getElementById("host"); const hostEl = document.getElementById("host");
hostEl.value = localStorage.getItem("host");
const requestHost = (prompt, def = "http://127.0.0.1:7860") => { const requestHost = (prompt, def = "http://127.0.0.1:7860") => {
let value = window.prompt(prompt, def); let value = window.prompt(prompt, def);
@ -549,7 +551,9 @@ function changeSnapMode() {
} }
function changeMaskBlur() { function changeMaskBlur() {
stableDiffusionData.mask_blur = document.getElementById("maskBlur").value; stableDiffusionData.mask_blur = parseInt(
document.getElementById("maskBlur").value
);
localStorage.setItem("mask_blur", stableDiffusionData.mask_blur); localStorage.setItem("mask_blur", stableDiffusionData.mask_blur);
} }

View file

@ -24,6 +24,11 @@ mouse.listen.world.onmousemove.on((evn) => {
*/ */
const toggledebug = () => { const toggledebug = () => {
const hidden = debugCanvas.style.display === "none"; const hidden = debugCanvas.style.display === "none";
if (hidden) debugLayer.unhide(); if (hidden) {
else debugLayer.hide(); debugLayer.unhide();
debug = true;
} else {
debugLayer.hide();
debug = false;
}
}; };

View file

@ -54,7 +54,7 @@ mouse.registerContext(
const target = evn.target; const target = evn.target;
// Get element bounding rect // Get element bounding rect
const bb = target.getBoundingClientRect(); const bb = imageCollection.element.getBoundingClientRect();
// Get element width/height (css, cause I don't trust client sizes in chrome anymore) // Get element width/height (css, cause I don't trust client sizes in chrome anymore)
const w = imageCollection.size.w; const w = imageCollection.size.w;
@ -148,11 +148,13 @@ mouse.listen.window.onwheel.on((evn) => {
viewport.transform(imageCollection.element); viewport.transform(imageCollection.element);
debugCtx.clearRect(0, 0, debugCanvas.width, debugCanvas.height); if (debug) {
debugCtx.fillStyle = "#F0F"; debugCtx.clearRect(0, 0, debugCanvas.width, debugCanvas.height);
debugCtx.beginPath(); debugCtx.fillStyle = "#F0F";
debugCtx.arc(viewport.cx, viewport.cy, 5, 0, Math.PI * 2); debugCtx.beginPath();
debugCtx.fill(); debugCtx.arc(viewport.cx, viewport.cy, 5, 0, Math.PI * 2);
debugCtx.fill();
}
} }
}); });
@ -173,11 +175,13 @@ mouse.listen.window.btn.middle.onpaint.on((evn) => {
} }
viewport.transform(imageCollection.element); viewport.transform(imageCollection.element);
debugCtx.clearRect(0, 0, debugCanvas.width, debugCanvas.height); if (debug) {
debugCtx.fillStyle = "#F0F"; debugCtx.clearRect(0, 0, debugCanvas.width, debugCanvas.height);
debugCtx.beginPath(); debugCtx.fillStyle = "#F0F";
debugCtx.arc(viewport.cx, viewport.cy, 5, 0, Math.PI * 2); debugCtx.beginPath();
debugCtx.fill(); debugCtx.arc(viewport.cx, viewport.cy, 5, 0, Math.PI * 2);
debugCtx.fill();
}
}); });
mouse.listen.window.btn.middle.onpaintend.on((evn) => { mouse.listen.window.btn.middle.onpaintend.on((evn) => {

View file

@ -14,6 +14,9 @@ keyboard.onShortcut({key: "KeyD"}, () => {
keyboard.onShortcut({key: "KeyM"}, () => { keyboard.onShortcut({key: "KeyM"}, () => {
tools.maskbrush.enable(); tools.maskbrush.enable();
}); });
keyboard.onShortcut({key: "KeyC"}, () => {
tools.colorbrush.enable();
});
keyboard.onShortcut({key: "KeyI"}, () => { keyboard.onShortcut({key: "KeyI"}, () => {
tools.img2img.enable(); tools.img2img.enable();
}); });

View file

@ -15,6 +15,7 @@ toolbar.addSeparator();
* Mask Brush tool * Mask Brush tool
*/ */
tools.maskbrush = maskBrushTool(); tools.maskbrush = maskBrushTool();
tools.colorbrush = colorBrushTool();
/** /**
* Image Editing tools * Image Editing tools

View file

@ -278,7 +278,30 @@ commands.createCommand(
} }
// Apply command // Apply command
state.context.clearRect(state.box.x, state.box.y, state.box.w, state.box.h); const style = state.context.fillStyle;
state.context.fillStyle = "black";
const op = state.context.globalCompositeOperation;
state.context.globalCompositeOperation = "destination-out";
if (options.mask)
state.context.drawImage(
options.mask,
state.box.x,
state.box.y,
state.box.w,
state.box.h
);
else
state.context.fillRect(
state.box.x,
state.box.y,
state.box.w,
state.box.h
);
state.context.fillStyle = style;
state.context.globalCompositeOperation = op;
}, },
(title, state) => { (title, state) => {
// Clear destination area // Clear destination area

View file

@ -288,7 +288,7 @@ window.addEventListener(
window.addEventListener( window.addEventListener(
"mousemove", "mousemove",
(evn) => { (evn) => {
mouse._contexts.forEach((context) => { mouse._contexts.forEach(async (context) => {
const target = context.target; const target = context.target;
const name = context.name; const name = context.name;

View file

@ -172,13 +172,30 @@ const layers = {
* Moves this layer to another location * Moves this layer to another location
* *
* @param {number} x X coordinate of the top left of the canvas * @param {number} x X coordinate of the top left of the canvas
* @param {number} y X coordinate of the top left of the canvas * @param {number} y Y coordinate of the top left of the canvas
*/ */
moveTo(x, y) { moveTo(x, y) {
canvas.style.left = `${x}px`; canvas.style.left = `${x}px`;
canvas.style.top = `${y}px`; canvas.style.top = `${y}px`;
}, },
/**
* Resizes layer in place
*
* @param {number} w New width
* @param {number} h New height
*/
resize(w, h) {
canvas.width = Math.round(
options.resolution.w * (w / options.bb.w)
);
canvas.height = Math.round(
options.resolution.h * (h / options.bb.h)
);
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
},
// Hides this layer (don't draw) // Hides this layer (don't draw)
hide() { hide() {
this.canvas.style.display = "none"; this.canvas.style.display = "none";

View file

@ -197,57 +197,58 @@ function getBoundingBox(cx, cy, w, h, gridSnap = null) {
}; };
} }
class NoContentError extends Error {}
/** /**
* Crops a given canvas to content, returning a new canvas object with the content in it. * Crops a given canvas to content, returning a new canvas object with the content in it.
* *
* @param {HTMLCanvasElement} sourceCanvas Canvas to get a content crop from * @param {HTMLCanvasElement} sourceCanvas Canvas to get a content crop from
* @returns {HTMLCanvasElement} A new canvas with the cropped part of the image * @param {object} options Extra options
* @param {number} [options.border=0] Extra border around the content
* @returns {{canvas: HTMLCanvasElement, bb: BoundingBox}} A new canvas with the cropped part of the image
*/ */
function cropCanvas(sourceCanvas) { function cropCanvas(sourceCanvas, options = {}) {
var w = sourceCanvas.width; defaultOpt(options, {border: 0});
var h = sourceCanvas.height;
var pix = {x: [], y: []};
var imageData = sourceCanvas.getContext("2d").getImageData(0, 0, w, h);
var x, y, index;
for (y = 0; y < h; y++) { const w = sourceCanvas.width;
for (x = 0; x < w; x++) { const h = sourceCanvas.height;
var imageData = sourceCanvas.getContext("2d").getImageData(0, 0, w, h);
/** @type {BoundingBox} */
const bb = {x: 0, y: 0, w: 0, h: 0};
let minx = w;
let maxx = -1;
let miny = h;
let maxy = -1;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
// lol i need to learn what this part does // lol i need to learn what this part does
index = (y * w + x) * 4; // OHHH OK this is setting the imagedata.data uint8clampeddataarray index for the specified x/y coords const index = (y * w + x) * 4; // OHHH OK this is setting the imagedata.data uint8clampeddataarray index for the specified x/y coords
//this part i get, this is checking that 4th RGBA byte for opacity //this part i get, this is checking that 4th RGBA byte for opacity
if (imageData.data[index + 3] > 0) { if (imageData.data[index + 3] > 0) {
pix.x.push(x); minx = Math.min(minx, x);
pix.y.push(y); maxx = Math.max(maxx, x);
miny = Math.min(miny, y);
maxy = Math.max(maxy, y);
} }
} }
} }
// ...need to learn what this part does too :badpokerface:
// is this just determining the boundaries of non-transparent pixel data?
pix.x.sort(function (a, b) {
return a - b;
});
pix.y.sort(function (a, b) {
return a - b;
});
var n = pix.x.length - 1;
w = pix.x[n] - pix.x[0] + 1;
h = pix.y[n] - pix.y[0] + 1;
// yup sure looks like it
try { bb.x = minx - options.border;
var cut = sourceCanvas bb.y = miny - options.border;
.getContext("2d") bb.w = maxx - minx + 2 * options.border;
.getImageData(pix.x[0], pix.y[0], w, h); bb.h = maxy - miny + 2 * options.border;
var cutCanvas = document.createElement("canvas");
cutCanvas.width = w; if (maxx < 0) throw new NoContentError("Canvas has no content to crop");
cutCanvas.height = h;
cutCanvas.getContext("2d").putImageData(cut, 0, 0); var cutCanvas = document.createElement("canvas");
} catch (ex) { cutCanvas.width = bb.w;
// probably empty image cutCanvas.height = bb.h;
//TODO confirm edge cases? cutCanvas
cutCanvas = null; .getContext("2d")
} .drawImage(sourceCanvas, bb.x, bb.y, bb.w, bb.h, 0, 0, bb.w, bb.h);
return cutCanvas; return {canvas: cutCanvas, bb};
} }
/** /**
@ -276,7 +277,7 @@ function downloadCanvas(options = {}) {
if (options.filename) link.download = options.filename; if (options.filename) link.download = options.filename;
var croppedCanvas = options.cropToContent var croppedCanvas = options.cropToContent
? cropCanvas(options.canvas) ? cropCanvas(options.canvas).canvas
: options.canvas; : options.canvas;
if (croppedCanvas != null) { if (croppedCanvas != null) {
link.href = croppedCanvas.toDataURL("image/png"); link.href = croppedCanvas.toDataURL("image/png");

247
js/ui/tool/colorbrush.js Normal file
View file

@ -0,0 +1,247 @@
const _color_brush_draw_callback = (evn, state) => {
const ctx = state.drawLayer.ctx;
ctx.strokeStyle = state.color;
ctx.filter = "blur(" + state.brushBlur + "px)";
ctx.lineWidth = state.brushSize;
ctx.beginPath();
ctx.moveTo(
evn.px === undefined ? evn.x : evn.px,
evn.py === undefined ? evn.y : evn.py
);
ctx.lineTo(evn.x, evn.y);
ctx.lineJoin = ctx.lineCap = "round";
ctx.stroke();
};
const _color_brush_erase_callback = (evn, state, ctx) => {
ctx.strokeStyle = "black";
ctx.lineWidth = state.brushSize;
ctx.beginPath();
ctx.moveTo(
evn.px === undefined ? evn.x : evn.px,
evn.py === undefined ? evn.y : evn.py
);
ctx.lineTo(evn.x, evn.y);
ctx.lineJoin = ctx.lineCap = "round";
ctx.stroke();
};
const colorBrushTool = () =>
toolbar.registerTool(
"res/icons/brush.svg",
"Color Brush",
(state, opt) => {
// Draw new cursor immediately
ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height);
state.movecb({...mouse.coords.world.pos});
state.drawLayer = imageCollection.registerLayer(null, {
after: imgLayer,
});
state.eraseLayer = imageCollection.registerLayer(null, {
after: imgLayer,
});
state.eraseLayer.canvas.style.display = "none";
state.eraseBackup = imageCollection.registerLayer(null, {
after: imgLayer,
});
state.eraseBackup.canvas.style.display = "none";
// Start Listeners
mouse.listen.world.onmousemove.on(state.movecb);
mouse.listen.world.onwheel.on(state.wheelcb);
mouse.listen.world.btn.left.onpaintstart.on(state.drawstartcb);
mouse.listen.world.btn.left.onpaint.on(state.drawcb);
mouse.listen.world.btn.left.onpaintend.on(state.drawendcb);
mouse.listen.world.btn.right.onpaintstart.on(state.erasestartcb);
mouse.listen.world.btn.right.onpaint.on(state.erasecb);
mouse.listen.world.btn.right.onpaintend.on(state.eraseendcb);
// Display Color
setMask("none");
},
(state, opt) => {
// Clear Listeners
mouse.listen.world.onmousemove.clear(state.movecb);
mouse.listen.world.onwheel.clear(state.wheelcb);
mouse.listen.world.btn.left.onpaintstart.clear(state.drawstartcb);
mouse.listen.world.btn.left.onpaint.clear(state.drawcb);
mouse.listen.world.btn.left.onpaintend.clear(state.drawendcb);
mouse.listen.world.btn.right.onpaintstart.clear(state.erasestartcb);
mouse.listen.world.btn.right.onpaint.clear(state.erasecb);
mouse.listen.world.btn.right.onpaintend.clear(state.eraseendcb);
// Delete layer
imageCollection.deleteLayer(state.drawLayer);
imageCollection.deleteLayer(state.eraseBackup);
imageCollection.deleteLayer(state.eraseLayer);
},
{
init: (state) => {
state.config = {
brushScrollSpeed: 1 / 5,
minBrushSize: 2,
maxBrushSize: 500,
minBlur: 0,
maxBlur: 30,
};
state.color = "#FFFFFF";
state.brushSize = 32;
state.brushBlur = 0;
state.affectMask = true;
state.setBrushSize = (size) => {
state.brushSize = size;
state.ctxmenu.brushSizeRange.value = size;
state.ctxmenu.brushSizeText.value = size;
};
state.movecb = (evn) => {
// draw big translucent white blob cursor
ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height);
ovCtx.beginPath();
ovCtx.arc(evn.x, evn.y, state.brushSize / 2, 0, 2 * Math.PI, true); // for some reason 4x on an arc is === to 8x on a line???
ovCtx.fillStyle = state.color + "50";
ovCtx.fill();
};
state.wheelcb = (evn) => {
if (!evn.evn.ctrlKey) {
state.brushSize = state.setBrushSize(
state.brushSize -
Math.floor(state.config.brushScrollSpeed * evn.delta)
);
ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height);
state.movecb(evn);
}
};
state.drawstartcb = (evn) => {
if (state.affectMask) _mask_brush_draw_callback(evn, state);
_color_brush_draw_callback(evn, state);
};
state.drawcb = (evn) => {
if (state.affectMask) _mask_brush_draw_callback(evn, state);
_color_brush_draw_callback(evn, state);
};
state.drawendcb = (evn) => {
const canvas = state.drawLayer.canvas;
const ctx = state.drawLayer.ctx;
const cropped = cropCanvas(canvas, {border: 10});
const bb = cropped.bb;
commands.runCommand("drawImage", "Color Brush Draw", {
image: cropped.canvas,
...bb,
});
ctx.clearRect(bb.x, bb.y, bb.w, bb.h);
};
state.erasestartcb = (evn) => {
if (state.affectMask) _mask_brush_erase_callback(evn, state);
// Make a backup of the current image to apply erase later
const bkpcanvas = state.eraseBackup.canvas;
const bkpctx = state.eraseBackup.ctx;
bkpctx.clearRect(0, 0, bkpcanvas.width, bkpcanvas.height);
bkpctx.drawImage(imgCanvas, 0, 0);
imgCtx.globalCompositeOperation = "destination-out";
_color_brush_erase_callback(evn, state, imgCtx);
imgCtx.globalCompositeOperation = "source-over";
_color_brush_erase_callback(evn, state, state.eraseLayer.ctx);
};
state.erasecb = (evn) => {
if (state.affectMask) _mask_brush_erase_callback(evn, state);
imgCtx.globalCompositeOperation = "destination-out";
_color_brush_erase_callback(evn, state, imgCtx);
imgCtx.globalCompositeOperation = "source-over";
_color_brush_erase_callback(evn, state, state.eraseLayer.ctx);
};
state.eraseendcb = (evn) => {
const canvas = state.eraseLayer.canvas;
const ctx = state.eraseLayer.ctx;
const bkpcanvas = state.eraseBackup.canvas;
const cropped = cropCanvas(canvas, {border: 10});
const bb = cropped.bb;
imgCtx.clearRect(0, 0, imgCanvas.width, imgCanvas.height);
imgCtx.drawImage(bkpcanvas, 0, 0);
commands.runCommand("eraseImage", "Color Brush Erase", {
mask: cropped.canvas,
...bb,
});
ctx.clearRect(bb.x, bb.y, bb.w, bb.h);
};
},
populateContextMenu: (menu, state) => {
if (!state.ctxmenu) {
state.ctxmenu = {};
// Affects Mask Checkbox
const affectMaskCheckbox = _toolbar_input.checkbox(
state,
"affectMask",
"Affect Mask"
).label;
state.ctxmenu.affectMaskCheckbox = affectMaskCheckbox;
// Brush size slider
const brushSizeSlider = _toolbar_input.slider(
state,
"brushSize",
"Brush Size",
state.config.minBrushSize,
state.config.maxBrushSize,
1
);
state.ctxmenu.brushSizeSlider = brushSizeSlider.slider;
state.setBrushSize = brushSizeSlider.setValue;
// Brush size slider
const brushBlurSlider = _toolbar_input.slider(
state,
"brushBlur",
"Brush Blur",
state.config.minBlur,
state.config.maxBlur,
1
);
state.ctxmenu.brushBlurSlider = brushBlurSlider.slider;
// Brush color
const brushColorPicker = document.createElement("input");
brushColorPicker.type = "color";
brushColorPicker.style.width = "100%";
brushColorPicker.value = state.color;
brushColorPicker.addEventListener("input", (evn) => {
state.color = evn.target.value;
});
state.ctxmenu.brushColorPicker = brushColorPicker;
}
menu.appendChild(state.ctxmenu.affectMaskCheckbox);
menu.appendChild(state.ctxmenu.brushSizeSlider);
menu.appendChild(state.ctxmenu.brushBlurSlider);
menu.appendChild(state.ctxmenu.brushColorPicker);
},
shortcut: "C",
}
);

5
res/icons/brush.svg Normal file
View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m9.06 11.9 8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"></path>
<path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"></path>
</svg>

After

Width:  |  Height:  |  Size: 413 B