Merge branch 'main' into manual-scripts

# Conflicts:
#	index.html
#	js/ui/tool/dream.js
This commit is contained in:
tim h 2023-01-28 09:47:41 -06:00
commit 8eef3392ed
29 changed files with 1432 additions and 215 deletions

View file

@ -1,5 +1,6 @@
:root {
--c-primary: #2c3333;
--c-disabled: rgb(81, 81, 81);
--c-hover: hsl(180, 7%, 30%);
--c-active: hsl(180, 7%, 25%);
--c-primary-accent: hsl(180, 7%, 40%);

View file

@ -71,6 +71,11 @@
transform: rotate(-90deg);
}
.ui.icon > .icon-scroll {
-webkit-mask-image: url("../res/icons/scroll.svg");
mask-image: url("../res/icons/scroll.svg");
}
.ui.icon > .icon-settings {
-webkit-mask-image: url("../res/icons/settings.svg");
mask-image: url("../res/icons/settings.svg");
@ -100,6 +105,39 @@
mask-image: url("../res/icons/paintbrush.svg");
}
.ui.inline-icon.icon-save::after,
.ui.icon > .icon-save {
-webkit-mask-image: url("../res/icons/save.svg");
mask-image: url("../res/icons/save.svg");
}
.ui.inline-icon.icon-pencil::after,
.ui.icon > .icon-pencil {
-webkit-mask-image: url("../res/icons/pencil.svg");
mask-image: url("../res/icons/pencil.svg");
}
.ui.inline-icon.icon-download::after,
.ui.icon > .icon-download {
-webkit-mask-image: url("../res/icons/download.svg");
mask-image: url("../res/icons/download.svg");
}
.ui.inline-icon.icon-upload::after,
.ui.icon > .icon-upload {
-webkit-mask-image: url("../res/icons/upload.svg");
mask-image: url("../res/icons/upload.svg");
}
.ui.inline-icon.icon-more-horizontal::after,
.ui.icon > .icon-more-horizontal {
-webkit-mask-image: url("../res/icons/more-horizontal.svg");
mask-image: url("../res/icons/more-horizontal.svg");
}
.ui.inline-icon.icon-trash::after,
.ui.icon > .icon-trash {
-webkit-mask-image: url("../res/icons/trash.svg");
mask-image: url("../res/icons/trash.svg");
}
.ui.inline-icon.icon-expand::after,
.ui.icon > .icon-expand {
-webkit-mask-image: url("../res/icons/expand.svg");

View file

@ -227,11 +227,25 @@ body {
width: 100%;
}
.host-field-wrapper input {
flex-shrink: 0;
.host-field-wrapper > .host-field {
display: flex;
width: calc(100% - 15px);
}
.host-field-wrapper > .host-field > .label {
padding-left: 5px;
padding-right: 5px;
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
background-color: var(--c-primary);
color: var(--c-text);
}
.host-field-wrapper input {
display: block;
min-width: 0;
border: 0;
}

59
css/ui/workspace.css Normal file
View file

@ -0,0 +1,59 @@
#workspace-select input.autocomplete-text {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
padding-left: 5px;
border: none;
text-overflow: ellipsis;
}
#workspace-select-area .buttons > *:last-child {
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
}
.workspace-btn {
cursor: pointer;
border: 0;
width: 21px;
height: 21px;
background-color: var(--c-primary);
}
.workspace-btn:disabled {
cursor: default;
background-color: var(--c-disabled) !important;
}
.workspace-btn:hover {
background-color: var(--c-hover);
}
.workspace-btn:active {
background-color: var(--c-active);
}
.workspace-collapsible {
position: relative;
width: 0;
overflow: visible;
}
.workspace-collapsible > *:first-child {
display: flex;
width: fit-content;
height: 21px;
transition-duration: 50ms;
}
.workspace-collapsible.collapsed > *:first-child {
width: 0 !important;
overflow: hidden !important;
}

View file

