Skip to content

Commit 0bdc1e2

Browse files
committed
New search parsing; Add gallery tab; Directory support; Fixed image loading with # in path
1 parent def6771 commit 0bdc1e2

File tree

6 files changed

+187
-80
lines changed

6 files changed

+187
-80
lines changed

README.md

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ Advantages over the Extra Network Tabs:
1515
* [Weights](#weights) placed in braces { }'s *(eg {1.0} or {0.7-0.8})* in the filename are automatically set in the LoRA's trigger.
1616
* Some characters not compatible with filenames are automatically converted from placeholders, such as ©️ to : *(for [keywords with weights](#weights))*
1717
* Sort by Name, Date Modified, or try Random sort for inspiration.
18-
* Support for multiple images per LoRA/model/etc in a [modal gallery](#modal). Hover over a card & click the folder icon.
18+
* Support for multiple images per LoRA/model/etc in a [modal gallery](#modal) (including filename [search](#search)). Hover over a card & click the folder icon.
1919
* Support for [displaying a companion .txt file](#modal) to store descriptions, notes, and prompts. Hover over a card & click the document icon.
20+
* [Gallery](#modal) tab for arbitrary image folders, such as saved generation results.
2021

2122
<br />
2223

@@ -52,7 +53,7 @@ cd ../app && npm install
5253

5354
Then add content:
5455

55-
- Populate the folders in `api/networks/` with your files: `lora`, `checkpoints`, `embeddings`, `hypernets`, and `styles`.
56+
- Populate the folders in `api/networks/` with your files: `lora`, `checkpoints`, `embeddings`, `hypernets`, `styles`, and `gallery`.
5657
- Edit `api/networks/styles.csv` with *(only)* `name,prompt` on the first line, and your styles (following the format shown [below](#styles)) on the subsequent lines.
5758

5859
***OR***
@@ -89,16 +90,30 @@ ln -s ~/webui/styles.csv styles.csv
8990
Filenames following the suggested naming convention are ***optional***, but to get the full convivence of the Extra Network Browser, the following format is suggested:
9091

9192
``` ini
92-
name_v1_author [keyword1, keyword2] [keyword3, keyword4] (suggested model) {weight1-weight2} #tag1 #tag2.safetensors
93+
name_v1_author [keyword1, keyword2] [keyword3, keyword4] (suggested model) {weight1-weight2}.safetensors
9394
```
9495

95-
With matching files of the same name ending in **.jpeg** in the same folder, max height 336px. *(Width is auto-cropped to center at 224px)*
96+
Or simply:
97+
```
98+
My-LoRA.safetensors
99+
```
100+
101+
and a matching file of the same name ending in **.jpeg** (e.g. ```My-LoRA.jpeg```) in the same folder for the preview. Max height is auto-sized to 336px, width is auto centered at a max 224px.
102+
103+
#### Additional Images
96104
<a id="modal"></a>
97-
Saving additional images as `filename. (1).jpeg`, `filename. (2).jpeg` and so on will populate a modal gallery popup. You can then navigate to the prev / next network card with the left / right arrow keys while the modal is open.
105+
*Option 1:*<br/>
106+
Saving additional images as `My-LoRA. (1).jpeg`, `My-LoRA. (2).jpeg` and so on will populate a modal gallery popup. You can then navigate to the prev / next network card with the left / right arrow keys while the modal is open.
98107

99108
To quickly rename a batch of images in this pattern in Windows, select multiple images, then rename them as `filename..jpeg` (two dots) -- windows will automatically add ` (1)`, ` (2)` and so on.
100109

101-
Finally, add a `filename.txt` file in the same folder for a quick info modal. Great for storing descriptions, notes, and sample prompts. You can switch between the modal gallery and the modal notes with the up / down arrow keys while the modal is open.
110+
*Option 2:*<br/>
111+
Make additional subfolders with the same name as the model, and place .jpegs inside with any name you'd like.<br/>
112+
e.g. `api/networks/lora/My-LoRA/file123.jpg`
113+
114+
Finally, you can add a `filename.txt` file in the same folder for a quick info modal. Great for storing descriptions, notes, and sample prompts. You can switch between the modal gallery and the modal notes with the up / down arrow keys while the modal is open.
115+
116+
**Note: Use the full matching filename, minus extension, of the related model/item.**
102117

103118
<br />
104119

@@ -127,8 +142,8 @@ magick mogrify -format jpeg *.png
127142
<a id="keywords"></a>
128143
### the LoRA / image pair:
129144

130-
`api/networks/lora/example-lora_v1_johndoe [mylora] [anotherkeyword] (RevAnimated) {0.7-0.8} #style.safetensors`<br />
131-
`api/networks/lora/example-lora_v1_johndoe [mylora] [anotherkeyword] (RevAnimated) {0.7-0.8} #style.jpeg`
145+
`api/networks/lora/example-lora_v1_johndoe [mylora] [anotherkeyword] (RevAnimated) {0.7-0.8}.safetensors`<br />
146+
`api/networks/lora/example-lora_v1_johndoe [mylora] [anotherkeyword] (RevAnimated) {0.7-0.8}.jpeg`
132147

133148
will copy to the clipboard:
134149

@@ -144,13 +159,13 @@ If you choose to follow a different naming convention without keywords or weight
144159
<a id="weights"></a>
145160
### Another example, Keywords with Weights:
146161

147-
`api/networks/lora/example-lora_v1_johndoe [anotherlora, watercolor (medium), (awesome©️1.4}] [anotherkeyword] (RevAnimated) {1.0} #style.safetensors`
162+
`api/networks/lora/example-lora_v1_johndoe [anotherlora, watercolor (medium), (awesome©️1.4}] [anotherkeyword] (RevAnimated) {1.0}.safetensors`
148163

149-
`api/networks/lora/example-lora_v1_johndoe [anotherlora, watercolor (medium), (awesome©️1.4}] [anotherkeyword] (RevAnimated) {1.0} #style.jpeg`
164+
`api/networks/lora/example-lora_v1_johndoe [anotherlora, watercolor (medium), (awesome©️1.4}] [anotherkeyword] (RevAnimated) {1.0}.jpeg`
150165

151166
Will copy to the clipboard:
152167

153-
`anotherlora, watercolor \(medium\), (awesome:1.4), anotherkeyword <lora:nother-lora_v1_johndoe [anotherlora, (awesome:1.4}] [anotherkeyword] (RevAnimated) {1.0} #style:1.0>`
168+
`anotherlora, watercolor \(medium\), (awesome:1.4), anotherkeyword <lora:nother-lora_v1_johndoe [anotherlora, (awesome:1.4}] [anotherkeyword] (RevAnimated) {1.0}:1.0>`
154169

155170
Note the ( )'s are escaped as needed on non-weights, and ©️ is replaced by : (because : can't be in a filename). I've found this very useful when dealing with keywords that have suggested weights.
156171

@@ -211,6 +226,34 @@ In this example, your matching image files in `api/networks/styles` would be:
211226
`api/networks/styles/My-LoRA (John Doe) - Style 1.jpeg`<br/>
212227
`api/networks/styles/My-LoRA (John Doe) - Style 2.jpeg`
213228

229+
---
230+
231+
<a id="gallery"></a>
232+
### Gallery:
233+
234+
The Gallery tab *("G")* looks for *subfolders* inside the `api/networks/gallery/` folder and displays a card for each, using a .jpeg matching the folder name in the `/gallery`.
235+
Examples:
236+
```
237+
api/networks/gallery/saved-images/ # Gallery folder with images
238+
api/networks/gallery/saved-images.jpeg # Gallery folder thumbnail image
239+
240+
api/networks/gallery/testing-images/ # Gallery folder with images
241+
api/networks/gallery/testing-images.jpeg # Gallery folder thumbnail image
242+
```
243+
244+
<a id="search"></a>
245+
## Advanced Search:
246+
Searching in field at the top will filter the results of the tab you are in: LoRAs / Models / Styles / Gallery, and etc.
247+
248+
You can further drill-down to the *"additional images"* in the [modal gallery](#modal) using a **`>`** character followed by a filename found *inside* of one of the results
249+
250+
Examples:
251+
```
252+
Watercolor # Filters tab to Watercolor-Lora, Watercolor_v2, etc
253+
Watercolor > 2024 # Filters to individual images inside Watercolor-Lora, Watercolor_v2, etc with filenames containing "2024"
254+
>2024 # Filters to any filename containing '2024' in any LoRA/model/style/etc in the tab.
255+
```
256+
214257
<br />
215258

216259
## Contributing

api/index.js

Lines changed: 128 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ app.use('/styles', express.static('networks/styles'))
1919
app.use('/checkpoints', express.static('networks/checkpoints'))
2020
app.use('/embeddings', express.static('networks/embeddings'))
2121
app.use('/hypernets', express.static('networks/hypernets'))
22+
app.use('/gallery', express.static('networks/gallery'))
2223

2324
// Endpoint to return images
2425
app.get('/images', (req, res) => {
@@ -65,72 +66,128 @@ app.get('/images', (req, res) => {
6566
// ********* GLOB *********
6667
// Everything else uses a GLOB
6768

68-
const pattern = searchTerm ? `*${searchTerm}*` : '*'
69-
let ext = null
70-
if (searchType == "lora" || searchType == "checkpoints") {
71-
ext = "safetensors"
72-
} else if (searchType == "embeddings" || searchType == "hypernets") {
73-
ext = "(pt|safetensors)"
74-
} else {
75-
return res.status(400).send({
76-
message: 'Invalid type requested.'
77-
});
78-
}
79-
const files = fg.globSync([`networks/${searchType}/**/${pattern}.${ext}`], { dot: true, caseSensitiveMatch: false, stats: true })
80-
images = files.map(file => {
81-
82-
// Expected filename example:
83-
// "name1-name2_author [keyword1, keyword2] [keyword3, keyword4] (model) {weight1-weight2} #tag1 #tag2.jpeg"
84-
85-
let filematch = file.name.match(/(.*)\.(safetensors|ckpt|pt|bin)$/)
86-
filename = filematch ? filematch[1] + "." + imgExt : ""
87-
let noext = filematch ? filematch[1] : ""
88-
let words = noext.split(' ')
89-
let path = file.path.replace(file.name, '').replace('networks/', '').toLowerCase()
90-
let name = noext.match(/^(.*)_([0-9a-zA-Z]+)\s/)
91-
name = name ? name[1] : null
92-
let author = noext.match(/_([0-9a-zA-Z]+)\s/)
93-
author = author ? author[1] : null
94-
let hashtagWords = words.filter(word => word.startsWith("#"))
95-
let tags = hashtagWords.map(word => word.slice(1))
96-
let weight = noext.match(/{(?:[0-9]*\.?[0-9]+\s?-)?([0-9]*\.?[0-9]+)}/)
97-
weight = weight ? weight[1] : "1.0"
98-
99-
let keywords = filename.match(/\[(.*)\]/)
100-
if (keywords) {
101-
keywords = keywords[1]
102-
keywords = keywords.replaceAll(/©/g, ':')
103-
keywords = keywords.replaceAll(//g, '>')
104-
keywords = keywords.replaceAll(//g, '<')
105-
keywords = keywords.replaceAll(/(\w+?) \((\w+?)\)/gi, '$1 \\($2\\)')
106-
keywords = keywords.replaceAll('[', '')
107-
keywords = keywords.replaceAll(']', ', ')
69+
if (searchType == "gallery") {
70+
// Gallery searches for a list of folders rather than a list of models.
71+
72+
let pattern = ''
73+
let extraPattern = ''
74+
let _onlyDirectories = true
75+
76+
if (searchTerm) {
77+
if (searchTerm.split(">").length > 1) {
78+
pattern = searchTerm.split(">")[0] ? `*${searchTerm.split(">")[0]}*` : '*'
79+
extraPattern = '/' + (searchTerm.split(">")[1] ? `*${searchTerm.split(">")[1]}*` : '*')
80+
_onlyDirectories = false
81+
} else {
82+
pattern = `*${searchTerm}*`
83+
}
84+
} else {
85+
pattern = '*'
10886
}
10987

110-
let prompt = null
111-
if (searchType == "lora") {
112-
prompt = `${keywords ? keywords+" " : ""}<lora:${noext}:${weight}>`
113-
} else if (searchType == "hypernets") {
114-
prompt = `${keywords ? keywords+" " : ""}<hypernet:${noext}:${weight}>`
88+
const files = fg.globSync([`networks/${searchType}/**/${pattern}${extraPattern}`], { onlyDirectories: _onlyDirectories, dot: true, caseSensitiveMatch: false, stats: true })
89+
90+
images = files.map(file => {
91+
// Expected filename example:
92+
// "name1-name2_author [keyword1, keyword2] [keyword3, keyword4] (model) {weight1-weight2} #tag1 #tag2.jpeg"
93+
94+
let filename = (file.name.indexOf("." + imgExt) > -1) ? file.name : file.name + "." + imgExt
95+
let path = file.path.replace(file.name, '').replace('networks/', '').toLowerCase()
96+
97+
// This return only for files.map
98+
return {
99+
filename: filename,
100+
path: path,
101+
}
102+
})
103+
res.json(images)
104+
105+
} else {
106+
let ext = null
107+
if (searchType == "lora" || searchType == "checkpoints") {
108+
ext = "safetensors"
109+
} else if (searchType == "embeddings" || searchType == "hypernets") {
110+
ext = "(pt|safetensors)"
115111
} else {
116-
prompt = noext
112+
return res.status(400).send({
113+
message: 'Invalid type requested.'
114+
});
117115
}
118116

119-
// This return is for the files map
120-
return {
121-
filename: filename,
122-
path: path,
123-
name: name,
124-
author: author,
125-
tags: tags,
126-
keywords: keywords,
127-
weight: weight,
128-
prompt: prompt,
129-
mtimeMs: file.stats.mtimeMs,
130-
mtime: file.stats.mtime,
117+
let pattern = ''
118+
let extraPattern = ''
119+
120+
if (searchTerm) {
121+
if (searchTerm.split(">").length > 1) {
122+
pattern = searchTerm.split(">")[0] ? `*${searchTerm.split(">")[0]}*` : '*'
123+
extraPattern = '/' + (searchTerm.split(">")[1] ? `*${searchTerm.split(">")[1]}*` : '*')
124+
} else {
125+
pattern = `*${searchTerm}*`
126+
extraPattern = "." + ext
127+
}
128+
} else {
129+
pattern = '*'
130+
extraPattern = "." + ext
131131
}
132-
})
133-
res.json(images)
132+
133+
const files = fg.globSync([`networks/${searchType}/**/${pattern}${extraPattern}`], { dot: true, caseSensitiveMatch: false, stats: true })
134+
135+
images = files.map(file => {
136+
// Expected filename example:
137+
// "name1-name2_author [keyword1, keyword2] [keyword3, keyword4] (model) {weight1-weight2} #tag1 #tag2.jpeg"
138+
139+
let filematch = file.name.match(/(.*)\.(.*?)$/)
140+
let filename = filematch ? filematch[1] + "." + imgExt : ""
141+
let noext = filematch ? filematch[1] : ""
142+
let words = noext.split(' ')
143+
let path = file.path.replace(file.name, '').replace('networks/', '').toLowerCase()
144+
let name = noext.match(/^(.*)_([0-9a-zA-Z]+)\s/)
145+
name = name ? name[1] : null
146+
let author = noext.match(/_([0-9a-zA-Z]+)\s/)
147+
author = author ? author[1] : null
148+
let hashtagWords = words.filter(word => word.startsWith("#"))
149+
let tags = hashtagWords.map(word => word.slice(1))
150+
let weight = noext.match(/{(?:[0-9]*\.?[0-9]+\s?-)?([0-9]*\.?[0-9]+)}/)
151+
weight = weight ? weight[1] : "1.0"
152+
153+
let keywords = filename.match(/\[(.*)\]/)
154+
if (keywords) {
155+
keywords = keywords[1]
156+
keywords = keywords.replaceAll(/©/g, ':')
157+
keywords = keywords.replaceAll(//g, '>')
158+
keywords = keywords.replaceAll(//g, '<')
159+
keywords = keywords.replaceAll(/(\w+?) \((\w+?)\)/gi, '$1 \\($2\\)')
160+
keywords = keywords.replaceAll('[', '')
161+
keywords = keywords.replaceAll(']', ', ')
162+
}
163+
164+
let prompt = null
165+
if (searchType == "lora") {
166+
prompt = `${keywords ? keywords+" " : ""}<lora:${noext}:${weight}>`
167+
} else if (searchType == "hypernets") {
168+
prompt = `${keywords ? keywords+" " : ""}<hypernet:${noext}:${weight}>`
169+
} else {
170+
prompt = noext
171+
}
172+
173+
// This return only for files.map
174+
return {
175+
filename: filename,
176+
path: path,
177+
name: name,
178+
author: author,
179+
tags: tags,
180+
keywords: keywords,
181+
weight: weight,
182+
prompt: prompt,
183+
mtimeMs: file.stats.mtimeMs,
184+
mtime: file.stats.mtime,
185+
}
186+
})
187+
188+
res.json(images)
189+
}
190+
134191
}
135192
})
136193

@@ -141,24 +198,28 @@ app.get('/moreImages', (req, res) => {
141198
message: 'Missing query.'
142199
});
143200

144-
const noext = search.replace('.' + imgExt, '')
145-
.replaceAll('$','\\$')
201+
// Chars like {} break the glob search if unescaped.
202+
const filteredSearch = search.replaceAll('$','\\$')
146203
.replaceAll('^','\\^')
147-
.replaceAll('+','\\+')
148204
.replaceAll('?','\\?')
149205
.replaceAll('(','\\(')
150206
.replaceAll(')','\\)')
151207
.replaceAll('[','\\[')
152208
.replaceAll(']','\\]')
153209
.replaceAll('{','\\{')
154210
.replaceAll('}','\\}')
155-
const query = `networks/${noext}(.*|).${imgExt}`
156-
const files = fg.globSync([query], { dot: true, caseSensitiveMatch: false, stats: true })
211+
212+
const noext = filteredSearch.substring(filteredSearch.lastIndexOf('/') + 1).replace('.' + imgExt, '')
213+
const searchPath = filteredSearch.substring(0, filteredSearch.lastIndexOf('/'))
214+
215+
const query = `networks/${searchPath}/${noext}(.*|).${imgExt}`
216+
const queryFolder = `networks/${searchPath}/${noext}/*.${imgExt}`
217+
const files = fg.globSync([query, queryFolder], { dot: true, caseSensitiveMatch: false, stats: true })
157218

158219
const images = files.map(file => {
159-
const path = file.path.replace(file.name, '').replace('networks/', '').toLowerCase()
220+
const path = file.path.replace(file.name, '').replace('networks/', '').toLowerCase()
160221

161-
// This return is for the files map
222+
// This return is only for files.map
162223
return {
163224
filename: file.name,
164225
path: path,

api/networks/init.bat

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ mkdir checkpoints
44
mkdir embeddings
55
mkdir hypernets
66
mkdir styles
7+
mkdir gallery
78
echo n | copy /-y styles.csv.example styles.csv

app/src/GetImages.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ export default function GetImages() {
223223
<span title="Embedding" className={`typeOption ${type == "embeddings" ? "highlight" : ""}`} onClick={() => handleTypeChange("embeddings")}>E</span>
224224
<span title="HyperNetwork" className={`typeOption ${type == "hypernets" ? "highlight" : ""}`} onClick={() => handleTypeChange("hypernets")}>H</span>
225225
<span title="Checkpoint" className={`typeOption ${type == "checkpoints" ? "highlight" : ""}`} onClick={() => handleTypeChange("checkpoints")}>C</span>
226+
<span title="Gallery" className={`typeOption ${type == "gallery" ? "highlight" : ""}`} onClick={() => handleTypeChange("gallery")}>G</span>
226227
</div>
227228
<input id="imgSearch" icon='search' placeholder='Search...'
228229
onChange={handleSearchInputChange}
@@ -260,7 +261,7 @@ export default function GetImages() {
260261
{!moreLoading && !moreError && images[moreIndex] && moreImages[0] && !moreDocument && moreImages.map((image, index) => (
261262
<div key={index+1000000} className="imgCard" onClick={() => {navigator.clipboard.writeText(images[moreIndex].prompt)}}>
262263
<img width="224" height="336"
263-
src={"http://localhost:3000/" + image.path + encodeURIComponent(image.filename)}
264+
src={"http://localhost:3000/" + image.path.replace('#','%23') + encodeURIComponent(image.filename)}
264265
loading={index <= 60 ? "eager" : "lazy"}
265266
title={image.filename}
266267
/>

app/src/Image.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ export default function Image(props) {
22
return (
33
<div className="imgCard" onClick={() => {navigator.clipboard.writeText(props.prompt)}}>
44
<img width="224" height="336"
5-
src={"http://localhost:3000/" + props.path + encodeURIComponent(props.filename)}
5+
src={"http://localhost:3000/" + props.path.replace('#','%23') + encodeURIComponent(props.filename)}
66
loading={props.index <= 60 ? "eager" : "lazy"}
77
title={props.path + props.filename}
88
/>

update.bat

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ git pull
55
cd %~dp0api && call npm install --no-audit
66
cd %~dp0app && call npm install --no-audit
77
cd %~dp0app && call npm run build
8+
cd %~dp0api/networks && call init.bat
89
cd %~dp0
910
echo Update complete.
1011
echo Run start.bat to begin.

0 commit comments

Comments
 (0)