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:
Weixuan Fu
2024-02-06 11:52:43 +08:00
committed by GitHub
75 changed files with 2329 additions and 1087 deletions
+20 -13
View File
@@ -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
View File
@@ -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")
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
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
View File
@@ -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
View File
@@ -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};
@@ -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};
+30 -29
View File
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -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
+6 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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>
);
}
+228 -63
View File
@@ -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();
}}
/>
</>
);
}
+89
View File
@@ -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>
</>
);
}
+16 -14
View File
@@ -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 -47
View File
@@ -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>
+18
View File
@@ -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;
}
}
+91
View File
@@ -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>
);
}
+39
View File
@@ -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>
);
}
+66
View File
@@ -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>
);
}
+3 -1
View File
@@ -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,
+105 -61
View File
@@ -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)}
/>
)}
</>
);
}
+2 -2
View File
@@ -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 && (
+1 -1
View File
@@ -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>}
+59
View File
@@ -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>
);
}
+183 -81
View File
@@ -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>
);
}
+1
View File
@@ -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;
+36
View File
@@ -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];
};
+2 -12
View File
@@ -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;
+17 -4
View File
@@ -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;
+1 -5
View File
@@ -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: {
+24 -7
View File
@@ -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) {
+6 -1
View File
@@ -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;
+35
View File
@@ -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;
}
}
+39 -8
View File
@@ -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 = {
+18 -3
View File
@@ -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(),
]);
}
+17
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 />
+1
View File
@@ -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>
);
}
+6
View File
@@ -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;
}
+39
View File
@@ -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>
);
}
+92
View File
@@ -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}`;
}
+19
View File
@@ -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>
</>
);
}
+7 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
}
+263
View File
@@ -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);
});
}