Merge pull request #185 from 11cafe/beta
folder import, folder always at top setting, show nsfw setting, create version dialog, drag drop folder to folder
This commit is contained in:
@@ -2,13 +2,13 @@
|
||||
|
||||
A ComfyUI workflow management extension to **organize and manage all your workflows, models and generated images in one place**. Seamlessly switch between workflows, track version history and image generation history, 1 click install models from Civit ai, browse/update your installed models
|
||||
|
||||
If you have questions or suggestions, please [join our Discord](https://discord.gg/HHvfEurv2Z)!
|
||||
If you have questions or suggestions, please [join our Discord](https://discord.gg/HHvfEurv2Z)!
|
||||
|
||||
<a href="https://discord.gg/HHvfEurv2Z">
|
||||
<img src="https://animeartmagazine.com/wp-content/uploads/2022/05/discord-button600.webp" alt="Join Discord" width="120"/>
|
||||
</a>
|
||||
</a>
|
||||
|
||||
✨Checkout our new ComfyUI custom nodes library wiki 📚 (beta testing): https://www.comfyspace.art/
|
||||
✨Checkout our new ComfyUI custom nodes library wiki 📚 (beta testing): https://www.comfyspace.art/
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -41,22 +41,23 @@ It is recommended to do git clone than downloading zip folder, because you can g
|
||||
- 📑Quickly duplicate flow in right-click menu
|
||||
- cloud sync & backup workspace so you will never lose your data (Upcoming!)
|
||||
- One-click share workflow (Upcoming!)
|
||||
|
||||
|
||||
Recommend using Chrome browser for the best experience. Safari doesn't work well. Edge or firefox should be fine too.
|
||||
|
||||
### v1.7 ✨New ComfyUI model manager, view all your models, browse and install models from civit ai, find missing models in workflow automatically in civit ai and hugging face!
|
||||
open workflow in new tab, 1click model install from civitai, automatic install missing models for workflow, view image gen metadata in gallery (prompt, sampler, models used).
|
||||
|
||||
open workflow in new tab, 1click model install from civitai, automatic install missing models for workflow, view image gen metadata in gallery (prompt, sampler, models used).
|
||||
|
||||
<img width="300" alt="Screenshot 2024-02-02 at 5 51 31 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/a95dbf03-bd5a-43ce-af1f-b6ff246acf77">
|
||||
|
||||
<img width="700" alt="Screenshot 2024-02-02 at 5 51 13 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/86dc9007-c45c-4b7b-b74a-76ddc93c89a8">
|
||||
|
||||
|
||||
<img width="400" alt="Screenshot 2024-01-23 at 12 27 34 AM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/3b543024-3a6b-4fca-9980-e535d83e206e">
|
||||
|
||||
<img width="200" alt="Screenshot 2024-01-23 at 12 26 56 AM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/d29d0831-a15a-4597-acf6-facee4dbc76e">
|
||||
|
||||
### V1.5
|
||||
|
||||
Manually save your workflow by pressing the 💾 button, or ⌨**Shift+S** shortcut (customize this shortcut in Settings). Every time you save, it will create a new record in **Version History**. You can go back and switch versions in version history like git.
|
||||
Every image/video you generate will be saved in the gallery of the current workflow. You can set any image in the gallery as the cover photo of the workflow.
|
||||
|
||||
@@ -76,11 +77,11 @@ Bulk import flows:
|
||||
|
||||
<img width="400" alt="Screenshot 2024-01-02 at 10 22 45 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/884c8852-73c7-41d6-9179-7fb1fcea9d04">
|
||||
|
||||
add tags to flow:
|
||||
add tags to flow:
|
||||
|
||||
<img width="400" alt="Screenshot 2023-12-13 at 1 54 04 AM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/6bf4d22b-f52a-4d23-8882-4a31162616f3">
|
||||
|
||||
Workflow operations: Manual Save, Download, Discard unsaved changes:
|
||||
Workflow operations: Manual Save, Download, Discard unsaved changes:
|
||||
|
||||
<img width="500" alt="Screenshot 2023-12-29 at 9 59 57 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/88c82672-ebf2-4feb-bf5b-74b447843c2c">
|
||||
|
||||
@@ -90,7 +91,7 @@ duplicate flow by right click menu option:
|
||||
|
||||
### Where are my workflow and workspace data stored?
|
||||
|
||||
🍺 All your workflows are saved in a folder in `/ComfyUI/my_workflows`, you can change this path in settings.
|
||||
🍺 All your workflows are saved in a folder in `/ComfyUI/my_workflows`, you can change this path in settings.
|
||||
|
||||
⚠️Note: this is a ONE-WAY sync folder by default, that will only reflect changes made from your ComfyUI workspace browser; If you add some files manually using your OS file system (e.g. Finder in MacOS, File Explorer in Windows), workspace manager will not be able to pickup those changes. You need to use the "Import" button in files side bar to add new files to workspace manager.
|
||||
|
||||
@@ -99,6 +100,7 @@ duplicate flow by right click menu option:
|
||||
Our internal db data is stored in your disk under `/ComfyUI/custom_nodes/comfyui-workspace-manager/db/` We also store a backup copy of your db data in your browser cache in case you lose your workspace data when deleting comfyui-workspace-manager and reinstall. Press F12 or Inspect in right click browser menu, go to Application -> IndexedDB -> WorkspaceDB to see your data
|
||||
|
||||
### How do I save my workflow?
|
||||
|
||||
Save your workflow by either clicking the 💾 floppy disk icon button or ⌨**Shift+S** shortcut (customize this shortcut in Settings)
|
||||
Every time you save, it will create a new record in **Version History**. You can go back and switch versions in version history like git.
|
||||
|
||||
@@ -112,7 +114,6 @@ By default, We also enabled auto-saving your current workflow as a cache in case
|
||||
<img width="297" alt="Screenshot 2024-01-23 at 12 37 37 AM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/9ca0a63e-ddae-4436-a374-6ef0276c23fb">
|
||||
<img width="402" alt="Screenshot 2024-01-22 at 2 33 00 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/061a8463-f7e6-4985-9516-8117d7a5edd5">
|
||||
|
||||
|
||||
**Looking for colaborators and coworkers** to develop this ComfyUI project management tool. If you know React, Python or ML model deployment, please reach out to weixuanfu01@gmail.com We want to make it easy to **manage, share and deploy** ComfyUI project.
|
||||
|
||||
## Upcoming Features
|
||||
@@ -133,17 +134,21 @@ By default, We also enabled auto-saving your current workflow as a cache in case
|
||||
➡️ Share and deploy your workflow to cloud in 1click and other people can easily run in browser using cloud GPU with no setup. It should be as easy as running Google Docs or Figma.
|
||||
|
||||
## Beta Preview
|
||||
|
||||
We're constantly pushing new features in beta branch, and only wait til it's thoroghly tested then push it to main. But if you would like to join our Beta Preview Program, you can receive latest feature updates pre-launch! Here is how to switch to beta, it requires you have some basic knowledge about command line and git.
|
||||
|
||||
- Make sure you have [git installed](https://git-scm.com/downloads)
|
||||
- Open your terminal, go to the ComfyUI directory (where you usually start your python server by `python main.py`
|
||||
- in ComfyUI root directory, `cd custom_nodes && cd comfyui-workspace-manager`
|
||||
- inside custom_nodes/comfyui-workspace-manager, do `git fetch origin beta && git checkout beta`, you should see something like:
|
||||
|
||||
```
|
||||
From https://github.com/11cafe/comfyui-workspace-manager
|
||||
* branch beta -> FETCH_HEAD
|
||||
Switched to branch 'beta'
|
||||
Your branch is up to date with 'origin/beta'.
|
||||
```
|
||||
|
||||
- now go back to ComfyUI restart your python server by `python main.py`
|
||||
|
||||
‼️ Be careful that beta branch can be unstable, so please please make sure you have backups for all your workflows! **Before switching to beta, please make a copy of these folders somewhere:**
|
||||
@@ -153,11 +158,12 @@ or for extra safety, you can make a copy of the entire `ComfyUI/custom_nodes/com
|
||||
|
||||
if anything goes wrong, you can always paste back these folders. Also, you should have your /my_workflows folder (workspace save directory) in safe place always
|
||||
|
||||
|
||||
If anything goes wrong, and you want to revert back to main, please do the following:
|
||||
|
||||
- inside custom_nodes/comfyui-workspace-manager, do `git checkout main`, then restart your python server and you are back into main branch!
|
||||
|
||||
## Credits
|
||||
|
||||
ComfyUI: https://github.com/comfyanonymous/ComfyUI
|
||||
|
||||
ComfyUI Browser: https://github.com/talesofai/comfyui-browser (Media display code for reference)
|
||||
@@ -190,12 +196,14 @@ cd custom_nodes && git clone https://github.com/11cafe/comfyui-workspace-manager
|
||||
#### IndexDB trouble shooting
|
||||
|
||||
If you see indexdb version mismatch error in console that looks like:
|
||||
|
||||
```
|
||||
VersionError: The requested version (10) is less than the existing version (30)."
|
||||
name
|
||||
:
|
||||
:
|
||||
"DatabaseClosedError"
|
||||
```
|
||||
|
||||
please delete indexdb manually F12 Dev tools -> Application -> IndexDB -> WorkspaceManagerDB
|
||||
<img width="460" alt="Screenshot 2024-01-21 at 11 21 55 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/adcde47f-c620-42ad-9367-e16e5be92be1">
|
||||
|
||||
@@ -203,4 +211,3 @@ and delete localstorage.WORKSPACE_INDEXDB_BACKFILL
|
||||
<img width="460" alt="Screenshot 2024-01-21 at 11 29 02 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/f3c17034-c863-481a-8771-8e7e87bcee07">
|
||||
|
||||
only do this if you are a developer
|
||||
|
||||
|
||||
+19
-24
@@ -215,7 +215,7 @@ async def api_view_file(request):
|
||||
file_path = os.path.join(output_path, filename)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
return web.Response(status=200)
|
||||
return web.Response(status=404)
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
media_file = f.read()
|
||||
@@ -261,6 +261,22 @@ def file_handle(name, file, existFlowIds, fileList):
|
||||
else:
|
||||
fileList.append(fileInfo)
|
||||
|
||||
def folder_handle(path, existFlowIds):
|
||||
fileList = []
|
||||
for item in os.listdir(path):
|
||||
item_path = os.path.join(path, item)
|
||||
if os.path.isfile(item_path) and item_path.endswith('.json'):
|
||||
with open(item_path, 'r') as f:
|
||||
file_handle(item, f, existFlowIds, fileList)
|
||||
|
||||
elif os.path.isdir(item_path):
|
||||
fileList.append({
|
||||
'name': item,
|
||||
'list': folder_handle(item_path, existFlowIds)
|
||||
})
|
||||
return fileList
|
||||
|
||||
|
||||
# Scan all files and subfolders in the local save directory.
|
||||
# For files, compare the extra.workspace_info.id in the json format file with the flow of the current DB to determine whether it is a flow that needs to be added;
|
||||
# For subfolders, scan the json files in the subfolder and use the same processing method as the file to determine whether it is a flow that needs to be added;
|
||||
@@ -270,29 +286,8 @@ async def scan_local_new_files(request):
|
||||
path = reqJson['path']
|
||||
existFlowIds = reqJson['existFlowIds']
|
||||
|
||||
fileList = []
|
||||
folderList = []
|
||||
|
||||
for item in os.listdir(path):
|
||||
item_path = os.path.join(path, item)
|
||||
if os.path.isfile(item_path) and item_path.endswith('.json'):
|
||||
with open(item_path, 'r') as f:
|
||||
file_handle(item, f, existFlowIds, fileList)
|
||||
|
||||
elif os.path.isdir(item_path):
|
||||
folder = {
|
||||
'name': item,
|
||||
'list': []
|
||||
}
|
||||
for sub_item in os.listdir(item_path):
|
||||
sub_item_path = os.path.join(item_path, sub_item)
|
||||
if os.path.isfile(sub_item_path) and sub_item_path.endswith('.json'):
|
||||
with open(sub_item_path, 'r') as f:
|
||||
file_handle(sub_item, f, existFlowIds, folder['list'])
|
||||
|
||||
if len(folder['list']) > 0:
|
||||
folderList.append(folder)
|
||||
return web.Response(text=json.dumps({'fileList': fileList, 'folderList': folderList}), content_type='application/json')
|
||||
fileList = folder_handle(path, existFlowIds)
|
||||
return web.Response(text=json.dumps(fileList), content_type='application/json')
|
||||
|
||||
|
||||
@server.PromptServer.instance.routes.post("/workspace/delete_folder")
|
||||
|
||||
Vendored
-15
File diff suppressed because one or more lines are too long
Vendored
+15
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{r as l,j as e,B as p,P as f}from"./input.js";import{app as o}from"/scripts/app.js";import{G as x,I as j}from"./ModelManagerTopbar-NeKA5IAs.js";import{T as h,H as v,z as m,B as w,V as _,aa as M,F as y,a1 as E}from"./App-EbJ8EOYb.js";import{V as I}from"./chunk-NTCQBYKE-HF59WNUB.js";import{I as S,G as k}from"./chunk-JARCRF6W-po8hqWTL.js";import"/scripts/api.js";import"./chunk-24I2HV4N-zY_d-lSh.js";function b({model:s}){const[n,t]=l.useState([]),r=C(s.received_value),d=async()=>{const i=await(await fetch(`https://huggingface.co/api/models?limit=3&search=${r}&full=true`)).json(),a=[];i.slice(0,1).forEach(({modelId:c,siblings:g})=>{a.push({name:c,downloadUrl:`https://huggingface.co/${c}/resolve/main/`,url:`https://huggingface.co/${c}/tree/main`})}),t(a)};return l.useEffect(()=>{d()},[]),e.jsx(x,{p:3,shadow:"md",borderWidth:"1px",children:e.jsxs(I,{align:"start",children:[e.jsx(h,{children:s.class_type}),e.jsxs(v,{ml:3,children:[e.jsxs(h,{children:[s.input_name,": "]}),e.jsx(h,{color:"red.400",children:s.received_value})]}),n.map(({url:u,name:i})=>e.jsx(p,{size:"sm",onClick:a=>{a.stopPropagation(),window.open(u,"_blank")},leftIcon:e.jsx(S,{width:5,src:"https://huggingface.co/datasets/huggingface/brand-assets/resolve/main/hf-logo.png"}),rightIcon:e.jsx(m,{size:20}),children:i})),e.jsx(p,{colorScheme:"blue",mt:5,iconSpacing:1,leftIcon:e.jsx(m,{size:20}),size:"sm",onClick:()=>{window.open(`https://civitai.com/search/models?sortBy=models_v5&query=${r}`,"_blank")},children:"Search in CivitAI"})]})})}function C(s){return s.replace(/^.*(\\|\/|\:)/,"").replace(/\.[^/.]+$/,"").replaceAll("_"," ").replace(/([A-Z])/g," $1").trim()}function z({onClose:s,missingModels:n}){l.useEffect(()=>(o.canvasEl.addEventListener("click",s),()=>{o.canvasEl.removeEventListener("click",s)}),[]);const t=440;return e.jsx(f,{children:e.jsx(w,{style:{width:t},children:e.jsxs(_,{width:t,height:"100vh",p:4,gap:2,position:"fixed",top:0,left:0,shadow:"xl",zIndex:1e3,overflowY:"auto",children:[e.jsx(M,{children:e.jsxs(y,{justifyContent:"space-between",alignContent:"center",children:[e.jsxs(E,{size:"md",mr:2,children:["Missing Models (",n.length,")"]}),e.jsx(j,{})]})}),e.jsx(k,{templateColumns:"1",gap:1,marginTop:2,children:n.map(r=>e.jsx(b,{model:r},r.received_value))})]})})})}function G({}){const[s,n]=l.useState(!1),[t,r]=l.useState([]);return l.useEffect(()=>{const d=o.queuePrompt;o.queuePrompt=async function(){try{await d.apply(o,[...arguments])}finally{const u=o.lastNodeErrors??{};r(Object.values(u).flatMap(i=>i.errors.filter(a=>a.type==="value_not_in_list").map(a=>{const{input_name:c,received_value:g}=a.extra_info;return{class_type:i.class_type,input_name:c,received_value:g}})))}}},[]),t.length===0?null:e.jsxs(e.Fragment,{children:[e.jsxs(p,{size:"sm","aria-label":"missing models",px:2,onClick:()=>n(!0),children:["Install Missing (",t.length,")"]}),s&&e.jsx(z,{onClose:()=>n(!1),missingModels:t})]})}export{G as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{r as o,j as e,B as p,P as f}from"./input.js";import{app as l}from"/scripts/app.js";import{G as x,I as j}from"./ModelManagerTopbar-USvLFV5F.js";import{T as h,H as v,a3 as m,B as _,a5 as w,a6 as M,ai as y,F as E,ab as I}from"./App-OS6BLwC7.js";import{V as S}from"./chunk-NTCQBYKE-LgT_gA-8.js";import{I as b,G as k}from"./chunk-JARCRF6W-PrvQlAIl.js";import"/scripts/api.js";function C({model:s}){const[n,t]=o.useState([]),i=D(s.received_value),d=async()=>{const r=await(await fetch(`https://huggingface.co/api/models?limit=3&search=${i}&full=true`)).json(),a=[];r.slice(0,1).forEach(({modelId:c,siblings:g})=>{a.push({name:c,downloadUrl:`https://huggingface.co/${c}/resolve/main/`,url:`https://huggingface.co/${c}/tree/main`})}),t(a)};return o.useEffect(()=>{d()},[]),e.jsx(x,{p:3,shadow:"md",borderWidth:"1px",children:e.jsxs(S,{align:"start",children:[e.jsx(h,{children:s.class_type}),e.jsxs(v,{ml:3,children:[e.jsxs(h,{children:[s.input_name,": "]}),e.jsx(h,{color:"red.400",children:s.received_value})]}),n.map(({url:u,name:r})=>e.jsx(p,{size:"sm",onClick:a=>{a.stopPropagation(),window.open(u,"_blank")},leftIcon:e.jsx(b,{width:5,src:"https://huggingface.co/datasets/huggingface/brand-assets/resolve/main/hf-logo.png"}),rightIcon:e.jsx(m,{size:20}),children:r})),e.jsx(p,{colorScheme:"blue",mt:5,iconSpacing:1,leftIcon:e.jsx(m,{size:20}),size:"sm",onClick:()=>{window.open(`https://civitai.com/search/models?sortBy=models_v5&query=${i}`,"_blank")},children:"Search in CivitAI"})]})})}function D(s){return s.replace(/^.*(\\|\/|\:)/,"").replace(/\.[^/.]+$/,"").replaceAll("_"," ").replace(/([A-Z])/g," $1").trim()}function z({onClose:s,missingModels:n}){o.useEffect(()=>(l.canvasEl.addEventListener("click",s),()=>{l.canvasEl.removeEventListener("click",s)}),[]);const t=440;return e.jsx(f,{children:e.jsx(_,{style:{width:t},children:e.jsxs(w,{width:t,height:"100vh",p:4,gap:2,position:"fixed",top:0,left:0,shadow:"xl",zIndex:M,overflowY:"auto",children:[e.jsx(y,{children:e.jsxs(E,{justifyContent:"space-between",alignContent:"center",children:[e.jsxs(I,{size:"md",mr:2,children:["Missing Models (",n.length,")"]}),e.jsx(j,{})]})}),e.jsx(k,{templateColumns:"1",gap:1,marginTop:2,children:n.map(i=>e.jsx(C,{model:i},i.received_value))})]})})})}function F({}){const[s,n]=o.useState(!1),[t,i]=o.useState([]);return o.useEffect(()=>{const d=l.queuePrompt;l.queuePrompt=async function(){try{await d.apply(l,[...arguments])}finally{const u=l.lastNodeErrors??{};i(Object.values(u).flatMap(r=>r.errors.filter(a=>a.type==="value_not_in_list").map(a=>{const{input_name:c,received_value:g}=a.extra_info;return{class_type:r.class_type,input_name:c,received_value:g}})))}}},[]),t.length===0?null:e.jsxs(e.Fragment,{children:[e.jsxs(p,{size:"sm","aria-label":"missing models",px:2,onClick:()=>n(!0),children:["Install Missing (",t.length,")"]}),s&&e.jsx(z,{onClose:()=>n(!1),missingModels:t})]})}export{F as default};
|
||||
+1
@@ -0,0 +1 @@
|
||||
import{G as U,I as C}from"./chunk-JARCRF6W-PrvQlAIl.js";import{f as x,D as b,j as p,aa as S,r as h}from"./input.js";import{ac as y,h as v,a9 as E}from"./App-OS6BLwC7.js";var V=x(function(o,s){const{columns:l,spacingX:a,spacingY:n,spacing:r,minChildWidth:e,...c}=o,i=b(),f=e?A(e,i):M(l);return p.jsx(U,{ref:s,gap:r,columnGap:a,rowGap:n,templateColumns:f,...c})});V.displayName="SimpleGrid";function k(t){return typeof t=="number"?`${t}px`:t}function A(t,o){return y(t,s=>{const l=S("sizes",s,k(s))(o);return s===null?null:`repeat(auto-fit, minmax(${l}, 1fr))`})}function M(t){return y(t,o=>o===null?null:`repeat(${o}, minmax(0, 1fr))`)}var R=v("chevron-right","IconChevronRight",[["path",{d:"M9 6l6 6l-6 6",key:"svg-0"}]]);function O(t){return new Promise((o,s)=>{const l=new FileReader;l.onload=a=>{const n=new Uint8Array(a.target.result),r=new DataView(n.buffer);r.getUint32(0)!==2303741511&&(console.error("Not a valid PNG file"),s());let e=8;const c={};for(;e<n.length;){const i=r.getUint32(e),f=String.fromCharCode(...n.slice(e+4,e+8));if(f==="tEXt"||f=="comf"){let u=e+8;for(;n[u]!==0;)u++;const g=String.fromCharCode(...n.slice(e+8,u)),d=n.slice(u+1,e+8+i),m=Array.from(d).map(w=>String.fromCharCode(w)).join("");c[g]=m}e+=12+i}o(c)},l.readAsArrayBuffer(t)})}function D(t){const o=new Uint16Array(t.slice(0,2))[0]===18761;function s(r,e,c){const i=t.slice(r,r+c);if(c===2)return new DataView(i.buffer,i.byteOffset,i.byteLength).getUint16(0,e);if(c===4)return new DataView(i.buffer,i.byteOffset,i.byteLength).getUint32(0,e)}const l=s(4,o,4);function a(r){const e=s(r,o,2),c={};for(let i=0;i<e;i++){const f=r+2+i*12,u=s(f,o,2),g=s(f+2,o,2),d=s(f+4,o,4),m=s(f+8,o,4);let w;g===2&&(w=String.fromCharCode(...t.slice(m,m+d-1))),c[u]=w}return c}return a(l)}function G(t){return new Promise(o=>{const s=new FileReader;s.onload=l=>{var c;const a=new Uint8Array((c=l==null?void 0:l.target)==null?void 0:c.result),n=new DataView(a.buffer);if(n.getUint32(0)!==1380533830||n.getUint32(8)!==1464156752){console.error("Not a valid WEBP file"),o();return}let r=12;const e={};for(;r<a.length;){const i=n.getUint32(r+4,!0);if(String.fromCharCode(...a.slice(r,r+4))==="EXIF"){String.fromCharCode(...a.slice(r+8,r+8+6))=="Exif\0\0"&&(r+=6);const u=D(a.slice(r+8,r+8+i));for(const g in u){const d=u[g],m=d.indexOf(":");e[d.slice(0,m)]=d.slice(m+1)}}r+=8+i}o(e)},s.readAsArrayBuffer(t)})}function N(t){return!!(t!=null&&t.endsWith(".webm")||t!=null&&t.endsWith(".mp4"))}function F(t){return new Promise(o=>{const s=new FileReader;s.onload=l=>{const a=new Uint8Array(l.target.result),n=new DataView(a.buffer),r=new TextDecoder;if(n.getUint32(0)==440786851){let e=12;for(;e<a.length-16;){if(n.getUint16(e)==17543&&String.fromCharCode(...a.slice(e-7,e))==="COMMENT"){const i=n.getUint32(e+2),f=Math.clz32(i)+1;if(f<4){const u=i>>8*(4-f)&~(1<<7*f),g=r.decode(a.slice(e+2+f,e+2+f+u)),d=JSON.parse(g);o(d);return}}e+=1}}else if(n.getUint32(4)==1718909296&&n.getUint32(8)==1769172845){let e=a.length-4;for(;e>16;){if(n.getUint32(e)==1684108385&&n.getUint32(e-8)==2841865588){n.getUint32(e+4),n.getUint32(e+8);const c=n.getUint32(e-4)-4*4,i=r.decode(a.slice(e+12,e+12+c)),f=JSON.parse(i);o(f);return}e-=1}}else{console.error("Unknown magic: "+n.getUint32(0)),o();return}},s.readAsArrayBuffer(t)})}function $({mediaLocalPath:t,size:o,autoPlay:s,hideBrokenImage:l,onBrokenLink:a}){const[n,r]=h.useState(!0);return h.useEffect(()=>{l&&(async()=>{try{if(!(await fetch(`/workspace/view_media?filename=${t}`)).ok)throw new Error("Media not found");r(!0)}catch{r(!1),a==null||a()}})()},[]),n?E(t)?p.jsx(C,{borderRadius:3,boxSize:`${o}px`,objectFit:"cover",src:`/workspace/view_media?filename=${t}`,alt:"workflow image renamed or moved from output folder"}):p.jsx("video",{width:o,height:o,src:`/workspace/view_media?filename=${t}`,loop:!0,autoPlay:s??!1,muted:!0,children:p.jsx("track",{kind:"captions"})}):null}export{R as I,$ as M,V as S,F as a,G as b,O as g,N as i};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.drag-model-manager-top-bar-icon{visibility:hidden!important}.model-manager-top-bar:hover .drag-model-manager-top-bar-icon{visibility:visible!important}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{f as v,j as b,r as h,t as E,b as w}from"./input.js";var k=v(function(r,a){const{htmlWidth:e,htmlHeight:t,alt:i,...l}=r;return b.jsx("img",{width:e,height:t,ref:a,alt:i,...l})});k.displayName="NativeImage";function G(n){const{loading:r,src:a,srcSet:e,onLoad:t,onError:i,crossOrigin:l,sizes:u,ignoreFallback:g}=n,[c,m]=h.useState("pending");h.useEffect(()=>{m(a?"loading":"pending")},[a]);const s=h.useRef(),p=h.useCallback(()=>{if(!a)return;d();const o=new Image;o.src=a,l&&(o.crossOrigin=l),e&&(o.srcset=e),u&&(o.sizes=u),r&&(o.loading=r),o.onload=f=>{d(),m("loaded"),t==null||t(f)},o.onerror=f=>{d(),m("failed"),i==null||i(f)},s.current=o},[a,l,e,u,t,i,r]),d=()=>{s.current&&(s.current.onload=null,s.current.onerror=null,s.current=null)};return E(()=>{if(!g)return c==="loading"&&p(),()=>{d()}},[c,p,g]),g?"loaded":c}var x=(n,r)=>n!=="loaded"&&r==="beforeLoadOrError"||n==="failed"&&r==="onError";function y(n,r=[]){const a=Object.assign({},n);for(const e of r)e in a&&delete a[e];return a}var N=v(function(r,a){const{fallbackSrc:e,fallback:t,src:i,srcSet:l,align:u,fit:g,loading:c,ignoreFallback:m,crossOrigin:s,fallbackStrategy:p="beforeLoadOrError",referrerPolicy:d,...o}=r,f=e!==void 0||t!==void 0,I=c!=null||m||!f,R=G({...r,crossOrigin:s,ignoreFallback:I}),j=x(R,p),F={ref:a,objectFit:g,objectPosition:u,...I?o:y(o,["onError","onLoad"])};return j?t||b.jsx(w.img,{as:k,className:"chakra-image__placeholder",src:e,...F}):b.jsx(w.img,{as:k,src:i,srcSet:l,crossOrigin:s,loading:c,referrerPolicy:d,className:"chakra-image",...F})});N.displayName="Image";var C=v(function(r,a){const{templateAreas:e,gap:t,rowGap:i,columnGap:l,column:u,row:g,autoFlow:c,autoRows:m,templateRows:s,autoColumns:p,templateColumns:d,...o}=r,f={display:"grid",gridTemplateAreas:e,gridGap:t,gridRowGap:i,gridColumnGap:l,gridAutoColumns:p,gridColumn:u,gridRow:g,gridAutoFlow:c,gridAutoRows:m,gridTemplateRows:s,gridTemplateColumns:d};return b.jsx(w.div,{ref:a,__css:f,...o})});C.displayName="Grid";export{C as G,N as I};
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
import{f as t}from"./App-EbJ8EOYb.js";import{f as o,j as s}from"./input.js";var e=o((a,r)=>s.jsx(t,{align:"center",...a,direction:"column",ref:r}));e.displayName="VStack";export{e as V};
|
||||
import{j as t}from"./App-OS6BLwC7.js";import{f as o,j as s}from"./input.js";var e=o((a,r)=>s.jsx(t,{align:"center",...a,direction:"column",ref:r}));e.displayName="VStack";export{e as V};
|
||||
Vendored
+30
-29
File diff suppressed because one or more lines are too long
@@ -10,7 +10,7 @@ import threading
|
||||
import server
|
||||
import time
|
||||
from .missing_models import find_missing_models # type: ignore noqa
|
||||
from .model_list import start_populate_file_hash_dict
|
||||
from .model_list import start_populate_file_hash_dict, save_file_hash
|
||||
|
||||
comfy_path = os.path.dirname(folder_paths.__file__)
|
||||
def download_url_with_wget(url, save_path):
|
||||
@@ -91,6 +91,9 @@ async def install_model(request):
|
||||
json_data = await request.json()
|
||||
url = json_data['url']
|
||||
save_path = get_model_path(json_data)
|
||||
file_hash = json_data['file_hash']
|
||||
if file_hash is not None:
|
||||
save_file_hash(save_path, file_hash)
|
||||
|
||||
with download_tasks_lock:
|
||||
# Add the task to the list
|
||||
|
||||
@@ -25,6 +25,10 @@ file_list_lock = threading.Lock()
|
||||
populate_started = False
|
||||
populate_done = False
|
||||
|
||||
def save_file_hash(file_path, file_hash):
|
||||
global file_hash_dict
|
||||
file_hash_dict[file_path] = file_hash
|
||||
|
||||
def compute_hash(file_path):
|
||||
# sha256 hash of the file
|
||||
sha256_hash = hashlib.sha256()
|
||||
@@ -47,7 +51,8 @@ def process_file(folder, file):
|
||||
def populate_file_hash_dict():
|
||||
global populate_done, file_list
|
||||
local_file_list = []
|
||||
for folder in folder_paths.folder_names_and_paths:
|
||||
folder_list = dict(folder_paths.folder_names_and_paths)
|
||||
for folder in folder_list:
|
||||
if (folder == "configs" or folder == "custom_nodes"):
|
||||
continue
|
||||
files = folder_paths.get_filename_list(folder)
|
||||
|
||||
+11
-1
@@ -122,6 +122,16 @@ export async function openWorkflowsFolder() {
|
||||
}
|
||||
}
|
||||
|
||||
export type ScanLocalFile = {
|
||||
name: string;
|
||||
json: string;
|
||||
};
|
||||
export type ScanLocalFolder = {
|
||||
name: string;
|
||||
list: ScanLocalResult[];
|
||||
};
|
||||
export type ScanLocalResult = ScanLocalFile | ScanLocalFolder;
|
||||
|
||||
export async function scanLocalNewFiles(path: string, existFlowIds: string[]) {
|
||||
try {
|
||||
const response = await fetch("/workspace/scan_local_new_files", {
|
||||
@@ -134,7 +144,7 @@ export async function scanLocalNewFiles(path: string, existFlowIds: string[]) {
|
||||
existFlowIds,
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
const result: ScanLocalResult[] = await response.json();
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Error scan local new files:", error);
|
||||
|
||||
+26
-12
@@ -35,6 +35,7 @@ const RecentFilesDrawer = React.lazy(
|
||||
const GalleryModal = React.lazy(() => import("./gallery/GalleryModal"));
|
||||
import { scanLocalNewFiles } from "./Api";
|
||||
import { IconExternalLink } from "@tabler/icons-react";
|
||||
import { DRAWER_Z_INDEX } from "./const";
|
||||
const ModelManagerTopbar = React.lazy(
|
||||
() => import("./model-manager/topbar/ModelManagerTopbar"),
|
||||
);
|
||||
@@ -85,8 +86,16 @@ export default function App() {
|
||||
// User clicked OK
|
||||
if (curFlowID.current) {
|
||||
const flow = await workflowsTable?.get(curFlowID.current);
|
||||
if (flow && flow.lastSavedJson) {
|
||||
app.loadGraphData(JSON.parse(flow.lastSavedJson));
|
||||
if (flow) {
|
||||
if (flow.lastSavedJson) {
|
||||
app.loadGraphData(JSON.parse(flow.lastSavedJson));
|
||||
} else {
|
||||
// no last saved json, may cuz user just disabled autosave
|
||||
// save current json as last saved json
|
||||
// this maynot be ideal but better than losing all changes
|
||||
app.loadGraphData(JSON.parse(flow.json));
|
||||
saveCurWorkflow();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,6 +109,8 @@ export default function App() {
|
||||
workflowsTable?.updateCurWorkflowID(id);
|
||||
if (id == null) {
|
||||
document.title = "ComfyUI";
|
||||
window.location.hash = "";
|
||||
localStorage.removeItem("curFlowID");
|
||||
return;
|
||||
}
|
||||
if (getWorkflowIdInUrlHash()) {
|
||||
@@ -154,11 +165,9 @@ export default function App() {
|
||||
await userSettingsTable?.getSetting("myWorkflowsDir");
|
||||
const allFlows = await workflowsTable?.listAll();
|
||||
const existFlowIds = (allFlows && allFlows.map((flow) => flow.id)) || [];
|
||||
const { fileList, folderList } = await scanLocalNewFiles(
|
||||
myWorkflowsDir!,
|
||||
existFlowIds,
|
||||
);
|
||||
await syncNewFlowOfLocalDisk(fileList, folderList);
|
||||
const fileList = await scanLocalNewFiles(myWorkflowsDir!, existFlowIds);
|
||||
if (!fileList || fileList.length === 0) return;
|
||||
await syncNewFlowOfLocalDisk(fileList);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -198,8 +207,10 @@ export default function App() {
|
||||
|
||||
const flow = await workflowsTable?.get(id);
|
||||
|
||||
// If the currently loaded flow does not exist, you need to clear the URL hash and localStorage to avoid popping up another prompt that the flow does not exist when refreshing the page.
|
||||
if (flow == null) {
|
||||
alert("Error: Workflow not found! id: " + id);
|
||||
setCurFlowIDAndName(null, "");
|
||||
return;
|
||||
}
|
||||
setCurFlowIDAndName(id, flow.name);
|
||||
@@ -241,6 +252,7 @@ export default function App() {
|
||||
label: "Discard",
|
||||
colorScheme: "red",
|
||||
onClick: () => {
|
||||
discardUnsavedChanges();
|
||||
loadWorkflowIDImpl(id);
|
||||
},
|
||||
},
|
||||
@@ -452,7 +464,7 @@ export default function App() {
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
|
||||
api.addEventListener("executed", (e: any) => {
|
||||
const handleExecuted = (e: any) => {
|
||||
e.detail?.output?.images?.forEach(
|
||||
(im: { filename: string; subfolder: string; type: string }) => {
|
||||
if (im.type === "output") {
|
||||
@@ -467,12 +479,15 @@ export default function App() {
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
api.addEventListener("executed", handleExecuted);
|
||||
return () => {
|
||||
// window.removeEventListener("message", authTokenListener);
|
||||
window.removeEventListener("keydown", shortcutListener);
|
||||
window.removeEventListener("change", fileInputListener);
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
window.removeEventListener("executed", handleExecuted);
|
||||
clearInterval(autoSaveTimer.current);
|
||||
};
|
||||
}, []);
|
||||
@@ -499,13 +514,12 @@ export default function App() {
|
||||
<Portal containerRef={workspaceContainerRef}>
|
||||
<Box
|
||||
style={{
|
||||
width: "100vh",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
lineHeight: "24px",
|
||||
}}
|
||||
zIndex={1000}
|
||||
zIndex={DRAWER_Z_INDEX}
|
||||
draggable={false}
|
||||
>
|
||||
<Topbar
|
||||
|
||||
@@ -6,6 +6,7 @@ const vhPolyfill = css`
|
||||
:root,
|
||||
:host {
|
||||
--chakra-vh: 100vh;
|
||||
--toast-z-index: 17000000;
|
||||
}
|
||||
|
||||
@supports (height: -webkit-fill-available) {
|
||||
|
||||
@@ -4,26 +4,37 @@ import {
|
||||
isFolder,
|
||||
workflowsTable,
|
||||
} from "../db-tables/WorkspaceDB";
|
||||
import { useState, memo, useContext, useEffect, MouseEvent } from "react";
|
||||
import {
|
||||
useState,
|
||||
memo,
|
||||
useContext,
|
||||
useEffect,
|
||||
MouseEvent,
|
||||
DragEvent,
|
||||
} from "react";
|
||||
import { IconChevronDown, IconChevronRight } from "@tabler/icons-react";
|
||||
import { RecentFilesContext } from "../WorkspaceContext";
|
||||
import WorkflowListItem from "./WorkflowListItem";
|
||||
import FilesListFolderItemRightClickMenu from "./FilesListFolderItemRightClickMenu";
|
||||
import { ESortTypes, sortTypeLocalStorageKey } from "./types";
|
||||
import { Folder, Workflow } from "../types/dbTypes";
|
||||
import ItemsList from "./ItemsList";
|
||||
|
||||
type Props = {
|
||||
folder: Folder;
|
||||
};
|
||||
export default memo(function FilesListFolderItem({ folder }: Props) {
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState(folder.isCollapse ?? false);
|
||||
const [isCollapsed, setIsCollapsed] = useState(folder.isCollapse ?? true);
|
||||
const [children, setChildren] = useState<Array<Folder | Workflow>>([]);
|
||||
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { colorMode } = useColorMode();
|
||||
const { draggingFile, refreshFolderStamp, onRefreshFilesList } =
|
||||
useContext(RecentFilesContext);
|
||||
const {
|
||||
draggingFile,
|
||||
setDraggingFile,
|
||||
refreshFolderStamp,
|
||||
onRefreshFilesList,
|
||||
} = useContext(RecentFilesContext);
|
||||
const activeStyle =
|
||||
colorMode === "light"
|
||||
? { backgroundColor: "#E2E8F0" }
|
||||
@@ -45,6 +56,24 @@ export default memo(function FilesListFolderItem({ folder }: Props) {
|
||||
setMenuPosition({ x: event.clientX, y: event.clientY });
|
||||
setIsMenuOpen(true);
|
||||
};
|
||||
const handleDrop = async (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!draggingFile) return setIsActive(false);
|
||||
if (isFolder(draggingFile)) {
|
||||
if (draggingFile.id === folder.id) return setIsActive(false);
|
||||
await foldersTable?.update({
|
||||
id: draggingFile.id,
|
||||
parentFolderID: folder.id,
|
||||
});
|
||||
} else if (!isFolder(draggingFile)) {
|
||||
await workflowsTable?.updateFlow(draggingFile.id, {
|
||||
parentFolderID: folder.id,
|
||||
});
|
||||
}
|
||||
await onRefreshFilesList?.();
|
||||
setIsActive(false);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!!folder.isCollapse === isCollapsed) return;
|
||||
foldersTable?.update({
|
||||
@@ -62,24 +91,22 @@ export default memo(function FilesListFolderItem({ folder }: Props) {
|
||||
setIsCollapsed(!isCollapsed);
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
draggable="true"
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsActive(true);
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
setIsActive(false);
|
||||
}}
|
||||
onDrop={async () => {
|
||||
if (draggingFile && !isFolder(draggingFile)) {
|
||||
await workflowsTable?.updateFlow(draggingFile.id, {
|
||||
parentFolderID: folder.id,
|
||||
});
|
||||
await onRefreshFilesList?.();
|
||||
}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsActive(false);
|
||||
}}
|
||||
onDragStart={() => setDraggingFile?.(folder)}
|
||||
onDrop={handleDrop}
|
||||
_hover={activeStyle}
|
||||
style={isActive || isMenuOpen ? activeStyle : undefined}
|
||||
className="droppable"
|
||||
>
|
||||
<HStack gap={1}>
|
||||
{isCollapsed ? (
|
||||
@@ -97,23 +124,16 @@ export default memo(function FilesListFolderItem({ folder }: Props) {
|
||||
</svg>
|
||||
</HStack>
|
||||
<Text>{folder.name}</Text>
|
||||
|
||||
<FilesListFolderItemRightClickMenu
|
||||
isopen={isMenuOpen}
|
||||
menuPosition={menuPosition}
|
||||
onClose={() => setIsMenuOpen(false)}
|
||||
folder={folder}
|
||||
/>
|
||||
</HStack>
|
||||
<FilesListFolderItemRightClickMenu
|
||||
isopen={isMenuOpen}
|
||||
menuPosition={menuPosition}
|
||||
onClose={() => setIsMenuOpen(false)}
|
||||
folder={folder}
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<Stack ml={4} gap={0}>
|
||||
{children.map((file) => {
|
||||
if (isFolder(file)) {
|
||||
return <FilesListFolderItem key={file.id} folder={file} />;
|
||||
} else {
|
||||
return <WorkflowListItem key={file.id} workflow={file} />;
|
||||
}
|
||||
})}
|
||||
<ItemsList items={children} />
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Box, Menu, MenuList, MenuItem } from "@chakra-ui/react";
|
||||
import { MouseEvent, useContext, useRef, useState } from "react";
|
||||
import { MouseEvent, useContext, useState } from "react";
|
||||
import { RecentFilesContext } from "../WorkspaceContext";
|
||||
import { workflowsTable, foldersTable } from "../db-tables/WorkspaceDB";
|
||||
import EditFolderNameModal from "../components/EditFolderName";
|
||||
import {
|
||||
IconFileImport,
|
||||
IconFolderPlus,
|
||||
IconPencil,
|
||||
IconPlus,
|
||||
@@ -48,7 +47,6 @@ export default function FilesListFolderItemRightClickMenu({
|
||||
});
|
||||
onRefreshFilesList && onRefreshFilesList();
|
||||
};
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const openDeleteConfirm = async (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
@@ -87,7 +85,12 @@ export default function FilesListFolderItemRightClickMenu({
|
||||
return (
|
||||
<>
|
||||
<Box position="absolute" top={menuPosition.y} left={menuPosition.x}>
|
||||
<Menu isOpen={isopen} onClose={onClose} isLazy>
|
||||
<Menu
|
||||
isOpen={isopen}
|
||||
onClose={onClose}
|
||||
isLazy
|
||||
lazyBehavior="keepMounted"
|
||||
>
|
||||
<MenuList>
|
||||
<MenuItem
|
||||
icon={<IconPencil size={19} />}
|
||||
@@ -114,22 +117,14 @@ export default function FilesListFolderItemRightClickMenu({
|
||||
<MenuItem onClick={onClickNewFile} icon={<IconPlus size={19} />}>
|
||||
New file
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
icon={<IconFileImport size={19} />}
|
||||
>
|
||||
Import workflows
|
||||
</MenuItem>
|
||||
<ImportFlowsFileInput
|
||||
parentFolderID={folder.id}
|
||||
onlyImportFile={true}
|
||||
/>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</Box>
|
||||
<ImportFlowsFileInput
|
||||
parentFolderID={folder.id}
|
||||
fileInputRef={fileInputRef}
|
||||
/>
|
||||
|
||||
{isRenameOpen && (
|
||||
<EditFolderNameModal
|
||||
folder={folder}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Button } from "@chakra-ui/react";
|
||||
import { IconFileImport } from "@tabler/icons-react";
|
||||
import { useRef } from "react";
|
||||
import ImportFlowsFileInput from "./ImportFlowsFileInput";
|
||||
|
||||
interface Props {
|
||||
parentFolderID?: string;
|
||||
}
|
||||
export default function ImportFileButton({}: Props) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<Button
|
||||
leftIcon={<IconFileImport size={18} />}
|
||||
variant="outline"
|
||||
iconSpacing={1}
|
||||
size={"sm"}
|
||||
colorScheme="teal"
|
||||
onClick={() => {
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
Import
|
||||
<ImportFlowsFileInput fileInputRef={fileInputRef} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +1,247 @@
|
||||
import { ChangeEvent, useContext } from "react";
|
||||
import { workflowsTable } from "../db-tables/WorkspaceDB";
|
||||
import { ChangeEvent, useContext, useRef, useState } from "react";
|
||||
import { Button, Menu, MenuButton, MenuList, MenuItem } from "@chakra-ui/react";
|
||||
import { IconFile, IconFileImport, IconFolder } from "@tabler/icons-react";
|
||||
import { workflowsTable, foldersTable } from "../db-tables/WorkspaceDB";
|
||||
import { RecentFilesContext } from "../WorkspaceContext";
|
||||
import { getPngMetadata } from "../utils";
|
||||
import { ImportWorkflow } from "./types";
|
||||
import { getPngMetadata } from "../utils/mediaMetadataUtils";
|
||||
import { ImportWorkflow, importMenuItemList } from "./types";
|
||||
|
||||
interface WorkflowFile {
|
||||
name: string;
|
||||
json: string;
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
name: string;
|
||||
isFile: boolean;
|
||||
file?: WorkflowFile;
|
||||
children?: TreeNode[];
|
||||
parentFolderID?: string;
|
||||
}
|
||||
|
||||
interface HTMLInputDirectoryElement extends HTMLInputElement {
|
||||
webkitdirectory: boolean;
|
||||
}
|
||||
interface Props {
|
||||
fileInputRef: React.RefObject<HTMLInputElement>;
|
||||
onlyImportFile?: boolean;
|
||||
parentFolderID?: string;
|
||||
}
|
||||
export default function ImportFlowsFileInput({
|
||||
parentFolderID,
|
||||
fileInputRef,
|
||||
onlyImportFile = false,
|
||||
}: Props) {
|
||||
const { onRefreshFilesList } = useContext(RecentFilesContext);
|
||||
const [importType, setImportType] = useState("file"); // file/folder
|
||||
const fileInputRef = useRef<HTMLInputDirectoryElement>(null);
|
||||
|
||||
const onClick = (type: string) => {
|
||||
setImportType(type);
|
||||
if (type === "folder") {
|
||||
fileInputRef.current!.setAttribute("webkitdirectory", "");
|
||||
} else {
|
||||
fileInputRef.current!.removeAttribute("webkitdirectory");
|
||||
}
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const parseFile = async (file: File): Promise<WorkflowFile | null> => {
|
||||
if (file.type === "image/png") {
|
||||
const pngInfo = await getPngMetadata(file);
|
||||
// @ts-expect-error
|
||||
const flowJson = pngInfo?.workflow;
|
||||
if (flowJson) {
|
||||
return {
|
||||
json: flowJson,
|
||||
name: file.name.replace(".png", ""),
|
||||
};
|
||||
}
|
||||
} else if (
|
||||
file.type === "application/json" ||
|
||||
file.name?.endsWith(".json")
|
||||
) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const result = e.target?.result;
|
||||
if (typeof result === "string") {
|
||||
resolve({
|
||||
json: result,
|
||||
name: file.name.replace(".json", ""),
|
||||
});
|
||||
} else {
|
||||
reject(new Error("File content is not a string"));
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const createFileAndFolderFormTree = async (treeNode: TreeNode) => {
|
||||
// Traverse the folders in the tree through queue breadth first, creating folders and files under the current folder in sequence;
|
||||
const queue = [treeNode];
|
||||
while (queue.length > 0) {
|
||||
const currentNode = queue.shift();
|
||||
|
||||
if (currentNode && !currentNode.isFile) {
|
||||
const folder = await foldersTable?.create({
|
||||
name: currentNode.name,
|
||||
parentFolderID: currentNode.parentFolderID,
|
||||
});
|
||||
if (currentNode.children && currentNode.children.length) {
|
||||
const fileList: WorkflowFile[] = [];
|
||||
currentNode.children.forEach((child) => {
|
||||
if (child.isFile && child.file) {
|
||||
fileList.push(child.file);
|
||||
} else {
|
||||
child.parentFolderID = folder?.id;
|
||||
queue.push(child);
|
||||
}
|
||||
});
|
||||
if (fileList.length) {
|
||||
await workflowsTable?.batchCreateFlows(
|
||||
fileList,
|
||||
undefined,
|
||||
folder?.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
onRefreshFilesList?.();
|
||||
};
|
||||
|
||||
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if (!files) return;
|
||||
const parsedFileList: ImportWorkflow[] = [];
|
||||
for (const file of files) {
|
||||
if (file.type === "image/png") {
|
||||
const pngInfo = await getPngMetadata(file);
|
||||
// @ts-expect-error
|
||||
const flowJson = pngInfo?.workflow;
|
||||
if (flowJson) {
|
||||
parsedFileList.push({
|
||||
json: flowJson,
|
||||
name: file.name.replace(".png", ""),
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
file.type === "application/json" ||
|
||||
file.name?.endsWith(".json")
|
||||
) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const result = e.target?.result;
|
||||
if (typeof result === "string") {
|
||||
parsedFileList.push({
|
||||
json: result,
|
||||
name: file.name.replace(".json", ""),
|
||||
});
|
||||
resolve("");
|
||||
} else {
|
||||
reject(new Error("File content is not a string"));
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedFileList.length) {
|
||||
await workflowsTable?.batchCreateFlows(
|
||||
parsedFileList,
|
||||
undefined,
|
||||
parentFolderID,
|
||||
);
|
||||
onRefreshFilesList && onRefreshFilesList();
|
||||
if (importType === "folder") {
|
||||
// Create a root node based on the selected directory
|
||||
const rootNode: TreeNode = {
|
||||
name: files[0].webkitRelativePath.split("/")[0],
|
||||
isFile: false,
|
||||
children: [],
|
||||
};
|
||||
|
||||
// Create nested tree file structure from file path
|
||||
for (const file of files) {
|
||||
if (file.name !== ".DS_Store") {
|
||||
const splitPath = file.webkitRelativePath.split("/");
|
||||
let currentLevel = rootNode.children ?? [];
|
||||
|
||||
// Start traversing the second part of the path
|
||||
for (let index = 1; index < splitPath.length; index++) {
|
||||
const part = splitPath[index];
|
||||
let existingNode = currentLevel.find(
|
||||
(child) => child.name === part,
|
||||
);
|
||||
|
||||
if (!existingNode) {
|
||||
existingNode = {
|
||||
name: part,
|
||||
isFile: index === splitPath.length - 1,
|
||||
children: index === splitPath.length - 1 ? undefined : [],
|
||||
};
|
||||
|
||||
if (existingNode.isFile) {
|
||||
const parseFileResult = await parseFile(file);
|
||||
if (parseFileResult) {
|
||||
existingNode.file = parseFileResult;
|
||||
}
|
||||
}
|
||||
currentLevel.push(existingNode);
|
||||
}
|
||||
|
||||
if (existingNode && !existingNode.isFile) {
|
||||
currentLevel = existingNode.children!;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
createFileAndFolderFormTree(rootNode);
|
||||
} else {
|
||||
const parsedFileList: ImportWorkflow[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const parseFileResult = await parseFile(file);
|
||||
if (parseFileResult) {
|
||||
parsedFileList.push(parseFileResult);
|
||||
}
|
||||
}
|
||||
if (parsedFileList.length) {
|
||||
await workflowsTable?.batchCreateFlows(
|
||||
parsedFileList,
|
||||
undefined,
|
||||
parentFolderID,
|
||||
);
|
||||
onRefreshFilesList && onRefreshFilesList();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
style={{ display: "none" }}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,image/png"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
{onlyImportFile ? (
|
||||
<MenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick("file");
|
||||
}}
|
||||
icon={<IconFileImport size={19} />}
|
||||
>
|
||||
Import workflows
|
||||
</MenuItem>
|
||||
) : (
|
||||
<Menu closeOnSelect={true}>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
leftIcon={<IconFileImport size={18} />}
|
||||
variant="outline"
|
||||
iconSpacing={1}
|
||||
size={"sm"}
|
||||
colorScheme="teal"
|
||||
>
|
||||
Import
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
{importMenuItemList.map((item, index) => (
|
||||
<MenuItem
|
||||
key={index}
|
||||
onClick={() => {
|
||||
onClick(item.type);
|
||||
}}
|
||||
iconSpacing={2}
|
||||
icon={
|
||||
item.type === "folder" ? (
|
||||
<IconFolder size={18} />
|
||||
) : (
|
||||
<IconFile size={18} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
)}
|
||||
<input
|
||||
style={{ display: "none" }}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,image/png"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { DragEvent, useContext, useEffect, useState } from "react";
|
||||
import { RecentFilesContext } from "../WorkspaceContext";
|
||||
import {
|
||||
foldersTable,
|
||||
isFolder,
|
||||
workflowsTable,
|
||||
} from "../db-tables/WorkspaceDB";
|
||||
import { Folder, Workflow } from "../types/dbTypes";
|
||||
import FilesListFolderItem from "./FilesListFolderItem";
|
||||
import WorkflowListItem from "./WorkflowListItem";
|
||||
import { Box } from "@chakra-ui/react";
|
||||
import { userSettingsTable } from "../db-tables/WorkspaceDB";
|
||||
|
||||
export default function ItemsList({
|
||||
items,
|
||||
}: {
|
||||
items: Array<Folder | Workflow>;
|
||||
}) {
|
||||
const [folderOnTop, setFolderOnTop] = useState(false);
|
||||
const folders = items.filter(isFolder);
|
||||
const workflows = items.filter((item): item is Workflow => !isFolder(item));
|
||||
const parentFolderID = workflows[0]?.parentFolderID;
|
||||
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
const { onRefreshFilesList, draggingFile, refreshFolderStamp } =
|
||||
useContext(RecentFilesContext);
|
||||
|
||||
const handleDrop = async (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!draggingFile) return setIsDraggingOver(false);
|
||||
if (isFolder(draggingFile)) {
|
||||
if (draggingFile.id === parentFolderID) return setIsDraggingOver(false);
|
||||
await foldersTable?.update({
|
||||
id: draggingFile.id,
|
||||
parentFolderID: parentFolderID,
|
||||
});
|
||||
} else {
|
||||
await workflowsTable?.updateFlow(draggingFile.id, {
|
||||
parentFolderID: parentFolderID,
|
||||
});
|
||||
}
|
||||
await onRefreshFilesList?.();
|
||||
setIsDraggingOver(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
userSettingsTable?.getSetting("foldersOnTop").then((res) => {
|
||||
setFolderOnTop(res ?? false);
|
||||
});
|
||||
}, [refreshFolderStamp]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{folderOnTop &&
|
||||
folders.map((folder) => (
|
||||
<FilesListFolderItem key={folder.id} folder={folder} />
|
||||
))}
|
||||
<Box
|
||||
border={
|
||||
isDraggingOver ? "2px dashed #718096" : "2px dashed transparent"
|
||||
}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDraggingOver(true);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDraggingOver(false);
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
className="droppable"
|
||||
>
|
||||
{folderOnTop
|
||||
? workflows.map((workflow) => (
|
||||
<WorkflowListItem key={workflow.id} workflow={workflow} />
|
||||
))
|
||||
: items.map((n) => {
|
||||
if (isFolder(n)) {
|
||||
return <FilesListFolderItem folder={n} key={n.id} />;
|
||||
}
|
||||
return <WorkflowListItem key={n.id} workflow={n} />;
|
||||
})}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Box,
|
||||
Flex,
|
||||
Tooltip,
|
||||
Switch,
|
||||
} from "@chakra-ui/react";
|
||||
import { useEffect, useState, useRef, useCallback, useContext } from "react";
|
||||
import {
|
||||
@@ -28,19 +29,19 @@ import {
|
||||
import { RecentFilesContext, WorkspaceContext } from "../WorkspaceContext";
|
||||
import RecentFilesDrawerMenu from "./RecentFilesDrawerMenu";
|
||||
import { sortFileItem } from "../utils";
|
||||
import WorkflowListItem from "./WorkflowListItem";
|
||||
import MultipleSelectionOperation from "./MultipleSelectionOperation";
|
||||
import { ESortTypes, sortTypeLocalStorageKey } from "./types";
|
||||
// @ts-ignore
|
||||
// @ts-expect-error ComfyUI import
|
||||
import { app } from "/scripts/app.js";
|
||||
import { insertWorkflowToCanvas3 } from "./InsertWorkflowToCanvas";
|
||||
import FilesListFolderItem from "./FilesListFolderItem";
|
||||
import { useDebounce } from "../customHooks/useDebounce";
|
||||
import SearchInput from "../components/SearchInput";
|
||||
import { openWorkflowsFolder } from "../Api";
|
||||
import { Folder, Workflow } from "../types/dbTypes";
|
||||
import ImportFileButton from "./ImportFileButton";
|
||||
import MyTagsRow from "./MyTagsRow";
|
||||
import ImportFlowsFileInput from "./ImportFlowsFileInput";
|
||||
import ItemsList from "./ItemsList";
|
||||
import { DRAWER_Z_INDEX } from "../const";
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
@@ -53,7 +54,7 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
|
||||
>([]);
|
||||
const aloneFlowsAndFoldersRef = useRef<Array<Folder | Workflow>>([]);
|
||||
const allFlowsRef = useRef<Array<Workflow>>([]);
|
||||
const { loadWorkflowID } = useContext(WorkspaceContext);
|
||||
const { loadWorkflowID, curFlowID } = useContext(WorkspaceContext);
|
||||
const [selectedTag, setSelectedTag] = useState<string>();
|
||||
const [multipleState, setMultipleState] = useState(false);
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
|
||||
@@ -129,7 +130,7 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
|
||||
|
||||
useEffect(() => {
|
||||
loadLatestWorkflows();
|
||||
const handleDrop = async (e: any) => {
|
||||
const handleDrop = async (e: { canvasX: number; canvasY: number }) => {
|
||||
if (draggingWorkflowID.current) {
|
||||
const flow = await workflowsTable?.get(draggingWorkflowID.current);
|
||||
flow &&
|
||||
@@ -162,6 +163,12 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
|
||||
const batchOperationCallback = (type: string, value: unknown) => {
|
||||
switch (type) {
|
||||
case "batchDelete":
|
||||
curFlowID &&
|
||||
workflowsTable?.get(curFlowID).then((res) => {
|
||||
if (!res) {
|
||||
loadWorkflowID?.(null);
|
||||
}
|
||||
});
|
||||
loadLatestWorkflows();
|
||||
setMultipleState(false);
|
||||
setSelectedKeys([]);
|
||||
@@ -212,7 +219,7 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
|
||||
top={0}
|
||||
left={0}
|
||||
shadow={"xl"}
|
||||
zIndex={1000}
|
||||
zIndex={DRAWER_Z_INDEX}
|
||||
// boxShadow={"rgba(200, 200, 200, 0.4) 1px 4px 8px 1px"}
|
||||
>
|
||||
<Flex alignItems={"center"} justifyContent={"space-between"}>
|
||||
@@ -225,7 +232,7 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
|
||||
<Link onClick={openCognitoPopup}>Login</Link>
|
||||
</Tooltip> */}
|
||||
</HStack>
|
||||
<ImportFileButton />
|
||||
<ImportFlowsFileInput />
|
||||
<Tooltip label="Open workspace save directory">
|
||||
<IconButton
|
||||
aria-label="Open workspace save directory"
|
||||
@@ -325,12 +332,7 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
|
||||
onUpdateSearchValue={onUpdateSearchValue}
|
||||
/>
|
||||
<Flex overflowY={"auto"} overflowX={"hidden"} direction="column">
|
||||
{currentRenderingData.map((n) => {
|
||||
if (isFolder(n)) {
|
||||
return <FilesListFolderItem folder={n} key={n.id} />;
|
||||
}
|
||||
return <WorkflowListItem key={n.id} workflow={n} />;
|
||||
})}
|
||||
<ItemsList items={currentRenderingData} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
|
||||
@@ -11,21 +11,20 @@ import {
|
||||
Tooltip,
|
||||
} from "@chakra-ui/react";
|
||||
import { IconExternalLink } from "@tabler/icons-react";
|
||||
import { isFolder, workflowsTable } from "../db-tables/WorkspaceDB";
|
||||
import { formatTimestamp, openWorkflowInNewTab, isImageFormat } from "../utils";
|
||||
import AddTagToWorkflowPopover from "./AddTagToWorkflowPopover";
|
||||
import { useState, memo, ChangeEvent, useContext } from "react";
|
||||
import { MouseEvent, useState, memo, ChangeEvent, useContext } from "react";
|
||||
import WorkflowListItemRightClickMenu from "./WorkflowListItemRightClickMenu";
|
||||
import DeleteConfirm from "../components/DeleteConfirm";
|
||||
import { RecentFilesContext, WorkspaceContext } from "../WorkspaceContext";
|
||||
import { Workflow } from "../types/dbTypes";
|
||||
import MediaPreview from "../components/MediaPreview";
|
||||
|
||||
type Props = {
|
||||
workflow: Workflow;
|
||||
};
|
||||
export default memo(function WorkflowListItem({ workflow }: Props) {
|
||||
const { colorMode } = useColorMode();
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const {
|
||||
@@ -34,8 +33,6 @@ export default memo(function WorkflowListItem({ workflow }: Props) {
|
||||
onMultiSelectFlow,
|
||||
onDeleteFlow,
|
||||
multiSelectedFlowsID,
|
||||
onRefreshFilesList,
|
||||
draggingFile,
|
||||
} = useContext(RecentFilesContext);
|
||||
const isChecked =
|
||||
multiSelectedFlowsID &&
|
||||
@@ -43,7 +40,7 @@ export default memo(function WorkflowListItem({ workflow }: Props) {
|
||||
multiSelectedFlowsID.includes(workflow.id);
|
||||
const { curFlowID, loadWorkflowID } = useContext(WorkspaceContext);
|
||||
const isSelected = curFlowID === workflow.id;
|
||||
const handleContextMenu = (event: any) => {
|
||||
const handleContextMenu = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
setMenuPosition({ x: event.clientX, y: event.clientY });
|
||||
setIsMenuOpen(true);
|
||||
@@ -58,30 +55,12 @@ export default memo(function WorkflowListItem({ workflow }: Props) {
|
||||
<Box
|
||||
flexShrink={1}
|
||||
flexGrow={1}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDraggingOver(true);
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
setIsDraggingOver(false);
|
||||
}}
|
||||
onDrop={async () => {
|
||||
if (draggingFile && !isFolder(draggingFile)) {
|
||||
await workflowsTable?.updateFlow(draggingFile.id, {
|
||||
parentFolderID: workflow.parentFolderID,
|
||||
});
|
||||
await onRefreshFilesList?.();
|
||||
}
|
||||
setIsDraggingOver(false);
|
||||
}}
|
||||
backgroundColor={
|
||||
isSelected ? "teal.200" : isMenuOpen ? hoverBgColor : undefined
|
||||
}
|
||||
color={isSelected && !isMultiSelecting ? "#333" : undefined}
|
||||
draggable={!isMultiSelecting}
|
||||
onDragStart={(e) => {
|
||||
setDraggingFile && setDraggingFile(workflow);
|
||||
}}
|
||||
onDragStart={() => setDraggingFile?.(workflow)}
|
||||
borderRadius={6}
|
||||
px={1}
|
||||
py={1}
|
||||
@@ -93,25 +72,13 @@ export default memo(function WorkflowListItem({ workflow }: Props) {
|
||||
}}
|
||||
>
|
||||
<HStack>
|
||||
{workflow.coverMediaPath != null &&
|
||||
(isImageFormat(workflow.coverMediaPath) ? (
|
||||
<Image
|
||||
borderRadius={3}
|
||||
boxSize="60px"
|
||||
objectFit="cover"
|
||||
src={`/workspace/view_media?filename=${workflow.coverMediaPath}`}
|
||||
alt={workflow.name ?? "workflow cover image"}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
width={60}
|
||||
height={60}
|
||||
src={`/workspace/view_media?filename=${workflow.coverMediaPath}`}
|
||||
muted={true}
|
||||
>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
))}
|
||||
{workflow.coverMediaPath != null && (
|
||||
<MediaPreview
|
||||
mediaLocalPath={workflow.coverMediaPath}
|
||||
size={60}
|
||||
hideBrokenImage
|
||||
/>
|
||||
)}
|
||||
|
||||
<Stack textAlign={"left"} gap={0}>
|
||||
<Text fontWeight={"500"}>{workflow.name ?? "untitled"}</Text>
|
||||
@@ -120,9 +87,6 @@ export default memo(function WorkflowListItem({ workflow }: Props) {
|
||||
</Text>
|
||||
</Stack>
|
||||
</HStack>
|
||||
{isDraggingOver && (
|
||||
<Box width={"100%"} mt={2} height={"2px"} backgroundColor={"#4299E1"} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import { userSettingsTable } from "../db-tables/WorkspaceDB";
|
||||
import { ShortcutSettings } from "../settings/ShortcutSettings";
|
||||
import { validateOrSaveAllJsonFileMyWorkflows } from "../utils";
|
||||
import AutoSaveSettings from "../settings/AutoSaveSettings";
|
||||
import FolderOnTopSettings from "../settings/FolderOnTopSettings";
|
||||
import TwoWaySyncSettings from "../settings/TwoWaySyncSettings";
|
||||
|
||||
export default function WorkspaceSettingsModal({
|
||||
@@ -289,6 +290,7 @@ export default function WorkspaceSettingsModal({
|
||||
<ShortcutSettings />
|
||||
<AutoSaveSettings />
|
||||
<TwoWaySyncSettings />
|
||||
<FolderOnTopSettings />
|
||||
</VStack>
|
||||
</HStack>
|
||||
</ModalBody>
|
||||
|
||||
@@ -26,3 +26,21 @@ export type ImportWorkflow = {
|
||||
json: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export const importMenuItemList = [
|
||||
{
|
||||
title: "Import Workflows",
|
||||
type: "file",
|
||||
},
|
||||
{
|
||||
title: "Import Folder",
|
||||
type: "folder",
|
||||
},
|
||||
];
|
||||
|
||||
// Solve TS errors caused by webkitdirectory
|
||||
declare module "react" {
|
||||
interface InputHTMLAttributes<T> extends HTMLAttributes<T> {
|
||||
webkitdirectory?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
FormErrorMessage,
|
||||
Input,
|
||||
} from "@chakra-ui/react";
|
||||
import { workflowVersionsTable } from "../db-tables/WorkspaceDB";
|
||||
import { useState, ChangeEvent } from "react";
|
||||
// @ts-ignore
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
interface Props {
|
||||
workflowId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
export default function CreateVersionDialog({ workflowId, onClose }: Props) {
|
||||
const [newVersionName, setNewVersionName] = useState("");
|
||||
const [submitError, setSubmitError] = useState("");
|
||||
|
||||
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setNewVersionName(event.target.value);
|
||||
submitError && setSubmitError("");
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
const trimName = newVersionName.trim();
|
||||
setNewVersionName(trimName);
|
||||
const versionList =
|
||||
(await workflowVersionsTable?.listByWorkflowID(workflowId)) ?? [];
|
||||
if (versionList.some((version) => version.name === trimName)) {
|
||||
setSubmitError(
|
||||
"The name is duplicated, please modify it and submit again.",
|
||||
);
|
||||
} else {
|
||||
const graphJson = JSON.stringify(app.graph.serialize());
|
||||
await workflowVersionsTable?.add({
|
||||
name: newVersionName,
|
||||
workflowID: workflowId,
|
||||
createTime: Date.now(),
|
||||
json: graphJson,
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={true} onClose={onClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Create Version</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<FormControl isInvalid={!!submitError}>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input
|
||||
value={newVersionName}
|
||||
onChange={handleChange}
|
||||
autoFocus
|
||||
onKeyUp={(e) => {
|
||||
e.code === "Enter" &&
|
||||
!submitError &&
|
||||
newVersionName &&
|
||||
onSubmit();
|
||||
}}
|
||||
/>
|
||||
{submitError && <FormErrorMessage>{submitError}</FormErrorMessage>}
|
||||
</FormControl>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
colorScheme="teal"
|
||||
mr={3}
|
||||
onClick={onSubmit}
|
||||
isDisabled={!newVersionName || !!submitError}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import React, { useRef } from "react";
|
||||
import { Box, useOutsideClick } from "@chakra-ui/react";
|
||||
|
||||
type Props = {
|
||||
menuButton: React.ReactElement;
|
||||
options: React.ReactElement;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
export default function CustomMenu({
|
||||
options,
|
||||
menuButton,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useOutsideClick({
|
||||
ref: ref,
|
||||
handler: () => onClose(),
|
||||
});
|
||||
|
||||
return (
|
||||
<Box position="relative">
|
||||
<Box>{menuButton}</Box>
|
||||
{isOpen && (
|
||||
<Box
|
||||
ref={ref}
|
||||
mt="8px"
|
||||
shadow="md"
|
||||
p="2"
|
||||
position="absolute"
|
||||
zIndex="dropdown"
|
||||
>
|
||||
{options}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import React, { useState, useRef } from "react";
|
||||
import { Box, Button, Card, useOutsideClick } from "@chakra-ui/react";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
|
||||
export interface CustomSelectorOption {
|
||||
label: string;
|
||||
value: string;
|
||||
icon?: React.ReactElement;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
options: CustomSelectorOption[];
|
||||
};
|
||||
export default function CustomSelector({ options }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [selectedOption, setSelectedOption] = useState<
|
||||
CustomSelectorOption | undefined
|
||||
>(options[0]);
|
||||
useOutsideClick({
|
||||
ref: ref,
|
||||
handler: () => setIsOpen(false),
|
||||
});
|
||||
|
||||
const toggleDropdown = () => setIsOpen(!isOpen);
|
||||
|
||||
return (
|
||||
<Box position="relative">
|
||||
<Button
|
||||
onClick={toggleDropdown}
|
||||
rightIcon={<IconChevronDown />}
|
||||
leftIcon={selectedOption?.icon}
|
||||
>
|
||||
{selectedOption?.label}
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<Card
|
||||
gap={4}
|
||||
ref={ref}
|
||||
mt="2"
|
||||
shadow="md"
|
||||
borderWidth="1px"
|
||||
p="2"
|
||||
position="absolute"
|
||||
zIndex="dropdown"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
setSelectedOption(option);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
leftIcon={option.icon}
|
||||
justifyContent="flex-start"
|
||||
variant="ghost"
|
||||
width="full"
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -10,11 +10,13 @@ const POSITION = { x: 0, y: 0 };
|
||||
|
||||
interface Props {
|
||||
onDragEnd: (position: { x: number; y: number }) => void;
|
||||
dragIconId: string;
|
||||
}
|
||||
|
||||
export default function Draggable({
|
||||
children,
|
||||
onDragEnd,
|
||||
dragIconId,
|
||||
}: PropsWithChildren<Props>) {
|
||||
const originRef = useRef(POSITION);
|
||||
const isDraggingRef = useRef(false);
|
||||
@@ -27,7 +29,7 @@ export default function Draggable({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if ([e.target?.id, e.target?.parentNode?.id].includes("dragPanelIcon")) {
|
||||
if ([e.target?.id, e.target?.parentNode?.id].includes(dragIconId)) {
|
||||
originRef.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
|
||||
@@ -7,10 +7,8 @@ import {
|
||||
} from "react";
|
||||
import {
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
IconButton,
|
||||
Button,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
@@ -23,8 +21,8 @@ import {
|
||||
FormLabel,
|
||||
FormErrorMessage,
|
||||
Input,
|
||||
Portal,
|
||||
Tooltip,
|
||||
DarkMode,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
IconArrowBackUpDouble,
|
||||
@@ -32,12 +30,16 @@ import {
|
||||
IconDeviceFloppy,
|
||||
IconDownload,
|
||||
IconHistory,
|
||||
IconShare2,
|
||||
IconVersions,
|
||||
} from "@tabler/icons-react";
|
||||
import { workflowsTable, userSettingsTable } from "../db-tables/WorkspaceDB";
|
||||
import { WorkspaceContext } from "../WorkspaceContext";
|
||||
import { Overlay } from "./Overlay";
|
||||
import { VersionHistoryDrawer } from "./VersionHistoryDrawer";
|
||||
import { Workflow } from "../types/dbTypes";
|
||||
import ShareDialog from "../share/ShareDialog";
|
||||
import CustomMenu from "./CustomMenu";
|
||||
import CreateVersionDialog from "./CreateVersionDialog";
|
||||
|
||||
export default function DropdownTitle() {
|
||||
const {
|
||||
@@ -47,6 +49,7 @@ export default function DropdownTitle() {
|
||||
saveCurWorkflow,
|
||||
} = useContext(WorkspaceContext);
|
||||
|
||||
const [isOpenNewVersion, setIsOpenNewVersion] = useState(false);
|
||||
const [isOpenNewName, setIsOpenNewName] = useState(false);
|
||||
const [isVersionHistoryOpen, setIsVersionHistoryOpen] = useState(false);
|
||||
const [newFlowName, setNewFlowName] = useState("");
|
||||
@@ -54,6 +57,7 @@ export default function DropdownTitle() {
|
||||
const [workflow, setWorkflow] = useState<Workflow>();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [saveShortcut, setSaveShortcut] = useState("");
|
||||
const [isShareOpen, setIsShareOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (curFlowID) {
|
||||
@@ -106,7 +110,7 @@ export default function DropdownTitle() {
|
||||
const [closeTimeoutId, setCloseTimeoutId] = useState<number>();
|
||||
|
||||
const delayedClose = () => {
|
||||
setCloseTimeoutId(setTimeout(() => setIsOpen(false), 300)); // delay of 300ms
|
||||
setCloseTimeoutId(setTimeout(() => setIsOpen(false), 400)); // delay of 300ms
|
||||
};
|
||||
|
||||
const onOpen = () => {
|
||||
@@ -120,66 +124,97 @@ export default function DropdownTitle() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu isLazy={true} isOpen={isOpen} gutter={4}>
|
||||
<MenuButton as="div" onMouseEnter={onOpen} onMouseLeave={delayedClose}>
|
||||
<IconButton
|
||||
icon={<IconChevronDown size={20} />}
|
||||
aria-label="menu"
|
||||
size={"xs"}
|
||||
backgroundColor={"#323232"}
|
||||
color={"white"}
|
||||
/>
|
||||
</MenuButton>
|
||||
<Portal>
|
||||
<MenuList
|
||||
minWidth={150}
|
||||
zIndex={1000}
|
||||
onMouseEnter={onOpen}
|
||||
onMouseLeave={delayedClose}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={saveCurWorkflow}
|
||||
icon={<IconDeviceFloppy size={20} />}
|
||||
iconSpacing={1}
|
||||
command={saveShortcut}
|
||||
<CustomMenu
|
||||
isOpen={isOpen}
|
||||
onClose={delayedClose}
|
||||
menuButton={
|
||||
<DarkMode>
|
||||
<Button
|
||||
px={1}
|
||||
height={"29px"}
|
||||
aria-label="menu"
|
||||
size={"sm"}
|
||||
colorScheme="teal"
|
||||
onClick={onOpen}
|
||||
onMouseEnter={onOpen}
|
||||
onMouseLeave={delayedClose}
|
||||
>
|
||||
File
|
||||
<IconChevronDown size={20} />
|
||||
</Button>
|
||||
</DarkMode>
|
||||
}
|
||||
options={
|
||||
<Menu isOpen={true}>
|
||||
<MenuList
|
||||
minWidth={150}
|
||||
zIndex={1000}
|
||||
onMouseEnter={onOpen}
|
||||
onMouseLeave={delayedClose}
|
||||
>
|
||||
Save
|
||||
</MenuItem>
|
||||
<Tooltip label="Revert workflow to your last saved version. You will lose all changes made since your last save.">
|
||||
<MenuItem
|
||||
onClick={discardUnsavedChanges}
|
||||
icon={<IconArrowBackUpDouble size={20} />}
|
||||
onClick={saveCurWorkflow}
|
||||
icon={<IconDeviceFloppy size={20} />}
|
||||
iconSpacing={1}
|
||||
isDisabled={workflow?.lastSavedJson == null}
|
||||
command={saveShortcut}
|
||||
>
|
||||
Discard unsaved changes
|
||||
Save
|
||||
</MenuItem>
|
||||
</Tooltip>
|
||||
<MenuItem
|
||||
onClick={handleDownload}
|
||||
icon={<IconDownload size={20} />}
|
||||
iconSpacing={1}
|
||||
>
|
||||
Download
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => setIsOpenNewName(true)}
|
||||
icon={<IconDeviceFloppy size={20} />}
|
||||
iconSpacing={1}
|
||||
>
|
||||
Save As
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => setIsVersionHistoryOpen(true)}
|
||||
icon={<IconHistory size={20} />}
|
||||
iconSpacing={1}
|
||||
>
|
||||
Versions History
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
{isOpen && <Overlay backgroundColor={null} />}
|
||||
</Portal>
|
||||
</Menu>
|
||||
<Tooltip label="Revert workflow to your last saved version. You will lose all changes made since your last save.">
|
||||
<MenuItem
|
||||
onClick={discardUnsavedChanges}
|
||||
icon={<IconArrowBackUpDouble size={20} />}
|
||||
iconSpacing={1}
|
||||
isDisabled={workflow?.lastSavedJson == null}
|
||||
>
|
||||
Discard unsaved changes
|
||||
</MenuItem>
|
||||
</Tooltip>
|
||||
<MenuItem
|
||||
onClick={handleDownload}
|
||||
icon={<IconDownload size={20} />}
|
||||
iconSpacing={1}
|
||||
>
|
||||
Download
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => setIsOpenNewName(true)}
|
||||
icon={<IconDeviceFloppy size={20} />}
|
||||
iconSpacing={1}
|
||||
>
|
||||
Save As
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => setIsOpenNewVersion(true)}
|
||||
icon={<IconVersions size={20} />}
|
||||
iconSpacing={1}
|
||||
>
|
||||
Create Version
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => setIsVersionHistoryOpen(true)}
|
||||
icon={<IconHistory size={20} />}
|
||||
iconSpacing={1}
|
||||
>
|
||||
Versions History
|
||||
</MenuItem>
|
||||
{/* <MenuItem
|
||||
onClick={() => setIsShareOpen(true)}
|
||||
icon={<IconShare2 size={20} />}
|
||||
iconSpacing={1}
|
||||
>
|
||||
Share
|
||||
</MenuItem> */}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
}
|
||||
/>
|
||||
{isShareOpen && workflowsTable?.curWorkflow && (
|
||||
<ShareDialog
|
||||
workflow={workflowsTable?.curWorkflow}
|
||||
onClose={() => setIsShareOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{isVersionHistoryOpen && (
|
||||
<VersionHistoryDrawer onClose={() => setIsVersionHistoryOpen(false)} />
|
||||
)}
|
||||
@@ -197,7 +232,10 @@ export default function DropdownTitle() {
|
||||
onChange={handleChange}
|
||||
autoFocus
|
||||
onKeyUp={(e) => {
|
||||
e.code === "Enter" && !submitError && onSubmit();
|
||||
e.code === "Enter" &&
|
||||
!submitError &&
|
||||
newFlowName &&
|
||||
onSubmit();
|
||||
}}
|
||||
/>
|
||||
{submitError && (
|
||||
@@ -219,6 +257,12 @@ export default function DropdownTitle() {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
{isOpenNewVersion && (
|
||||
<CreateVersionDialog
|
||||
workflowId={curFlowID!}
|
||||
onClose={() => setIsOpenNewVersion(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function EditFlowName({
|
||||
as="div"
|
||||
color="white"
|
||||
onClick={startEdit}
|
||||
maxW={240}
|
||||
maxW={200}
|
||||
fontWeight={500}
|
||||
whiteSpace="nowrap"
|
||||
overflow="hidden"
|
||||
@@ -105,7 +105,7 @@ export default function EditFlowName({
|
||||
onChange={handleChange}
|
||||
autoFocus
|
||||
onKeyUp={(e) => {
|
||||
e.code === "Enter" && !submitError && onSubmit();
|
||||
e.code === "Enter" && !submitError && editName && onSubmit();
|
||||
}}
|
||||
/>
|
||||
{submitError && (
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function EditFolderNameModal({ folder, onclose }: Props) {
|
||||
onChange={handleChange}
|
||||
autoFocus
|
||||
onKeyUp={(e) => {
|
||||
e.code === "Enter" && !submitError && onSubmit();
|
||||
e.code === "Enter" && !submitError && editName && onSubmit();
|
||||
}}
|
||||
/>
|
||||
{submitError && <FormErrorMessage>{submitError}</FormErrorMessage>}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Image } from "@chakra-ui/react";
|
||||
import { isImageFormat } from "../utils";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
mediaLocalPath: string;
|
||||
size: number;
|
||||
autoPlay?: boolean;
|
||||
onBrokenLink?: () => void;
|
||||
hideBrokenImage?: boolean;
|
||||
};
|
||||
export default function MediaPreview({
|
||||
mediaLocalPath,
|
||||
size,
|
||||
autoPlay,
|
||||
hideBrokenImage,
|
||||
onBrokenLink,
|
||||
}: Props) {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMediaExists = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/workspace/view_media?filename=${mediaLocalPath}`,
|
||||
);
|
||||
if (!response.ok) throw new Error("Media not found");
|
||||
setIsVisible(true);
|
||||
} catch (error) {
|
||||
setIsVisible(false);
|
||||
onBrokenLink?.();
|
||||
}
|
||||
};
|
||||
|
||||
hideBrokenImage && checkMediaExists();
|
||||
}, []);
|
||||
|
||||
if (!isVisible) return null;
|
||||
return isImageFormat(mediaLocalPath) ? (
|
||||
<Image
|
||||
borderRadius={3}
|
||||
boxSize={`${size}px`}
|
||||
objectFit="cover"
|
||||
src={`/workspace/view_media?filename=${mediaLocalPath}`}
|
||||
alt="workflow image renamed or moved from output folder"
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
width={size}
|
||||
height={size}
|
||||
src={`/workspace/view_media?filename=${mediaLocalPath}`}
|
||||
loop={true}
|
||||
autoPlay={autoPlay ?? false}
|
||||
muted={true}
|
||||
>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
@@ -10,100 +9,203 @@ import {
|
||||
Stack,
|
||||
StackDivider,
|
||||
Text,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
useToast,
|
||||
} from "@chakra-ui/react";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import { changelogsTable, workflowsTable } from "../db-tables/WorkspaceDB";
|
||||
import {
|
||||
changelogsTable,
|
||||
workflowsTable,
|
||||
workflowVersionsTable,
|
||||
} from "../db-tables/WorkspaceDB";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { WorkspaceContext } from "../WorkspaceContext";
|
||||
import { formatTimestamp } from "../utils";
|
||||
// @ts-ignore
|
||||
import { app } from "/scripts/app.js";
|
||||
import { Changelog, Workflow } from "../types/dbTypes";
|
||||
import { Changelog, WorkflowVersion } from "../types/dbTypes";
|
||||
import DeleteConfirm from "./DeleteConfirm";
|
||||
|
||||
export function VersionHistoryDrawer({ onClose }: { onClose: () => void }) {
|
||||
const toast = useToast();
|
||||
const { curFlowID, isDirty } = useContext(WorkspaceContext);
|
||||
const [active, setActive] = useState(0); // 0: version、1: changelog
|
||||
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
|
||||
const [curWorkflow, setCurWorkflow] = useState<Workflow>();
|
||||
const [changelogs, setChangelogs] = useState<Changelog[]>([]);
|
||||
const [versions, setVersions] = useState<WorkflowVersion[]>([]);
|
||||
|
||||
const loadChangeLogs = async (flowId: string) => {
|
||||
const workflow = await workflowsTable?.get(flowId);
|
||||
const changelogs = await changelogsTable?.listByWorkflowID(flowId);
|
||||
setChangelogs(changelogs ?? []);
|
||||
const selectedChangelog = changelogs?.filter(
|
||||
(c) => c.json === workflow?.lastSavedJson,
|
||||
);
|
||||
const selectedChangelogID = selectedChangelog?.[0]?.id;
|
||||
selectedChangelogID && setSelectedVersion(selectedChangelogID);
|
||||
};
|
||||
|
||||
const loadVersions = async () => {
|
||||
const vers =
|
||||
(await workflowVersionsTable?.listByWorkflowID(curFlowID!)) ?? [];
|
||||
setVersions(vers);
|
||||
|
||||
const graphJson = JSON.stringify(app.graph.serialize());
|
||||
const selectedVer = vers?.filter((c) => c.json === graphJson);
|
||||
const selectedVerID = selectedVer?.[0]?.id;
|
||||
selectedVerID && setSelectedVersion(selectedVerID);
|
||||
};
|
||||
|
||||
const onDelete = (id: string) => {
|
||||
workflowVersionsTable?.delete(id).then(() => {
|
||||
loadVersions();
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async (flowId: string) => {
|
||||
const workflow = await workflowsTable?.get(flowId);
|
||||
if (!workflow || !flowId) {
|
||||
alert("No current workflow found!");
|
||||
return;
|
||||
}
|
||||
setCurWorkflow(workflow);
|
||||
const changelogs = await changelogsTable?.listByWorkflowID(flowId);
|
||||
setChangelogs(changelogs ?? []);
|
||||
const selectedChangelog = changelogs?.filter(
|
||||
(c) => c.json === workflow?.lastSavedJson,
|
||||
);
|
||||
const selectedChangelogID = selectedChangelog?.[0]?.id;
|
||||
selectedChangelogID && setSelectedVersion(selectedChangelogID);
|
||||
switch (active) {
|
||||
case 0:
|
||||
loadVersions();
|
||||
break;
|
||||
case 1:
|
||||
loadChangeLogs(curFlowID!);
|
||||
break;
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => {
|
||||
app.canvasEl.addEventListener("click", onClose);
|
||||
return () => {
|
||||
app.canvasEl.removeEventListener("click", onClose);
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
loadData(curFlowID ?? "");
|
||||
}, []);
|
||||
|
||||
if (!curFlowID || !curWorkflow) {
|
||||
return null;
|
||||
} else {
|
||||
return (
|
||||
<Card
|
||||
width={400}
|
||||
height={"100vh"}
|
||||
pl={4}
|
||||
pr={5}
|
||||
pt={4}
|
||||
gap={0}
|
||||
position={"fixed"}
|
||||
top={0}
|
||||
left={0}
|
||||
zIndex={1000}
|
||||
>
|
||||
<CardHeader pb={1} mb={0}>
|
||||
<Flex justifyContent={"space-between"} alignItems={"center"}>
|
||||
<Heading size="md">Version History</Heading>
|
||||
<IconButton
|
||||
size={"sm"}
|
||||
icon={<IconX />}
|
||||
onClick={onClose}
|
||||
variant={"ghost"}
|
||||
aria-label="close version history"
|
||||
/>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
<CardBody overflowY={"auto"} gap={0}>
|
||||
<Text mb={4}>All the saved changes of this workflow</Text>
|
||||
<Stack divider={<StackDivider />} spacing={2}>
|
||||
{changelogs?.map((c) => {
|
||||
return (
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"ghost"}
|
||||
onClick={() => {
|
||||
if (isDirty) {
|
||||
alert(
|
||||
"You have unsaved changes, please save or discard your changes to proceed switching version, in case losing your changes.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
app.loadGraphData(JSON.parse(c.json));
|
||||
workflowsTable?.updateFlow(curFlowID, {
|
||||
lastSavedJson: c.json,
|
||||
});
|
||||
onClose();
|
||||
}}
|
||||
isActive={c.id === selectedVersion}
|
||||
>
|
||||
Saved at {formatTimestamp(c.createTime, true)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Card
|
||||
width={400}
|
||||
height={"100vh"}
|
||||
gap={0}
|
||||
position={"fixed"}
|
||||
top={0}
|
||||
left={0}
|
||||
zIndex={1000}
|
||||
>
|
||||
<CardHeader pb={1} mb={0}>
|
||||
<Flex justifyContent={"space-between"} alignItems={"center"}>
|
||||
<Heading size="md">Version History</Heading>
|
||||
<IconButton
|
||||
size={"sm"}
|
||||
icon={<IconX />}
|
||||
onClick={onClose}
|
||||
variant={"ghost"}
|
||||
aria-label="close version history"
|
||||
/>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
<CardBody overflowY={"auto"} gap={0}>
|
||||
<Tabs isFitted variant="enclosed" onChange={setActive}>
|
||||
<TabList mb="1em">
|
||||
<Tab>Versions</Tab>
|
||||
<Tab>Change History</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<Stack divider={<StackDivider />} spacing={2}>
|
||||
{versions?.map((version) => {
|
||||
return (
|
||||
<Flex
|
||||
key={version.id}
|
||||
w={"100%"}
|
||||
mb={1}
|
||||
justify={"space-between"}
|
||||
backgroundColor={
|
||||
version.id === selectedVersion ? "teal.300" : undefined
|
||||
}
|
||||
borderRadius={4}
|
||||
>
|
||||
<Stack textAlign={"left"} gap={0}>
|
||||
<Text fontWeight={"500"}>
|
||||
{version.name ?? "untitled"}
|
||||
</Text>
|
||||
<Text color={"GrayText"} fontSize={"sm"}>
|
||||
Created: {formatTimestamp(version.createTime)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Flex alignItems="center" gap={2}>
|
||||
<Button
|
||||
size={"sm"}
|
||||
onClick={() => {
|
||||
if (isDirty) {
|
||||
alert(
|
||||
"You have unsaved changes, please save or discard your changes to proceed switching version, in case losing your changes.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
app.loadGraphData(JSON.parse(version.json));
|
||||
workflowsTable?.updateFlow(curFlowID!, {
|
||||
lastSavedJson: version.json,
|
||||
json: version.json,
|
||||
});
|
||||
toast({
|
||||
title: `Switched to version "${version.name}"`,
|
||||
status: "success",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Load
|
||||
</Button>
|
||||
<DeleteConfirm
|
||||
promptMessage="Are you sure you want to delete this version?"
|
||||
onDelete={() => {
|
||||
onDelete(version.id);
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<Text mb={4}>All the saved changes of this workflow</Text>
|
||||
<Stack divider={<StackDivider />} spacing={2}>
|
||||
{changelogs?.map((c) => {
|
||||
return (
|
||||
<Button
|
||||
key={c.id}
|
||||
size={"sm"}
|
||||
variant={"ghost"}
|
||||
onClick={() => {
|
||||
if (isDirty) {
|
||||
alert(
|
||||
"You have unsaved changes, please save or discard your changes to proceed switching version, in case losing your changes.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
app.loadGraphData(JSON.parse(c.json));
|
||||
workflowsTable?.updateFlow(curFlowID!, {
|
||||
lastSavedJson: c.json,
|
||||
});
|
||||
onClose();
|
||||
}}
|
||||
isActive={c.id === selectedVersion}
|
||||
>
|
||||
Saved at {formatTimestamp(c.createTime, true)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,3 +2,4 @@ export const LEGACY_COMFYSPACE_TRACKING_FIELD_NAME = "comfyspace_tracking";
|
||||
export const COMFYSPACE_TRACKING_FIELD_NAME = "workspace_info";
|
||||
export const COMFYSPACE_AUTH_ENDPOINT = "comfyspace_auth";
|
||||
export const COMFYSPACE_AUTH_REDIRECT_URL = "http://localhost:8188/";
|
||||
export const DRAWER_Z_INDEX = 9999999;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useCallback, useRef, useState, SetStateAction, Dispatch } from "react";
|
||||
|
||||
const isFunction = <S>(
|
||||
setStateAction: SetStateAction<S>,
|
||||
): setStateAction is (prevState: S) => S =>
|
||||
typeof setStateAction === "function";
|
||||
|
||||
type ReadOnlyRefObject<T> = {
|
||||
readonly current: T;
|
||||
};
|
||||
|
||||
type UseStateRef = {
|
||||
<S>(
|
||||
initialState: S | (() => S),
|
||||
): [S, Dispatch<SetStateAction<S>>, ReadOnlyRefObject<S>];
|
||||
<S = undefined>(): [
|
||||
S | undefined,
|
||||
Dispatch<SetStateAction<S | undefined>>,
|
||||
ReadOnlyRefObject<S | undefined>,
|
||||
];
|
||||
};
|
||||
|
||||
export const useStateRef: UseStateRef = <S>(initialState?: S | (() => S)) => {
|
||||
const [state, setState] = useState(initialState);
|
||||
const ref = useRef(state);
|
||||
|
||||
const dispatch: typeof setState = useCallback((setStateAction: any) => {
|
||||
ref.current = isFunction(setStateAction)
|
||||
? setStateAction(ref.current)
|
||||
: setStateAction;
|
||||
|
||||
setState(ref.current);
|
||||
}, []);
|
||||
|
||||
return [state, dispatch, ref];
|
||||
};
|
||||
@@ -20,21 +20,11 @@ export class ChangelogsTable extends TableBase<Changelog> {
|
||||
.equals(workflowID)
|
||||
.reverse()
|
||||
.sortBy("createTime");
|
||||
if (objects?.length) return objects;
|
||||
|
||||
const records = await this.getRecords();
|
||||
return Object.values(records)
|
||||
.filter((c) => c.workflowID === workflowID)
|
||||
.sort((a, b) => b.createTime - a.createTime);
|
||||
return objects;
|
||||
}
|
||||
public async getLastestByWorkflowID(workflowID: string): Promise<Changelog> {
|
||||
const objects = await this.listByWorkflowID(workflowID);
|
||||
if (objects?.length) return objects[0];
|
||||
const records = await this.getRecords();
|
||||
const all = Object.values(records)
|
||||
.filter((c) => c.workflowID === workflowID)
|
||||
.sort((a, b) => b.createTime - a.createTime);
|
||||
return all[0];
|
||||
return objects[0];
|
||||
}
|
||||
public async create(input: {
|
||||
json: string;
|
||||
|
||||
@@ -23,7 +23,10 @@ export class FoldersTable extends TableBase<Folder> {
|
||||
name: string;
|
||||
parentFolderID?: string;
|
||||
}): Promise<Folder> {
|
||||
const uniqueName = await this.generateUniqueName(input.name);
|
||||
const uniqueName = await this.generateUniqueName(
|
||||
input.name,
|
||||
input.parentFolderID,
|
||||
);
|
||||
const folder: Folder = {
|
||||
id: uuidv4(),
|
||||
name: uniqueName,
|
||||
@@ -46,6 +49,10 @@ export class FoldersTable extends TableBase<Folder> {
|
||||
if (folder == null) {
|
||||
return;
|
||||
}
|
||||
const nameChanged = "name" in input && input.name != folder.name;
|
||||
const parentFolderChanged =
|
||||
"parentFolderID" in input &&
|
||||
input.parentFolderID != folder.parentFolderID;
|
||||
const newRecord = {
|
||||
...folder,
|
||||
...input,
|
||||
@@ -53,11 +60,17 @@ export class FoldersTable extends TableBase<Folder> {
|
||||
if (input.name != null) {
|
||||
newRecord.updateTime = Date.now();
|
||||
}
|
||||
if (parentFolderChanged) {
|
||||
input.name = await this.generateUniqueName(
|
||||
newRecord.name,
|
||||
newRecord.parentFolderID ?? undefined,
|
||||
);
|
||||
}
|
||||
await indexdb.folders.update(input.id, input);
|
||||
this.saveDiskDB();
|
||||
|
||||
// folder moved or renamed - move all workflows to the right directory(not required when folded state changes)
|
||||
if (input.name != null || input.parentFolderID != null) {
|
||||
if (nameChanged || parentFolderChanged) {
|
||||
validateOrSaveAllJsonFileMyWorkflows(true);
|
||||
}
|
||||
}
|
||||
@@ -109,10 +122,10 @@ export class FoldersTable extends TableBase<Folder> {
|
||||
this.saveDiskDB();
|
||||
}
|
||||
|
||||
public async generateUniqueName(name?: string) {
|
||||
public async generateUniqueName(name?: string, parentFolderID?: string) {
|
||||
let newFlowName = name ?? "New folder";
|
||||
const folderNameList = await this.listAll().then((list) =>
|
||||
list.map((f) => f.name),
|
||||
list.filter((f) => f.parentFolderID == parentFolderID).map((f) => f.name),
|
||||
);
|
||||
if (folderNameList.includes(newFlowName)) {
|
||||
let num = 2;
|
||||
|
||||
@@ -15,11 +15,7 @@ export class MediaTable extends TableBase<Media> {
|
||||
.equals(workflowID)
|
||||
.reverse()
|
||||
.sortBy("createTime");
|
||||
if (medias.length > 0) return medias;
|
||||
const records = await this.getRecords();
|
||||
return Object.values(records)
|
||||
.filter((c) => c.workflowID === workflowID)
|
||||
.sort((a, b) => b.createTime - a.createTime);
|
||||
return medias;
|
||||
}
|
||||
|
||||
public async create(input: {
|
||||
|
||||
@@ -3,8 +3,9 @@ import { getWorkspaceIndexDB } from "./IndexDBUtils";
|
||||
import { TableBaseModel } from "../types/dbTypes";
|
||||
import { Table } from "./WorkspaceDB";
|
||||
import { indexdb } from "./indexdb";
|
||||
import { v4 } from "uuid";
|
||||
|
||||
export class TableBase<T> {
|
||||
export class TableBase<T extends TableBaseModel> {
|
||||
public readonly tableName: Table;
|
||||
|
||||
protected constructor(tableName: Table) {
|
||||
@@ -16,16 +17,32 @@ export class TableBase<T> {
|
||||
objs.forEach((f) => {
|
||||
// only tags table is using name as primary key, we either give up backup that table to diskDB
|
||||
// or add a id as primary key to it
|
||||
backup[f.id ?? f.name] = f;
|
||||
if (f.id) {
|
||||
backup[f.id] = f;
|
||||
} else if (f.name) {
|
||||
backup[f.name] = f;
|
||||
} else {
|
||||
console.error("TableBaseModel should have id or name");
|
||||
}
|
||||
});
|
||||
saveDB(this.tableName, JSON.stringify(backup));
|
||||
}
|
||||
|
||||
public async add(newItem: T): Promise<T> {
|
||||
public async add(
|
||||
newItem: Omit<T, "id" | "createTime"> &
|
||||
Partial<Pick<T, "id" | "createTime">>,
|
||||
): Promise<T> {
|
||||
if (!newItem.id) {
|
||||
newItem.id = v4();
|
||||
}
|
||||
if (!newItem.createTime) {
|
||||
newItem.createTime = Date.now();
|
||||
}
|
||||
await indexdb[this.tableName].add(newItem as any);
|
||||
await this.saveDiskDB();
|
||||
return newItem;
|
||||
return newItem as T;
|
||||
}
|
||||
|
||||
public async put(newItem: T): Promise<T> {
|
||||
await indexdb[this.tableName].put(newItem as any);
|
||||
await this.saveDiskDB();
|
||||
@@ -33,12 +50,12 @@ export class TableBase<T> {
|
||||
}
|
||||
public async listAll(): Promise<T[]> {
|
||||
const list = await indexdb[this.tableName].toArray();
|
||||
return list as T[];
|
||||
return list as any[];
|
||||
}
|
||||
|
||||
public async batchQuery(ids: string[]): Promise<T[]> {
|
||||
const list = await indexdb[this.tableName].bulkGet(ids);
|
||||
return list as T[];
|
||||
return list as any[];
|
||||
}
|
||||
|
||||
public async getRecords(): Promise<Record<string, T>> {
|
||||
@@ -62,7 +79,7 @@ export class TableBase<T> {
|
||||
|
||||
public async get(id: string): Promise<T | undefined> {
|
||||
const obj = await indexdb[this.tableName].get(id);
|
||||
return obj as T;
|
||||
return obj as any;
|
||||
}
|
||||
|
||||
public async delete(id: string) {
|
||||
|
||||
@@ -26,12 +26,17 @@ export class UserSettingsTable extends TableBase<UserSettings> {
|
||||
top: 0,
|
||||
left: 0,
|
||||
},
|
||||
modelManagerTopBarStyle: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
},
|
||||
myWorkflowsDir: "",
|
||||
shortcuts: {
|
||||
save: "Shift+S",
|
||||
},
|
||||
autoSave: true,
|
||||
twoWaySync: false,
|
||||
foldersOnTop: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,7 +58,7 @@ export class UserSettingsTable extends TableBase<UserSettings> {
|
||||
};
|
||||
await this.put(newSettings);
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(newPairs, "autoSave") &&
|
||||
Object.hasOwn(newPairs, "autoSave") &&
|
||||
newPairs.autoSave !== undefined
|
||||
) {
|
||||
this._autoSave = newPairs.autoSave;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Table } from "./WorkspaceDB";
|
||||
import { WorkflowVersion } from "../types/dbTypes";
|
||||
import { TableBase } from "./TableBase";
|
||||
import { indexdb } from "./indexdb";
|
||||
|
||||
export class WorkflowVersionsTable extends TableBase<WorkflowVersion> {
|
||||
static readonly TABLE_NAME: Table = "workflowVersions";
|
||||
constructor() {
|
||||
super("workflowVersions");
|
||||
}
|
||||
static async load(): Promise<WorkflowVersionsTable> {
|
||||
const instance = new WorkflowVersionsTable();
|
||||
return instance;
|
||||
}
|
||||
|
||||
public async listByWorkflowID(
|
||||
workflowID: string,
|
||||
): Promise<WorkflowVersion[]> {
|
||||
const objects = (await indexdb[this.tableName]
|
||||
.where("workflowID")
|
||||
.equals(workflowID)
|
||||
.reverse()
|
||||
.sortBy("createTime")) as WorkflowVersion[];
|
||||
return objects ?? [];
|
||||
}
|
||||
|
||||
public async getLatestByWorkflowID(
|
||||
workflowID: string,
|
||||
): Promise<WorkflowVersion | null> {
|
||||
const objects = await this.listByWorkflowID(workflowID);
|
||||
if (objects?.length) return objects[0];
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { foldersTable } from "./WorkspaceDB";
|
||||
import { Workflow } from "../types/dbTypes";
|
||||
import { generateUniqueName, sortFileItem } from "../utils";
|
||||
import { sortFileItem } from "../utils";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { TableBase } from "./TableBase";
|
||||
import { indexdb } from "./indexdb";
|
||||
@@ -46,14 +46,48 @@ export class WorkflowsTable extends TableBase<Workflow> {
|
||||
public async latestVersionCheck() {
|
||||
if (this._curWorkflow) {
|
||||
const curFlowInDB = await this.get(this._curWorkflow.id);
|
||||
return curFlowInDB?.updateTime === this._curWorkflow.updateTime;
|
||||
if (curFlowInDB) {
|
||||
return curFlowInDB?.updateTime === this._curWorkflow.updateTime;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async generateUniqueName(name?: string, parentFolderID?: string) {
|
||||
/**
|
||||
* Generate a unique name
|
||||
* For imported scenes, the default name is the file name.
|
||||
* For new scenes, the default name is Untitled Flow.
|
||||
* Get the full workflow list. If the default name already exists, search incrementally starting from 2.
|
||||
* Looks for a unique name in the format `${default name} ${number}`.
|
||||
*/
|
||||
let newFlowName = name ?? "Untitled Flow";
|
||||
const flowList = (await this.listAll()) ?? [];
|
||||
const flowNameList = flowList
|
||||
.filter((f) => f.parentFolderID == parentFolderID)
|
||||
.map((flow) => flow.name);
|
||||
if (flowNameList.includes(newFlowName)) {
|
||||
let num = 2;
|
||||
let flag = true;
|
||||
while (flag) {
|
||||
if (flowNameList.includes(`${newFlowName} ${num}`)) {
|
||||
num++;
|
||||
} else {
|
||||
newFlowName = `${newFlowName} ${num}`;
|
||||
flag = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return newFlowName;
|
||||
}
|
||||
|
||||
public async createFlow(input: Partial<Workflow>): Promise<Workflow> {
|
||||
const { id, json, name } = input;
|
||||
const newFlowName = await generateUniqueName(name);
|
||||
const newFlowName = await this.generateUniqueName(
|
||||
name,
|
||||
input.parentFolderID,
|
||||
);
|
||||
const uuid = id ?? uuidv4(); // ⇨ '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d'
|
||||
const time = Date.now();
|
||||
const newWorkflow: Workflow = {
|
||||
@@ -113,10 +147,7 @@ export class WorkflowsTable extends TableBase<Workflow> {
|
||||
}
|
||||
await this.saveDiskDB();
|
||||
// save to my_workflows/
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(input, "name") ||
|
||||
Object.prototype.hasOwnProperty.call(input, "parentFolderID")
|
||||
) {
|
||||
if ("name" in input || "parentFolderID" in input) {
|
||||
// renamed file or moved file folder
|
||||
await deleteJsonFileMyWorkflows(before);
|
||||
await saveJsonFileMyWorkflows(after);
|
||||
@@ -146,7 +177,7 @@ export class WorkflowsTable extends TableBase<Workflow> {
|
||||
const newFlowName =
|
||||
flow.name && isOverwriteExistingFile
|
||||
? flow.name
|
||||
: await generateUniqueName(flow.name);
|
||||
: await this.generateUniqueName(flow.name, parentFolderID);
|
||||
const uuid = uuidv4();
|
||||
const time = Date.now();
|
||||
const newWorkflow: Workflow = {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { UserSettingsTable } from "./UserSettingsTable";
|
||||
import { TagsTable } from "./tagsTable";
|
||||
import { indexdb } from "./indexdb";
|
||||
import { Folder, Workflow } from "../types/dbTypes";
|
||||
import { WorkflowVersionsTable } from "./WorkflowVersionsTable";
|
||||
|
||||
export type Table =
|
||||
| "workflows"
|
||||
@@ -14,11 +15,11 @@ export type Table =
|
||||
| "folders"
|
||||
| "changelogs"
|
||||
| "media"
|
||||
| "models";
|
||||
| "models"
|
||||
| "workflowVersions";
|
||||
|
||||
export function isFolder(n: Folder | Workflow): n is Folder {
|
||||
// @ts-ignore
|
||||
return n.type === "folder";
|
||||
return "type" in n && n.type === "folder";
|
||||
}
|
||||
|
||||
export let workflowsTable: WorkflowsTable | null = null;
|
||||
@@ -27,6 +28,7 @@ export let userSettingsTable: UserSettingsTable | null = null;
|
||||
export let foldersTable: FoldersTable | null = null;
|
||||
export let changelogsTable: ChangelogsTable | null = null;
|
||||
export let mediaTable: MediaTable | null = null;
|
||||
export let workflowVersionsTable: WorkflowVersionsTable | null = null;
|
||||
|
||||
export async function loadDBs() {
|
||||
const loadWorkflows = async () => {
|
||||
@@ -47,6 +49,9 @@ export async function loadDBs() {
|
||||
const loadMedia = async () => {
|
||||
mediaTable = new MediaTable();
|
||||
};
|
||||
const loadWorkflowVersions = async () => {
|
||||
workflowVersionsTable = await WorkflowVersionsTable.load();
|
||||
};
|
||||
await Promise.all([
|
||||
loadWorkflows(),
|
||||
loadTags(),
|
||||
@@ -54,6 +59,7 @@ export async function loadDBs() {
|
||||
loadFolders(),
|
||||
loadChangelogs(),
|
||||
loadMedia(),
|
||||
loadWorkflowVersions(),
|
||||
]);
|
||||
if (localStorage.getItem("WORKSPACE_INDEXDB_BACKFILL") !== "true") {
|
||||
await backfillIndexdb();
|
||||
@@ -120,6 +126,14 @@ export async function backfillIndexdb() {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
const backfillWorkflowVersions = async () => {
|
||||
try {
|
||||
const all = await workflowVersionsTable?.getRecords();
|
||||
all && (await indexdb.workflowVersions.bulkAdd(Object.values(all)));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
await Promise.all([
|
||||
backfillWorkflows(),
|
||||
backfillFolders(),
|
||||
@@ -127,5 +141,6 @@ export async function backfillIndexdb() {
|
||||
backfillChangelogs(),
|
||||
backfillTags(),
|
||||
backfillUserSettings(),
|
||||
backfillWorkflowVersions(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Tag,
|
||||
UserSettings,
|
||||
WORKSPACE_INDEXDB_NAME,
|
||||
WorkflowVersion,
|
||||
} from "../types/dbTypes";
|
||||
|
||||
class ManagerDB extends Dexie {
|
||||
@@ -21,6 +22,7 @@ class ManagerDB extends Dexie {
|
||||
userSettings!: Table<UserSettings, string>;
|
||||
models!: Table<Model, string>;
|
||||
cache!: Table<LocalCache, string>;
|
||||
workflowVersions!: Table<WorkflowVersion, string>;
|
||||
|
||||
constructor() {
|
||||
super(WORKSPACE_INDEXDB_NAME);
|
||||
@@ -38,6 +40,21 @@ class ManagerDB extends Dexie {
|
||||
.upgrade((trans) => {
|
||||
// Here you can write logic to initialize or migrate data to the new 'media' table, if necessary
|
||||
});
|
||||
this.version(2)
|
||||
.stores({
|
||||
workflows: "&id, name, parentFolderID", // Primary key and indexed props
|
||||
changelogs: "&id, workflowID",
|
||||
media: "&id, workflowID",
|
||||
folders: "&id, name, parentFolderID",
|
||||
tags: "&name",
|
||||
userSettings: "&id",
|
||||
models: "&id, fileName, fileHash",
|
||||
cache: "&id",
|
||||
workflowVersions: "&id, name, workflowID",
|
||||
})
|
||||
.upgrade((trans) => {
|
||||
// Here you can write logic to initialize or migrate data to the new 'media' table, if necessary
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+14
-165
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
Button,
|
||||
HStack,
|
||||
Text,
|
||||
Modal,
|
||||
@@ -8,38 +7,25 @@ import {
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Image,
|
||||
Link,
|
||||
Stack,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Heading,
|
||||
Checkbox,
|
||||
} from "@chakra-ui/react";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { mediaTable, workflowsTable } from "../db-tables/WorkspaceDB";
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconDownload,
|
||||
IconPin,
|
||||
IconPinFilled,
|
||||
IconTrash,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { IconArrowLeft, IconX } from "@tabler/icons-react";
|
||||
import { WorkspaceContext } from "../WorkspaceContext";
|
||||
import { formatTimestamp, isImageFormat } from "../utils";
|
||||
import { useDialog } from "../components/AlertDialogProvider";
|
||||
import { Media } from "../types/dbTypes";
|
||||
import { MetaDataInfo } from "./components/MetaDataInfo.tsx";
|
||||
const IMAGE_SIZE = 200;
|
||||
import MediaPreview from "./components/GalleryMediaItem.tsx";
|
||||
|
||||
export default function GalleryModal({ onclose }: { onclose: () => void }) {
|
||||
const { curFlowID, loadFilePath } = useContext(WorkspaceContext);
|
||||
const { curFlowID } = useContext(WorkspaceContext);
|
||||
const [workflowName, setWorkflowName] = useState("");
|
||||
const [selectedID, setSelectedID] = useState<string[]>([]);
|
||||
const [isSelecting, setIsSelecting] = useState(false);
|
||||
const [coverPath, setCoverPath] = useState("");
|
||||
const [images, setImages] = useState<Media[]>([]);
|
||||
const { showDialog } = useDialog();
|
||||
const [metaData, setMetaData] = useState<Media>();
|
||||
|
||||
const loadData = async () => {
|
||||
@@ -63,9 +49,6 @@ export default function GalleryModal({ onclose }: { onclose: () => void }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onRefreshImagesList = () => {
|
||||
loadData();
|
||||
};
|
||||
const onClickMedia = (media: Media) => {
|
||||
if (isSelecting) {
|
||||
if (selectedID.includes(media.id)) {
|
||||
@@ -139,151 +122,17 @@ export default function GalleryModal({ onclose }: { onclose: () => void }) {
|
||||
{!metaData ? (
|
||||
<HStack wrap={"wrap"}>
|
||||
{images.map((media) => {
|
||||
if (media.localPath == null) {
|
||||
return null;
|
||||
}
|
||||
const mediaPreview = isImageFormat(media.localPath) ? (
|
||||
<Link
|
||||
isExternal
|
||||
onClick={() => onClickMedia(media)}
|
||||
position={"relative"}
|
||||
>
|
||||
{isSelecting && (
|
||||
<Checkbox
|
||||
isChecked={selectedID.includes(media.id)}
|
||||
position={"absolute"}
|
||||
top={2}
|
||||
left={2}
|
||||
/>
|
||||
)}
|
||||
<Image
|
||||
borderRadius={3}
|
||||
boxSize={IMAGE_SIZE + "px"}
|
||||
objectFit="cover"
|
||||
src={`/workspace/view_media?filename=${media.localPath}`}
|
||||
alt={"workflow image renamed or moved from output folder"}
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<Link isExternal onClick={() => onClickMedia(media)}>
|
||||
{isSelecting && (
|
||||
<Checkbox isChecked={selectedID.includes(media.id)} />
|
||||
)}
|
||||
<video
|
||||
width={IMAGE_SIZE}
|
||||
height={IMAGE_SIZE}
|
||||
src={`/workspace/view_media?filename=${media.localPath}`}
|
||||
loop={true}
|
||||
autoPlay={true}
|
||||
muted={true}
|
||||
>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
</Link>
|
||||
);
|
||||
const isCover =
|
||||
coverPath != null && coverPath === media.localPath;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
width={IMAGE_SIZE}
|
||||
justifyContent={"space-between"}
|
||||
mb={2}
|
||||
>
|
||||
<Tooltip label={formatTimestamp(media.createTime, true)}>
|
||||
{mediaPreview}
|
||||
</Tooltip>
|
||||
<Tooltip label={media.localPath}>
|
||||
<Text color={"GrayText"} noOfLines={1}>
|
||||
{media.localPath}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<HStack
|
||||
justifyContent={"space-between"}
|
||||
hidden={isSelecting}
|
||||
>
|
||||
<Tooltip label="Set as cover">
|
||||
<IconButton
|
||||
size={"sm"}
|
||||
variant={"ghost"}
|
||||
icon={
|
||||
isCover ? (
|
||||
<IconPinFilled size={19} />
|
||||
) : (
|
||||
<IconPin size={19} />
|
||||
)
|
||||
}
|
||||
aria-label="set as cover"
|
||||
isActive={isCover}
|
||||
onClick={() => {
|
||||
workflowsTable?.updateFlow(curFlowID, {
|
||||
coverMediaPath: media.localPath,
|
||||
});
|
||||
setCoverPath(media.localPath);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Button
|
||||
flexGrow={1}
|
||||
onClick={() =>
|
||||
showDialog("How do you want to load this workflow?", [
|
||||
{
|
||||
label: "Load in new workflow",
|
||||
onClick: () => {
|
||||
loadFilePath(media.localPath);
|
||||
},
|
||||
colorScheme: "teal",
|
||||
},
|
||||
{
|
||||
label: "Overwrite current workflow",
|
||||
onClick: () =>
|
||||
loadFilePath(media.localPath, true),
|
||||
colorScheme: "red",
|
||||
},
|
||||
])
|
||||
}
|
||||
size={"sm"}
|
||||
>
|
||||
Load
|
||||
</Button>
|
||||
<Tooltip label="Donwload image from gallery">
|
||||
<Link
|
||||
href={`/workspace/view_media?filename=${media.localPath}`}
|
||||
download={media.localPath}
|
||||
>
|
||||
<IconButton
|
||||
size={"sm"}
|
||||
variant={"ghost"}
|
||||
icon={<IconDownload size={19} />}
|
||||
aria-label="donwload image from gallery"
|
||||
/>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
<Tooltip label="Remove image from gallery">
|
||||
<IconButton
|
||||
size={"sm"}
|
||||
variant={"ghost"}
|
||||
icon={<IconTrash size={19} />}
|
||||
aria-label="remove image from gallery"
|
||||
colorScheme="red"
|
||||
onClick={async () => {
|
||||
const res = confirm(
|
||||
"Are you sure to remove this image from gallery? (won't delete image on your disk)",
|
||||
);
|
||||
if (!res) return;
|
||||
await mediaTable?.delete(media.id);
|
||||
if (isCover) {
|
||||
await workflowsTable?.updateFlow(curFlowID, {
|
||||
coverMediaPath: undefined,
|
||||
});
|
||||
setCoverPath("");
|
||||
}
|
||||
onRefreshImagesList();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</Stack>
|
||||
<MediaPreview
|
||||
key={media.id}
|
||||
selectedID={selectedID}
|
||||
media={media}
|
||||
isSelecting={isSelecting}
|
||||
onClickMedia={onClickMedia}
|
||||
onRefreshImagesList={loadData}
|
||||
coverPath={coverPath}
|
||||
setCoverPath={setCoverPath}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</HStack>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import {
|
||||
Link,
|
||||
Checkbox,
|
||||
Stack,
|
||||
HStack,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Text,
|
||||
Button,
|
||||
} from "@chakra-ui/react";
|
||||
import { formatTimestamp } from "../../utils";
|
||||
import { Media } from "../../types/dbTypes";
|
||||
import {
|
||||
IconDownload,
|
||||
IconPin,
|
||||
IconPinFilled,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { mediaTable, workflowsTable } from "../../db-tables/WorkspaceDB";
|
||||
import { WorkspaceContext } from "../../WorkspaceContext";
|
||||
import { useDialog } from "../../components/AlertDialogProvider";
|
||||
import MediaPreview from "../../components/MediaPreview";
|
||||
|
||||
interface MediaPreviewProps {
|
||||
media: Media;
|
||||
isSelecting: boolean;
|
||||
selectedID: string[];
|
||||
onClickMedia: (media: Media) => void;
|
||||
onRefreshImagesList: () => void;
|
||||
coverPath: string;
|
||||
setCoverPath: (path: string) => void;
|
||||
}
|
||||
|
||||
const GalleryMediaItem: React.FC<MediaPreviewProps> = ({
|
||||
media,
|
||||
isSelecting,
|
||||
selectedID,
|
||||
onClickMedia,
|
||||
onRefreshImagesList,
|
||||
coverPath,
|
||||
setCoverPath,
|
||||
}) => {
|
||||
const IMAGE_SIZE = 180; // Define your image size
|
||||
const { curFlowID, loadFilePath } = useContext(WorkspaceContext);
|
||||
const { showDialog } = useDialog();
|
||||
if (media.localPath == null) {
|
||||
return null;
|
||||
}
|
||||
const isCover = coverPath != null && coverPath === media.localPath;
|
||||
if (curFlowID == null) {
|
||||
return null;
|
||||
}
|
||||
const mediaPreview = (
|
||||
<Link isExternal onClick={() => onClickMedia(media)} position="relative">
|
||||
{isSelecting && (
|
||||
<Checkbox
|
||||
isChecked={selectedID.includes(media.id)}
|
||||
position="absolute"
|
||||
top={2}
|
||||
left={2}
|
||||
/>
|
||||
)}
|
||||
<MediaPreview
|
||||
mediaLocalPath={media.localPath}
|
||||
size={IMAGE_SIZE}
|
||||
onBrokenLink={() => {
|
||||
// setIsVisible(false);
|
||||
}}
|
||||
autoPlay={true}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack width={IMAGE_SIZE} mb={2}>
|
||||
<Tooltip label={formatTimestamp(media.createTime, true)}>
|
||||
{mediaPreview}
|
||||
</Tooltip>
|
||||
<Tooltip label={media.localPath}>
|
||||
<Text color={"GrayText"} noOfLines={1}>
|
||||
{media.localPath}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<HStack hidden={isSelecting} gap={0}>
|
||||
<Tooltip label="Set as cover">
|
||||
<IconButton
|
||||
size={"sm"}
|
||||
variant={"ghost"}
|
||||
icon={isCover ? <IconPinFilled size={19} /> : <IconPin size={19} />}
|
||||
aria-label="set as cover"
|
||||
onClick={() => {
|
||||
workflowsTable?.updateFlow(curFlowID, {
|
||||
coverMediaPath: media.localPath,
|
||||
});
|
||||
setCoverPath(media.localPath);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Button
|
||||
flexGrow={1}
|
||||
onClick={() =>
|
||||
showDialog("How do you want to load this workflow?", [
|
||||
{
|
||||
label: "Load in new workflow",
|
||||
onClick: () => {
|
||||
loadFilePath(media.localPath);
|
||||
},
|
||||
colorScheme: "teal",
|
||||
},
|
||||
{
|
||||
label: "Overwrite current workflow",
|
||||
onClick: () => loadFilePath(media.localPath, true),
|
||||
colorScheme: "red",
|
||||
},
|
||||
])
|
||||
}
|
||||
size={"sm"}
|
||||
>
|
||||
Load
|
||||
</Button>
|
||||
<Tooltip label="Donwload image from gallery">
|
||||
<Link
|
||||
href={`/workspace/view_media?filename=${media.localPath}`}
|
||||
download={media.localPath}
|
||||
>
|
||||
<IconButton
|
||||
size={"sm"}
|
||||
variant={"ghost"}
|
||||
icon={<IconDownload size={19} />}
|
||||
aria-label="donwload image from gallery"
|
||||
/>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
<Tooltip label="Remove image from gallery">
|
||||
<IconButton
|
||||
size={"sm"}
|
||||
variant={"ghost"}
|
||||
icon={<IconTrash size={19} />}
|
||||
aria-label="remove image from gallery"
|
||||
colorScheme="red"
|
||||
onClick={async () => {
|
||||
const res = confirm(
|
||||
"Are you sure to remove this image from gallery? (won't delete image on your disk)",
|
||||
);
|
||||
if (!res) return;
|
||||
await mediaTable?.delete(media.id);
|
||||
if (isCover) {
|
||||
await workflowsTable?.updateFlow(curFlowID, {
|
||||
coverMediaPath: undefined,
|
||||
});
|
||||
setCoverPath("");
|
||||
}
|
||||
onRefreshImagesList();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default GalleryMediaItem;
|
||||
+41
-40
@@ -1,54 +1,55 @@
|
||||
/* eslint-disable */
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck
|
||||
import {getPngMetadata, getVideoMetadata, getWebpMetadata, isVideoName} from '../utils.tsx'
|
||||
import {FC} from 'react'
|
||||
import {Media} from '../types/dbTypes.ts'
|
||||
import {
|
||||
getPngMetadata,
|
||||
getVideoMetadata,
|
||||
getWebpMetadata,
|
||||
isVideoName,
|
||||
} from "../utils/mediaMetadataUtils.ts";
|
||||
import { FC } from "react";
|
||||
import { Media } from "../types/dbTypes.ts";
|
||||
|
||||
export type MetaData<T = any> = {prompt: T, workflow: T}
|
||||
export type MetaData<T = any> = { prompt: T; workflow: T };
|
||||
|
||||
export type MetaBoxTypeCom = FC<{metaData: MetaData, media: Media}>
|
||||
export type MetaBoxTypeCom = FC<{ metaData: MetaData; media: Media }>;
|
||||
|
||||
export function getMetadataFromUrl(url: string) {
|
||||
const extension = url.split(".").pop();
|
||||
return fetch(url)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
return response.blob();
|
||||
})
|
||||
.then(async blob => {
|
||||
const fileName = url.split("/").pop() ?? '';
|
||||
const fileObj = new File([blob], fileName);
|
||||
if (isVideoName(url)) {
|
||||
return getVideoMetadata(fileObj) as Promise<MetaData>
|
||||
}
|
||||
if (extension === 'webp') {
|
||||
return getWebpMetadata(fileObj) as Promise<MetaData>
|
||||
}
|
||||
const metaData = await getPngMetadata(fileObj) as MetaData<string>;
|
||||
const prompt = JSON.parse(metaData?.prompt)
|
||||
const workflow = JSON.parse(metaData?.workflow)
|
||||
return {
|
||||
prompt,
|
||||
workflow,
|
||||
} as MetaData
|
||||
});
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
return response.blob();
|
||||
})
|
||||
.then(async (blob) => {
|
||||
const fileName = url.split("/").pop() ?? "";
|
||||
const fileObj = new File([blob], fileName);
|
||||
if (isVideoName(url)) {
|
||||
return getVideoMetadata(fileObj) as Promise<MetaData>;
|
||||
}
|
||||
if (extension === "webp") {
|
||||
return getWebpMetadata(fileObj) as Promise<MetaData>;
|
||||
}
|
||||
const metaData = (await getPngMetadata(fileObj)) as MetaData<string>;
|
||||
const prompt = JSON.parse(metaData?.prompt);
|
||||
const workflow = JSON.parse(metaData?.workflow);
|
||||
return {
|
||||
prompt,
|
||||
workflow,
|
||||
} as MetaData;
|
||||
});
|
||||
}
|
||||
|
||||
export const ifString = (t: any) => {
|
||||
if (typeof t === 'string') {
|
||||
return t
|
||||
if (typeof t === "string") {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function clipboard(text: string) {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(function () {
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error('Unable to copy to clipboard: ', err);
|
||||
});
|
||||
.writeText(text)
|
||||
.then(function () {})
|
||||
.catch(function (err) {
|
||||
console.error("Unable to copy to clipboard: ", err);
|
||||
});
|
||||
}
|
||||
|
||||
+28
-3
@@ -1,7 +1,7 @@
|
||||
import React, { Suspense } from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { ColorModeScript } from "@chakra-ui/react";
|
||||
import { ChakraProvider } from "@chakra-ui/react";
|
||||
import { ChakraProvider, extendTheme } from "@chakra-ui/react";
|
||||
import { AlertDialogProvider } from "./components/AlertDialogProvider.tsx";
|
||||
import CSSReset from "./MyCSSReset.tsx";
|
||||
|
||||
@@ -13,11 +13,36 @@ const App = React.lazy(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
/**
|
||||
* Since the z-index of other plug-ins is set to 1111111,
|
||||
* part of the content of our Drawer component is covered,
|
||||
* so the z-index is adjusted uniformly and multiplied by 10000 based on the default value.
|
||||
*/
|
||||
const zIndices = {
|
||||
hide: -1,
|
||||
auto: "auto",
|
||||
base: 0,
|
||||
docked: 10,
|
||||
dropdown: 10000000,
|
||||
sticky: 11000000,
|
||||
banner: 12000000,
|
||||
overlay: 13000000,
|
||||
modal: 14000000,
|
||||
popover: 15000000,
|
||||
skipLink: 16000000,
|
||||
toast: 17000000,
|
||||
tooltip: 18000000,
|
||||
};
|
||||
|
||||
const theme = extendTheme({
|
||||
zIndices,
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(topbar).render(
|
||||
<React.StrictMode>
|
||||
<ChakraProvider resetCSS={false} disableGlobalStyle={true}>
|
||||
<ChakraProvider resetCSS={false} disableGlobalStyle={true} theme={theme}>
|
||||
<CSSReset scope=".workspace_manager" />
|
||||
<ColorModeScript />
|
||||
<ColorModeScript initialColorMode="dark" />
|
||||
<AlertDialogProvider>
|
||||
<Suspense>
|
||||
<App />
|
||||
|
||||
@@ -5,6 +5,7 @@ export type InstallModelsApiInput = {
|
||||
save_path: string;
|
||||
url: string;
|
||||
filename: string;
|
||||
file_hash?: string;
|
||||
name: string;
|
||||
};
|
||||
let cancelInstall = false;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Button,
|
||||
Input,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@chakra-ui/react";
|
||||
import { useState } from "react";
|
||||
import { setCivitApiKey } from "../../utils/civitUtils";
|
||||
@@ -31,13 +32,17 @@ export default function AddApiKeyPopover() {
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<Button size={"sm"} py={1} mr={8}>
|
||||
Set API Key
|
||||
Set Civitai API Key
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent p={5}>
|
||||
<PopoverArrow />
|
||||
<PopoverCloseButton />
|
||||
<Stack spacing={4}>
|
||||
<Text fontSize={14}>
|
||||
Some Civitai.com models require user login to download, you will
|
||||
nedd a Civitai API key to download in that case
|
||||
</Text>
|
||||
<Input
|
||||
value={apiKeyInput}
|
||||
onChange={(e) => setApiKeyInput(e.target.value)}
|
||||
|
||||
@@ -9,17 +9,21 @@ import {
|
||||
Select,
|
||||
Text,
|
||||
Input,
|
||||
Stack,
|
||||
} from "@chakra-ui/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { getAllFoldersList } from "../../Api";
|
||||
import { CivitiModelFileVersion } from "../types";
|
||||
|
||||
interface ChooseFolderProps {
|
||||
isOpen: boolean;
|
||||
file?: CivitiModelFileVersion;
|
||||
onClose: () => void;
|
||||
selectFolder: (folderPath: string, url: string) => void;
|
||||
}
|
||||
export default function ChooseFolder({
|
||||
isOpen,
|
||||
file,
|
||||
onClose,
|
||||
selectFolder,
|
||||
}: ChooseFolderProps) {
|
||||
@@ -57,23 +61,30 @@ export default function ChooseFolder({
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogBody>
|
||||
<Text>Model download url</Text>
|
||||
<Input
|
||||
placeholder="https://civitai.com/api/download/models/311399"
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
value={url}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Select option"
|
||||
value={folderPath}
|
||||
onChange={(e) => setFolderPath(e.target.value)}
|
||||
>
|
||||
{foldersList.map((folderPath) => (
|
||||
<option key={folderPath} value={folderPath}>
|
||||
{folderPath}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Stack spacing={4}>
|
||||
{file?.downloadUrl || (
|
||||
<>
|
||||
<Text>Model download url</Text>
|
||||
<Input
|
||||
placeholder="https://civitai.com/api/download/models/311399"
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
value={url}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Text>Choose model install folder</Text>
|
||||
<Select
|
||||
placeholder="Select option"
|
||||
value={folderPath}
|
||||
onChange={(e) => setFolderPath(e.target.value)}
|
||||
>
|
||||
{foldersList.map((folderPath) => (
|
||||
<option key={folderPath} value={folderPath}>
|
||||
{folderPath}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Stack>
|
||||
</AlertDialogBody>
|
||||
|
||||
<AlertDialogFooter>
|
||||
|
||||
@@ -22,6 +22,7 @@ import InstallProgress from "./InstallProgress";
|
||||
import { indexdb } from "../../db-tables/indexdb";
|
||||
import AddApiKeyPopover from "./AddApiKeyPopover";
|
||||
import { getCivitApiKey } from "../../utils/civitUtils";
|
||||
import { useStateRef } from "../../customHooks/useStateRef";
|
||||
|
||||
type CivitModelQueryParams = {
|
||||
types?: MODEL_TYPE;
|
||||
@@ -64,7 +65,7 @@ interface Props {
|
||||
modelType?: MODEL_TYPE;
|
||||
}
|
||||
export default function InatallModelsModal({
|
||||
onclose,
|
||||
onclose: onCloseInstallModelsModal,
|
||||
searchQuery: searchQueryProp = "",
|
||||
modelType: modelTypeProp,
|
||||
}: Props) {
|
||||
@@ -74,8 +75,8 @@ export default function InatallModelsModal({
|
||||
const toast = useToast();
|
||||
const [installing, setInstalling] = useState<string[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState(searchQueryProp);
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const file = useRef<CivitiModelFileVersion>();
|
||||
const { isOpen, onOpen, onClose: onCloseChooseFolderModal } = useDisclosure();
|
||||
const [fileState, setFile, file] = useStateRef<CivitiModelFileVersion>();
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const params: CivitModelQueryParams = {
|
||||
@@ -153,34 +154,36 @@ export default function InatallModelsModal({
|
||||
installModelsApi({
|
||||
filename: file.current.name,
|
||||
name: file.current.name,
|
||||
file_hash: file.current.hashes?.SHA256,
|
||||
save_path: folderPath,
|
||||
url,
|
||||
});
|
||||
onClose();
|
||||
setFile(undefined);
|
||||
onCloseChooseFolderModal();
|
||||
};
|
||||
const onClickInstallModel = (
|
||||
_file: CivitiModelFileVersion,
|
||||
model: CivitiModel,
|
||||
) => {
|
||||
const folderPath: string | null =
|
||||
MODEL_TYPE_TO_FOLDER_MAPPING[model.type as MODEL_TYPE];
|
||||
file.current = _file;
|
||||
const folderPath = MODEL_TYPE_TO_FOLDER_MAPPING[model.type as MODEL_TYPE];
|
||||
setFile(_file);
|
||||
if (folderPath == null) {
|
||||
onOpen();
|
||||
} else {
|
||||
downloadModels(folderPath);
|
||||
}
|
||||
};
|
||||
const customUrlDownload = () => {
|
||||
onOpen();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [modelType]);
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={true} onClose={onclose} blockScrollOnMount={true}>
|
||||
<Modal
|
||||
isOpen={true}
|
||||
onClose={onCloseInstallModelsModal}
|
||||
blockScrollOnMount={true}
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent width={"90%"} maxWidth={"90vw"} height={"90vh"}>
|
||||
<ModalHeader>
|
||||
@@ -193,7 +196,7 @@ export default function InatallModelsModal({
|
||||
setSearchQuery={setSearchQuery}
|
||||
onSearch={loadData}
|
||||
/>
|
||||
<Button size={"sm"} py={1} mr={8} onClick={customUrlDownload}>
|
||||
<Button size={"sm"} py={1} mr={8} onClick={onOpen}>
|
||||
Custom URL Install
|
||||
</Button>
|
||||
<AddApiKeyPopover />
|
||||
@@ -253,10 +256,11 @@ export default function InatallModelsModal({
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<ChooseFolder
|
||||
file={fileState}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
selectFolder={(folderPath: string, customUrl: string) => {
|
||||
file.current = { id: 0, downloadUrl: customUrl };
|
||||
onClose={onCloseChooseFolderModal}
|
||||
selectFolder={(folderPath: string, customUrl?: string) => {
|
||||
if (!file.current) setFile({ id: 0, downloadUrl: customUrl });
|
||||
downloadModels(folderPath);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useEffect } from "react";
|
||||
import { app } from "/scripts/app.js";
|
||||
import InstallModelsButton from "../install-models/InstallModelsButton";
|
||||
import MissingModelItem from "./MissingModelItem";
|
||||
import { DRAWER_Z_INDEX } from "../../const";
|
||||
|
||||
export interface MissingModel {
|
||||
class_type: string;
|
||||
@@ -49,7 +50,7 @@ export default function MissingModelsListDrawer({
|
||||
top={0}
|
||||
left={0}
|
||||
shadow={"xl"}
|
||||
zIndex={1000}
|
||||
zIndex={DRAWER_Z_INDEX}
|
||||
overflowY={"auto"}
|
||||
>
|
||||
<CardHeader>
|
||||
|
||||
@@ -4,8 +4,26 @@ import { useEffect, useState } from "react";
|
||||
import { indexdb } from "../../db-tables/indexdb";
|
||||
import { Model } from "../../types/dbTypes";
|
||||
import type { DragEvent } from "react";
|
||||
// @ts-ignore
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
import { userSettingsTable } from "../../db-tables/WorkspaceDB";
|
||||
|
||||
interface ResponsePartial {
|
||||
id: number;
|
||||
modelId: number;
|
||||
name: string;
|
||||
model: {
|
||||
name: string;
|
||||
type: string;
|
||||
nsfw: boolean;
|
||||
};
|
||||
images: {
|
||||
url: string;
|
||||
nsfw: "None" | "Soft" | "Mature" | "X";
|
||||
width: number;
|
||||
height: number;
|
||||
hash: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: ModelsListRespItem;
|
||||
@@ -42,9 +60,17 @@ export function ModelItem({ data }: Props) {
|
||||
try {
|
||||
const url = `https://civitai.com/api/v1/model-versions/by-hash/${data.file_hash}`;
|
||||
const resp = await fetch(url);
|
||||
const json = await resp.json();
|
||||
const image = json?.images?.at(0);
|
||||
const image_url = image?.url;
|
||||
const json: ResponsePartial = await resp.json();
|
||||
let image_url: string | undefined;
|
||||
const showNsfwThumbnail = await userSettingsTable?.getSetting(
|
||||
"showNsfwModelThumbnail",
|
||||
);
|
||||
if (showNsfwThumbnail === true) {
|
||||
image_url = json?.images?.[0]?.url;
|
||||
} else if (!json.model.nsfw) {
|
||||
const sfwImage = json.images.find((i) => i.nsfw === "None");
|
||||
image_url = sfwImage?.url;
|
||||
}
|
||||
image_url && setUrl(image_url);
|
||||
|
||||
indexdb.models.add({
|
||||
@@ -52,8 +78,8 @@ export function ModelItem({ data }: Props) {
|
||||
fileHash: data.file_hash,
|
||||
fileFolder: data.model_type,
|
||||
fileName: data.model_name,
|
||||
civitModelID: json.modelId,
|
||||
civitModelVersionID: json.id,
|
||||
civitModelID: String(json.modelId),
|
||||
civitModelVersionID: String(json.id),
|
||||
imageUrl: image_url ?? null,
|
||||
});
|
||||
} catch (e) {}
|
||||
@@ -83,7 +109,7 @@ export function ModelItem({ data }: Props) {
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<Spinner />
|
||||
{hashing && <Spinner />}
|
||||
<Text
|
||||
position="absolute"
|
||||
bottom="0"
|
||||
@@ -117,6 +143,7 @@ export function ModelItem({ data }: Props) {
|
||||
height={178}
|
||||
objectFit="cover"
|
||||
borderRadius={4}
|
||||
cursor={model?.civitModelID != null ? "pointer" : "auto"}
|
||||
onClick={() => {
|
||||
if (
|
||||
model?.civitModelID == null ||
|
||||
@@ -138,7 +165,7 @@ export function ModelItem({ data }: Props) {
|
||||
color="white"
|
||||
textAlign="center"
|
||||
p="0"
|
||||
fontSize={12}
|
||||
fontSize={14}
|
||||
borderBottomRightRadius={4}
|
||||
borderBottomLeftRadius={4}
|
||||
>
|
||||
|
||||
@@ -5,22 +5,27 @@ import {
|
||||
Flex,
|
||||
Heading,
|
||||
Spinner,
|
||||
Text,
|
||||
Portal,
|
||||
Input,
|
||||
} from "@chakra-ui/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ModelsTags } from "./ModelsTags";
|
||||
import { ModelsList } from "./ModelsList";
|
||||
// @ts-ignore
|
||||
// @ts-expect-error ComfyUI imports
|
||||
import { app } from "/scripts/app.js";
|
||||
import InstallModelsButton from "../install-models/InstallModelsButton";
|
||||
import { ModelsListRespItem } from "../types";
|
||||
import { useUpdateModels } from "../hooks/useUpdateModels";
|
||||
import { DRAWER_Z_INDEX } from "../../const";
|
||||
import ShowNsfwModelThumbnailSettings from "../../settings/ShowNsfwModelThumbnailSettings";
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ModelsListDrawer({ onClose }: Props) {
|
||||
const [selectedModel, setSelectedModel] = useState("checkpoints");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const { loading, modelTypeList, modelsList } = useUpdateModels();
|
||||
|
||||
@@ -30,10 +35,15 @@ export default function ModelsListDrawer({ onClose }: Props) {
|
||||
// filter by model type
|
||||
useEffect(() => {
|
||||
const res = modelsList.filter((item) => {
|
||||
if (searchQuery.length) {
|
||||
return item.model_name
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase());
|
||||
}
|
||||
return item.model_type === selectedModel;
|
||||
});
|
||||
setCurModelList(res);
|
||||
}, [selectedModel, modelsList]);
|
||||
}, [selectedModel, modelsList, searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
app.canvasEl.addEventListener("click", onClose);
|
||||
@@ -47,6 +57,15 @@ export default function ModelsListDrawer({ onClose }: Props) {
|
||||
const renderContent = () => {
|
||||
return (
|
||||
<>
|
||||
<Flex gap={4} justifyContent={"center"} alignItems={"center"} mb={2}>
|
||||
<Input
|
||||
size={"sm"}
|
||||
placeholder="Search"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<ShowNsfwModelThumbnailSettings />
|
||||
</Flex>
|
||||
<ModelsTags
|
||||
modelTypeList={modelTypeList}
|
||||
setSelectedModel={setSelectedModel}
|
||||
@@ -74,7 +93,7 @@ export default function ModelsListDrawer({ onClose }: Props) {
|
||||
top={0}
|
||||
left={0}
|
||||
shadow={"xl"}
|
||||
zIndex={1000}
|
||||
zIndex={DRAWER_Z_INDEX}
|
||||
overflowY={"auto"}
|
||||
>
|
||||
<CardHeader>
|
||||
@@ -82,6 +101,7 @@ export default function ModelsListDrawer({ onClose }: Props) {
|
||||
<Heading size={"md"} mr={2}>
|
||||
Models
|
||||
</Heading>
|
||||
|
||||
<InstallModelsButton />
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { Box, Button, HStack } from "@chakra-ui/react";
|
||||
import { Button, HStack } from "@chakra-ui/react";
|
||||
import { lazy, useEffect, useState, DragEvent } from "react";
|
||||
import ModelsListDrawer from "../models-list-drawer/ModelsListDrawer";
|
||||
import { IconGripVertical } from "@tabler/icons-react";
|
||||
import Draggable from "../../components/Draggable";
|
||||
|
||||
// @ts-ignore
|
||||
import { app } from "/scripts/app.js";
|
||||
import "./index.css";
|
||||
import { userSettingsTable } from "../../db-tables/WorkspaceDB";
|
||||
import { ModelManagerPosition } from "../../types/dbTypes";
|
||||
|
||||
const AddMissingModelsButton = lazy(
|
||||
() => import("./InstallMissingModelsButton"),
|
||||
);
|
||||
interface Props {}
|
||||
|
||||
export default function ModelManagerTopbar({}: Props) {
|
||||
export default function ModelManagerTopbar() {
|
||||
const [showMyModels, setShowMyModels] = useState(false);
|
||||
const [positionStyle, setPositionStyle] = useState<ModelManagerPosition>();
|
||||
|
||||
const handleModelDrop = async (
|
||||
e: DragEvent<HTMLCanvasElement> & { canvasX: number; canvasY: number },
|
||||
) => {
|
||||
@@ -26,28 +33,87 @@ export default function ModelManagerTopbar({}: Props) {
|
||||
node.configure({ widgets_values: [modelRelativePath] });
|
||||
app.graph.add(node);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
userSettingsTable?.getSetting("modelManagerTopBarStyle").then((res) => {
|
||||
updatePosition(res, false);
|
||||
});
|
||||
app.canvasEl.addEventListener("drop", handleModelDrop);
|
||||
return () => {
|
||||
app.canvasEl.removeEventListener("drop", handleModelDrop);
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<HStack position={"fixed"} top={2} right={2} gap={2}>
|
||||
<AddMissingModelsButton />
|
||||
|
||||
<Button
|
||||
size={"sm"}
|
||||
colorScheme="blue"
|
||||
aria-label="My models"
|
||||
onClick={() => setShowMyModels(true)}
|
||||
px={1}
|
||||
>
|
||||
Models
|
||||
</Button>
|
||||
{showMyModels && (
|
||||
<ModelsListDrawer onClose={() => setShowMyModels(false)} />
|
||||
const updatePosition = (
|
||||
position?: ModelManagerPosition,
|
||||
needUpdateDB: boolean = false,
|
||||
) => {
|
||||
const { top: curTop = 0, right: curRight = 0 } = positionStyle || {};
|
||||
const { top = 0, right = 0 } = position ?? {};
|
||||
let newTop = curTop + top;
|
||||
let newRight = positionStyle === undefined ? right : curRight - right;
|
||||
const clientWidth = document.documentElement.clientWidth;
|
||||
const clientHeight = document.documentElement.clientHeight;
|
||||
const panelElement = document.getElementById("modelManagerTopBar");
|
||||
const offsetWidth = panelElement?.offsetWidth || 224;
|
||||
|
||||
if (newTop + 32 > clientHeight) newTop = clientHeight - 38;
|
||||
if (newRight + offsetWidth > clientWidth)
|
||||
newRight = clientWidth - offsetWidth - 4;
|
||||
|
||||
setPositionStyle({
|
||||
top: Math.max(4, newTop),
|
||||
right: Math.max(4, newRight),
|
||||
});
|
||||
|
||||
needUpdateDB &&
|
||||
userSettingsTable?.upsert({
|
||||
modelManagerTopBarStyle: { top: newTop, right: newRight },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
onDragEnd={(position: { x: number; y: number }) => {
|
||||
updatePosition({ top: position.y, right: position.x }, true);
|
||||
}}
|
||||
dragIconId="dragModelManagerTopBarIcon"
|
||||
>
|
||||
{positionStyle ? (
|
||||
<HStack
|
||||
style={{
|
||||
position: "fixed",
|
||||
...positionStyle,
|
||||
}}
|
||||
gap={2}
|
||||
draggable={false}
|
||||
id="modelManagerTopBar"
|
||||
className="model-manager-top-bar"
|
||||
>
|
||||
<IconGripVertical
|
||||
id="dragModelManagerTopBarIcon"
|
||||
className="drag-model-manager-top-bar-icon"
|
||||
cursor="move"
|
||||
size={15}
|
||||
color="#FFF"
|
||||
/>
|
||||
<AddMissingModelsButton />
|
||||
<Button
|
||||
size={"sm"}
|
||||
colorScheme="blue"
|
||||
aria-label="My models"
|
||||
onClick={() => setShowMyModels(true)}
|
||||
px={1}
|
||||
>
|
||||
Models
|
||||
</Button>
|
||||
{showMyModels && (
|
||||
<ModelsListDrawer onClose={() => setShowMyModels(false)} />
|
||||
)}
|
||||
</HStack>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</HStack>
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
.drag-model-manager-top-bar-icon {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
.model-manager-top-bar:hover .drag-model-manager-top-bar-icon {
|
||||
visibility: visible !important;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Checkbox, Stack } from "@chakra-ui/react";
|
||||
import { useState, useEffect, ChangeEvent, useContext } from "react";
|
||||
import { userSettingsTable } from "../db-tables/WorkspaceDB";
|
||||
import { RecentFilesContext } from "../WorkspaceContext";
|
||||
|
||||
export default function FolderOnTopSettings() {
|
||||
const { setRefreshFolderStamp } = useContext(RecentFilesContext);
|
||||
const [checked, setChecked] = useState(false);
|
||||
|
||||
const getSettings = () => {
|
||||
userSettingsTable?.getSetting("foldersOnTop").then((res) => {
|
||||
setChecked(!!res);
|
||||
});
|
||||
};
|
||||
|
||||
const onFolderOnTopChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const state = e.target.checked;
|
||||
userSettingsTable
|
||||
?.upsert({
|
||||
foldersOnTop: state,
|
||||
})
|
||||
.then(() => {
|
||||
getSettings();
|
||||
setRefreshFolderStamp(Date.now());
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getSettings();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Checkbox isChecked={checked} onChange={onFolderOnTopChange}>
|
||||
Folders always on top
|
||||
</Checkbox>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Checkbox } from "@chakra-ui/react";
|
||||
import { useEffect, ChangeEvent } from "react";
|
||||
import { userSettingsTable } from "../db-tables/WorkspaceDB";
|
||||
import { indexdb } from "../db-tables/indexdb";
|
||||
import { Model } from "../types/dbTypes";
|
||||
import { useStateRef } from "../customHooks/useStateRef";
|
||||
|
||||
interface ResponsePartial {
|
||||
id: number;
|
||||
modelId: number;
|
||||
name: string;
|
||||
model: {
|
||||
name: string;
|
||||
type: string;
|
||||
nsfw: boolean;
|
||||
};
|
||||
images: {
|
||||
url: string;
|
||||
nsfw: "None" | "Soft" | "Mature" | "X";
|
||||
width: number;
|
||||
height: number;
|
||||
hash: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export default function ShowNsfwModelThumbnailSettings() {
|
||||
const [checkedState, setChecked, checked] = useStateRef(false);
|
||||
|
||||
const updateShowThumbnail = () => {
|
||||
userSettingsTable?.getSetting("showNsfwModelThumbnail").then((res) => {
|
||||
setChecked(res ?? false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateShowThumbnail();
|
||||
}, []);
|
||||
|
||||
const onShowThumbnailsChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const state = e.target.checked;
|
||||
userSettingsTable
|
||||
?.upsert({
|
||||
showNsfwModelThumbnail: state,
|
||||
})
|
||||
.then(() => {
|
||||
updateShowThumbnail();
|
||||
reFetchThumbnails();
|
||||
window.dispatchEvent(new Event("showNsfwModelThumbnail"));
|
||||
});
|
||||
};
|
||||
|
||||
const reFetchThumbnails = async () => {
|
||||
const models = await indexdb.models.toArray();
|
||||
for (let i = 0; i < models.length; i += 5) {
|
||||
// fetch 5 at a time, to avoid rate limit
|
||||
const slice = models.slice(i, i + 5);
|
||||
await Promise.all(slice.map(getThumbnail));
|
||||
}
|
||||
};
|
||||
const getThumbnail = async (model: Model) => {
|
||||
try {
|
||||
const url = `https://civitai.com/api/v1/model-versions/by-hash/${model.fileHash}`;
|
||||
const resp = await fetch(url);
|
||||
const json: ResponsePartial = await resp.json();
|
||||
let image_url: string | undefined;
|
||||
if (checked.current) {
|
||||
image_url = json?.images?.[0]?.url;
|
||||
} else if (!json.model.nsfw) {
|
||||
const sfwImage = json.images.find((i) => i.nsfw === "None");
|
||||
image_url = sfwImage?.url;
|
||||
}
|
||||
|
||||
indexdb.models.update(model.id, {
|
||||
imageUrl: image_url ?? null,
|
||||
});
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
isChecked={checkedState}
|
||||
onChange={onShowThumbnailsChange}
|
||||
width={"100px"}
|
||||
>
|
||||
Show NSFW
|
||||
</Checkbox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
Button,
|
||||
HStack,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Select,
|
||||
Text,
|
||||
} from "@chakra-ui/react";
|
||||
import { Workflow } from "../types/dbTypes";
|
||||
import { useState } from "react";
|
||||
import CustomDropdown, {
|
||||
CustomSelectorOption,
|
||||
} from "../components/CustomSelector";
|
||||
import {
|
||||
IconExternalLink,
|
||||
IconLink,
|
||||
IconLock,
|
||||
IconWorld,
|
||||
} from "@tabler/icons-react";
|
||||
import { workflowVersionsTable } from "../db-tables/WorkspaceDB";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
workflow: Workflow;
|
||||
}
|
||||
|
||||
const privacyOptions: CustomSelectorOption[] = [
|
||||
{ label: "Private", value: "PRIVATE", icon: <IconLock /> },
|
||||
{
|
||||
label: "Anyone with the link can access",
|
||||
value: "UNLISTED",
|
||||
icon: <IconLink />,
|
||||
},
|
||||
{ label: "Public", value: "PUBLIC", icon: <IconWorld /> },
|
||||
];
|
||||
export default function ShareDialog({ onClose, workflow }: Props) {
|
||||
const [versionName, setVersionName] = useState(
|
||||
"version " + getCurDateString(),
|
||||
);
|
||||
// const onShare = async () => {
|
||||
// const url = await workflowVersionsTable?.add({
|
||||
// workflowID: workflow.id,
|
||||
// name: versionName,
|
||||
// createTime: Date.now(),
|
||||
// privacy: "public",
|
||||
// json: workflow.json,
|
||||
// remoteUrl: "",
|
||||
// });
|
||||
// if (url) {
|
||||
// alert(url);
|
||||
// }
|
||||
// };
|
||||
return (
|
||||
<Modal isOpen={true} onClose={onClose} size={"lg"}>
|
||||
<ModalOverlay />
|
||||
<ModalContent width={["98%", "90%", "50%"]} maxWidth={"600px"}>
|
||||
<ModalHeader>Share "{workflow.name}"</ModalHeader>
|
||||
|
||||
<ModalBody pb={10}>
|
||||
<Text mb={3}>Version name</Text>
|
||||
<HStack>
|
||||
<Input
|
||||
value={versionName}
|
||||
maxWidth={440}
|
||||
onChange={(e) => {
|
||||
setVersionName(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
<Text mt={4} mb={3}></Text>
|
||||
<CustomDropdown options={privacyOptions} />
|
||||
<HStack justifyContent={"flex-end"} mt={16}>
|
||||
<Button colorScheme="teal">Share</Button>
|
||||
</HStack>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function getCurDateString() {
|
||||
const currentDate = new Date();
|
||||
const year = currentDate.getFullYear();
|
||||
const month = String(currentDate.getMonth() + 1).padStart(2, "0"); // Months are 0-based in JS
|
||||
const day = String(currentDate.getDate()).padStart(2, "0");
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Button } from "@chakra-ui/react";
|
||||
import { IconUsersGroup } from "@tabler/icons-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { workflowsTable } from "../db-tables/WorkspaceDB";
|
||||
|
||||
export function SharedTopbarButton({}) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {}}
|
||||
aria-label={"Shared"}
|
||||
size={"sm"}
|
||||
leftIcon={<IconUsersGroup />}
|
||||
>
|
||||
Public
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export function Topbar({
|
||||
onDragEnd={(position: { x: number; y: number }) => {
|
||||
updatePanelPosition({ top: position.y, left: position.x }, true);
|
||||
}}
|
||||
dragIconId="dragPanelIcon"
|
||||
>
|
||||
<HStack
|
||||
style={{
|
||||
@@ -75,9 +76,6 @@ export function Topbar({
|
||||
>
|
||||
<HStack gap={0}>
|
||||
<IconPlus size={16} color={"white"} />
|
||||
{/* <Text color={"white"} fontSize={"sm"}>
|
||||
New
|
||||
</Text> */}
|
||||
</HStack>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -92,11 +90,11 @@ export function Topbar({
|
||||
}}
|
||||
/>
|
||||
{curFlowID && (
|
||||
<HStack gap={"1px"}>
|
||||
<HStack gap={"4px"}>
|
||||
<Tooltip label="Open gallery">
|
||||
<IconButton
|
||||
onClick={() => setRoute("gallery")}
|
||||
icon={<IconPhoto size={20} color="white" />}
|
||||
icon={<IconPhoto size={22} color="white" />}
|
||||
size={"sm"}
|
||||
aria-label="open gallery"
|
||||
variant={"ghost"}
|
||||
@@ -108,16 +106,17 @@ export function Topbar({
|
||||
{curFlowID && isDirty ? (
|
||||
<Tooltip label="Save workflow">
|
||||
<IconButton
|
||||
style={{ width: 22 }}
|
||||
// style={{ width: 26 }}
|
||||
onClick={saveCurWorkflow}
|
||||
icon={<IconDeviceFloppy size={22} color="white" />}
|
||||
icon={<IconDeviceFloppy size={23} color="white" />}
|
||||
size={"xs"}
|
||||
paddingY={4}
|
||||
aria-label="save workspace"
|
||||
variant={"ghost"}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div style={{ width: 22 }} />
|
||||
<div style={{ width: 1 }} />
|
||||
)}
|
||||
<IconGripVertical
|
||||
id="dragPanelIcon"
|
||||
|
||||
+20
-2
@@ -17,8 +17,9 @@ export interface Workflow extends SortableItem {
|
||||
}
|
||||
|
||||
export interface TableBaseModel {
|
||||
id: string;
|
||||
name: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
createTime?: number;
|
||||
}
|
||||
|
||||
export type Tags = {
|
||||
@@ -45,15 +46,32 @@ export type Model = {
|
||||
imageUrl?: string | null;
|
||||
};
|
||||
|
||||
export interface ModelManagerPosition {
|
||||
top: number;
|
||||
right: number;
|
||||
}
|
||||
export type WorkflowVersion = {
|
||||
id: string;
|
||||
name: string;
|
||||
workflowID: string;
|
||||
json: string;
|
||||
createTime: number;
|
||||
privacy?: "public" | "private";
|
||||
remoteUrl?: string;
|
||||
};
|
||||
|
||||
export type UserSettings = {
|
||||
id: string;
|
||||
myWorkflowsDir: string;
|
||||
topBarStyle: PanelPosition;
|
||||
modelManagerTopBarStyle: ModelManagerPosition;
|
||||
shortcuts: {
|
||||
save: string;
|
||||
};
|
||||
autoSave?: boolean;
|
||||
twoWaySync?: boolean;
|
||||
foldersOnTop?: boolean;
|
||||
showNsfwModelThumbnail?: boolean;
|
||||
};
|
||||
|
||||
export interface PanelPosition {
|
||||
|
||||
+15
-295
@@ -1,5 +1,11 @@
|
||||
// @ts-ignore
|
||||
import { deleteFile, updateFile } from "./Api";
|
||||
import {
|
||||
ScanLocalFile,
|
||||
ScanLocalFolder,
|
||||
ScanLocalResult,
|
||||
deleteFile,
|
||||
updateFile,
|
||||
} from "./Api";
|
||||
import { ESortTypes } from "./RecentFilesDrawer/types";
|
||||
import {
|
||||
workflowsTable,
|
||||
@@ -220,292 +226,6 @@ export function insertWorkflowToCanvas(json: string, insertPos?: number[]) {
|
||||
canvas = null;
|
||||
}
|
||||
|
||||
export async function generateUniqueName(name?: string) {
|
||||
/**
|
||||
* Generate a unique name
|
||||
* For imported scenes, the default name is the file name.
|
||||
* For new scenes, the default name is Untitled Flow.
|
||||
* Get the full workflow list. If the default name already exists, search incrementally starting from 2.
|
||||
* Looks for a unique name in the format `${default name} ${number}`.
|
||||
*/
|
||||
let newFlowName = name ?? "Untitled Flow";
|
||||
const flowList = (await workflowsTable?.listAll()) ?? [];
|
||||
const flowNameList = flowList.map((flow) => flow.name);
|
||||
if (flowNameList.includes(newFlowName)) {
|
||||
let num = 2;
|
||||
let flag = true;
|
||||
while (flag) {
|
||||
if (flowNameList.includes(`${newFlowName} ${num}`)) {
|
||||
num++;
|
||||
} else {
|
||||
newFlowName = `${newFlowName} ${num}`;
|
||||
flag = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return newFlowName;
|
||||
}
|
||||
|
||||
export function getPngMetadata(file: File) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
// Get the PNG data as a Uint8Array
|
||||
// @ts-expect-error
|
||||
const pngData = new Uint8Array(event.target.result);
|
||||
const dataView = new DataView(pngData.buffer);
|
||||
|
||||
// Check that the PNG signature is present
|
||||
if (dataView.getUint32(0) !== 0x89504e47) {
|
||||
console.error("Not a valid PNG file");
|
||||
reject();
|
||||
}
|
||||
|
||||
// Start searching for chunks after the PNG signature
|
||||
let offset = 8;
|
||||
const txt_chunks: Record<string, string> = {};
|
||||
// Loop through the chunks in the PNG file
|
||||
while (offset < pngData.length) {
|
||||
// Get the length of the chunk
|
||||
const length = dataView.getUint32(offset);
|
||||
// Get the chunk type
|
||||
const type = String.fromCharCode(
|
||||
...pngData.slice(offset + 4, offset + 8),
|
||||
);
|
||||
if (type === "tEXt" || type == "comf") {
|
||||
// Get the keyword
|
||||
let keyword_end = offset + 8;
|
||||
while (pngData[keyword_end] !== 0) {
|
||||
keyword_end++;
|
||||
}
|
||||
const keyword = String.fromCharCode(
|
||||
...pngData.slice(offset + 8, keyword_end),
|
||||
);
|
||||
// Get the text
|
||||
const contentArraySegment = pngData.slice(
|
||||
keyword_end + 1,
|
||||
offset + 8 + length,
|
||||
);
|
||||
const contentJson = Array.from(contentArraySegment)
|
||||
.map((s) => String.fromCharCode(s))
|
||||
.join("");
|
||||
txt_chunks[keyword] = contentJson;
|
||||
}
|
||||
|
||||
offset += 12 + length;
|
||||
}
|
||||
|
||||
resolve(txt_chunks);
|
||||
};
|
||||
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
function parseExifData(exifData) {
|
||||
// Check for the correct TIFF header (0x4949 for little-endian or 0x4D4D for big-endian)
|
||||
const isLittleEndian = new Uint16Array(exifData.slice(0, 2))[0] === 0x4949;
|
||||
|
||||
// Function to read 16-bit and 32-bit integers from binary data
|
||||
function readInt(offset, isLittleEndian, length) {
|
||||
const arr = exifData.slice(offset, offset + length);
|
||||
if (length === 2) {
|
||||
return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint16(
|
||||
0,
|
||||
isLittleEndian,
|
||||
);
|
||||
} else if (length === 4) {
|
||||
return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint32(
|
||||
0,
|
||||
isLittleEndian,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Read the offset to the first IFD (Image File Directory)
|
||||
const ifdOffset = readInt(4, isLittleEndian, 4);
|
||||
|
||||
function parseIFD(offset) {
|
||||
const numEntries = readInt(offset, isLittleEndian, 2);
|
||||
const result = {};
|
||||
|
||||
for (let i = 0; i < numEntries; i++) {
|
||||
const entryOffset = offset + 2 + i * 12;
|
||||
const tag = readInt(entryOffset, isLittleEndian, 2);
|
||||
const type = readInt(entryOffset + 2, isLittleEndian, 2);
|
||||
const numValues = readInt(entryOffset + 4, isLittleEndian, 4);
|
||||
const valueOffset = readInt(entryOffset + 8, isLittleEndian, 4);
|
||||
|
||||
// Read the value(s) based on the data type
|
||||
let value;
|
||||
if (type === 2) {
|
||||
// ASCII string
|
||||
value = String.fromCharCode(
|
||||
...exifData.slice(valueOffset, valueOffset + numValues - 1),
|
||||
);
|
||||
}
|
||||
|
||||
result[tag] = value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Parse the first IFD
|
||||
const ifdData = parseIFD(ifdOffset);
|
||||
return ifdData;
|
||||
}
|
||||
|
||||
export function getWebpMetadata(file: File) {
|
||||
return new Promise((r) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const webp = new Uint8Array(event?.target?.result as Iterable<number>);
|
||||
const dataView = new DataView(webp.buffer);
|
||||
|
||||
// Check that the WEBP signature is present
|
||||
if (
|
||||
dataView.getUint32(0) !== 0x52494646 ||
|
||||
dataView.getUint32(8) !== 0x57454250
|
||||
) {
|
||||
console.error("Not a valid WEBP file");
|
||||
r();
|
||||
return;
|
||||
}
|
||||
|
||||
// Start searching for chunks after the WEBP signature
|
||||
let offset = 12;
|
||||
const txt_chunks = {};
|
||||
// Loop through the chunks in the WEBP file
|
||||
while (offset < webp.length) {
|
||||
const chunk_length = dataView.getUint32(offset + 4, true);
|
||||
const chunk_type = String.fromCharCode(
|
||||
...webp.slice(offset, offset + 4),
|
||||
);
|
||||
if (chunk_type === "EXIF") {
|
||||
if (
|
||||
String.fromCharCode(...webp.slice(offset + 8, offset + 8 + 6)) ==
|
||||
"Exif\0\0"
|
||||
) {
|
||||
offset += 6;
|
||||
}
|
||||
const data = parseExifData(
|
||||
webp.slice(offset + 8, offset + 8 + chunk_length),
|
||||
);
|
||||
for (const key in data) {
|
||||
const value = data[key];
|
||||
const index = value.indexOf(":");
|
||||
txt_chunks[value.slice(0, index)] = value.slice(index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
offset += 8 + chunk_length;
|
||||
}
|
||||
|
||||
r(txt_chunks);
|
||||
};
|
||||
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function isVideoName(name: string) {
|
||||
if (name?.endsWith(".webm")) {
|
||||
return true;
|
||||
}
|
||||
if (name?.endsWith(".mp4")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getVideoMetadata(file) {
|
||||
return new Promise((r) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const videoData = new Uint8Array(event.target.result);
|
||||
const dataView = new DataView(videoData.buffer);
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
// Check for known valid magic strings
|
||||
if (dataView.getUint32(0) == 0x1a45dfa3) {
|
||||
//webm
|
||||
//see http://wiki.webmproject.org/webm-metadata/global-metadata
|
||||
//and https://www.matroska.org/technical/elements.html
|
||||
//contrary to specs, tag seems consistently at start
|
||||
//COMMENT + 0x4487 + packed length?
|
||||
//length 0x8d8 becomes 0x48d8
|
||||
//
|
||||
//description for variable length ints https://github.com/ietf-wg-cellar/ebml-specification/blob/master/specification.markdown
|
||||
let offset = 4 + 8; //COMMENT is 7 chars + 1 to realign
|
||||
while (offset < videoData.length - 16) {
|
||||
//Check for text tags
|
||||
if (dataView.getUint16(offset) == 0x4487) {
|
||||
//check that name of tag is COMMENT
|
||||
const name = String.fromCharCode(
|
||||
...videoData.slice(offset - 7, offset),
|
||||
);
|
||||
if (name === "COMMENT") {
|
||||
const vint = dataView.getUint32(offset + 2);
|
||||
const n_octets = Math.clz32(vint) + 1;
|
||||
if (n_octets < 4) {
|
||||
//250MB sanity cutoff
|
||||
const length =
|
||||
(vint >> (8 * (4 - n_octets))) & ~(1 << (7 * n_octets));
|
||||
const content = decoder.decode(
|
||||
videoData.slice(
|
||||
offset + 2 + n_octets,
|
||||
offset + 2 + n_octets + length,
|
||||
),
|
||||
);
|
||||
const json = JSON.parse(content);
|
||||
r(json);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
offset += 1;
|
||||
}
|
||||
} else if (
|
||||
dataView.getUint32(4) == 0x66747970 &&
|
||||
dataView.getUint32(8) == 0x69736f6d
|
||||
) {
|
||||
//mp4
|
||||
//see https://developer.apple.com/documentation/quicktime-file-format
|
||||
//Seems to make no guarantee for alignment
|
||||
let offset = videoData.length - 4;
|
||||
while (offset > 16) {
|
||||
//rough safe guess
|
||||
if (dataView.getUint32(offset) == 0x64617461) {
|
||||
//any data tag
|
||||
if (dataView.getUint32(offset - 8) == 0xa9636d74) {
|
||||
//cmt data tag
|
||||
const type = dataView.getUint32(offset + 4); //seemingly 1
|
||||
const locale = dataView.getUint32(offset + 8); //seemingly 0
|
||||
const size = dataView.getUint32(offset - 4) - 4 * 4;
|
||||
const content = decoder.decode(
|
||||
videoData.slice(offset + 12, offset + 12 + size),
|
||||
);
|
||||
const json = JSON.parse(content);
|
||||
r(json);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
offset -= 1;
|
||||
}
|
||||
} else {
|
||||
console.error("Unknown magic: " + dataView.getUint32(0));
|
||||
r();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
export const matchSaveWorkflowShortcut = async (event: KeyboardEvent) => {
|
||||
const short = await userSettingsTable?.getSetting("shortcuts").then((res) => {
|
||||
return res?.save;
|
||||
@@ -546,16 +266,15 @@ export function getFileUrl(relativePath: string) {
|
||||
}
|
||||
|
||||
export async function syncNewFlowOfLocalDisk(
|
||||
singleFlowList: Workflow[],
|
||||
folderList: {
|
||||
name: string;
|
||||
list: Workflow[];
|
||||
}[],
|
||||
scanList: ScanLocalResult[],
|
||||
parentFolderID?: string,
|
||||
) {
|
||||
if (singleFlowList.length) {
|
||||
await workflowsTable?.batchCreateFlows(singleFlowList, true);
|
||||
const fileList = scanList.filter((s): s is ScanLocalFile => "json" in s);
|
||||
if (fileList.length) {
|
||||
await workflowsTable?.batchCreateFlows(fileList, true, parentFolderID);
|
||||
}
|
||||
|
||||
const folderList = scanList.filter((s): s is ScanLocalFolder => "list" in s);
|
||||
if (folderList.length) {
|
||||
const currentFolderList = await foldersTable?.listAll();
|
||||
|
||||
@@ -571,11 +290,12 @@ export async function syncNewFlowOfLocalDisk(
|
||||
} else {
|
||||
const newFolder = await foldersTable?.create({
|
||||
name: folder.name,
|
||||
parentFolderID,
|
||||
});
|
||||
folderId = newFolder?.id;
|
||||
}
|
||||
|
||||
await workflowsTable?.batchCreateFlows(folder.list, true, folderId);
|
||||
await syncNewFlowOfLocalDisk(folder.list, folderId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
export function getPngMetadata(file: File) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
// Get the PNG data as a Uint8Array
|
||||
// @ts-expect-error
|
||||
const pngData = new Uint8Array(event.target.result);
|
||||
const dataView = new DataView(pngData.buffer);
|
||||
|
||||
// Check that the PNG signature is present
|
||||
if (dataView.getUint32(0) !== 0x89504e47) {
|
||||
console.error("Not a valid PNG file");
|
||||
reject();
|
||||
}
|
||||
|
||||
// Start searching for chunks after the PNG signature
|
||||
let offset = 8;
|
||||
const txt_chunks: Record<string, string> = {};
|
||||
// Loop through the chunks in the PNG file
|
||||
while (offset < pngData.length) {
|
||||
// Get the length of the chunk
|
||||
const length = dataView.getUint32(offset);
|
||||
// Get the chunk type
|
||||
const type = String.fromCharCode(
|
||||
...pngData.slice(offset + 4, offset + 8),
|
||||
);
|
||||
if (type === "tEXt" || type == "comf") {
|
||||
// Get the keyword
|
||||
let keyword_end = offset + 8;
|
||||
while (pngData[keyword_end] !== 0) {
|
||||
keyword_end++;
|
||||
}
|
||||
const keyword = String.fromCharCode(
|
||||
...pngData.slice(offset + 8, keyword_end),
|
||||
);
|
||||
// Get the text
|
||||
const contentArraySegment = pngData.slice(
|
||||
keyword_end + 1,
|
||||
offset + 8 + length,
|
||||
);
|
||||
const contentJson = Array.from(contentArraySegment)
|
||||
.map((s) => String.fromCharCode(s))
|
||||
.join("");
|
||||
txt_chunks[keyword] = contentJson;
|
||||
}
|
||||
|
||||
offset += 12 + length;
|
||||
}
|
||||
|
||||
resolve(txt_chunks);
|
||||
};
|
||||
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
function parseExifData(exifData: any) {
|
||||
// Check for the correct TIFF header (0x4949 for little-endian or 0x4D4D for big-endian)
|
||||
const isLittleEndian = new Uint16Array(exifData.slice(0, 2))[0] === 0x4949;
|
||||
|
||||
// Function to read 16-bit and 32-bit integers from binary data
|
||||
function readInt(
|
||||
offset: number,
|
||||
isLittleEndian: boolean | undefined,
|
||||
length: number,
|
||||
) {
|
||||
const arr = exifData.slice(offset, offset + length);
|
||||
if (length === 2) {
|
||||
return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint16(
|
||||
0,
|
||||
isLittleEndian,
|
||||
);
|
||||
} else if (length === 4) {
|
||||
return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint32(
|
||||
0,
|
||||
isLittleEndian,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Read the offset to the first IFD (Image File Directory)
|
||||
const ifdOffset = readInt(4, isLittleEndian, 4);
|
||||
|
||||
function parseIFD(offset: number) {
|
||||
const numEntries = readInt(offset, isLittleEndian, 2);
|
||||
const result = {};
|
||||
|
||||
for (let i = 0; i < numEntries; i++) {
|
||||
const entryOffset = offset + 2 + i * 12;
|
||||
const tag = readInt(entryOffset, isLittleEndian, 2);
|
||||
const type = readInt(entryOffset + 2, isLittleEndian, 2);
|
||||
const numValues = readInt(entryOffset + 4, isLittleEndian, 4);
|
||||
const valueOffset = readInt(entryOffset + 8, isLittleEndian, 4);
|
||||
|
||||
// Read the value(s) based on the data type
|
||||
let value;
|
||||
if (type === 2) {
|
||||
// ASCII string
|
||||
value = String.fromCharCode(
|
||||
...exifData.slice(valueOffset, valueOffset + numValues - 1),
|
||||
);
|
||||
}
|
||||
|
||||
result[tag] = value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Parse the first IFD
|
||||
const ifdData = parseIFD(ifdOffset);
|
||||
return ifdData;
|
||||
}
|
||||
|
||||
export function getWebpMetadata(file: File) {
|
||||
return new Promise((r) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const webp = new Uint8Array(event?.target?.result as Iterable<number>);
|
||||
const dataView = new DataView(webp.buffer);
|
||||
|
||||
// Check that the WEBP signature is present
|
||||
if (
|
||||
dataView.getUint32(0) !== 0x52494646 ||
|
||||
dataView.getUint32(8) !== 0x57454250
|
||||
) {
|
||||
console.error("Not a valid WEBP file");
|
||||
r();
|
||||
return;
|
||||
}
|
||||
|
||||
// Start searching for chunks after the WEBP signature
|
||||
let offset = 12;
|
||||
const txt_chunks = {};
|
||||
// Loop through the chunks in the WEBP file
|
||||
while (offset < webp.length) {
|
||||
const chunk_length = dataView.getUint32(offset + 4, true);
|
||||
const chunk_type = String.fromCharCode(
|
||||
...webp.slice(offset, offset + 4),
|
||||
);
|
||||
if (chunk_type === "EXIF") {
|
||||
if (
|
||||
String.fromCharCode(...webp.slice(offset + 8, offset + 8 + 6)) ==
|
||||
"Exif\0\0"
|
||||
) {
|
||||
offset += 6;
|
||||
}
|
||||
const data = parseExifData(
|
||||
webp.slice(offset + 8, offset + 8 + chunk_length),
|
||||
);
|
||||
for (const key in data) {
|
||||
const value = data[key];
|
||||
const index = value.indexOf(":");
|
||||
txt_chunks[value.slice(0, index)] = value.slice(index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
offset += 8 + chunk_length;
|
||||
}
|
||||
|
||||
r(txt_chunks);
|
||||
};
|
||||
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function isVideoName(name: string) {
|
||||
if (name?.endsWith(".webm")) {
|
||||
return true;
|
||||
}
|
||||
if (name?.endsWith(".mp4")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getVideoMetadata(file) {
|
||||
return new Promise((r) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const videoData = new Uint8Array(event.target.result);
|
||||
const dataView = new DataView(videoData.buffer);
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
// Check for known valid magic strings
|
||||
if (dataView.getUint32(0) == 0x1a45dfa3) {
|
||||
//webm
|
||||
//see http://wiki.webmproject.org/webm-metadata/global-metadata
|
||||
//and https://www.matroska.org/technical/elements.html
|
||||
//contrary to specs, tag seems consistently at start
|
||||
//COMMENT + 0x4487 + packed length?
|
||||
//length 0x8d8 becomes 0x48d8
|
||||
//
|
||||
//description for variable length ints https://github.com/ietf-wg-cellar/ebml-specification/blob/master/specification.markdown
|
||||
let offset = 4 + 8; //COMMENT is 7 chars + 1 to realign
|
||||
while (offset < videoData.length - 16) {
|
||||
//Check for text tags
|
||||
if (dataView.getUint16(offset) == 0x4487) {
|
||||
//check that name of tag is COMMENT
|
||||
const name = String.fromCharCode(
|
||||
...videoData.slice(offset - 7, offset),
|
||||
);
|
||||
if (name === "COMMENT") {
|
||||
const vint = dataView.getUint32(offset + 2);
|
||||
const n_octets = Math.clz32(vint) + 1;
|
||||
if (n_octets < 4) {
|
||||
//250MB sanity cutoff
|
||||
const length =
|
||||
(vint >> (8 * (4 - n_octets))) & ~(1 << (7 * n_octets));
|
||||
const content = decoder.decode(
|
||||
videoData.slice(
|
||||
offset + 2 + n_octets,
|
||||
offset + 2 + n_octets + length,
|
||||
),
|
||||
);
|
||||
const json = JSON.parse(content);
|
||||
r(json);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
offset += 1;
|
||||
}
|
||||
} else if (
|
||||
dataView.getUint32(4) == 0x66747970 &&
|
||||
dataView.getUint32(8) == 0x69736f6d
|
||||
) {
|
||||
//mp4
|
||||
//see https://developer.apple.com/documentation/quicktime-file-format
|
||||
//Seems to make no guarantee for alignment
|
||||
let offset = videoData.length - 4;
|
||||
while (offset > 16) {
|
||||
//rough safe guess
|
||||
if (dataView.getUint32(offset) == 0x64617461) {
|
||||
//any data tag
|
||||
if (dataView.getUint32(offset - 8) == 0xa9636d74) {
|
||||
//cmt data tag
|
||||
const type = dataView.getUint32(offset + 4); //seemingly 1
|
||||
const locale = dataView.getUint32(offset + 8); //seemingly 0
|
||||
const size = dataView.getUint32(offset - 4) - 4 * 4;
|
||||
const content = decoder.decode(
|
||||
videoData.slice(offset + 12, offset + 12 + size),
|
||||
);
|
||||
const json = JSON.parse(content);
|
||||
r(json);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
offset -= 1;
|
||||
}
|
||||
} else {
|
||||
console.error("Unknown magic: " + dataView.getUint32(0));
|
||||
r();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user