workflows

This commit is contained in:
kaj
2023-07-24 17:01:14 -08:00
parent 0a92760725
commit e9657bb20f
11 changed files with 571 additions and 46 deletions

View File

@@ -0,0 +1,7 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" fill="#3D3D3D"/>
<path d="M5 21H17.25C17.7141 21 18.1592 20.8104 18.4874 20.4728C18.8156 20.1352 19 19.6774 19 19.2V7.95L14.1875 3H6.75C6.28587 3 5.84075 3.18964 5.51256 3.52721C5.18437 3.86477 5 4.32261 5 4.8V8.4" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 3V8H19" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 17L12 14.5L9 12" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 12L3 14.5L6 17" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 757 B

View File

@@ -18,6 +18,7 @@ use tauri_plugin_upload;
mod server;
mod settings;
mod show_path;
mod workflows;
static WINDOW: OnceLock<Window> = OnceLock::new();
@@ -84,6 +85,9 @@ fn main() {
show_path::show_in_folder,
settings::get_setting,
settings::set_setting,
workflows::fetch_workflows,
workflows::save_workflow,
workflows::delete_workflow,
])
.build(context)
.expect("error while building tauri application")

View File

@@ -0,0 +1,94 @@
use std::{
fs,
io::{BufReader, BufWriter},
};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct Workflow {
pub name: String,
pub description: Option<String>,
pub icon: Option<String>,
pub serialized_workflow: String,
pub created_at: String,
}
#[tauri::command]
pub fn fetch_workflows(handle: tauri::AppHandle) -> Result<Vec<Workflow>, String> {
let mut workflows: Vec<Workflow> = Vec::new();
let mut path = handle.path_resolver().app_data_dir().unwrap();
path.push("workflows");
// create directory if it doesn't exist
std::fs::create_dir_all(&path).unwrap();
for entry in fs::read_dir(path).unwrap() {
let entry = entry.unwrap();
let path = entry.path();
let metadata = fs::metadata(&path).unwrap();
if metadata.is_file() {
let workflow_file = fs::File::open(path).unwrap();
let reader = BufReader::new(workflow_file);
if let Ok(workflow_data) = serde_json::from_reader(reader) {
workflows.push(workflow_data);
}
}
}
Ok(workflows)
}
#[tauri::command]
pub fn save_workflow(
handle: tauri::AppHandle,
serialized_workflow: String,
name: String,
description: Option<String>,
icon: Option<String>,
) -> Result<(), String> {
let mut workflow_path = handle.path_resolver().app_data_dir().unwrap();
workflow_path.push("workflows");
// create directory if it doesn't exist
std::fs::create_dir_all(&workflow_path).unwrap();
let file_name = name.replace(" ", "_").to_lowercase();
workflow_path.push(format!("{file_name}.json"));
let workflow_file = fs::File::create(workflow_path).unwrap();
let writer = BufWriter::new(workflow_file);
match serde_json::to_writer(
writer,
&Workflow {
name,
description,
icon,
serialized_workflow,
created_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
.to_string(),
},
) {
Ok(_) => Ok(()),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
pub fn delete_workflow(handle: tauri::AppHandle, name: String) -> Result<(), String> {
let mut workflow_path = handle.path_resolver().app_data_dir().unwrap();
workflow_path.push("workflows");
// create directory if it doesn't exist
std::fs::create_dir_all(&workflow_path).unwrap();
workflow_path.push(format!("{name}.json"));
match fs::remove_file(workflow_path) {
Ok(_) => Ok(()),
Err(e) => Err(e.to_string()),
}
}

View File

@@ -1,4 +1,3 @@
import * as StableStudio from "@stability/stablestudio-plugin";
import { useLocation } from "react-router-dom";
import { create } from "zustand";
import { shallow } from "zustand/shallow";
@@ -7,7 +6,7 @@ import { Generation } from "~/Generation";
export type ComfyApp = {
setup: () => void;
registerNodes: () => void;
loadGraphData: (graph: Graph) => void;
loadGraphData: (graph?: Graph) => void;
graphToPrompt: () => Promise<{
workflow: any;
prompt: any;
@@ -18,6 +17,7 @@ export type ComfyApp = {
clean: () => void;
api: ComfyAPI;
graph: {
serialize: () => any;
_nodes: {
title: string;
type: string;

View File

@@ -77,13 +77,12 @@ export namespace Sidebar {
const areStylesEnabled = Generation.Image.Styles.useAreEnabled();
return (
<>
{areStylesEnabled && (
<App.Sidebar.Section divider defaultExpanded padding="sm">
<div className="flex flex-col gap-2">
<Generation.Image.Style.Dropdown id={id} />
</div>
</App.Sidebar.Section>
)}
<App.Sidebar.Section divider defaultExpanded padding="sm">
<div className="flex flex-col gap-2">
{areStylesEnabled && <Generation.Image.Style.Dropdown id={id} />}
<Generation.Image.Workflow.Dropdown />
</div>
</App.Sidebar.Section>
<Generation.Image.Prompt.Sidebar.Section id={id} />
{variant === "generate" && (
<Generation.Image.Input.Image.Sidebar.Section id={id} />

View File

@@ -0,0 +1,128 @@
import { Generation } from "~/Generation";
import { GlobalState } from "~/GlobalState";
import { Theme } from "~/Theme";
export function Modal() {
const {
image,
open,
name,
description,
setImage,
setName,
setOpen,
setDescription,
} = Modal.State.use();
const [saving, setSaving] = useState(false);
return (
<Theme.Modal
modalName="Download"
open={open}
onClose={() => setOpen(false)}
>
<Theme.Modal.Panel className="flex w-[25rem] grow">
<Theme.Modal.TopBar onClose={() => setOpen(false)}>
<Theme.Modal.Title className="text-lg">
Save workflow
</Theme.Modal.Title>
</Theme.Modal.TopBar>
<div className="flex flex-col gap-3 p-2">
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1">
<Theme.Label className="mb-0 ml-0">Name</Theme.Label>
<Theme.Input
fullWidth
placeholder="Name"
value={name}
onChange={setName}
/>
</div>
<div className="flex flex-col gap-1">
<Theme.Label className="mb-0 ml-0">Description</Theme.Label>
<Theme.Input
fullWidth
autoSize
placeholder="Description"
value={description}
onChange={setDescription}
/>
</div>
<div className="flex flex-col gap-1">
<Theme.Label className="mb-0 ml-0">Icon</Theme.Label>
<input
type={"file"}
accept={"image/*"}
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const result = e.target?.result;
if (typeof result !== "string") return;
setImage(result);
};
reader.readAsDataURL(file);
}}
/>
</div>
</div>
<Theme.Button
fullWidth
size="lg"
color="brand"
icon={Theme.Icon.Download}
loading={saving}
onClick={async () => {
if (!image || !name) return;
setSaving(true);
try {
await Generation.Image.Workflow.saveWorkflow(
name,
image,
description
);
} finally {
setSaving(false);
}
setOpen(false);
}}
>
Save
</Theme.Button>
</div>
</Theme.Modal.Panel>
</Theme.Modal>
);
}
export namespace Modal {
export type State = {
image?: string | null;
name: string;
open: boolean;
description: string;
setImage: (image?: string) => void;
setName: (name: string) => void;
setOpen: (open: boolean) => void;
setDescription: (description: string) => void;
};
export namespace State {
export const use = GlobalState.create<State>((set) => ({
image: null,
name: "",
open: false,
description: "",
setImage: (image) => set({ image }),
setName: (name) => set({ name }),
setOpen: (open) => set({ open, name: "", image: null, description: "" }),
setDescription: (description) => set({ description }),
}));
}
}

View File

@@ -0,0 +1,188 @@
import { invoke } from "@tauri-apps/api/tauri";
import { create } from "zustand";
import { Comfy } from "~/Comfy";
import { Theme } from "~/Theme";
import { Modal } from "./Modal";
export type Workflow = {
name: string;
icon?: string;
description?: string;
serialized_workflow: string;
created_at: string;
};
export declare namespace Workflow {
export { Modal };
}
export namespace Workflow {
export const saveWorkflow = async (
name: string,
icon: string,
description: string
) => {
const comfyApp = Comfy.get();
if (!comfyApp) return;
const serializedWorkflow = JSON.stringify(comfyApp.graph.serialize(), null);
try {
await invoke("save_workflow", {
name,
icon,
serializedWorkflow,
description,
});
} catch (error) {
console.error(error);
}
console.log("saved! reloading workflows");
try {
Workflow.fetchWorkflows();
} catch (error) {
console.error(error);
}
};
function SlightBox({
onClick,
name,
icon,
}: {
onClick: (e: any) => void;
name: string;
icon: React.ReactNode;
}) {
return (
<div
key="save"
onClick={onClick}
className={classes(
"group flex cursor-pointer flex-col rounded duration-100"
)}
>
<div className="mb-2 aspect-square min-h-0 w-full min-w-0 rounded-lg border border-dashed border-transparent border-zinc-600 duration-100 group-hover:border-solid group-hover:border-zinc-400">
{icon}
</div>
<h1
className={classes(
"w-full grow select-none text-zinc-400 group-hover:text-zinc-200"
)}
>
{name}
</h1>
</div>
);
}
export const fetchWorkflows = async () => {
const response = await invoke("fetch_workflows");
Workflow.use.getState().setWorkflows(response as Workflow[]);
};
export const applyWorkflow = async (workflowId: string) => {
const workflow = use
.getState()
.workflows.find((workflow) => workflow.name === workflowId);
if (!workflow) return;
const comfyApp = Comfy.get();
if (!comfyApp) return;
// turn serialized_workflow string into a file
const blob = new Blob([workflow.serialized_workflow], {
type: "text/json",
});
const file = new File([blob], "workflow.json", { type: "text/json" });
// load the file into comfy
comfyApp.handleFile(file);
};
export const use = create<{
workflows: Workflow[];
setWorkflows: (workflows: Workflow[]) => void;
}>((set) => ({
workflows: [],
setWorkflows: (workflows) => set({ workflows }),
}));
export function Dropdown({ className }: Styleable) {
const { workflows, setWorkflows } = Workflow.use();
useEffect(() => {
fetchWorkflows();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const options = useMemo(
() => [
{
value: " save",
image: "/save.svg",
component: (onClick: any) => (
<SlightBox
onClick={(e) => {
Modal.State.use.getState().setOpen(true);
onClick(e);
}}
name="Save workflow"
icon={<Theme.Icon.Save color="#7F7F7F" />}
/>
),
},
...(workflows ?? [])
.sort((a, b) =>
new Date(a.created_at) > new Date(b.created_at) ? -1 : 1
)
.map((workflow: Workflow) => ({
value: workflow.name,
name: workflow.name,
image: workflow.icon ?? "/file-code.svg",
onDelete: async () => {
await invoke("delete_workflow", {
name: workflow.name.replace(/ /g, "_").toLowerCase(),
});
setWorkflows(workflows.filter((w) => w.name !== workflow.name));
},
})),
{
value: " default",
image: "/rotate.svg",
component: (onClick: any) => (
<SlightBox
onClick={(e) => {
Comfy.get()?.loadGraphData();
onClick(e);
}}
name="Load default"
icon={<Theme.Icon.Rotate color="#7F7F7F" />}
/>
),
},
],
[setWorkflows, workflows]
);
return (
<>
<Theme.Popout
title="Workflows"
label="Workflow"
options={options}
value={null}
placeholder={"Select workflow"}
className={classes(className)}
onClick={applyWorkflow}
/>
<Modal />
</>
);
}
}

View File

@@ -24,6 +24,7 @@ import { Style, Styles } from "./Style";
import { TopBar } from "./TopBar";
import { Upscale, Upscales } from "./Upscale";
import { Variations } from "./Variation";
import { Workflow } from "./Workflow";
export * from "./Images";
@@ -227,6 +228,7 @@ export declare namespace Image {
Upscales,
Upscale,
Exception,
Workflow,
};
}
@@ -260,6 +262,7 @@ export namespace Image {
Image.Upscales = Upscales;
Image.Upscale = Upscale;
Image.Exception = Exception;
Image.Workflow = Workflow;
export const get = (id: ID): Image | undefined =>
Images.State.use(({ images }) => images[id]);

View File

@@ -425,6 +425,70 @@ export function Upscale(props: Props) {
));
}
export function Rotate(props: Props) {
return defaults(props)(({ width, height, color, ...props }) => (
<svg
width={width}
height={height}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M7 12C7 12.9889 7.29324 13.9556 7.84265 14.7778C8.39206 15.6001 9.17295 16.241 10.0866 16.6194C11.0002 16.9978 12.0055 17.0969 12.9754 16.9039C13.9454 16.711 14.8363 16.2348 15.5355 15.5355C16.2348 14.8363 16.711 13.9454 16.9039 12.9754C17.0969 12.0055 16.9978 11.0002 16.6194 10.0866C16.241 9.17295 15.6001 8.39206 14.7778 7.84265C13.9556 7.29324 12.9889 7 12 7C10.6022 7.00526 9.26054 7.55068 8.25556 8.52222L7 9.77778"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1"
/>
<path
d="M7 7V10H10"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1"
/>
</svg>
));
}
export function Save(props: Props) {
return defaults(props)(({ width, height, color, ...props }) => (
<svg
width={width}
height={height}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
strokeWidth="0.9"
{...props}
>
<path
d="M15.8889 17H8.11111C7.81643 17 7.53381 16.8829 7.32544 16.6746C7.11706 16.4662 7 16.1836 7 15.8889V8.11111C7 7.81643 7.11706 7.53381 7.32544 7.32544C7.53381 7.11706 7.81643 7 8.11111 7H14.2222L17 9.77778V15.8889C17 16.1836 16.8829 16.4662 16.6746 16.6746C16.4662 16.8829 16.1836 17 15.8889 17Z"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1"
/>
<path
d="M15 17V13H9V17"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1"
/>
<path
d="M9 7V10H13"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1"
/>
</svg>
));
}
const defaults =
(props: Props) =>
(render: (props: Props) => JSX.Element): JSX.Element =>

View File

@@ -77,6 +77,8 @@ import {
Instagram,
ModelIcon,
Rectangle,
Rotate,
Save,
Scale,
ShareIcon,
SlidersIcon,
@@ -169,6 +171,8 @@ export declare namespace Icon {
Upscale,
Keyboard,
ChevronsLeftRight,
Rotate,
Save,
};
}
@@ -262,6 +266,8 @@ export namespace Icon {
Icon.Upscale = makeComponent(Upscale);
Icon.Keyboard = makeComponent(Keyboard);
Icon.ChevronsLeftRight = makeComponent(ChevronsLeftRight);
Icon.Rotate = makeComponent(Rotate);
Icon.Save = makeComponent(Save);
export function Invisible(props: Props) {
return (

View File

@@ -5,11 +5,18 @@ import { Box } from "~/Geometry";
import { Theme } from "~/Theme";
export type Option = {
name: React.ReactNode;
value: any;
image?: string;
disabled?: boolean;
};
onDelete?: () => void;
} & (
| {
name: React.ReactNode;
}
| {
component: (onClick: (value: any) => void) => React.ReactNode;
}
);
export function Popout({
onClick,
@@ -109,7 +116,11 @@ export function Popout({
/>
)}
<h1 className="w-full grow select-none">
{valueOption?.name ?? placeholder ?? "Select"}
{(valueOption && "name" in valueOption
? valueOption?.name
: undefined) ??
placeholder ??
"Select"}
</h1>
<Theme.Icon.ChevronRight className="h-6 w-6" strokeWidth={1.5} />
</div>
@@ -154,40 +165,61 @@ function Floating({
>
{!!children
? children
: options.map((option, index) => (
<div
key={index}
onClick={
option.disabled ? doNothing : () => onClick(option.value)
}
className={classes(
"group flex cursor-pointer flex-col rounded duration-100",
option.disabled && "opacity-muted cursor-not-allowed"
)}
>
{option.image ? (
<img
className="mb-2 aspect-square h-full w-full rounded-lg border border-transparent duration-100 group-hover:border-zinc-200"
src={option.image}
alt="Preset Image"
/>
) : (
hasImages && (
<div className="mb-2 flex aspect-square w-full items-center justify-center rounded-lg border border-transparent bg-black/20 duration-100 group-hover:border-zinc-200">
<Theme.Icon.Slash className="opacity-muted h-12 w-12" />
</div>
)
)}
<h1
className={classes(
"w-full grow select-none text-zinc-400 group-hover:text-zinc-200",
option.value === value && "font-medium text-white"
)}
>
{option.name}
</h1>
</div>
))}
: options.map(
(option, index) =>
("component" in option && option.component(onClick)) || (
<div
key={`${option.value}-${index}-option`}
onClick={
option.disabled ? doNothing : () => onClick(option.value)
}
className={classes(
"group relative flex cursor-pointer flex-col rounded duration-100",
option.disabled && "opacity-muted cursor-not-allowed"
)}
>
{option.onDelete && (
<Theme.Tooltip
content="Delete"
placement="right"
showArrow
distance={15}
>
<div
onClick={(e) => {
e.stopPropagation();
option.onDelete?.();
}}
className="group/delete pointer-events-none absolute -right-4 -top-4 h-fit w-fit scale-50 cursor-pointer rounded-xl bg-zinc-900 p-1 text-white/75 opacity-0 duration-150 ease-in-out hover:text-white group-hover:pointer-events-auto group-hover:-right-2 group-hover:-top-2 group-hover:scale-100 group-hover:opacity-100"
>
<Theme.Icon.X color="currentColor" />
</div>
</Theme.Tooltip>
)}
{option.image ? (
<img
className="mb-2 aspect-square w-full select-none rounded-lg border border-transparent object-cover duration-100 group-hover:border-zinc-200"
src={option.image}
alt="Preset Image"
/>
) : (
hasImages && (
<div className="mb-2 flex aspect-square w-full items-center justify-center rounded-lg border border-transparent bg-black/20 duration-100 group-hover:border-zinc-200">
<Theme.Icon.Slash className="opacity-muted h-12 w-12" />
</div>
)
)}
<h1
className={classes(
"w-full grow select-none text-zinc-400 group-hover:text-zinc-200",
option.value === value && "font-medium text-white"
)}
>
{"name" in option && option.name}
</h1>
</div>
)
)}
</div>
</div>
);