@ -4,14 +4,15 @@
<meta charset="utf-8" />
<title>openOutpaint 🐠</title>
<!-- CSS Variables -->
<link href="css/colors.css?v=3f81e80" rel="stylesheet" />
<link href="css/icons.css?v=9ae0466" rel="stylesheet" />
<link href="css/colors.css?v=f732f19" rel="stylesheet" />
<link href="css/icons.css?v=466e14e" rel="stylesheet" />
<link href="css/index.css?v=882f400" rel="stylesheet" />
<link href="css/index.css?v=61e08f5" rel="stylesheet" />
<link href="css/layers.css?v=92c0352" rel="stylesheet" />
<link href="css/ui/generic.css?v=30837f8" rel="stylesheet" />
<link href="css/ui/workspace.css?v=2a9fdf7" rel="stylesheet" />
<link href="css/ui/history.css?v=0b03861" rel="stylesheet" />
<link href="css/ui/layers.css?v=1d66c2b" rel="stylesheet" />
<link href="css/ui/toolbar.css?v=109c78f" rel="stylesheet" />
@ -38,17 +39,57 @@
</button>
</div>
<div id="info" class="menu-container" style="min-width: 200px">
<label>
Host
<div class="host-field-wrapper">
<input id="host" value="http://127.0.0.1:7860" />
<div
id="workspace-select-area"
style="display: flex; margin-bottom: 5px">
<div id="workspace-select"></div>
<div class="buttons" style="display: flex">
<button
id="save-workspace-btn"
class="ui inline-icon icon-save workspace-btn"
title="Save Workspace"></button>
<button
id="rename-workspace-btn"
class="ui inline-icon icon-pencil workspace-btn"
title="Rename Workspace"></button>
<button
id="more-workspace-btn"
class="ui inline-icon icon-more-horizontal workspace-btn"
title="More Options"></button>
<div
id="connection-status-indicator"
class="connection-status before">
<span id="connection-status-indicator-text">Waiting</span>
id="more-workspace-menu"
class="workspace-collapsible collapsed">
<div>
<button
id="export-workspace-btn"
class="ui inline-icon icon-download workspace-btn"
title="Export Workspace"></button>
<button
id="import-workspace-btn"
class="ui inline-icon icon-upload workspace-btn"
title="Import Workspace"></button>
<button
id="delete-workspace-btn"
class="ui inline-icon icon-trash workspace-btn"
title="Delete Workspace"></button>
</div>
<div style="width: 5px; background-color: var(--c-primary)"></div>
</div>
<div style="width: 5px; background-color: var(--c-primary)"></div>
</div>
</label>
</div>
<div class="host-field-wrapper">
<div class="host-field">
<div class="label">Host</div>
<input id="host" value="http://127.0.0.1:7860" />
</div>
<div
id="connection-status-indicator"
class="connection-status before">
<span id="connection-status-indicator-text">Waiting</span>
</div>
</div>
<!-- Prompts section -->
<button type="button" class="collapsible">Prompts</button>
@ -88,7 +129,7 @@
<button id="refreshModelsBtn" onclick="getModels(true)">
<img
class="refreshbutton"
src="./res/icons/refresh-cw.svg"
src="./res/icons/refresh-cw.svg?v=f627140"
alt="refresh models"
title="refresh models" />
</button>
@ -100,7 +141,7 @@
type="number"
id="seed"
onchange="changeSeed()"
min="1"
min="-1"
max="9999999999"
value="-1"
step="1" />
@ -242,7 +283,16 @@
<!-- History -->
<div id="ui-history" class="floating-window" style="right: 10px; top: 10px">
<div class="draggable floating-window-title">History</div>
<div class="draggable floating-window-title">
History
<div style="flex: 1"></div>
<button
id="history-logs-btn"
class="ui icon header-button"
title="Generate History Log">
<div class="icon-scroll"></div>
</button>
</div>
<div class="menu-container" style="min-width: 200px">
<div id="history" class="history"></div>
<div class="button-array" style="padding: 10px">
@ -375,22 +425,27 @@
<div class="ui separator"></div>
<iframe
id="page-overlay"
src="pages/configuration.html?v=7fca00b"></iframe>
src="pages/configuration.html?v=fdbd833"></iframe>
</div>
</div>
<!-- Basics -->
<script src="js/global.js?v=ac30d16" type="text/javascript"></script>
<script src="js/defaults.js?v=5b06818" type="text/javascript"></script>
<!-- Base Libs -->
<script src="js/lib/util.js?v=e82dd04" type="text/javascript"></script>
<script src="js/lib/events.js?v=2ab7933" type="text/javascript"></script>
<script
src="js/lib/workspaces.js?v=4fbd55b"
type="text/javascript"></script>
<script src="js/lib/db.js?v=434363b" type="text/javascript"></script>
<script src="js/lib/input.js?v=769485c" type="text/javascript"></script>
<script src="js/lib/layers.js?v=a1f8aea" type="text/javascript"></script>
<script src="js/lib/commands.js?v=bf23c83" type="text/javascript"></script>
<script src="js/lib/layers.js?v=1a452a1" type="text/javascript"></script>
<script src="js/lib/commands.js?v=ad60afc" type="text/javascript"></script>
<script src="js/lib/toolbar.js?v=306d637" type="text/javascript"></script>
<script src="js/lib/ui.js?v=fe9b702" type="text/javascript"></script>
<script src="js/lib/ui.js?v=17014b3" type="text/javascript"></script>
<script
src="js/initalize/layers.populate.js?v=066dc8e"
@ -402,13 +457,13 @@
<!-- Content -->
<script src="js/prompt.js?v=7a1c68c" type="text/javascript"></script>
<script src="js/index.js?v=04d2bb0" type="text/javascript"></script>
<script src="js/index.js?v=b1402ee" type="text/javascript"></script>
<script
src="js/ui/floating/history.js?v=fc92d14"
src="js/ui/floating/history.js?v=4f29db4"
type="text/javascript"></script>
<script
src="js/ui/floating/layers.js?v=8e66543"
src="js/ui/floating/layers.js?v=aa78ec8"
type="text/javascript"></script>
<!-- Load Tools -->
@ -416,22 +471,25 @@
src="js/ui/tool/generic.js?v=3e678e0"
type="text/javascript"></script>
<script src="js/ui/tool/dream.js?v=07abfe8" type="text/javascript"></script>
<script src="js/ui/tool/dream.js?v=d1ba895" type="text/javascript"></script>
<script
src="js/ui/tool/maskbrush.js?v=d88810f"
type="text/javascript"></script>
<script
src="js/ui/tool/colorbrush.js?v=6f1d2f4"
src="js/ui/tool/colorbrush.js?v=46011ee"
type="text/javascript"></script>
<script
src="js/ui/tool/select.js?v=f290e83"
src="js/ui/tool/select.js?v=63fe672"
type="text/javascript"></script>
<script src="js/ui/tool/stamp.js?v=4a86ff8" type="text/javascript"></script>
<script src="js/ui/tool/stamp.js?v=455f9fe" type="text/javascript"></script>
<script
src="js/ui/tool/interrogate.js?v=e579ff1"
type="text/javascript"></script>
<!-- Initialize -->
<script
src="js/initalize/workspace.populate.js?v=e5586da"
type="text/javascript"></script>
<script
src="js/initalize/shortcuts.populate.js?v=e68546f"
type="text/javascript"></script>

28
js/defaults.js Normal file
View file

@ -0,0 +1,28 @@
/**
* Default settings for local configurations
*/
const localDefaults = {
/** Default Host */
host: "http://127.0.0.1:7860",
};
/**
* Default settings for workspace configurations
*/
const workspaceDefaults = {
/** Default Prompt - REQ */
prompt: "ocean floor scientific expedition, underwater wildlife",
/** Default Negative Prompt - REQ */
neg_prompt:
"people, person, humans, human, divers, diver, glitch, error, text, watermark, bad quality, blurry",
/** Default Stable Diffusion Seed - REQ */
seed: -1,
/** Default CFG Scale - REQ */
cfg_scale: 7.0,
/** Default steps - REQ */
steps: 30,
/** Default Resolution */
resolution: 512,
};

View file

@ -145,6 +145,19 @@ var url = "/sdapi/v1/";
const basePixelCount = 64; //64 px - ALWAYS 64 PX
var focused = true;
function getSDData() {
const w = workspaces.current.settings;
w.ste;
return {
prompt: w.prompt,
negative_prompt: w.neg_prompt,
seed: w.seed,
cfg_scale: w.cfg_scale,
steps: w.steps,
};
}
function startup() {
testHostConfiguration();
loadSettings();
@ -210,8 +223,6 @@ function testHostConfiguration() {
host = value;
hostEl.value = host;
localStorage.setItem("openoutpaint/host", host);
testHostConfiguration();
};
const current = localStorage.getItem("openoutpaint/host");
@ -487,10 +498,19 @@ async function testHostConnection() {
function newImage(evt) {
clearPaintedMask();
uil.layers.forEach(({layer}) => {
commands.runCommand("eraseImage", "Clear Canvas", {
...layer.bb,
ctx: layer.ctx,
});
commands.runCommand(
"eraseImage",
"Clear Canvas",
{
...layer.bb,
ctx: layer.ctx,
},
{
extra: {
log: `Cleared Canvas`,
},
}
);
});
}
@ -567,16 +587,16 @@ const makeSlider = (
textStep = null,
valuecb = null
) => {
const local = lsKey && localStorage.getItem(`openoutpaint/${lsKey}`);
const local = lsKey && workspaces.current.settings[lsKey];
const def = parseFloat(local === null ? defaultValue : local);
let cb = (v) => {
stableDiffusionData[lsKey] = v;
if (lsKey) localStorage.setItem(`openoutpaint/${lsKey}`, v);
if (lsKey) workspaces.current.settings[lsKey] = v;
};
if (valuecb) {
cb = (v) => {
valuecb(v);
localStorage.setItem(`openoutpaint/${lsKey}`, v);
if (lsKey) workspaces.current.settings[lsKey] = v;
};
}
return createSlider(label, el, {
@ -848,6 +868,103 @@ function drawBackground() {
}
}
async function exportWorkspaceState() {
return {
defaultLayer: {
id: uil.layerIndex.default.id,
name: uil.layerIndex.default.name,
},
bb: {
x: imageCollection.bb.x,
y: imageCollection.bb.y,
w: imageCollection.bb.w,
h: imageCollection.bb.h,
},
history: await commands.export(),
};
}
async function importWorkspaceState(state) {
// Start from zero, effectively
await commands.clear();
// Setup initial layer
const layer = uil.layerIndex.default;
layer.deletable = true;
await commands.runCommand(
"addLayer",
"Temporary Layer",
{name: "Temporary Layer", key: "tmp"},
{recordHistory: false}
);
await commands.runCommand(
"deleteLayer",
"Deleted Layer",
{
layer,
},
{recordHistory: false}
);
await commands.runCommand(
"addLayer",
"Initial Layer Creation",
{
id: state.defaultLayer.id,
name: state.defaultLayer.name,
key: "default",
deletable: false,
},
{recordHistory: false}
);
await commands.runCommand(
"deleteLayer",
"Deleted Layer",
{
layer: uil.layerIndex.tmp,
},
{recordHistory: false}
);
// Resize canvas to match original size
const sbb = new BoundingBox(state.bb);
const bb = imageCollection.bb;
let eleft = 0;
if (bb.x > sbb.x) eleft = bb.x - sbb.x;
let etop = 0;
if (bb.y > sbb.y) etop = bb.y - sbb.y;
let eright = 0;
if (bb.tr.x < sbb.tr.x) eright = sbb.tr.x - bb.tr.x;
let ebottom = 0;
if (bb.br.y < sbb.br.y) ebottom = sbb.br.y - bb.br.y;
imageCollection.expand(eleft, etop, eright, ebottom);
// Run commands in order
for (const command of state.history) {
await commands.import(command);
}
}
async function saveWorkspaceToFile() {
const workspace = await exportWorkspaceState();
const blob = new Blob([JSON.stringify(workspace)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
var link = document.createElement("a"); // Or maybe get it from the current document
link.href = url;
link.download = `${new Date().toISOString()}_openOutpaint_workspace.json`;
link.click();
}
async function getUpscalers() {
var url = document.getElementById("host").value + "/sdapi/v1/upscalers";
let upscalers = [];
@ -1167,11 +1284,6 @@ function loadSettings() {
localStorage.getItem("openoutpaint/mask_blur") == null
? 0
: localStorage.getItem("openoutpaint/mask_blur");
var _seed =
localStorage.getItem("openoutpaint/seed") == null
? -1
: localStorage.getItem("openoutpaint/seed");
let _enable_hr =
localStorage.getItem("openoutpaint/enable_hr") === null
? false
@ -1202,7 +1314,7 @@ function loadSettings() {
// set the values into the UI
document.getElementById("maskBlur").value = Number(_mask_blur);
document.getElementById("seed").value = Number(_seed);
document.getElementById("seed").value = workspaces.current.settings.seed;
document.getElementById("cbxHRFix").checked = Boolean(_enable_hr);
document.getElementById("cbxRestoreFaces").checked = Boolean(_restore_faces);
document.getElementById("cbxSyncCursorSize").checked =

View file

@ -0,0 +1,178 @@
(() => {
const saveWorkspaceBtn = document.getElementById("save-workspace-btn");
const renameWorkspaceBtn = document.getElementById("rename-workspace-btn");
const moreWorkspaceBtn = document.getElementById("more-workspace-btn");
const expandedWorkspaceMenu = document.getElementById("more-workspace-menu");
const exportWorkspaceBtn = document.getElementById("export-workspace-btn");
const importWorkspaceBtn = document.getElementById("import-workspace-btn");
const deleteWorkspaceBtn = document.getElementById("delete-workspace-btn");
moreWorkspaceBtn.addEventListener("click", () => {
expandedWorkspaceMenu.classList.toggle("collapsed");
});
const workspaceAutocomplete = createAutoComplete(
"Workspace",
document.getElementById("workspace-select")
);
workspaceAutocomplete.options = [{name: "Default", value: "default"}];
workspaceAutocomplete.value = "default";
renameWorkspaceBtn.disabled = true;
deleteWorkspaceBtn.disabled = true;
workspaceAutocomplete.onchange.on(async ({name, value}) => {
if (value === "default") {
renameWorkspaceBtn.disabled = true;
deleteWorkspaceBtn.disabled = true;
await commands.clear();
return;
}
renameWorkspaceBtn.disabled = false;
deleteWorkspaceBtn.disabled = false;
const workspaces = db
.transaction("workspaces", "readonly")
.objectStore("workspaces");
workspaces.get(value).onsuccess = (e) => {
console.debug("[workspace.populate] Loading workspace");
const res = e.target.result;
const {workspace} = res;
importWorkspaceState(workspace);
};
});
/**
* Updates Workspace selection list
*/
const listWorkspaces = async (value = undefined) => {
const options = [{name: "Default", value: "default"}];
const workspaces = db
.transaction("workspaces", "readonly")
.objectStore("workspaces");
workspaces.openCursor().onsuccess = (e) => {
/** @type {IDBCursor} */
const c = e.target.result;
if (c) {
options.push({name: c.value.name, value: c.key});
c.continue();
} else {
const previousValue = workspaceAutocomplete.value;
workspaceAutocomplete.options = options;
workspaceAutocomplete.value = value ?? previousValue;
}
};
};
const saveWorkspaceToDB = async (value) => {
const workspace = await exportWorkspaceState();
const workspaces = db
.transaction("workspaces", "readwrite")
.objectStore("workspaces");
let id = value;
if (value === "default" && commands._history.length > 0) {
// If Workspace is the Default
const name = (prompt("Please enter the workspace name") ?? "").trim();
if (name) {
id = guid();
workspaces.add({id, name, workspace}).onsuccess = () => {
listWorkspaces(id);
alert(`Workspace saved as '${name}'`);
};
}
} else {
workspaces.get(id).onsuccess = (e) => {
const ws = e.target.result;
if (ws) {
workspaces.put({id, workspace}).onsuccess = () => {
alert(`Workspace saved as '${ws.value.name}'`);
listWorkspaces();
};
}
};
}
};
// Normal Workspace Export/Import
exportWorkspaceBtn.addEventListener("click", () => saveWorkspaceToFile());
importWorkspaceBtn.addEventListener("click", () => {
const input = document.createElement("input");
input.type = "file";
input.accept = "application/json";
input.addEventListener("change", async (evn) => {
let files = Array.from(input.files);
const json = await files[0].text();
await importWorkspaceState(JSON.parse(json));
saveWorkspaceToDB("default");
});
input.click();
});
const onDatabaseLoad = async () => {
// Get workspaces from database
listWorkspaces();
// Save Workspace Button
saveWorkspaceBtn.addEventListener("click", () =>
saveWorkspaceToDB(workspaceAutocomplete.value)
);
// Rename Workspace
renameWorkspaceBtn.addEventListener("click", () => {
const workspaces = db
.transaction("workspaces", "readwrite")
.objectStore("workspaces");
let id = workspaceAutocomplete.value;
workspaces.get(id).onsuccess = (e) => {
const workspace = e.target.result;
const name = prompt(
`Please enter the new workspace name.\nOriginal is '${workspace.name}'`
).trim();
if (!name) return;
workspace.name = name;
workspaces.put(workspace).onsuccess = () => {
listWorkspaces();
};
};
});
// Delete Workspace
deleteWorkspaceBtn.addEventListener("click", () => {
const workspaces = db
.transaction("workspaces", "readwrite")
.objectStore("workspaces");
let id = workspaceAutocomplete.value;
workspaces.get(id).onsuccess = (e) => {
const workspace = e.target.result;
if (
confirm(
`Do you really want to delete the workspace '${workspace.name}'?`
)
) {
workspaces.delete(id).onsuccess = (e) => {
listWorkspaces("default");
};
}
};
});
};
if (db) onDatabaseLoad();
else ondatabaseload.on(onDatabaseLoad);
})();

View file

@ -6,7 +6,9 @@
* @property {string} title The title passed to the command being run
* @property {() => void | Promise<void>} undo A method to undo whatever the command did
* @property {() => void | Promise<void>} redo A method to redo whatever undo did
* @property {() => any | Promise<any>} export A method to export the command
* @property {{[key: string]: any}} state The state of the current command instance
* @property {{[key: string]: any}} extra Extra information saved with the command
*/
/**
@ -14,6 +16,7 @@
*
* @typedef CommandExtraParams
* @property {boolean} recordHistory The title passed to the command being run
* @property {any} importData Data to restore the command from
* @property {Record<string, any>} extra Extra information to be stored in the history entry
*/

View file

@ -57,6 +57,44 @@ const commands = makeReadOnly(
}
},
/**
* Clears the history
*/
async clear() {
await this.undo(this._history.length);
this._history.splice(0, this._history.length);
_commands_events.emit({
action: "clear",
state: {},
current: commands._current,
});
},
/**
* Imports an exported command and runs it
*
* @param {{name: string, title: string, data: any}} exported Exported command
*/
async import(exported) {
await this.runCommand(
exported.command,
exported.title,
{},
{importData: exported.data}
);
},
/**
* Exports all commands in the history
*/
async export() {
return Promise.all(
this._history.map(async (command) => command.export())
);
},
/**
* Creates a basic command, that can be done and undone
*
@ -74,31 +112,57 @@ const commands = makeReadOnly(
* @param {string} name Command identifier (name)
* @param {CommandDoCallback} run A method that performs the action for the first time
* @param {CommandUndoCallback} undo A method that reverses what the run method did
* @param {object} options Extra options
* @param {CommandDoCallback} options.redo A method that redoes the action after undone (default: run)
* @param {object} opt Extra options
* @param {CommandDoCallback} opt.redo A method that redoes the action after undone (default: run)
* @param {(state: any) => any} opt.exportfn A method that exports a serializeable object
* @param {(value: any, state: any) => any} opt.importfn A method that imports a serializeable object
* @returns {Command}
*/
createCommand(name, run, undo, options = {}) {
defaultOpt(options, {
createCommand(name, run, undo, opt = {}) {
defaultOpt(opt, {
redo: run,
exportfn: null,
importfn: null,
});
const redo = options.redo;
const command = async function runWrapper(title, options, extra) {
const command = async function runWrapper(title, options, extra = {}) {
// Create copy of options and state object
const copy = {};
Object.assign(copy, options);
const state = {};
defaultOpt(extra, {
recordHistory: true,
importData: null,
});
const exportfn =
opt.exportfn ?? ((state) => Object.assign({}, state.serializeable));
const importfn =
opt.importfn ??
((value, state) => (state.serializeable = Object.assign({}, value)));
const redo = opt.redo;
/** @type {CommandEntry} */
const entry = {
id: guid(),
title,
state,
async export() {
return {
command: name,
title,
data: await exportfn(state),
};
},
extra: extra.extra,
};
if (extra.importData) {
await importfn(extra.importData, state);
state.imported = extra.importData;
}
// Attempt to run command
try {
console.debug(`[commands] Running '${title}'[${name}]`);
@ -209,47 +273,63 @@ commands.createCommand(
"drawImage",
(title, options, state) => {
if (
!options ||
options.image === undefined ||
options.x === undefined ||
options.y === undefined
!state.imported &&
(!options ||
options.image === undefined ||
options.x === undefined ||
options.y === undefined)
)
throw "Command drawImage requires options in the format: {image, x, y, w?, h?, ctx?}";
throw "Command drawImage requires options in the format: {image, x, y, w?, h?, layer?}";
// Check if we have state
if (!state.context) {
const context = options.ctx || uil.ctx;
state.context = context;
if (!state.layer) {
/** @type {Layer} */
let layer = options.layer;
if (!options.layer && state.layerId)
layer = imageCollection.layers[state.layerId];
// Saving what was in the canvas before the command
const imgData = context.getImageData(
options.x,
options.y,
options.w || options.image.width,
options.h || options.image.height
);
state.box = {
x: options.x,
y: options.y,
w: options.w || options.image.width,
h: options.h || options.image.height,
};
// Create Image
const cutout = document.createElement("canvas");
cutout.width = state.box.w;
cutout.height = state.box.h;
cutout.getContext("2d").putImageData(imgData, 0, 0);
state.original = new Image();
state.original.src = cutout.toDataURL();
if (!options.layer && !state.layerId) layer = uil.layer;
state.layer = layer;
state.context = layer.ctx;
if (!state.imported) {
const canvas = document.createElement("canvas");
canvas.width = options.image.width;
canvas.height = options.image.height;
canvas.getContext("2d").drawImage(options.image, 0, 0);
state.image = canvas;
// Saving what was in the canvas before the command
const imgData = state.context.getImageData(
options.x,
options.y,
options.w || options.image.width,
options.h || options.image.height
);
state.box = {
x: options.x,
y: options.y,
w: options.w || options.image.width,
h: options.h || options.image.height,
};
// Create Image
const cutout = document.createElement("canvas");
cutout.width = state.box.w;
cutout.height = state.box.h;
cutout.getContext("2d").putImageData(imgData, 0, 0);
state.original = cutout;
}
}
// Apply command
state.context.drawImage(
options.image,
state.image,
0,
0,
options.image.width,
options.image.height,
state.image.width,
state.image.height,
state.box.x,
state.box.y,
state.box.w,
@ -261,6 +341,51 @@ commands.createCommand(
state.context.clearRect(state.box.x, state.box.y, state.box.w, state.box.h);
// Undo
state.context.drawImage(state.original, state.box.x, state.box.y);
},
{
exportfn: (state) => {
const canvas = document.createElement("canvas");
canvas.width = state.image.width;
canvas.height = state.image.height;
canvas.getContext("2d").drawImage(state.image, 0, 0);
const originalc = document.createElement("canvas");
originalc.width = state.original.width;
originalc.height = state.original.height;
originalc.getContext("2d").drawImage(state.original, 0, 0);
return {
image: canvas.toDataURL(),
original: originalc.toDataURL(),
box: state.box,
layer: state.layer.id,
};
},
importfn: async (value, state) => {
state.box = value.box;
state.layerId = value.layer;
const img = document.createElement("img");
img.src = value.image;
await img.decode();
const imagec = document.createElement("canvas");
imagec.width = state.box.w;
imagec.height = state.box.h;
imagec.getContext("2d").drawImage(img, 0, 0);
const orig = document.createElement("img");
orig.src = value.original;
await orig.decode();
const originalc = document.createElement("canvas");
originalc.width = state.box.w;
originalc.height = state.box.h;
originalc.getContext("2d").drawImage(orig, 0, 0);
state.image = imagec;
state.original = originalc;
},
}
);
@ -268,18 +393,26 @@ commands.createCommand(
"eraseImage",
(title, options, state) => {
if (
!options ||
options.x === undefined ||
options.y === undefined ||
options.w === undefined ||
options.h === undefined
!state.imported &&
(!options ||
options.x === undefined ||
options.y === undefined ||
options.w === undefined ||
options.h === undefined)
)
throw "Command eraseImage requires options in the format: {x, y, w, h, ctx?}";
if (state.imported) {
state.layer = imageCollection.layers[state.layerId];
state.context = state.layer.ctx;
}
// Check if we have state
if (!state.context) {
const context = options.ctx || uil.ctx;
state.context = context;
if (!state.layer) {
const layer = (options.layer || state.layerId) ?? uil.layer;
state.layer = layer;
state.mask = options.mask;
state.context = layer.ctx;
// Saving what was in the canvas before the command
state.box = {
@ -295,7 +428,7 @@ commands.createCommand(
cutout
.getContext("2d")
.drawImage(
context.canvas,
state.context.canvas,
options.x,
options.y,
options.w,
@ -316,9 +449,9 @@ commands.createCommand(
const op = state.context.globalCompositeOperation;
state.context.globalCompositeOperation = "destination-out";
if (options.mask)
if (state.mask)
state.context.drawImage(
options.mask,
state.mask,
state.box.x,
state.box.y,
state.box.w,
@ -340,5 +473,59 @@ commands.createCommand(
state.context.clearRect(state.box.x, state.box.y, state.box.w, state.box.h);
// Undo
state.context.drawImage(state.original, state.box.x, state.box.y);
},
{
exportfn: (state) => {
let mask = null;
if (state.mask) {
const maskc = document.createElement("canvas");
maskc.width = state.mask.width;
maskc.height = state.mask.height;
maskc.getContext("2d").drawImage(state.mask, 0, 0);
mask = maskc.toDataURL();
}
const originalc = document.createElement("canvas");
originalc.width = state.original.width;
originalc.height = state.original.height;
originalc.getContext("2d").drawImage(state.original, 0, 0);
return {
original: originalc.toDataURL(),
mask,
box: state.box,
layer: state.layer.id,
};
},
importfn: async (value, state) => {
state.box = value.box;
state.layerId = value.layer;
if (value.mask) {
const mask = document.createElement("img");
mask.src = value.mask;
await mask.decode();
const maskc = document.createElement("canvas");
maskc.width = state.box.w;
maskc.height = state.box.h;
maskc.getContext("2d").drawImage(mask, 0, 0);
state.mask = maskc;
}
const orig = document.createElement("img");
orig.src = value.original;
await orig.decode();
const originalc = document.createElement("canvas");
originalc.width = state.box.w;
originalc.height = state.box.h;
originalc.getContext("2d").drawImage(orig, 0, 0);
state.original = originalc;
},
}
);

36
js/lib/db.js Normal file
View file

@ -0,0 +1,36 @@
const idb = window.indexedDB.open("openoutpaint", 2);
idb.onerror = (e) => {
console.warn("[stamp] Failed to connect to IndexedDB");
console.warn(e);
};
idb.onupgradeneeded = (e) => {
const db = e.target.result;
console.debug(`[stamp] Setting up database version ${db.version}`);
if (e.oldVersion < 1) {
// Resources Store
const resourcesStore = db.createObjectStore("resources", {
keyPath: "id",
});
resourcesStore.createIndex("name", "name", {unique: false});
}
// Workspaces Store
const workspacesStore = db.createObjectStore("workspaces", {
keyPath: "id",
});
workspacesStore.createIndex("name", "name", {unique: false});
};
/** @type {IDBDatabase} */
let db = null;
/** @type {Observer<{db: IDBDatabase}>} */
const ondatabaseload = new Observer();
idb.onsuccess = (e) => {
db = e.target.result;
ondatabaseload.emit({db});
};

View file

@ -38,7 +38,7 @@
* @property {Point} inputOffset The offset for calculating layer coordinates from input element input information
* @property {Point} origin The location of the origin ((0, 0) point) of the collection (If canvas goes from -64, -32 to 128, 512, it's (64, 32))
* @property {BoundingBox} bb The current bounding box of the collection, in layer coordinates
* @property {{[key: string]: Layer}} layers An object for quick access to named layers of the collection
* @property {{[key: string]: Layer}} layers An object for quick access to layers of the collection
* @property {Size} size The size of the collection (CSS)
* @property {Size} resolution The resolution of the collection (canvas)
* @property {function} expand Expands the collection and its full layers by the specified amounts

View file

@ -335,6 +335,7 @@ const layers = {
*
* @param {string | null} key Name and key to use to access layer. If null, it is a temporary layer.
* @param {object} options
* @param {string} options.id
* @param {string} options.name
* @param {?BoundingBox} options.bb
* @param {string} [options.category]
@ -346,9 +347,12 @@ const layers = {
*/
registerLayer(key = null, options = {}) {
// Make ID
const id = guid();
const id = options.id ?? guid();
defaultOpt(options, {
// ID of the layer
id: null,
// Display name for the layer
name: key || `Temporary ${id}`,
@ -618,6 +622,7 @@ const layers = {
collection._layers.splice(index, 0, layer);
}
if (key) collection.layers[key] = layer;
collection.layers[id] = layer;
if (key === null)
console.debug(
@ -651,7 +656,8 @@ const layers = {
layers.listen.onlayerdelete.emit({
layer: lobj,
});
if (lobj.key) delete collection.layers[lobj.key];
if (lobj.key) collection.layers[lobj.key] = undefined;
collection.layers[lobj.id] = undefined;
collection.element.removeChild(lobj.canvas);

View file

@ -209,7 +209,6 @@ function createSlider(name, wrapper, options = {}) {
* @param {{name: string, value: string, optionelcb: (el: HTMLOptionElement) => void}[]} options.options Options to add to the selector
* @param {object} extraEl Additional element to include in wrapper div (e.g. model refresh button)
* @param {string} extraClass Additional class to attach to the autocomplete input element
* @returns {AutoCompleteElement}
*/
function createAutoComplete(
name,

0
js/lib/workspaces.d.js Normal file
View file

128
js/lib/workspaces.js Normal file
View file

@ -0,0 +1,128 @@
/**
* Workspaces (or sessions) are settings and canvas state storage structures that can be changed at will, saved, and restored.
*/
/**
* Represents a workspace
*
* @template [S] Settings type
*/
class Workspace {
/**
* The name of the workspace
* @type {string}
*/
name = "Workspace Name";
/**
* Workspace default settings.
*
* @type {S}
*/
defaults = {};
/**
* Storage for workspace settings.
*
* @type {S}
*/
settings = new Proxy(
{},
{
get(t, name) {
if (t[name] === undefined)
t[name] =
JSON.parse(localStorage.getItem(`openoutpaint/${name}`)) ??
defaults[name];
return t[name];
},
set(t, name, value) {
localStorage.setItem(`openoutpaint/${name}`, JSON.stringify(value));
t[name] = value;
},
}
);
/**
* Storage for other data
*
* @type {Record<string, any>}
*/
data = new Proxy({}, {});
/**
* Saves the data to the workspace
*
* @param {string} key The key of the data to be saved (eg. history or layers)
* @param {any} data The data to be saved on this key. MUST BE SERIALIZABLE.
*/
save(key, data) {
this.data[key] = data;
}
/**
* Gets saved data from the workspace
*
* @param {string} key The key of the data to be saved (eg. history or layers)
* @param {any} data The data to be saved on this key. MUST BE SERIALIZABLE.
*/
load(key) {
return this.data[key];
}
/**
* @param {string} name The name of the workspace
* @param {Object} options
* @param {S} options.defaults Default workspace settings
*/
constructor(name, options = {}) {
defaultOpt(options, {
defaults: {},
});
this.name = name;
this.defaults = options.defaults;
}
}
const workspaces = {
/**
* Loaded workspace
*
* @type {Workspace<workspaceDefaults>}
*/
_workspace: null,
get current() {
return this._workspace;
},
/**
* On Workspace Changed
*
* @type {Observer<{workspace: Workspace<workspaceDefaults>}>}
*/
onchange: new Observer(),
/**
* Loads a workspace
*
* @param {Workspace<workspaceDefaults>} workspace Workspace to load
*/
loadWorkspace(workspace) {
console.info(`[workspaces] Loading workspace: ${workspace.name}`);
// Set current workspace
this._workspace = workspace;
// Notify observers that the workspace has changed
this.onchange.emit({workspace});
},
};
// Creates a new workspace instance
workspaces.loadWorkspace(
new Workspace("Default", {
workspaceDefaults,
})
);

View file

@ -1,4 +1,20 @@
(() => {
const historyLogBtn = document.getElementById("history-logs-btn");
historyLogBtn.addEventListener("click", () => {
let logs = "";
commands._history.forEach((entry) => {
if (entry.extra.log) logs += ` => ${entry.extra.log}\n`;
});
const blob = new Blob([logs], {type: "text/plain"});
const url = URL.createObjectURL(blob);
var link = document.createElement("a"); // Or maybe get it from the current document
link.href = url;
link.download = `${new Date().toISOString()}_openOutpaint_log.txt`;
link.click();
});
const historyView = document.getElementById("history");
const makeHistoryEntry = (index, id, title) => {
@ -25,7 +41,7 @@
};
_commands_events.on((message) => {
if (message.action === "run") {
if (message.action === "run" || message.action === "clear") {
Array.from(historyView.children).forEach((child) => {
if (
!commands._history.find((entry) => `hist-${entry.id}` === child.id)

View file

@ -8,6 +8,7 @@ const uil = {
_ui_layer_list: document.getElementById("layer-list"),
layers: [],
layerIndex: {},
_active: null,
set active(v) {
this.onactive.emit({
@ -26,6 +27,7 @@ const uil = {
return this._active;
},
/** @type {Layer} */
get layer() {
return this.active && this.active.layer;
},
@ -112,9 +114,18 @@ const uil = {
"click",
(evn) => {
evn.stopPropagation();
commands.runCommand("deleteLayer", "Deleted Layer", {
layer: uiLayer,
});
commands.runCommand(
"deleteLayer",
"Deleted Layer",
{
layer: uiLayer,
},
{
extra: {
log: `Deleted Layer ${uiLayer.name} [${uiLayer.id}]`,
},
}
);
},
{passive: false}
);
@ -321,6 +332,118 @@ const uil = {
},
};
class UILayer {
/** @type {string} Layer ID */
id;
/** @type {string} Display name of the layer */
name;
/** @type {Layer} Associated real layer */
layer;
/** @type {string} Custom key to access this layer */
key;
/** @type {string} The group the UI layer is on (for some categorization) */
group;
/** @type {boolean} If the layer displays the delete button */
deletable;
/** @type {HTMLElement} The entry element on the UI */
entry;
/** @type {boolean} [internal] Whether the layer is actually hidden right now */
_hidden;
/** @type {boolean} Whether the layer is hidden or not */
set hidden(v) {
if (v) {
this._hidden = true;
this.layer.hide(v);
this.entry && this.entry.classList.add("hidden");
} else {
this._hidden = false;
this.layer.unhide(v);
this.entry && this.entry.classList.remove("hidden");
}
}
get hidden() {
return this._hidden;
}
/** @type {CanvasRenderingContext2D} */
get ctx() {
return this.layer.ctx;
}
/** @type {HTMLCanvasElement} */
get canvas() {
return this.layer.canvas;
}
/**
* Creates a new UI Layer
*
* @param {string} name Display name of the layer
* @param {object} extra
* @param {string} extra.id The id of the layer to create
* @param {string} extra.group The group the layer is on (for some categorization)
* @param {string} extra.key Custom key to access this layer
* @param {string} extra.deletable If the layer displays the delete button
*/
constructor(name, extra = {}) {
defaultOpt(extra, {
id: null,
group: null,
key: null,
deletable: true,
});
this.layer = imageCollection.registerLayer(extra.key, {
id: extra.id,
name,
category: "user",
after:
(uil.layers.length > 0 && uil.layers[uil.layers.length - 1].layer) ||
bgLayer,
});
this.name = name;
this.id = this.layer.id;
this.key = extra.key;
this.group = extra.group;
this.deletable = extra.deletable;
this.hidden = false;
}
/**
* Register layer in uil
*/
register() {
uil.layers.push(this);
uil.layerIndex[this.id] = this;
uil.layerIndex[this.key] = this;
}
/**
* Removes layer registration from uil
*/
unregister() {
const index = uil.layers.findIndex((v) => v === this);
if (index === -1) throw new ReferenceError("Layer could not be found");
if (uil.active === this)
uil.active = uil.layers[index + 1] || uil.layers[index - 1];
uil.layers.splice(index, 1);
uil.layerIndex[this.id] = undefined;
uil.layerIndex[this.key] = undefined;
}
}
/**
* Command for creating a new layer
*/
@ -329,61 +452,73 @@ commands.createCommand(
(title, opt, state) => {
const options = Object.assign({}, opt) || {};
defaultOpt(options, {
id: guid(),
group: null,
name: "New Layer",
key: null,
deletable: true,
});
if (!state.layer) {
const {group, name} = options;
let {id, name, group, key, deletable} = state;
const layer = imageCollection.registerLayer(null, {
name,
category: "user",
after:
(uil.layers.length > 0 && uil.layers[uil.layers.length - 1].layer) ||
bgLayer,
if (!state.imported) {
id = options.id;
name = options.name;
group = options.group;
key = options.key;
deletable = options.deletable;
state.name = name;
state.group = group;
state.key = key;
state.deletable = deletable;
}
state.layer = new UILayer(name, {
id,
group,
key: key,
deletable: deletable,
});
state.layer = {
id: layer.id,
group,
name,
deletable: options.deletable,
_hidden: false,
set hidden(v) {
if (v) {
this._hidden = true;
this.layer.hide(v);
this.entry && this.entry.classList.add("hidden");
} else {
this._hidden = false;
this.layer.unhide(v);
this.entry && this.entry.classList.remove("hidden");
}
},
get hidden() {
return this._hidden;
},
entry: null,
layer,
};
if (state.hidden !== undefined) state.layer.hidden = state.hidden;
state.id = state.layer.id;
}
uil.layers.push(state.layer);
state.layer.register();
uil._syncLayers();
uil.active = state.layer;
},
(title, state) => {
const index = uil.layers.findIndex((v) => v === state.layer);
state.layer.unregister();
if (index === -1) throw new ReferenceError("Layer could not be found");
if (uil.active === state.layer)
uil.active = uil.layers[index + 1] || uil.layers[index - 1];
uil.layers.splice(index, 1);
uil._syncLayers();
},
{
exportfn(state) {
return {
id: state.layer.id,
hidden: state.layer.hidden,
name: state.layer.name,
group: state.group,
key: state.key,
deletable: state.deletable,
};
},
importfn(value, state) {
state.id = value.id;
state.hidden = value.hidden;
state.name = value.name;
state.group = value.group;
state.key = value.key;
state.deletable = value.deletable;
},
}
);
@ -424,6 +559,20 @@ commands.createCommand(
},
(title, state) => {
uil._moveLayerTo(state.layer, state.oldposition);
},
{
exportfn(state) {
return {
layer: state.layer.id,
position: state.position,
oldposition: state.oldposition,
};
},
importfn(value, state) {
state.layer = uil.layerIndex[value.layer];
state.position = value.position;
state.oldposition = value.oldposition;
},
}
);
@ -470,6 +619,18 @@ commands.createCommand(
uil._syncLayers();
state.layer.hidden = false;
},
{
exportfn(state) {
return {
layer: state.layer.id,
position: state.position,
};
},
importfn(value, state) {
state.layer = uil.layerIndex[value.layer];
state.position = value.position;
},
}
);
@ -485,27 +646,34 @@ commands.createCommand(
layerD: null,
});
const layerS = options.layer || uil.active;
if (state.imported) {
state.layerS = uil.layerIndex[state.layerSID];
state.layerD = uil.layerIndex[state.layerDID];
}
if (!layerS.deletable)
throw new TypeError(
"[layer.mergeLayer] Layer is a root layer and cannot be merged"
);
if (!state.layerS) {
const layerS = options.layer || uil.active;
const index = uil.layers.indexOf(layerS);
if (index === -1)
throw new ReferenceError("[layer.mergeLayer] Layer could not be found");
if (!layerS.deletable)
throw new TypeError(
"[layer.mergeLayer] Layer is a undeletable layer and cannot be merged"
);
if (index === 0 && !options.layerD)
throw new ReferenceError(
"[layer.mergeLayer] No layer below source layer exists"
);
const index = uil.layers.indexOf(layerS);
if (index === -1)
throw new ReferenceError("[layer.mergeLayer] Layer could not be found");
// Use layer under source layer to merge into if not given
const layerD = options.layerD || uil.layers[index - 1];
if (index === 0 && !options.layerD)
throw new ReferenceError(
"[layer.mergeLayer] No layer below source layer exists"
);
state.layerS = layerS;
state.layerD = layerD;
// Use layer under source layer to merge into if not given
const layerD = options.layerD || uil.layers[index - 1];
state.layerS = layerS;
state.layerD = layerD;
}
// REFERENCE: This is a great reference for metacommands (commands that use other commands)
// These commands should NOT record history as we are already executing a command
@ -516,7 +684,7 @@ commands.createCommand(
image: state.layerS.layer.canvas,
x: 0,
y: 0,
ctx: state.layerD.layer.ctx,
layer: state.layerD.layer,
},
{recordHistory: false}
);
@ -536,12 +704,22 @@ commands.createCommand(
state.drawCommand.redo();
state.delCommand.redo();
},
exportfn(state) {
return {
layerS: state.layerS.id,
layerD: state.layerD.id,
};
},
importfn(value, state) {
state.layerSID = value.layerS;
state.layerDID = value.layerD;
},
}
);
commands.runCommand(
"addLayer",
"Initial Layer Creation",
{name: "Default Image Layer", deletable: false},
{name: "Default Image Layer", key: "default", deletable: false},
{recordHistory: false}
);

View file

@ -290,10 +290,19 @@ const colorBrushTool = () =>
const cropped = cropCanvas(canvas, {border: 10});
const bb = cropped.bb;
commands.runCommand("drawImage", "Color Brush Draw", {
image: cropped.canvas,
...bb,
});
commands.runCommand(
"drawImage",
"Color Brush Draw",
{
image: cropped.canvas,
...bb,
},
{
extra: {
log: `Color brush drawn at x: ${bb.x}, y: ${bb.y}, width: ${bb.w}, height: ${bb.h}`,
},
}
);
ctx.clearRect(bb.x, bb.y, bb.w, bb.h);
};
@ -339,10 +348,19 @@ const colorBrushTool = () =>
uil.layer.clear();
uil.ctx.drawImageRoot(bkpcanvas, 0, 0);
commands.runCommand("eraseImage", "Color Brush Erase", {
mask: cropped.canvas,
...bb,
});
commands.runCommand(
"eraseImage",
"Color Brush Erase",
{
mask: cropped.canvas,
...bb,
},
{
extra: {
log: `Color brush erase at x: ${bb.x}, y: ${bb.y}, width: ${bb.w}, height: ${bb.h}`,
},
}
);
ctx.clearRect(bb.x, bb.y, bb.w, bb.h);
};

View file

@ -4,6 +4,29 @@
* @typedef StableDiffusionRequest
* @property {string} prompt Stable Diffusion prompt
* @property {string} negative_prompt Stable Diffusion negative prompt
*
* @property {number} width Stable Diffusion render width
* @property {number} height Stable Diffusion render height
*
* @property {number} n_iter Stable Diffusion number of iterations
* @property {number} batch_size Stable Diffusion images per batches
*
* @property {number} seed Stable Diffusion seed
* @property {number} steps Stable Diffusion step count
* @property {number} cfg_scale Stable Diffusion CFG scale
* @property {string} sampler_index Stable Diffusion sampler name
*
* @property {boolean} restore_faces WebUI face restoration
* @property {boolean} tiling WebUI tiling
* @property {string[]} styles WebUI styles
* @property {string} script_name WebUI script name
* @property {Array} script_args WebUI script args
*
* @property {string} mask Stable Diffusion mask (img2img)
* @property {number} mask_blur Stable Diffusion mask blur (img2img)
*
* @property {number} inpainting_fill Stable Diffusion inpainting fill (img2img)
* @property {boolean} inpaint_full_res Stable Diffusion full resolution (img2img)
*/
/**

View file

@ -526,6 +526,42 @@ const _generate = async (endpoint, request, bb, options = {}) => {
h: bb.h,
image: canvas,
});
let commandLog = "";
const addline = (v, newline = true) => {
commandLog += v;
if (newline) commandLog += "\n";
};
addline(
`Dreamed image at x: ${bb.x}, y: ${bb.y}, w: ${bb.w}, h: ${bb.h}`
);
addline(` - resolution: (${request.width}, ${request.height})`);
addline(" - generation:");
addline(` + Seed = ${seeds[at]}`);
addline(` + Steps = ${request.steps}`);
addline(` + CFG = ${request.cfg_scale}`);
addline(` + Sampler = ${request.sampler_index}`);
addline(` + Model = ${modelAutoComplete.value}`);
addline(` + +Prompt = ${request.prompt}`);
addline(` + -Prompt = ${request.negative_prompt}`, false);
commands.runCommand(
"drawImage",
"Image Dream",
{
x: bb.x,
y: bb.y,
w: bb.w,
h: bb.h,
image: canvas,
},
{
extra: {
log: commandLog,
},
}
);
clean(!toolbar._current_tool.state.preserveMasks);
});
};
@ -1040,8 +1076,18 @@ const dream_generate_callback = async (bb, resolution, state) => {
});
}
};
/**
* Erases an area from the canvas
*
* @param {BoundingBox} bb Bounding box of the area to be erased
*/
const dream_erase_callback = (bb) => {
commands.runCommand("eraseImage", "Erase Area", bb);
commands.runCommand("eraseImage", "Erase Area", bb, {
extra: {
log: `Erased area at x: ${bb.x}, y: ${bb.y}, width: ${bb.w}, height: ${bb.h}`,
},
});
};
function applyOvermask(canvas, ctx, px) {

View file

@ -248,6 +248,13 @@ const selectTransformTool = () =>
state.selected.position.x === state.original.sx &&
state.selected.position.y === state.original.sy &&
state.original.layer === uil.layer
) &&
!isCanvasBlank(
0,
0,
state.selected.canvas.width,
state.selected.canvas.height,
state.selected.canvas
)
) {
// Put original image back
@ -258,22 +265,61 @@ const selectTransformTool = () =>
);
// Erase Original Selection Area
commands.runCommand("eraseImage", "Transform Tool Erase", {
ctx: state.original.layer.ctx,
x: state.original.x,
y: state.original.y,
w: state.selected.canvas.width,
h: state.selected.canvas.height,
});
commands.runCommand(
"eraseImage",
"Transform Tool Erase",
{
layer: state.original.layer,
x: state.original.x,
y: state.original.y,
w: state.selected.canvas.width,
h: state.selected.canvas.height,
},
{
extra: {
log: `Erased original selection area at x: ${state.original.x}, y: ${state.original.y}, width: ${state.selected.canvas.width}, height: ${state.selected.canvas.height} from layer ${state.original.layer.id}`,
},
}
);
// Draw Image
const {canvas, bb} = cropCanvas(state.originalDisplayLayer.canvas, {
border: 10,
});
commands.runCommand("drawImage", "Transform Tool Apply", {
image: canvas,
...bb,
});
let commandLog = "";
const addline = (v, newline = true) => {
commandLog += v;
if (newline) commandLog += "\n";
};
addline(
`Draw selected area to x: ${bb.x}, y: ${bb.y}, width: ${bb.w}, height: ${bb.h} to layer ${state.original.layer.id}`
);
addline(
` - translation: (x: ${state.selected.position.x}, y: ${state.selected.position.y})`
);
addline(
` - rotation : ${
Math.round(1000 * ((180 * state.selected.rotation) / Math.PI)) /
1000
} degrees`,
false
);
commands.runCommand(
"drawImage",
"Transform Tool Apply",
{
image: canvas,
...bb,
},
{
extra: {
log: commandLog,
},
}
);
state.reset(true);
} else {
@ -454,7 +500,16 @@ const selectTransformTool = () =>
case "Delete":
// Deletes selected area
state.selected &&
commands.runCommand("eraseImage", "Erase Area", state.selected);
commands.runCommand(
"eraseImage",
"Erase Area",
state.selected,
{
extra: {
log: `[Placeholder] Delete selected area. TODO it's also broken`,
},
}
);
state.selected = null;
state.redraw();
}
@ -526,7 +581,11 @@ const selectTransformTool = () =>
const aux = state.original;
state.reset();
commands.runCommand("eraseImage", "Cut Image", aux);
commands.runCommand("eraseImage", "Cut Image", aux, {
extra: {
log: `Cut to clipboard a selected area at x: ${aux.x}, y: ${aux.y}, width: ${aux.w}, height: ${aux.h} from layer ${state.original.layer.id}`,
},
});
}
// Because firefox needs manual activation of the feature

View file

@ -146,13 +146,9 @@ const stampTool = () =>
};
// Open IndexedDB connection
const IDBOpenRequest = window.indexedDB.open("stamp", 1);
// Synchronizes resources array with the DOM and Local Storage
const syncResources = () => {
// Saves to IndexedDB
/** @type {IDBDatabase} */
const db = state.stampDB;
const resources = db
.transaction("resources", "readwrite")
.objectStore("resources");
@ -425,11 +421,38 @@ const stampTool = () =>
commands.runCommand("addLayer", "Added Layer", {});
}
const {canvas, bb} = cropCanvas(ovCanvas, {border: 10});
commands.runCommand("drawImage", "Image Stamp", {
image: canvas,
x: bb.x,
y: bb.y,
});
let commandLog = "";
const addline = (v, newline = true) => {
commandLog += v;
if (newline) commandLog += "\n";
};
addline(
`Stamped image '${resource.name}' to x: ${bb.x} and y: ${bb.y}`
);
addline(` - scaling : ${state.scale}`);
addline(
` - rotation: ${
Math.round(1000 * ((180 * rotation) / Math.PI)) / 1000
} degrees`,
false
);
commands.runCommand(
"drawImage",
"Image Stamp",
{
image: canvas,
x: bb.x,
y: bb.y,
},
{
extra: {
log: commandLog,
},
}
);
if (resource.temporary) {
state.deleteResource(resource.id);
@ -568,35 +591,9 @@ const stampTool = () =>
state.ctxmenu.resourceList = resourceList;
// Performs resource fetch from IndexedDB
IDBOpenRequest.onerror = (e) => {
console.warn("[stamp] Failed to connect to IndexedDB");
console.warn(e);
};
IDBOpenRequest.onupgradeneeded = (e) => {
const db = e.target.result;
console.debug(`[stamp] Setting up database version ${db.version}`);
const resourcesStore = db.createObjectStore("resources", {
keyPath: "id",
});
resourcesStore.createIndex("name", "name", {unique: false});
};
IDBOpenRequest.onsuccess = async (e) => {
const loadResources = async () => {
console.debug("[stamp] Connected to IndexedDB");
state.stampDB = e.target.result;
state.stampDB.onerror = (evn) => {
console.warn(`[stamp] Database Error:`);
console.warn(evn.target.errorCode);
};
/** @type {IDBDatabase} */
const db = state.stampDB;
/** @type {IDBRequest<{id: string, name: string, src: string}[]>} */
const FetchAllTransaction = db
.transaction("resources")
@ -626,6 +623,9 @@ const stampTool = () =>
syncResources();
};
};
if (db) loadResources();
else ondatabaseload.on(loadResources);
}
},
populateContextMenu: (menu, state) => {

View file

@ -4,10 +4,10 @@
<meta charset="utf-8" />
<title>openOutpaint 🐠</title>
<!-- CSS Variables -->
<link href="../css/colors.css?v=3f81e80" rel="stylesheet" />
<link href="../css/icons.css?v=9ae0466" rel="stylesheet" />
<link href="../css/colors.css?v=f732f19" rel="stylesheet" />
<link href="../css/icons.css?v=466e14e" rel="stylesheet" />
<link href="../css/index.css?v=882f400" rel="stylesheet" />
<link href="../css/index.css?v=61e08f5" rel="stylesheet" />
<link href="../css/layers.css?v=92c0352" rel="stylesheet" />
<link href="../css/ui/generic.css?v=30837f8" rel="stylesheet" />

View file

@ -0,0 +1,6 @@
<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">
<circle cx="12" cy="12" r="1"></circle>
<circle cx="19" cy="12" r="1"></circle>
<circle cx="5" cy="12" r="1"></circle>
</svg>

After

Width:  |  Height:  |  Size: 314 B

5
res/icons/pencil.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">
<line x1="18" y1="2" x2="22" y2="6"></line>
<path d="M7.5 20.5 19 9l-4-4L3.5 16.5 2 22z"></path>
</svg>

After

Width:  |  Height:  |  Size: 290 B

6
res/icons/save.svg Normal file
View file

@ -0,0 +1,6 @@
<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="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17 21 17 13 7 13 7 21"></polyline>
<polyline points="7 3 7 8 15 8"></polyline>
</svg>

After

Width:  |  Height:  |  Size: 374 B

7
res/icons/scroll.svg Normal file
View file

@ -0,0 +1,7 @@
<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="M10 17v2a2 2 0 0 1-2 2v0a2 2 0 0 1-2-2V5a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v3h3"></path>
<path d="M22 17v2a2 2 0 0 1-2 2H8"></path>
<path d="M19 17V5a2 2 0 0 0-2-2H4"></path>
<path d="M22 17H10"></path>
</svg>

After

Width:  |  Height:  |  Size: 404 B

6
res/icons/upload.svg Normal file
View file

@ -0,0 +1,6 @@
<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="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>

After

Width:  |  Height:  |  Size: 345 B