mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-23 19:51:15 -03:00
Compare commits
15 Commits
5c53968caa
...
v1.0.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94e1a8ac7b | ||
|
|
cc20d3b992 | ||
|
|
a74cbe7aa2 | ||
|
|
94edfaa190 | ||
|
|
31c54ff068 | ||
|
|
21872a8e9e | ||
|
|
612612f1c7 | ||
|
|
ff240db5b1 | ||
|
|
bcfed4b874 | ||
|
|
1352c6ecbe | ||
|
|
30b01b8a92 | ||
|
|
a105cb322b | ||
|
|
3bf396d003 | ||
|
|
60cfb3b8e0 | ||
|
|
6763abb83c |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,6 +15,7 @@ model_cache/
|
|||||||
# agent
|
# agent
|
||||||
.opencode/
|
.opencode/
|
||||||
.claude/
|
.claude/
|
||||||
|
.sisyphus/
|
||||||
.codex
|
.codex
|
||||||
|
|
||||||
# Vue widgets development cache (but keep build output)
|
# Vue widgets development cache (but keep build output)
|
||||||
|
|||||||
@@ -15,215 +15,222 @@
|
|||||||
"Phil",
|
"Phil",
|
||||||
"Carl G.",
|
"Carl G.",
|
||||||
"Arlecchino Shion",
|
"Arlecchino Shion",
|
||||||
"stone9k",
|
|
||||||
"$MetaSamsara",
|
"$MetaSamsara",
|
||||||
|
"Rob Williams",
|
||||||
|
"stone9k",
|
||||||
|
"runte3221",
|
||||||
|
"Kiba",
|
||||||
|
"Mozzel",
|
||||||
"itismyelement",
|
"itismyelement",
|
||||||
"Gingko Biloba",
|
"Gingko Biloba",
|
||||||
"onesecondinosaur",
|
"onesecondinosaur",
|
||||||
|
"Christian Byrne",
|
||||||
|
"DM",
|
||||||
|
"Sen314",
|
||||||
|
"Estragon",
|
||||||
"Takkan",
|
"Takkan",
|
||||||
"Charles Blakemore",
|
"Charles Blakemore",
|
||||||
"Rob Williams",
|
|
||||||
"Rosenthal",
|
"Rosenthal",
|
||||||
|
"ClockDaemon",
|
||||||
"Francisco Tatis",
|
"Francisco Tatis",
|
||||||
"Tobi_Swagg",
|
"Tobi_Swagg",
|
||||||
|
"SG",
|
||||||
|
"jmack",
|
||||||
"Andrew Wilson",
|
"Andrew Wilson",
|
||||||
"Greybush",
|
"Greybush",
|
||||||
|
"iamresist",
|
||||||
|
"Wolffen",
|
||||||
"Ricky Carter",
|
"Ricky Carter",
|
||||||
"JongWon Han",
|
"JongWon Han",
|
||||||
"VantAI",
|
"VantAI",
|
||||||
"runte3221",
|
"Tim",
|
||||||
|
"Michael Wong",
|
||||||
"Illrigger",
|
"Illrigger",
|
||||||
|
"Tom Corrigan",
|
||||||
|
"JackieWang",
|
||||||
"FreelancerZ",
|
"FreelancerZ",
|
||||||
|
"fnkylove",
|
||||||
|
"Echo",
|
||||||
|
"Lilleman",
|
||||||
|
"Robert Stacey",
|
||||||
|
"PM",
|
||||||
"Edgar Tejeda",
|
"Edgar Tejeda",
|
||||||
"Jorge Hussni",
|
"Jorge Hussni",
|
||||||
"Liam MacDougal",
|
"Liam MacDougal",
|
||||||
|
"Sterilized",
|
||||||
"Fraser Cross",
|
"Fraser Cross",
|
||||||
"Polymorphic Indeterminate",
|
"Polymorphic Indeterminate",
|
||||||
"Marc Whiffen",
|
"Marc Whiffen",
|
||||||
"Birdy",
|
"Birdy",
|
||||||
"Skalabananen",
|
"Skalabananen",
|
||||||
"Kiba",
|
"quarz",
|
||||||
"Reno Lam",
|
"Reno Lam",
|
||||||
"Mozzel",
|
"JSST",
|
||||||
"sig",
|
"sig",
|
||||||
"Christian Byrne",
|
|
||||||
"DM",
|
|
||||||
"Sen314",
|
|
||||||
"Estragon",
|
|
||||||
"J\\B/ 8r0wns0n",
|
"J\\B/ 8r0wns0n",
|
||||||
"Snaggwort",
|
"Snaggwort",
|
||||||
"ClockDaemon",
|
"Baekdoosixt",
|
||||||
"Jonathan Ross",
|
"Jonathan Ross",
|
||||||
"KD",
|
"KD",
|
||||||
"Omnidex",
|
"Omnidex",
|
||||||
"Nazono_hito",
|
"Nazono_hito",
|
||||||
|
"Melville Parrish",
|
||||||
|
"daniel dove",
|
||||||
|
"Lustre",
|
||||||
"Tyler Trebuchon",
|
"Tyler Trebuchon",
|
||||||
"Release Cabrakan",
|
"Release Cabrakan",
|
||||||
|
"JW Sin",
|
||||||
"contrite831",
|
"contrite831",
|
||||||
"SG",
|
"Alex",
|
||||||
"carozzz",
|
"carozzz",
|
||||||
|
"Marlon Daniels",
|
||||||
"James Dooley",
|
"James Dooley",
|
||||||
"zenbound",
|
"zenbound",
|
||||||
"Buzzard",
|
"Buzzard",
|
||||||
"jmack",
|
|
||||||
"Adam Shaw",
|
"Adam Shaw",
|
||||||
"Mark Corneglio",
|
"Mark Corneglio",
|
||||||
"SarcasticHashtag",
|
"SarcasticHashtag",
|
||||||
"Anthony Rizzo",
|
"Anthony Rizzo",
|
||||||
"iamresist",
|
|
||||||
"Gooohokrbe",
|
"Gooohokrbe",
|
||||||
"RedrockVP",
|
"RedrockVP",
|
||||||
"Wolffen",
|
|
||||||
"James Todd",
|
"James Todd",
|
||||||
|
"ASLPro3D",
|
||||||
"OldBones",
|
"OldBones",
|
||||||
|
"FinalyFree",
|
||||||
"Steven Pfeiffer",
|
"Steven Pfeiffer",
|
||||||
"Tim",
|
|
||||||
"Timmy",
|
"Timmy",
|
||||||
"Johnny",
|
"Johnny",
|
||||||
"Lisster",
|
|
||||||
"Michael Wong",
|
|
||||||
"whudunit",
|
|
||||||
"Tom Corrigan",
|
|
||||||
"dl0901dm",
|
|
||||||
"JackieWang",
|
|
||||||
"fnkylove",
|
|
||||||
"Yushio",
|
|
||||||
"Vik71it",
|
|
||||||
"Echo",
|
|
||||||
"Lilleman",
|
|
||||||
"Robert Stacey",
|
|
||||||
"PM",
|
|
||||||
"Todd Keck",
|
|
||||||
"Briton Heilbrun",
|
|
||||||
"Aleksander Wujczyk",
|
|
||||||
"BadassArabianMofo",
|
|
||||||
"Sterilized",
|
|
||||||
"Pascal Dahle",
|
|
||||||
"quarz",
|
|
||||||
"Penfore",
|
|
||||||
"Greg",
|
|
||||||
"JSST",
|
|
||||||
"lmsupporter",
|
|
||||||
"zounic",
|
|
||||||
"wfpearl",
|
|
||||||
"Baekdoosixt",
|
|
||||||
"Jack B Nimble",
|
|
||||||
"Melville Parrish",
|
|
||||||
"daniel dove",
|
|
||||||
"Lustre",
|
|
||||||
"JW Sin",
|
|
||||||
"Alex",
|
|
||||||
"bh",
|
|
||||||
"Marlon Daniels",
|
|
||||||
"Starkselle",
|
|
||||||
"Aaron Bleuer",
|
|
||||||
"LacesOut!",
|
|
||||||
"greebles",
|
|
||||||
"Cosmosis",
|
|
||||||
"M Postkasse",
|
|
||||||
"FloPro4Sho",
|
|
||||||
"ASLPro3D",
|
|
||||||
"Jacob Hoehler",
|
|
||||||
"FinalyFree",
|
|
||||||
"Weasyl",
|
|
||||||
"Lex Song",
|
|
||||||
"Cory Paza",
|
|
||||||
"Tak",
|
"Tak",
|
||||||
"Gonzalo Andre Allendes Lopez",
|
"Lisster",
|
||||||
"Zach Gonser",
|
"Zach Gonser",
|
||||||
"Big Red",
|
"Big Red",
|
||||||
"Jimmy Ledbetter",
|
"whudunit",
|
||||||
"Luc Job",
|
"Luc Job",
|
||||||
"Philip Hempel",
|
"dl0901dm",
|
||||||
"corde",
|
"corde",
|
||||||
"Nick Walker",
|
"Nick Walker",
|
||||||
"Julian V",
|
"Yushio",
|
||||||
"Steven Owens",
|
"Vik71it",
|
||||||
"Bishoujoker",
|
"Bishoujoker",
|
||||||
"aai",
|
"Todd Keck",
|
||||||
|
"Briton Heilbrun",
|
||||||
"Tori",
|
"Tori",
|
||||||
"wildnut",
|
"wildnut",
|
||||||
"jean jahren",
|
"jean jahren",
|
||||||
|
"Aleksander Wujczyk",
|
||||||
"AM Kuro",
|
"AM Kuro",
|
||||||
"ViperC",
|
"BadassArabianMofo",
|
||||||
"Ran C",
|
"Pascal Dahle",
|
||||||
"Sangheili460",
|
"Penfore",
|
||||||
|
"Greg",
|
||||||
"MagnaInsomnia",
|
"MagnaInsomnia",
|
||||||
"Karl P.",
|
|
||||||
"Akira_HentAI",
|
"Akira_HentAI",
|
||||||
"Gordon Cole",
|
"Gordon Cole",
|
||||||
"yuxz69",
|
"AbstractAss",
|
||||||
"esthe",
|
"lmsupporter",
|
||||||
"andrew.tappan",
|
"andrew.tappan",
|
||||||
"N/A",
|
"N/A",
|
||||||
|
"Greenmoustache",
|
||||||
|
"zounic",
|
||||||
|
"wfpearl",
|
||||||
|
"Eldithor",
|
||||||
|
"Jack B Nimble",
|
||||||
|
"JaxMax",
|
||||||
|
"bh",
|
||||||
|
"Jwk0205",
|
||||||
|
"Starkselle",
|
||||||
|
"Olive",
|
||||||
|
"Aaron Bleuer",
|
||||||
|
"LacesOut!",
|
||||||
|
"greebles",
|
||||||
|
"Some Guy Named Barry",
|
||||||
|
"Cosmosis",
|
||||||
|
"M Postkasse",
|
||||||
|
"FloPro4Sho",
|
||||||
|
"wamekukyouzin",
|
||||||
|
"Jacob Hoehler",
|
||||||
|
"Matt Wenzel",
|
||||||
|
"Weasyl",
|
||||||
|
"Lex Song",
|
||||||
|
"Cory Paza",
|
||||||
|
"Gonzalo Andre Allendes Lopez",
|
||||||
|
"Serge Bekenkamp",
|
||||||
|
"Jimmy Ledbetter",
|
||||||
|
"Philip Hempel",
|
||||||
|
"ApathyJones",
|
||||||
|
"Julian V",
|
||||||
|
"Steven Owens",
|
||||||
|
"dan",
|
||||||
|
"aai",
|
||||||
|
"Mouthlessman",
|
||||||
|
"otaku fra",
|
||||||
|
"ViperC",
|
||||||
|
"Ran C",
|
||||||
|
"MiraiKuriyamaSy",
|
||||||
|
"Sangheili460",
|
||||||
|
"Karl P.",
|
||||||
|
"yuxz69",
|
||||||
|
"Adam Taylor",
|
||||||
|
"Weird_With_A_Beard",
|
||||||
|
"esthe",
|
||||||
"The Spawn",
|
"The Spawn",
|
||||||
"graysock",
|
"graysock",
|
||||||
"Pozadine1",
|
"Pozadine1",
|
||||||
"Greenmoustache",
|
|
||||||
"fancypants",
|
|
||||||
"IamAyam",
|
|
||||||
"Eldithor",
|
|
||||||
"Joboshy",
|
|
||||||
"Digital",
|
|
||||||
"JaxMax",
|
|
||||||
"takyamtom",
|
|
||||||
"Bohemian Corporal",
|
|
||||||
"Dan",
|
|
||||||
"confiscated Zyra",
|
|
||||||
"Jwk0205",
|
|
||||||
"Bro Xie",
|
|
||||||
"yer fey",
|
|
||||||
"batblue",
|
|
||||||
"carey6409",
|
|
||||||
"Olive",
|
|
||||||
"太郎 ゲーム",
|
|
||||||
"Tee Gee",
|
|
||||||
"Some Guy Named Barry",
|
|
||||||
"jinxedx",
|
|
||||||
"tarek helmi",
|
|
||||||
"Max Marklund",
|
|
||||||
"AELOX",
|
|
||||||
"Dankin",
|
|
||||||
"Nicfit23",
|
|
||||||
"wamekukyouzin",
|
|
||||||
"drum matthieu",
|
|
||||||
"Dogmaster",
|
|
||||||
"Matt Wenzel",
|
|
||||||
"Frank Nitty",
|
|
||||||
"Pronredn",
|
|
||||||
"Christopher Michel",
|
|
||||||
"Serge Bekenkamp",
|
|
||||||
"DougPeterson",
|
|
||||||
"LeoZero",
|
|
||||||
"Antonio Pontes",
|
|
||||||
"ApathyJones",
|
|
||||||
"nahinahi9",
|
|
||||||
"lh qwe",
|
|
||||||
"Kevin John Duck",
|
|
||||||
"conner",
|
|
||||||
"Dustin Chen",
|
|
||||||
"dan",
|
|
||||||
"Blackfish95",
|
|
||||||
"Mouthlessman",
|
|
||||||
"Princess Bright Eyes",
|
|
||||||
"Paul Kroll",
|
|
||||||
"AbstractAss",
|
|
||||||
"otaku fra",
|
|
||||||
"Felipe dos Santos",
|
|
||||||
"Bas Imagineer",
|
|
||||||
"Markus",
|
|
||||||
"MiraiKuriyamaSy",
|
|
||||||
"Adam Taylor",
|
|
||||||
"Douglas Gaspar",
|
|
||||||
"Weird_With_A_Beard",
|
|
||||||
"AlexDuKaNa",
|
|
||||||
"George",
|
|
||||||
"dw",
|
|
||||||
"Qarob",
|
"Qarob",
|
||||||
"AIGooner",
|
"AIGooner",
|
||||||
"Luc",
|
"Luc",
|
||||||
"ProtonPrince",
|
"ProtonPrince",
|
||||||
"DiffDuck",
|
"DiffDuck",
|
||||||
|
"fancypants",
|
||||||
|
"IamAyam",
|
||||||
|
"Joboshy",
|
||||||
|
"Digital",
|
||||||
|
"takyamtom",
|
||||||
|
"Bohemian Corporal",
|
||||||
|
"Dan",
|
||||||
|
"confiscated Zyra",
|
||||||
|
"Bro Xie",
|
||||||
|
"yer fey",
|
||||||
|
"batblue",
|
||||||
|
"carey6409",
|
||||||
|
"太郎 ゲーム",
|
||||||
|
"Roslynd",
|
||||||
|
"Tee Gee",
|
||||||
|
"jinxedx",
|
||||||
|
"tarek helmi",
|
||||||
|
"Neco28",
|
||||||
|
"Max Marklund",
|
||||||
|
"AELOX",
|
||||||
|
"Dankin",
|
||||||
|
"Nicfit23",
|
||||||
|
"Cristian Vazquez",
|
||||||
|
"drum matthieu",
|
||||||
|
"Dogmaster",
|
||||||
|
"Frank Nitty",
|
||||||
|
"Magic Noob",
|
||||||
|
"Pronredn",
|
||||||
|
"Christopher Michel",
|
||||||
|
"DougPeterson",
|
||||||
|
"LeoZero",
|
||||||
|
"Antonio Pontes",
|
||||||
|
"Bruce",
|
||||||
|
"nahinahi9",
|
||||||
|
"lh qwe",
|
||||||
|
"Kevin John Duck",
|
||||||
|
"conner",
|
||||||
|
"Dustin Chen",
|
||||||
|
"Blackfish95",
|
||||||
|
"Princess Bright Eyes",
|
||||||
|
"Paul Kroll",
|
||||||
|
"Felipe dos Santos",
|
||||||
|
"Bas Imagineer",
|
||||||
|
"Markus",
|
||||||
|
"John Statham",
|
||||||
|
"Douglas Gaspar",
|
||||||
|
"AlexDuKaNa",
|
||||||
|
"George",
|
||||||
|
"dw",
|
||||||
|
"decoy",
|
||||||
"elu3199",
|
"elu3199",
|
||||||
"Hasturkun",
|
"Hasturkun",
|
||||||
"Jon Sandman",
|
"Jon Sandman",
|
||||||
@@ -233,56 +240,59 @@
|
|||||||
"wundershark",
|
"wundershark",
|
||||||
"mr_dinosaur",
|
"mr_dinosaur",
|
||||||
"Tyrswood",
|
"Tyrswood",
|
||||||
|
"Ray Wing",
|
||||||
|
"Ranzitho",
|
||||||
|
"Gus",
|
||||||
|
"MJG",
|
||||||
|
"David LaVallee",
|
||||||
"linnfrey",
|
"linnfrey",
|
||||||
"Pkrsky",
|
|
||||||
"奚明 刘",
|
"奚明 刘",
|
||||||
"Josef Lanzl",
|
"Josef Lanzl",
|
||||||
"Nerezza",
|
"Nerezza",
|
||||||
|
"sanborondon",
|
||||||
"Griffin Dahlberg",
|
"Griffin Dahlberg",
|
||||||
"준희 김",
|
"준희 김",
|
||||||
"Error_Rule34_Not_found",
|
"Error_Rule34_Not_found",
|
||||||
|
"Taylor Funk",
|
||||||
|
"aezin",
|
||||||
|
"jcay015",
|
||||||
"Gerald Welly",
|
"Gerald Welly",
|
||||||
"Roslynd",
|
"Erik Lopez",
|
||||||
|
"Mateo Curić",
|
||||||
"Geolog",
|
"Geolog",
|
||||||
"Neco28",
|
"Eris3D",
|
||||||
"Tomohiro Baba",
|
"Tomohiro Baba",
|
||||||
"David Ortega",
|
"David Ortega",
|
||||||
"Noora",
|
"Noora",
|
||||||
"Cristian Vazquez",
|
|
||||||
"Mattssn",
|
"Mattssn",
|
||||||
"Magic Noob",
|
"a _",
|
||||||
"Jeff",
|
"Jeff",
|
||||||
"Bruce",
|
"James Coleman",
|
||||||
"Kevin Christopher",
|
"Kevin Christopher",
|
||||||
|
"Emil Andersson",
|
||||||
"Ouro Boros",
|
"Ouro Boros",
|
||||||
"Chad Idk",
|
"Chad Idk",
|
||||||
"Yaboi",
|
|
||||||
"dd",
|
"dd",
|
||||||
"Steam Steam",
|
"Steam Steam",
|
||||||
"CryptoTraderJK",
|
"CryptoTraderJK",
|
||||||
"Davaitamin",
|
"Davaitamin",
|
||||||
"Dušan Ryban",
|
"Dušan Ryban",
|
||||||
"tedcor",
|
"tedcor",
|
||||||
|
"Sam",
|
||||||
"Fotek Design",
|
"Fotek Design",
|
||||||
"sjon kreutz",
|
"sjon kreutz",
|
||||||
"John Statham",
|
|
||||||
"MadSpin",
|
"MadSpin",
|
||||||
"Metryman55",
|
"Metryman55",
|
||||||
"inbijiburu",
|
"inbijiburu",
|
||||||
"decoy",
|
|
||||||
"Nick “Loadstone” D",
|
"Nick “Loadstone” D",
|
||||||
"Ray Wing",
|
|
||||||
"Ranzitho",
|
|
||||||
"Gus",
|
|
||||||
"地獄の禄",
|
"地獄の禄",
|
||||||
"MJG",
|
|
||||||
"David LaVallee",
|
|
||||||
"ae",
|
"ae",
|
||||||
"Tr4shP4nda",
|
"Tr4shP4nda",
|
||||||
"Gamalonia",
|
"Gamalonia",
|
||||||
"WRL_SPR",
|
"WRL_SPR",
|
||||||
"capn",
|
"capn",
|
||||||
"Joseph",
|
"Joseph",
|
||||||
|
"momokai",
|
||||||
"Mirko Katzula",
|
"Mirko Katzula",
|
||||||
"dan",
|
"dan",
|
||||||
"Piccio08",
|
"Piccio08",
|
||||||
@@ -296,54 +306,57 @@
|
|||||||
"kudari",
|
"kudari",
|
||||||
"Naomi Hale Danchi",
|
"Naomi Hale Danchi",
|
||||||
"dc7431",
|
"dc7431",
|
||||||
|
"epicgamer0020690",
|
||||||
|
"Joshua Porrata",
|
||||||
|
"SuBu",
|
||||||
|
"RedPIXel",
|
||||||
"Vir",
|
"Vir",
|
||||||
|
"Richard",
|
||||||
|
"Andrew",
|
||||||
"Brian M",
|
"Brian M",
|
||||||
"sanborondon",
|
"Robert Wegemund",
|
||||||
"Seth Christensen",
|
"Littlehuggy",
|
||||||
"Draven T",
|
"Draven T",
|
||||||
"Taylor Funk",
|
"mrjuan",
|
||||||
"aezin",
|
"Brian Buie",
|
||||||
"Thought2Form",
|
"Thought2Form",
|
||||||
"jcay015",
|
|
||||||
"Kevin Picco",
|
"Kevin Picco",
|
||||||
"Erik Lopez",
|
"Sadlip",
|
||||||
"Mateo Curić",
|
|
||||||
"Aquatic Coffee",
|
"Aquatic Coffee",
|
||||||
"Eris3D",
|
|
||||||
"m",
|
"m",
|
||||||
"ethanfel",
|
"ethanfel",
|
||||||
"Pierce McBride",
|
"Pierce McBride",
|
||||||
"Joshua Gray",
|
"Joshua Gray",
|
||||||
"Focuschannel",
|
"Focuschannel",
|
||||||
"Mikko Hemilä",
|
"Mikko Hemilä",
|
||||||
|
"Jacob McDaniel",
|
||||||
"Jamie Ogletree",
|
"Jamie Ogletree",
|
||||||
"a _",
|
"Temikus",
|
||||||
"James Coleman",
|
"Artokun",
|
||||||
|
"Michael Taylor",
|
||||||
|
"Derek Baker",
|
||||||
"Martial",
|
"Martial",
|
||||||
"Anthony Faxlandez",
|
"Anthony Faxlandez",
|
||||||
"battu",
|
"battu",
|
||||||
"Emil Andersson",
|
"Michael Anthony Scott",
|
||||||
|
"Atilla Berke Pekduyar",
|
||||||
|
"Decx _",
|
||||||
"Yuji Kaneko",
|
"Yuji Kaneko",
|
||||||
"Pat Hen",
|
"Pat Hen",
|
||||||
"semicolon drainpipe",
|
|
||||||
"Jordan Shaw",
|
"Jordan Shaw",
|
||||||
"Rops Alot",
|
"Rops Alot",
|
||||||
"Thesharingbrother",
|
"Thesharingbrother",
|
||||||
"Sam",
|
|
||||||
"Ace Ventura",
|
"Ace Ventura",
|
||||||
"ResidentDeviant",
|
"ResidentDeviant",
|
||||||
|
"四糸凜音",
|
||||||
"Nihongasuki",
|
"Nihongasuki",
|
||||||
"JC",
|
"JC",
|
||||||
"Prompt Pirate",
|
"Prompt Pirate",
|
||||||
"uwutismxd",
|
"uwutismxd",
|
||||||
"momokai",
|
|
||||||
"zenobeus",
|
"zenobeus",
|
||||||
"ken",
|
"ken",
|
||||||
"epicgamer0020690",
|
"Crocket",
|
||||||
"Joshua Porrata",
|
|
||||||
"keemun",
|
"keemun",
|
||||||
"SuBu",
|
|
||||||
"RedPIXel",
|
|
||||||
"Wind",
|
"Wind",
|
||||||
"Jackthemind",
|
"Jackthemind",
|
||||||
"Nexus",
|
"Nexus",
|
||||||
@@ -362,21 +375,26 @@
|
|||||||
"socrasteeze",
|
"socrasteeze",
|
||||||
"OrganicArtifact",
|
"OrganicArtifact",
|
||||||
"Stryker",
|
"Stryker",
|
||||||
|
"ResidentDeviant",
|
||||||
"MudkipMedkitz",
|
"MudkipMedkitz",
|
||||||
|
"deanbrian",
|
||||||
|
"Alex Wortman",
|
||||||
|
"Cody",
|
||||||
|
"smart.edge5178",
|
||||||
|
"InformedViewz",
|
||||||
|
"CHKeeho80",
|
||||||
|
"Bubbafett",
|
||||||
|
"leaf",
|
||||||
|
"Menard",
|
||||||
|
"Skyfire83",
|
||||||
|
"Adam Rinehart",
|
||||||
"gzmzmvp",
|
"gzmzmvp",
|
||||||
"raf8osz",
|
"raf8osz",
|
||||||
"ElitaSSJ4",
|
"ElitaSSJ4",
|
||||||
"Richard",
|
|
||||||
"blikkies",
|
"blikkies",
|
||||||
"Andrew",
|
|
||||||
"Chris",
|
"Chris",
|
||||||
"Robert Wegemund",
|
|
||||||
"Littlehuggy",
|
|
||||||
"Gregory Kozhemiak",
|
"Gregory Kozhemiak",
|
||||||
"mrjuan",
|
|
||||||
"Brian Buie",
|
|
||||||
"Shock Shockor",
|
"Shock Shockor",
|
||||||
"Sadlip",
|
|
||||||
"Goldwaters",
|
"Goldwaters",
|
||||||
"Eric Whitney",
|
"Eric Whitney",
|
||||||
"Joey Callahan",
|
"Joey Callahan",
|
||||||
@@ -390,30 +408,20 @@
|
|||||||
"Theerat Jiramate",
|
"Theerat Jiramate",
|
||||||
"aRtFuL_DodGeR",
|
"aRtFuL_DodGeR",
|
||||||
"Noah",
|
"Noah",
|
||||||
"Jacob McDaniel",
|
|
||||||
"X",
|
"X",
|
||||||
"Sloan Steddy",
|
"Sloan Steddy",
|
||||||
"Temikus",
|
"hexxish",
|
||||||
"Artokun",
|
|
||||||
"Michael Taylor",
|
|
||||||
"Derek Baker",
|
|
||||||
"CrimsonDX",
|
|
||||||
"Michael Anthony Scott",
|
|
||||||
"DarkSunset",
|
"DarkSunset",
|
||||||
"Atilla Berke Pekduyar",
|
|
||||||
"Nathan",
|
"Nathan",
|
||||||
"Billy Gladky",
|
"Billy Gladky",
|
||||||
"NICHOLAS BAXLEY",
|
"NICHOLAS BAXLEY",
|
||||||
"Decx _",
|
|
||||||
"Probis",
|
"Probis",
|
||||||
"Ed Wang",
|
"Ed Wang",
|
||||||
"ItsGeneralButtNaked",
|
"ItsGeneralButtNaked",
|
||||||
"Nimess",
|
|
||||||
"SRDB",
|
"SRDB",
|
||||||
"g unit",
|
"g unit",
|
||||||
"Distortik",
|
"Distortik",
|
||||||
"Youguang",
|
"Youguang",
|
||||||
"四糸凜音",
|
|
||||||
"Saya",
|
"Saya",
|
||||||
"andrewzpong",
|
"andrewzpong",
|
||||||
"FrxzenSnxw",
|
"FrxzenSnxw",
|
||||||
@@ -421,40 +429,38 @@
|
|||||||
"lrdchs",
|
"lrdchs",
|
||||||
"Tree Tagger",
|
"Tree Tagger",
|
||||||
"Inversity",
|
"Inversity",
|
||||||
"Crocket",
|
|
||||||
"AIVORY3D",
|
"AIVORY3D",
|
||||||
"Kevinj",
|
"Kevinj",
|
||||||
"Mitchell Robson",
|
"Mitchell Robson",
|
||||||
"Whitepinetrader",
|
"Whitepinetrader",
|
||||||
"ResidentDeviant",
|
|
||||||
"deanbrian",
|
|
||||||
"POPPIN",
|
"POPPIN",
|
||||||
"Alex Wortman",
|
"Ginnie",
|
||||||
"Cody",
|
|
||||||
"Raku",
|
"Raku",
|
||||||
"smart.edge5178",
|
"emadsultan",
|
||||||
"InformedViewz",
|
|
||||||
"CHKeeho80",
|
|
||||||
"Bubbafett",
|
|
||||||
"leaf",
|
|
||||||
"Menard",
|
|
||||||
"Skyfire83",
|
|
||||||
"Adam Rinehart",
|
|
||||||
"Pitpe11",
|
"Pitpe11",
|
||||||
"TheD1rtyD03",
|
"TheD1rtyD03",
|
||||||
"moonpetal",
|
"moonpetal",
|
||||||
"SomeDude",
|
"SomeDude",
|
||||||
"g9p0o",
|
"g9p0o",
|
||||||
|
"Pkrsky",
|
||||||
"TheHolySheep",
|
"TheHolySheep",
|
||||||
"Monte Won",
|
"Monte Won",
|
||||||
"SpringBootisTrash",
|
"SpringBootisTrash",
|
||||||
"carsten",
|
"carsten",
|
||||||
"ikok",
|
"ikok",
|
||||||
|
"quantenmecha",
|
||||||
|
"Jason+Nash",
|
||||||
|
"BillyBoy84",
|
||||||
|
"DarkRoast",
|
||||||
|
"letzte",
|
||||||
|
"Nasty+Hobbit",
|
||||||
|
"Sora+Yori",
|
||||||
|
"lrdchs2",
|
||||||
|
"Duk3+Rand0m",
|
||||||
"Nathen+Choi",
|
"Nathen+Choi",
|
||||||
"T",
|
"T",
|
||||||
"LarsesFPC",
|
"LarsesFPC",
|
||||||
"cocona",
|
"cocona",
|
||||||
"sfasdfasfdsa",
|
|
||||||
"Buecyb99",
|
"Buecyb99",
|
||||||
"Welkor",
|
"Welkor",
|
||||||
"David Schenck",
|
"David Schenck",
|
||||||
@@ -463,15 +469,15 @@
|
|||||||
"Ink Temptation",
|
"Ink Temptation",
|
||||||
"moranqianlong",
|
"moranqianlong",
|
||||||
"Kalli Core",
|
"Kalli Core",
|
||||||
|
"Time Valentine",
|
||||||
"elleshar666",
|
"elleshar666",
|
||||||
"ACTUALLY_the_Real_Willem_Dafoe",
|
"ACTUALLY_the_Real_Willem_Dafoe",
|
||||||
"Haru Yotu",
|
"Михал Михалыч",
|
||||||
|
"Matt",
|
||||||
"Kauffy",
|
"Kauffy",
|
||||||
"EpicElric",
|
|
||||||
"Kyron Mahan",
|
"Kyron Mahan",
|
||||||
"Edward Kennedy",
|
"Edward Kennedy",
|
||||||
"Justin Blaylock",
|
"Justin Blaylock",
|
||||||
"Matura Arbeit",
|
|
||||||
"Nick Kage",
|
"Nick Kage",
|
||||||
"TBitz33",
|
"TBitz33",
|
||||||
"Anonym dkjglfleeoeldldldlkf",
|
"Anonym dkjglfleeoeldldldlkf",
|
||||||
@@ -480,12 +486,14 @@
|
|||||||
"Cyrus Fett",
|
"Cyrus Fett",
|
||||||
"Ezokewn",
|
"Ezokewn",
|
||||||
"SendingRavens",
|
"SendingRavens",
|
||||||
"hexxish",
|
"Xenon Xue",
|
||||||
"notedfakes",
|
"notedfakes",
|
||||||
"Michael Docherty",
|
"Michael Docherty",
|
||||||
"Michael Scott",
|
"Michael Scott",
|
||||||
"Paul Hartsuyker",
|
"Paul Hartsuyker",
|
||||||
|
"Henrique Faiolli",
|
||||||
"elitassj",
|
"elitassj",
|
||||||
|
"Solixer",
|
||||||
"Jacob Winter",
|
"Jacob Winter",
|
||||||
"Ryan Presley Ng",
|
"Ryan Presley Ng",
|
||||||
"Wes Sims",
|
"Wes Sims",
|
||||||
@@ -494,7 +502,6 @@
|
|||||||
"David",
|
"David",
|
||||||
"Meilo",
|
"Meilo",
|
||||||
"Filippo Ferrari",
|
"Filippo Ferrari",
|
||||||
"Pen Bouryoung",
|
|
||||||
"shinonomeiro",
|
"shinonomeiro",
|
||||||
"Snille",
|
"Snille",
|
||||||
"MaartenAlbers",
|
"MaartenAlbers",
|
||||||
@@ -511,12 +518,21 @@
|
|||||||
"Kalnei",
|
"Kalnei",
|
||||||
"Scott",
|
"Scott",
|
||||||
"Muratoraccio",
|
"Muratoraccio",
|
||||||
"Ginnie",
|
|
||||||
"emadsultan",
|
|
||||||
"D",
|
"D",
|
||||||
"nanana",
|
"nanana",
|
||||||
|
"Dark_Pest",
|
||||||
|
"Alex",
|
||||||
|
"Jacky+Ho",
|
||||||
|
"Karru",
|
||||||
|
"ghoulars",
|
||||||
|
"ChaChanoKo",
|
||||||
|
"null",
|
||||||
|
"Beau",
|
||||||
|
"redcarrot",
|
||||||
|
"powerbot99",
|
||||||
"Fthehappy",
|
"Fthehappy",
|
||||||
"rsamerica",
|
"rsamerica",
|
||||||
|
"sfasdfasfdsa",
|
||||||
"Alan+Cano",
|
"Alan+Cano",
|
||||||
"FeralOpticsAI",
|
"FeralOpticsAI",
|
||||||
"Pavlaki",
|
"Pavlaki",
|
||||||
@@ -524,60 +540,50 @@
|
|||||||
"Doug+Rintoul",
|
"Doug+Rintoul",
|
||||||
"Noor",
|
"Noor",
|
||||||
"Yorunai",
|
"Yorunai",
|
||||||
"quantenmecha",
|
|
||||||
"abattoirblues",
|
"abattoirblues",
|
||||||
"Jason+Nash",
|
|
||||||
"BillyBoy84",
|
|
||||||
"zounik",
|
"zounik",
|
||||||
"DarkRoast",
|
|
||||||
"letzte",
|
|
||||||
"Nasty+Hobbit",
|
|
||||||
"Sora+Yori",
|
|
||||||
"lrdchs2",
|
|
||||||
"Duk3+Rand0m",
|
|
||||||
"4IXplr0r3r",
|
"4IXplr0r3r",
|
||||||
"hayden",
|
"hayden",
|
||||||
"ahoystan",
|
"ahoystan",
|
||||||
"Leland Saunders",
|
|
||||||
"Bob Barker",
|
"Bob Barker",
|
||||||
"edk",
|
"edk",
|
||||||
"JBsuede",
|
"JBsuede",
|
||||||
"Time Valentine",
|
"Christian Schäfer",
|
||||||
"Aeternyx",
|
|
||||||
"YOU SINWOO",
|
|
||||||
"りん あめ",
|
"りん あめ",
|
||||||
"ja s",
|
"ja s",
|
||||||
"Михал Михалыч",
|
|
||||||
"Matt",
|
|
||||||
"Doug Mason",
|
"Doug Mason",
|
||||||
"Jeremy Townsend",
|
"Jeremy Townsend",
|
||||||
|
"Locrospiel",
|
||||||
"Frogmilk",
|
"Frogmilk",
|
||||||
"Sean voets",
|
"Sean voets",
|
||||||
"Owen Gwosdz",
|
"Owen Gwosdz",
|
||||||
"SPJ",
|
"SPJ",
|
||||||
"Thomas Wanner",
|
"Kor",
|
||||||
|
"Joseph Hanson",
|
||||||
"Bryan Rutkowski",
|
"Bryan Rutkowski",
|
||||||
"Devil Lude",
|
"Devil Lude",
|
||||||
"David Murcko",
|
"David Murcko",
|
||||||
"kevin stoddard",
|
|
||||||
"Jack Dole",
|
"Jack Dole",
|
||||||
"max blo",
|
"max blo",
|
||||||
"Xenon Xue",
|
"Steven",
|
||||||
"CptNeo",
|
"CptNeo",
|
||||||
"JackJohnnyJim",
|
"JackJohnnyJim",
|
||||||
|
"TenaciousD",
|
||||||
"Dmitry Ryzhov",
|
"Dmitry Ryzhov",
|
||||||
|
"Khánh Đặng",
|
||||||
"Maso",
|
"Maso",
|
||||||
"Edward Ten Eyck",
|
"Edward Ten Eyck",
|
||||||
"Eric Ketchum",
|
"Eric Ketchum",
|
||||||
"Kevin Wallace",
|
"Kevin Wallace",
|
||||||
"Matheus Couto",
|
"Jimmy Borup",
|
||||||
"ChicRic",
|
"ChicRic",
|
||||||
"Henrique Faiolli",
|
|
||||||
"mercur",
|
"mercur",
|
||||||
"Solixer",
|
"Pete Pain",
|
||||||
"J C",
|
"RHopkirk",
|
||||||
"jinksta187",
|
"jinksta187",
|
||||||
"Andrew Wilkinson",
|
"Andrew Wilkinson",
|
||||||
|
"Yavizu3d",
|
||||||
|
"Maxim",
|
||||||
"Manu Thetug",
|
"Manu Thetug",
|
||||||
"Karlanx",
|
"Karlanx",
|
||||||
"Yves Poezevara",
|
"Yves Poezevara",
|
||||||
@@ -629,6 +635,20 @@
|
|||||||
"SelfishMedic",
|
"SelfishMedic",
|
||||||
"adderleighn",
|
"adderleighn",
|
||||||
"EnragedAntelope",
|
"EnragedAntelope",
|
||||||
|
"Drizzly",
|
||||||
|
"Sildoren",
|
||||||
|
"Darvidous",
|
||||||
|
"Seon+Song",
|
||||||
|
"2turbo",
|
||||||
|
"balut+omelette",
|
||||||
|
"Nebuleux",
|
||||||
|
"Dmitry+Viznesenskiy",
|
||||||
|
"Tanjin90",
|
||||||
|
"Somebody",
|
||||||
|
"sternenkrieger",
|
||||||
|
"eriick",
|
||||||
|
"Join+Chun",
|
||||||
|
"Pascalou",
|
||||||
"lighthawke",
|
"lighthawke",
|
||||||
"Terraformer",
|
"Terraformer",
|
||||||
"GDS+DEV",
|
"GDS+DEV",
|
||||||
@@ -651,77 +671,66 @@
|
|||||||
"D",
|
"D",
|
||||||
"datasl4ve",
|
"datasl4ve",
|
||||||
"Somebody",
|
"Somebody",
|
||||||
"Dark_Pest",
|
|
||||||
"Aza",
|
|
||||||
"Jacky+Ho",
|
|
||||||
"koopa990",
|
"koopa990",
|
||||||
"Karru",
|
|
||||||
"ChaChanoKo",
|
|
||||||
"null",
|
|
||||||
"bo",
|
|
||||||
"The+Forgetful+Dev",
|
"The+Forgetful+Dev",
|
||||||
"redcarrot",
|
|
||||||
"powerbot99",
|
|
||||||
"Mateusz+Kosela",
|
"Mateusz+Kosela",
|
||||||
"Bula",
|
"Bula",
|
||||||
"KUJYAKU",
|
"KUJYAKU",
|
||||||
"Coeur+de+cochon",
|
"Coeur+de+cochon",
|
||||||
"han b",
|
"han b",
|
||||||
"Nico",
|
"Nico",
|
||||||
|
"Maximilian Krischan",
|
||||||
"Banana Joe",
|
"Banana Joe",
|
||||||
"_ G3n",
|
"_ G3n",
|
||||||
"Donovan Jenkins",
|
"Donovan Jenkins",
|
||||||
"Tú Nguyễn Lý Hoàng",
|
"Tú Nguyễn Lý Hoàng",
|
||||||
|
"shira1011",
|
||||||
"Michael Eid",
|
"Michael Eid",
|
||||||
"beersandbacon",
|
"beersandbacon",
|
||||||
"Maximilian Pyko",
|
|
||||||
"Invis",
|
|
||||||
"Bob barker",
|
"Bob barker",
|
||||||
"Ben D",
|
"Ben D",
|
||||||
"Garrett Wood",
|
"G",
|
||||||
"Ronan Delevacq",
|
"Ronan Delevacq",
|
||||||
"james",
|
"james",
|
||||||
"Christian Schäfer",
|
|
||||||
"OrochiNights",
|
|
||||||
"Michael Zhu",
|
"Michael Zhu",
|
||||||
"gonzalo",
|
"Nemisu",
|
||||||
"Seraphy",
|
"Seraphy",
|
||||||
"雨の心 落",
|
"雨の心 落",
|
||||||
"AllTimeNoobie",
|
"AllTimeNoobie",
|
||||||
|
"Leslie Andrew Ridings",
|
||||||
"jumpd",
|
"jumpd",
|
||||||
"John C",
|
"John C",
|
||||||
"Rim",
|
"Rim",
|
||||||
"Dave Abraham",
|
"Dave Abraham",
|
||||||
"Joaquin Hierrezuelo",
|
"Joaquin Hierrezuelo",
|
||||||
"Dismem",
|
|
||||||
"Locrospiel",
|
|
||||||
"Jairus Knudsen",
|
"Jairus Knudsen",
|
||||||
"Jarrid Lee",
|
"Jarrid Lee",
|
||||||
|
"Poophead27 Blyat",
|
||||||
"Xan Dionysus",
|
"Xan Dionysus",
|
||||||
"Nathan lee",
|
"Nathan lee",
|
||||||
"Kor",
|
|
||||||
"Joseph Hanson",
|
|
||||||
"Mewtora",
|
|
||||||
"Middo",
|
"Middo",
|
||||||
"Forbidden Atelier",
|
"Forbidden Atelier",
|
||||||
"John Rednoulf",
|
"John Rednoulf",
|
||||||
"Spire",
|
"Spire",
|
||||||
|
"DrB",
|
||||||
|
"AZ Party Oasis",
|
||||||
"Adictedtohumping",
|
"Adictedtohumping",
|
||||||
"Boba Smith",
|
"Boba Smith",
|
||||||
"Towelie",
|
"Towelie",
|
||||||
"MR.Bear",
|
"MR.Bear",
|
||||||
|
"matt",
|
||||||
"dsffsdfsdfsdfsdfsdf",
|
"dsffsdfsdfsdfsdfsdf",
|
||||||
|
"somethingtosay8",
|
||||||
"Jean-françois SEMA",
|
"Jean-françois SEMA",
|
||||||
"Kurt",
|
"Kurt",
|
||||||
"ivistorm",
|
"ivistorm",
|
||||||
"Sauv",
|
"Sauv",
|
||||||
"Steven",
|
"jimyjomson",
|
||||||
"TenaciousD",
|
"Borte",
|
||||||
"Khánh Đặng",
|
|
||||||
"Chase Kwon",
|
"Chase Kwon",
|
||||||
"Ted Cart",
|
"Ted Cart",
|
||||||
|
"Sage Himeros",
|
||||||
"Inyoshu",
|
"Inyoshu",
|
||||||
"Goober719",
|
|
||||||
"Chad Barnes",
|
"Chad Barnes",
|
||||||
"Person Y",
|
"Person Y",
|
||||||
"David Spearing",
|
"David Spearing",
|
||||||
@@ -740,7 +749,8 @@
|
|||||||
"dxjaymz",
|
"dxjaymz",
|
||||||
"L C",
|
"L C",
|
||||||
"Dude",
|
"Dude",
|
||||||
|
"Somebody",
|
||||||
"CK"
|
"CK"
|
||||||
],
|
],
|
||||||
"totalCount": 739
|
"totalCount": 749
|
||||||
}
|
}
|
||||||
@@ -233,6 +233,7 @@
|
|||||||
"noCreditRequired": "Kein Credit erforderlich",
|
"noCreditRequired": "Kein Credit erforderlich",
|
||||||
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
||||||
"noTags": "Keine Tags",
|
"noTags": "Keine Tags",
|
||||||
|
"autoTags": "Auto-Tags",
|
||||||
"noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.",
|
"noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.",
|
||||||
"clearAll": "Alle Filter löschen",
|
"clearAll": "Alle Filter löschen",
|
||||||
"any": "Beliebig",
|
"any": "Beliebig",
|
||||||
@@ -1085,6 +1086,12 @@
|
|||||||
"countMessage": "Modelle werden dauerhaft gelöscht.",
|
"countMessage": "Modelle werden dauerhaft gelöscht.",
|
||||||
"action": "Alle löschen"
|
"action": "Alle löschen"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "Mehrere Rezepte löschen",
|
||||||
|
"message": "Sind Sie sicher, dass Sie alle ausgewählten Rezepte und ihre zugehörigen Dateien löschen möchten?",
|
||||||
|
"countMessage": "Rezepte werden dauerhaft gelöscht.",
|
||||||
|
"action": "Alle löschen"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "Alle {typePlural} auf Updates prüfen?",
|
"title": "Alle {typePlural} auf Updates prüfen?",
|
||||||
"message": "Damit werden alle {typePlural} in deiner Bibliothek auf Updates geprüft. Bei großen Sammlungen kann das etwas länger dauern.",
|
"message": "Damit werden alle {typePlural} in deiner Bibliothek auf Updates geprüft. Bei großen Sammlungen kann das etwas länger dauern.",
|
||||||
@@ -1957,4 +1964,4 @@
|
|||||||
"retry": "Wiederholen"
|
"retry": "Wiederholen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"noCreditRequired": "No Credit Required",
|
"noCreditRequired": "No Credit Required",
|
||||||
"allowSellingGeneratedContent": "Allow Selling",
|
"allowSellingGeneratedContent": "Allow Selling",
|
||||||
"noTags": "No tags",
|
"noTags": "No tags",
|
||||||
|
"autoTags": "Auto Tags",
|
||||||
"noBaseModelMatches": "No base models match the current search.",
|
"noBaseModelMatches": "No base models match the current search.",
|
||||||
"clearAll": "Clear All Filters",
|
"clearAll": "Clear All Filters",
|
||||||
"any": "Any",
|
"any": "Any",
|
||||||
@@ -1085,6 +1086,12 @@
|
|||||||
"countMessage": "models will be permanently deleted.",
|
"countMessage": "models will be permanently deleted.",
|
||||||
"action": "Delete All"
|
"action": "Delete All"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "Delete Multiple Recipes",
|
||||||
|
"message": "Are you sure you want to delete all selected recipes and their associated files?",
|
||||||
|
"countMessage": "recipes will be permanently deleted.",
|
||||||
|
"action": "Delete All"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "Check updates for all {typePlural}?",
|
"title": "Check updates for all {typePlural}?",
|
||||||
"message": "This checks every {typePlural} in your library for updates. Large collections may take a little longer.",
|
"message": "This checks every {typePlural} in your library for updates. Large collections may take a little longer.",
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"noCreditRequired": "Sin crédito requerido",
|
"noCreditRequired": "Sin crédito requerido",
|
||||||
"allowSellingGeneratedContent": "Venta permitida",
|
"allowSellingGeneratedContent": "Venta permitida",
|
||||||
"noTags": "Sin etiquetas",
|
"noTags": "Sin etiquetas",
|
||||||
|
"autoTags": "Etiquetas automáticas",
|
||||||
"noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.",
|
"noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.",
|
||||||
"clearAll": "Limpiar todos los filtros",
|
"clearAll": "Limpiar todos los filtros",
|
||||||
"any": "Cualquiera",
|
"any": "Cualquiera",
|
||||||
@@ -1085,6 +1086,12 @@
|
|||||||
"countMessage": "modelos serán eliminados permanentemente.",
|
"countMessage": "modelos serán eliminados permanentemente.",
|
||||||
"action": "Eliminar todo"
|
"action": "Eliminar todo"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "Eliminar múltiples recetas",
|
||||||
|
"message": "¿Estás seguro de que quieres eliminar todas las recetas seleccionadas y sus archivos asociados?",
|
||||||
|
"countMessage": "recetas serán eliminadas permanentemente.",
|
||||||
|
"action": "Eliminar todo"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "¿Comprobar actualizaciones para todos los {typePlural}?",
|
"title": "¿Comprobar actualizaciones para todos los {typePlural}?",
|
||||||
"message": "Esto comprobará las actualizaciones de todos los {typePlural} de tu biblioteca. En colecciones grandes puede tardar un poco más.",
|
"message": "Esto comprobará las actualizaciones de todos los {typePlural} de tu biblioteca. En colecciones grandes puede tardar un poco más.",
|
||||||
@@ -1957,4 +1964,4 @@
|
|||||||
"retry": "Reintentar"
|
"retry": "Reintentar"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"noCreditRequired": "Crédit non requis",
|
"noCreditRequired": "Crédit non requis",
|
||||||
"allowSellingGeneratedContent": "Vente autorisée",
|
"allowSellingGeneratedContent": "Vente autorisée",
|
||||||
"noTags": "Aucun tag",
|
"noTags": "Aucun tag",
|
||||||
|
"autoTags": "Auto-Tags",
|
||||||
"noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.",
|
"noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.",
|
||||||
"clearAll": "Effacer tous les filtres",
|
"clearAll": "Effacer tous les filtres",
|
||||||
"any": "N'importe quel",
|
"any": "N'importe quel",
|
||||||
@@ -1085,6 +1086,12 @@
|
|||||||
"countMessage": "modèles seront définitivement supprimés.",
|
"countMessage": "modèles seront définitivement supprimés.",
|
||||||
"action": "Tout supprimer"
|
"action": "Tout supprimer"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "Supprimer plusieurs recipes",
|
||||||
|
"message": "Êtes-vous sûr de vouloir supprimer toutes les recipes sélectionnées et leurs fichiers associés ?",
|
||||||
|
"countMessage": "recipes seront définitivement supprimées.",
|
||||||
|
"action": "Tout supprimer"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "Vérifier les mises à jour pour tous les {typePlural} ?",
|
"title": "Vérifier les mises à jour pour tous les {typePlural} ?",
|
||||||
"message": "Cette action vérifie les mises à jour pour tous les {typePlural} de votre bibliothèque. Les grandes collections peuvent prendre un peu plus de temps.",
|
"message": "Cette action vérifie les mises à jour pour tous les {typePlural} de votre bibliothèque. Les grandes collections peuvent prendre un peu plus de temps.",
|
||||||
@@ -1957,4 +1964,4 @@
|
|||||||
"retry": "Réessayer"
|
"retry": "Réessayer"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"noCreditRequired": "ללא קרדיט נדרש",
|
"noCreditRequired": "ללא קרדיט נדרש",
|
||||||
"allowSellingGeneratedContent": "אפשר מכירה",
|
"allowSellingGeneratedContent": "אפשר מכירה",
|
||||||
"noTags": "ללא תגיות",
|
"noTags": "ללא תגיות",
|
||||||
|
"autoTags": "תגיות אוטומטיות",
|
||||||
"noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
|
"noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
|
||||||
"clearAll": "נקה את כל המסננים",
|
"clearAll": "נקה את כל המסננים",
|
||||||
"any": "כלשהו",
|
"any": "כלשהו",
|
||||||
@@ -1085,6 +1086,12 @@
|
|||||||
"countMessage": "מודלים יימחקו לצמיתות.",
|
"countMessage": "מודלים יימחקו לצמיתות.",
|
||||||
"action": "מחק הכל"
|
"action": "מחק הכל"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "מחק מספר מתכונים",
|
||||||
|
"message": "האם אתה בטוח שברצונך למחוק את כל המתכונים שנבחרו ואת הקבצים הנלווים אליהם?",
|
||||||
|
"countMessage": "מתכונים יימחקו לצמיתות.",
|
||||||
|
"action": "מחק הכל"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "לבדוק עדכונים לכל ה-{typePlural}?",
|
"title": "לבדוק עדכונים לכל ה-{typePlural}?",
|
||||||
"message": "הפעולה תבדוק עדכונים עבור כל ה-{typePlural} בספרייה שלך. באוספים גדולים זה עלול לקחת מעט יותר זמן.",
|
"message": "הפעולה תבדוק עדכונים עבור כל ה-{typePlural} בספרייה שלך. באוספים גדולים זה עלול לקחת מעט יותר זמן.",
|
||||||
@@ -1957,4 +1964,4 @@
|
|||||||
"retry": "נסה שוב"
|
"retry": "נסה שוב"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"noCreditRequired": "クレジット不要",
|
"noCreditRequired": "クレジット不要",
|
||||||
"allowSellingGeneratedContent": "販売許可",
|
"allowSellingGeneratedContent": "販売許可",
|
||||||
"noTags": "タグなし",
|
"noTags": "タグなし",
|
||||||
|
"autoTags": "自動タグ",
|
||||||
"noBaseModelMatches": "現在の検索に一致するベースモデルはありません。",
|
"noBaseModelMatches": "現在の検索に一致するベースモデルはありません。",
|
||||||
"clearAll": "すべてのフィルタをクリア",
|
"clearAll": "すべてのフィルタをクリア",
|
||||||
"any": "いずれか",
|
"any": "いずれか",
|
||||||
@@ -1085,6 +1086,12 @@
|
|||||||
"countMessage": "モデルが完全に削除されます。",
|
"countMessage": "モデルが完全に削除されます。",
|
||||||
"action": "すべて削除"
|
"action": "すべて削除"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "複数のレシピを削除",
|
||||||
|
"message": "選択したすべてのレシピと関連ファイルを削除してもよろしいですか?",
|
||||||
|
"countMessage": "レシピが完全に削除されます。",
|
||||||
|
"action": "すべて削除"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "すべての{type}の更新を確認しますか?",
|
"title": "すべての{type}の更新を確認しますか?",
|
||||||
"message": "ライブラリ内のすべての{type}で更新を確認します。コレクションが大きい場合は時間がかかることがあります。",
|
"message": "ライブラリ内のすべての{type}で更新を確認します。コレクションが大きい場合は時間がかかることがあります。",
|
||||||
@@ -1957,4 +1964,4 @@
|
|||||||
"retry": "再試行"
|
"retry": "再試行"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"noCreditRequired": "크레딧 표기 없음",
|
"noCreditRequired": "크레딧 표기 없음",
|
||||||
"allowSellingGeneratedContent": "판매 허용",
|
"allowSellingGeneratedContent": "판매 허용",
|
||||||
"noTags": "태그 없음",
|
"noTags": "태그 없음",
|
||||||
|
"autoTags": "자동 태그",
|
||||||
"noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.",
|
"noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.",
|
||||||
"clearAll": "모든 필터 지우기",
|
"clearAll": "모든 필터 지우기",
|
||||||
"any": "아무",
|
"any": "아무",
|
||||||
@@ -1085,6 +1086,12 @@
|
|||||||
"countMessage": "개의 모델이 영구적으로 삭제됩니다.",
|
"countMessage": "개의 모델이 영구적으로 삭제됩니다.",
|
||||||
"action": "모두 삭제"
|
"action": "모두 삭제"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "여러 레시피 삭제",
|
||||||
|
"message": "선택된 모든 레시피와 관련 파일을 삭제하시겠습니까?",
|
||||||
|
"countMessage": "개의 레시피가 영구적으로 삭제됩니다.",
|
||||||
|
"action": "모두 삭제"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "{type} 전체 업데이트를 확인할까요?",
|
"title": "{type} 전체 업데이트를 확인할까요?",
|
||||||
"message": "라이브러리에 있는 모든 {type}의 업데이트를 확인합니다. 컬렉션이 클수록 시간이 조금 더 걸릴 수 있습니다.",
|
"message": "라이브러리에 있는 모든 {type}의 업데이트를 확인합니다. 컬렉션이 클수록 시간이 조금 더 걸릴 수 있습니다.",
|
||||||
@@ -1957,4 +1964,4 @@
|
|||||||
"retry": "다시 시도"
|
"retry": "다시 시도"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"noCreditRequired": "Без указания авторства",
|
"noCreditRequired": "Без указания авторства",
|
||||||
"allowSellingGeneratedContent": "Продажа разрешена",
|
"allowSellingGeneratedContent": "Продажа разрешена",
|
||||||
"noTags": "Без тегов",
|
"noTags": "Без тегов",
|
||||||
|
"autoTags": "Авто-теги",
|
||||||
"noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.",
|
"noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.",
|
||||||
"clearAll": "Очистить все фильтры",
|
"clearAll": "Очистить все фильтры",
|
||||||
"any": "Любой",
|
"any": "Любой",
|
||||||
@@ -1085,6 +1086,12 @@
|
|||||||
"countMessage": "моделей будут удалены навсегда.",
|
"countMessage": "моделей будут удалены навсегда.",
|
||||||
"action": "Удалить все"
|
"action": "Удалить все"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "Удалить несколько рецептов",
|
||||||
|
"message": "Вы уверены, что хотите удалить все выбранные рецепты и связанные с ними файлы?",
|
||||||
|
"countMessage": "рецептов будут удалены навсегда.",
|
||||||
|
"action": "Удалить все"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "Проверить обновления для всех {typePlural}?",
|
"title": "Проверить обновления для всех {typePlural}?",
|
||||||
"message": "Будут проверены обновления для всех {typePlural} в вашей библиотеке. Для больших коллекций это может занять немного больше времени.",
|
"message": "Будут проверены обновления для всех {typePlural} в вашей библиотеке. Для больших коллекций это может занять немного больше времени.",
|
||||||
@@ -1957,4 +1964,4 @@
|
|||||||
"retry": "Повторить"
|
"retry": "Повторить"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"noCreditRequired": "无需署名",
|
"noCreditRequired": "无需署名",
|
||||||
"allowSellingGeneratedContent": "允许销售",
|
"allowSellingGeneratedContent": "允许销售",
|
||||||
"noTags": "无标签",
|
"noTags": "无标签",
|
||||||
|
"autoTags": "自动标签",
|
||||||
"noBaseModelMatches": "没有基础模型符合当前搜索。",
|
"noBaseModelMatches": "没有基础模型符合当前搜索。",
|
||||||
"clearAll": "清除所有筛选",
|
"clearAll": "清除所有筛选",
|
||||||
"any": "任一",
|
"any": "任一",
|
||||||
@@ -1085,6 +1086,12 @@
|
|||||||
"countMessage": "模型将被永久删除。",
|
"countMessage": "模型将被永久删除。",
|
||||||
"action": "全部删除"
|
"action": "全部删除"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "删除多个配方",
|
||||||
|
"message": "你确定要删除所有选中的配方及其相关文件吗?",
|
||||||
|
"countMessage": "配方将被永久删除。",
|
||||||
|
"action": "全部删除"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "检查所有 {type} 的更新?",
|
"title": "检查所有 {type} 的更新?",
|
||||||
"message": "这会为库中的每个 {type} 检查更新,大型集合可能需要一些时间。",
|
"message": "这会为库中的每个 {type} 检查更新,大型集合可能需要一些时间。",
|
||||||
@@ -1957,4 +1964,4 @@
|
|||||||
"retry": "重试"
|
"retry": "重试"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"noCreditRequired": "無需署名",
|
"noCreditRequired": "無需署名",
|
||||||
"allowSellingGeneratedContent": "允許銷售",
|
"allowSellingGeneratedContent": "允許銷售",
|
||||||
"noTags": "無標籤",
|
"noTags": "無標籤",
|
||||||
|
"autoTags": "自動標籤",
|
||||||
"noBaseModelMatches": "沒有基礎模型符合目前的搜尋。",
|
"noBaseModelMatches": "沒有基礎模型符合目前的搜尋。",
|
||||||
"clearAll": "清除所有篩選",
|
"clearAll": "清除所有篩選",
|
||||||
"any": "任一",
|
"any": "任一",
|
||||||
@@ -1085,6 +1086,12 @@
|
|||||||
"countMessage": "模型將被永久刪除。",
|
"countMessage": "模型將被永久刪除。",
|
||||||
"action": "全部刪除"
|
"action": "全部刪除"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "刪除多個配方",
|
||||||
|
"message": "您確定要刪除所有選取的配方及其相關檔案嗎?",
|
||||||
|
"countMessage": "配方將被永久刪除。",
|
||||||
|
"action": "全部刪除"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "要檢查所有 {type} 的更新嗎?",
|
"title": "要檢查所有 {type} 的更新嗎?",
|
||||||
"message": "這會為資料庫中的每個 {type} 檢查更新,大型收藏可能會花上一些時間。",
|
"message": "這會為資料庫中的每個 {type} 檢查更新,大型收藏可能會花上一些時間。",
|
||||||
@@ -1957,4 +1964,4 @@
|
|||||||
"retry": "重試"
|
"retry": "重試"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,55 +16,65 @@ class RecipeEnricher:
|
|||||||
async def enrich_recipe(
|
async def enrich_recipe(
|
||||||
recipe: Dict[str, Any],
|
recipe: Dict[str, Any],
|
||||||
civitai_client: Any,
|
civitai_client: Any,
|
||||||
request_params: Optional[Dict[str, Any]] = None
|
request_params: Optional[Dict[str, Any]] = None,
|
||||||
|
prefetched_civitai_meta_raw: Optional[Dict[str, Any]] = None,
|
||||||
|
prefetched_model_version_id: Optional[int] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Enrich a recipe dictionary in-place with metadata from Civitai and embedded params.
|
Enrich a recipe dictionary in-place with metadata from Civitai and embedded params.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
recipe: The recipe dictionary to enrich. Must have 'gen_params' initialized.
|
recipe: The recipe dictionary to enrich. Must have 'gen_params' initialized.
|
||||||
civitai_client: Authenticated Civitai client instance.
|
civitai_client: Authenticated Civitai client instance.
|
||||||
request_params: (Optional) Parameters from a user request (e.g. import).
|
request_params: (Optional) Parameters from a user request (e.g. import).
|
||||||
|
prefetched_civitai_meta_raw: (Optional) Pre-fetched raw meta from Civitai
|
||||||
|
get_image_info, avoiding a duplicate API call.
|
||||||
|
prefetched_model_version_id: (Optional) Pre-fetched model version ID.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if the recipe was modified, False otherwise.
|
bool: True if the recipe was modified, False otherwise.
|
||||||
"""
|
"""
|
||||||
updated = False
|
updated = False
|
||||||
gen_params = recipe.get("gen_params", {})
|
gen_params = recipe.get("gen_params", {})
|
||||||
|
|
||||||
# 1. Fetch Civitai Info if available
|
# 1. Obtain Civitai metadata
|
||||||
civitai_meta = None
|
civitai_meta = None
|
||||||
model_version_id = None
|
model_version_id = prefetched_model_version_id
|
||||||
|
|
||||||
source_path = recipe.get("source_path", "")
|
source_path = recipe.get("source_path", "")
|
||||||
|
|
||||||
# Check if it's a Civitai image URL
|
if prefetched_civitai_meta_raw is not None:
|
||||||
image_id = extract_civitai_image_id(str(source_path))
|
raw_meta = prefetched_civitai_meta_raw
|
||||||
if image_id:
|
if isinstance(raw_meta, dict):
|
||||||
try:
|
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
|
||||||
image_info = await civitai_client.get_image_info(
|
civitai_meta = raw_meta["meta"]
|
||||||
image_id, source_url=str(source_path)
|
else:
|
||||||
)
|
civitai_meta = raw_meta
|
||||||
if image_info:
|
else:
|
||||||
# Handle nested meta often found in Civitai API responses
|
image_id = extract_civitai_image_id(str(source_path))
|
||||||
raw_meta = image_info.get("meta")
|
if image_id:
|
||||||
if isinstance(raw_meta, dict):
|
try:
|
||||||
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
|
image_info = await civitai_client.get_image_info(
|
||||||
civitai_meta = raw_meta["meta"]
|
image_id, source_url=str(source_path)
|
||||||
else:
|
)
|
||||||
civitai_meta = raw_meta
|
if image_info:
|
||||||
|
raw_meta = image_info.get("meta")
|
||||||
model_version_id = image_info.get("modelVersionId")
|
if isinstance(raw_meta, dict):
|
||||||
|
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
|
||||||
# If not at top level, check resources in meta
|
civitai_meta = raw_meta["meta"]
|
||||||
if not model_version_id and civitai_meta:
|
else:
|
||||||
resources = civitai_meta.get("civitaiResources", [])
|
civitai_meta = raw_meta
|
||||||
for res in resources:
|
|
||||||
if res.get("type") == "checkpoint":
|
model_version_id = image_info.get("modelVersionId")
|
||||||
model_version_id = res.get("modelVersionId")
|
except Exception as e:
|
||||||
break
|
logger.warning(f"Failed to fetch Civitai image info: {e}")
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to fetch Civitai image info: {e}")
|
if not model_version_id and civitai_meta:
|
||||||
|
resources = civitai_meta.get("civitaiResources", [])
|
||||||
|
for res in resources:
|
||||||
|
if res.get("type") == "checkpoint":
|
||||||
|
model_version_id = res.get("modelVersionId")
|
||||||
|
break
|
||||||
|
|
||||||
# 2. Merge Parameters
|
# 2. Merge Parameters
|
||||||
# Priority: request_params > civitai_meta > embedded (existing gen_params)
|
# Priority: request_params > civitai_meta > embedded (existing gen_params)
|
||||||
|
|||||||
@@ -301,6 +301,15 @@ class ModelListingHandler:
|
|||||||
for tag in exclude_tags:
|
for tag in exclude_tags:
|
||||||
if tag:
|
if tag:
|
||||||
tag_filters[tag] = "exclude"
|
tag_filters[tag] = "exclude"
|
||||||
|
|
||||||
|
auto_tag_filters: Dict[str, str] = {}
|
||||||
|
for tag in request.query.getall("auto_tag_include", []):
|
||||||
|
if tag:
|
||||||
|
auto_tag_filters[tag] = "include"
|
||||||
|
for tag in request.query.getall("auto_tag_exclude", []):
|
||||||
|
if tag:
|
||||||
|
auto_tag_filters[tag] = "exclude"
|
||||||
|
|
||||||
favorites_only = request.query.get("favorites_only", "false").lower() == "true"
|
favorites_only = request.query.get("favorites_only", "false").lower() == "true"
|
||||||
|
|
||||||
search_options = {
|
search_options = {
|
||||||
@@ -367,6 +376,7 @@ class ModelListingHandler:
|
|||||||
"fuzzy_search": fuzzy_search,
|
"fuzzy_search": fuzzy_search,
|
||||||
"base_models": base_models,
|
"base_models": base_models,
|
||||||
"tags": tag_filters,
|
"tags": tag_filters,
|
||||||
|
"auto_tags": auto_tag_filters,
|
||||||
"tag_logic": tag_logic,
|
"tag_logic": tag_logic,
|
||||||
"search_options": search_options,
|
"search_options": search_options,
|
||||||
"hash_filters": hash_filters,
|
"hash_filters": hash_filters,
|
||||||
|
|||||||
@@ -609,6 +609,7 @@ class RecipeManagementHandler:
|
|||||||
self._downloader_factory = downloader_factory
|
self._downloader_factory = downloader_factory
|
||||||
self._civitai_client_getter = civitai_client_getter
|
self._civitai_client_getter = civitai_client_getter
|
||||||
self._ws_manager = ws_manager
|
self._ws_manager = ws_manager
|
||||||
|
self._import_semaphore = asyncio.Semaphore(2)
|
||||||
|
|
||||||
async def save_recipe(self, request: web.Request) -> web.Response:
|
async def save_recipe(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
@@ -762,121 +763,28 @@ class RecipeManagementHandler:
|
|||||||
gen_params_request = self._parse_gen_params(params.get("gen_params"))
|
gen_params_request = self._parse_gen_params(params.get("gen_params"))
|
||||||
|
|
||||||
self._logger.info(
|
self._logger.info(
|
||||||
"Remote recipe import received: url=%s, request_gen_params_keys=%s, lora_count=%d, checkpoint_keys=%s",
|
"Remote recipe import received: url=%s, lora_count=%d",
|
||||||
image_url,
|
image_url,
|
||||||
sorted(gen_params_request.keys()) if gen_params_request else [],
|
|
||||||
len(lora_entries),
|
len(lora_entries),
|
||||||
|
)
|
||||||
|
self._logger.debug(
|
||||||
|
" gen_params_keys=%s, checkpoint_keys=%s",
|
||||||
|
sorted(gen_params_request.keys()) if gen_params_request else [],
|
||||||
sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [],
|
sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [],
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Initial Metadata Construction
|
# Throttle concurrent imports to avoid starving ComfyUI's event loop
|
||||||
metadata: Dict[str, Any] = {
|
async with self._import_semaphore:
|
||||||
"base_model": params.get("base_model", "") or "",
|
return await self._do_import_remote_recipe(
|
||||||
"loras": lora_entries,
|
image_url=image_url,
|
||||||
"gen_params": gen_params_request or {},
|
name=name,
|
||||||
"source_path": params.get("source_path") or image_url,
|
lora_entries=lora_entries,
|
||||||
}
|
checkpoint_entry=checkpoint_entry,
|
||||||
|
gen_params_request=gen_params_request,
|
||||||
# Checkpoint handling
|
tags=self._parse_tags(params.get("tags")),
|
||||||
if checkpoint_entry:
|
base_model=params.get("base_model", "") or "",
|
||||||
metadata["checkpoint"] = checkpoint_entry
|
source_path=params.get("source_path") or image_url,
|
||||||
# Ensure checkpoint is also in gen_params for consistency if needed by enricher?
|
|
||||||
# Actually enricher looks at metadata['checkpoint'], so this is fine.
|
|
||||||
|
|
||||||
# Try to resolve base model from checkpoint if not explicitly provided
|
|
||||||
if not metadata["base_model"]:
|
|
||||||
base_model_from_metadata = (
|
|
||||||
await self._resolve_base_model_from_checkpoint(checkpoint_entry)
|
|
||||||
)
|
|
||||||
if base_model_from_metadata:
|
|
||||||
metadata["base_model"] = base_model_from_metadata
|
|
||||||
|
|
||||||
tags = self._parse_tags(params.get("tags"))
|
|
||||||
|
|
||||||
# 3. Download Image
|
|
||||||
(
|
|
||||||
image_bytes,
|
|
||||||
extension,
|
|
||||||
civitai_meta_from_download,
|
|
||||||
) = await self._download_remote_media(image_url)
|
|
||||||
|
|
||||||
# 4. Extract Embedded Metadata
|
|
||||||
# Note: We still extract this here because Enricher currently expects 'gen_params' to already be populated
|
|
||||||
# with embedded data if we want it to merge it.
|
|
||||||
# However, logic in Enricher merges: request > civitai > embedded.
|
|
||||||
# So we should gather embedded params and put them into the recipe's gen_params (as initial state)
|
|
||||||
# OR pass them to enricher to handle?
|
|
||||||
# The interface of Enricher.enrich_recipe takes `recipe` (with gen_params) and `request_params`.
|
|
||||||
# So let's extract embedded and put it into recipe['gen_params'] but careful not to overwrite request params.
|
|
||||||
# Actually, `GenParamsMerger` which `Enricher` uses handles 3 layers.
|
|
||||||
# But `Enricher` interface is: recipe['gen_params'] (as embedded) + request_params + civitai (fetched internally).
|
|
||||||
# Wait, `Enricher` fetches Civitai info internally based on URL.
|
|
||||||
# `civitai_meta_from_download` is returned by `_download_remote_media` which might be useful if URL didn't have ID.
|
|
||||||
|
|
||||||
# Let's extract embedded metadata first
|
|
||||||
embedded_gen_params = {}
|
|
||||||
try:
|
|
||||||
with tempfile.NamedTemporaryFile(
|
|
||||||
suffix=extension, delete=False
|
|
||||||
) as temp_img:
|
|
||||||
temp_img.write(image_bytes)
|
|
||||||
temp_img_path = temp_img.name
|
|
||||||
|
|
||||||
try:
|
|
||||||
raw_embedded = ExifUtils.extract_image_metadata(temp_img_path)
|
|
||||||
if raw_embedded:
|
|
||||||
parser = (
|
|
||||||
self._analysis_service._recipe_parser_factory.create_parser(
|
|
||||||
raw_embedded
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if parser:
|
|
||||||
parsed_embedded = await parser.parse_metadata(
|
|
||||||
raw_embedded, recipe_scanner=recipe_scanner
|
|
||||||
)
|
|
||||||
if parsed_embedded and "gen_params" in parsed_embedded:
|
|
||||||
embedded_gen_params = parsed_embedded["gen_params"]
|
|
||||||
else:
|
|
||||||
embedded_gen_params = {"raw_metadata": raw_embedded}
|
|
||||||
finally:
|
|
||||||
if os.path.exists(temp_img_path):
|
|
||||||
os.unlink(temp_img_path)
|
|
||||||
except Exception as exc:
|
|
||||||
self._logger.warning(
|
|
||||||
"Failed to extract embedded metadata during import: %s", exc
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Pre-populate gen_params with embedded data so Enricher treats it as the "base" layer
|
|
||||||
if embedded_gen_params:
|
|
||||||
# Merge embedded into existing gen_params (which currently only has request params if any)
|
|
||||||
# But wait, we want request params to override everything.
|
|
||||||
# So we should set recipe['gen_params'] = embedded, and pass request params to enricher.
|
|
||||||
metadata["gen_params"] = embedded_gen_params
|
|
||||||
|
|
||||||
# 5. Enrich with unified logic
|
|
||||||
# This will fetch Civitai info (if URL matches) and merge: request > civitai > embedded
|
|
||||||
civitai_client = self._civitai_client_getter()
|
|
||||||
await RecipeEnricher.enrich_recipe(
|
|
||||||
recipe=metadata,
|
|
||||||
civitai_client=civitai_client,
|
|
||||||
request_params=gen_params_request, # Pass explicit request params here to override
|
|
||||||
)
|
|
||||||
|
|
||||||
# If we got civitai_meta from download but Enricher didn't fetch it (e.g. not a civitai URL or failed),
|
|
||||||
# we might want to manually merge it?
|
|
||||||
# But usually `import_remote_recipe` is used with Civitai URLs.
|
|
||||||
# For now, relying on Enricher's internal fetch is consistent with repair.
|
|
||||||
|
|
||||||
result = await self._persistence_service.save_recipe(
|
|
||||||
recipe_scanner=recipe_scanner,
|
|
||||||
image_bytes=image_bytes,
|
|
||||||
image_base64=None,
|
|
||||||
name=name,
|
|
||||||
tags=tags,
|
|
||||||
metadata=metadata,
|
|
||||||
extension=extension,
|
|
||||||
)
|
|
||||||
return web.json_response(result.payload, status=result.status)
|
|
||||||
except RecipeValidationError as exc:
|
except RecipeValidationError as exc:
|
||||||
return web.json_response({"error": str(exc)}, status=400)
|
return web.json_response({"error": str(exc)}, status=400)
|
||||||
except RecipeDownloadError as exc:
|
except RecipeDownloadError as exc:
|
||||||
@@ -887,6 +795,150 @@ class RecipeManagementHandler:
|
|||||||
)
|
)
|
||||||
return web.json_response({"error": str(exc)}, status=500)
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def _do_import_remote_recipe(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
image_url: str,
|
||||||
|
name: str,
|
||||||
|
lora_entries: list,
|
||||||
|
checkpoint_entry: dict,
|
||||||
|
gen_params_request: dict,
|
||||||
|
tags: list,
|
||||||
|
base_model: str,
|
||||||
|
source_path: str,
|
||||||
|
) -> web.Response:
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
metadata: Dict[str, Any] = {
|
||||||
|
"base_model": base_model,
|
||||||
|
"loras": lora_entries,
|
||||||
|
"gen_params": gen_params_request or {},
|
||||||
|
"source_path": source_path,
|
||||||
|
}
|
||||||
|
|
||||||
|
if checkpoint_entry:
|
||||||
|
metadata["checkpoint"] = checkpoint_entry
|
||||||
|
if not metadata["base_model"]:
|
||||||
|
base_model_from_metadata = (
|
||||||
|
await self._resolve_base_model_from_checkpoint(checkpoint_entry)
|
||||||
|
)
|
||||||
|
if base_model_from_metadata:
|
||||||
|
metadata["base_model"] = base_model_from_metadata
|
||||||
|
|
||||||
|
# Download image
|
||||||
|
(
|
||||||
|
image_bytes,
|
||||||
|
extension,
|
||||||
|
civitai_meta_raw,
|
||||||
|
model_version_id,
|
||||||
|
) = await self._download_remote_media(image_url)
|
||||||
|
|
||||||
|
# Extract embedded EXIF metadata (offloaded to thread pool in this call)
|
||||||
|
embedded_gen_params = {}
|
||||||
|
parsed_embedded = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
suffix=extension, delete=False
|
||||||
|
) as temp_img:
|
||||||
|
temp_img.write(image_bytes)
|
||||||
|
temp_img_path = temp_img.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_embedded = await asyncio.to_thread(
|
||||||
|
ExifUtils.extract_image_metadata, temp_img_path
|
||||||
|
)
|
||||||
|
if raw_embedded:
|
||||||
|
parser = (
|
||||||
|
self._analysis_service._recipe_parser_factory.create_parser(
|
||||||
|
raw_embedded
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if parser:
|
||||||
|
parsed_embedded = await parser.parse_metadata(
|
||||||
|
raw_embedded, recipe_scanner=recipe_scanner
|
||||||
|
)
|
||||||
|
if parsed_embedded and "gen_params" in parsed_embedded:
|
||||||
|
embedded_gen_params = parsed_embedded["gen_params"]
|
||||||
|
else:
|
||||||
|
embedded_gen_params = {"raw_metadata": raw_embedded}
|
||||||
|
finally:
|
||||||
|
if os.path.exists(temp_img_path):
|
||||||
|
os.unlink(temp_img_path)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.warning(
|
||||||
|
"Failed to extract embedded metadata during import: %s", exc
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse CivitAI API meta to discover all resources from modelVersionIds
|
||||||
|
# (modelVersionIds is injected at root level by _download_remote_media).
|
||||||
|
# Run unconditionally — EXIF parsing may succeed for gen_params but miss
|
||||||
|
# LoRAs since modelVersionIds is NOT embedded in the image EXIF.
|
||||||
|
civitai_parsed = None
|
||||||
|
if civitai_meta_raw:
|
||||||
|
civitai_inner_meta = civitai_meta_raw
|
||||||
|
if isinstance(civitai_meta_raw, dict) and "meta" in civitai_meta_raw:
|
||||||
|
civitai_inner_meta = civitai_meta_raw["meta"]
|
||||||
|
# modelVersionIds lives at outer meta level; propagate after unwrap
|
||||||
|
_mvids = civitai_meta_raw.get("modelVersionIds")
|
||||||
|
if _mvids and isinstance(civitai_inner_meta, dict):
|
||||||
|
civitai_inner_meta["modelVersionIds"] = _mvids
|
||||||
|
if isinstance(civitai_inner_meta, dict):
|
||||||
|
parser = self._analysis_service._recipe_parser_factory.create_parser(
|
||||||
|
civitai_inner_meta
|
||||||
|
)
|
||||||
|
if parser:
|
||||||
|
civitai_parsed = await parser.parse_metadata(
|
||||||
|
civitai_inner_meta, recipe_scanner=recipe_scanner
|
||||||
|
)
|
||||||
|
if civitai_parsed and "gen_params" in civitai_parsed:
|
||||||
|
# Merge: API gen_params override EXIF at field level,
|
||||||
|
# EXIF fills in fields the API doesn't have.
|
||||||
|
embedded_gen_params = {
|
||||||
|
**(embedded_gen_params or {}),
|
||||||
|
**civitai_parsed["gen_params"],
|
||||||
|
}
|
||||||
|
|
||||||
|
if embedded_gen_params:
|
||||||
|
metadata["gen_params"] = embedded_gen_params
|
||||||
|
|
||||||
|
# Merge LoRAs: prefer frontend resources, supplement with CivitAI modelVersionIds
|
||||||
|
if civitai_parsed:
|
||||||
|
civitai_loras = civitai_parsed.get("loras", [])
|
||||||
|
if civitai_loras and not metadata.get("loras"):
|
||||||
|
metadata["loras"] = civitai_loras
|
||||||
|
civitai_model = civitai_parsed.get("model")
|
||||||
|
if civitai_model and not metadata.get("checkpoint"):
|
||||||
|
metadata["checkpoint"] = civitai_model
|
||||||
|
elif parsed_embedded:
|
||||||
|
parsed_loras = parsed_embedded.get("loras")
|
||||||
|
if parsed_loras and not metadata.get("loras"):
|
||||||
|
metadata["loras"] = parsed_loras
|
||||||
|
parsed_model = parsed_embedded.get("model")
|
||||||
|
if parsed_model and not metadata.get("checkpoint"):
|
||||||
|
metadata["checkpoint"] = parsed_model
|
||||||
|
|
||||||
|
civitai_client = self._civitai_client_getter()
|
||||||
|
await RecipeEnricher.enrich_recipe(
|
||||||
|
recipe=metadata,
|
||||||
|
civitai_client=civitai_client,
|
||||||
|
request_params=gen_params_request,
|
||||||
|
prefetched_civitai_meta_raw=civitai_meta_raw,
|
||||||
|
prefetched_model_version_id=model_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self._persistence_service.save_recipe(
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
image_bytes=image_bytes,
|
||||||
|
image_base64=None,
|
||||||
|
name=name,
|
||||||
|
tags=tags,
|
||||||
|
metadata=metadata,
|
||||||
|
extension=extension,
|
||||||
|
)
|
||||||
|
return web.json_response(result.payload, status=result.status)
|
||||||
|
|
||||||
async def delete_recipe(self, request: web.Request) -> web.Response:
|
async def delete_recipe(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
await self._ensure_dependencies_ready()
|
await self._ensure_dependencies_ready()
|
||||||
@@ -1188,7 +1240,7 @@ class RecipeManagementHandler:
|
|||||||
"exclude": False,
|
"exclude": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _download_remote_media(self, image_url: str) -> tuple[bytes, str, Any]:
|
async def _download_remote_media(self, image_url: str) -> tuple[bytes, str, Any, Any]:
|
||||||
civitai_client = self._civitai_client_getter()
|
civitai_client = self._civitai_client_getter()
|
||||||
downloader = await self._downloader_factory()
|
downloader = await self._downloader_factory()
|
||||||
temp_path = None
|
temp_path = None
|
||||||
@@ -1236,10 +1288,31 @@ class RecipeManagementHandler:
|
|||||||
extension = ".webp" # Default to webp if unknown
|
extension = ".webp" # Default to webp if unknown
|
||||||
|
|
||||||
with open(temp_path, "rb") as file_obj:
|
with open(temp_path, "rb") as file_obj:
|
||||||
|
model_ver_id = None
|
||||||
|
civitai_meta_raw = (
|
||||||
|
image_info.get("meta") if civitai_image_id and image_info else None
|
||||||
|
)
|
||||||
|
if civitai_image_id and image_info:
|
||||||
|
model_ver_id = image_info.get("modelVersionId")
|
||||||
|
if not model_ver_id:
|
||||||
|
ids = image_info.get("modelVersionIds")
|
||||||
|
if isinstance(ids, list) and ids:
|
||||||
|
model_ver_id = ids[0]
|
||||||
|
|
||||||
|
# Inject root-level modelVersionIds into meta so downstream
|
||||||
|
# parsers (CivitaiApiMetadataParser) can discover ALL resources
|
||||||
|
# (checkpoint + LoRAs), not just the first model version ID.
|
||||||
|
# CivitAI API returns modelVersionIds at the root level of
|
||||||
|
# the image response, NOT inside the meta object.
|
||||||
|
mvids = image_info.get("modelVersionIds")
|
||||||
|
if mvids and isinstance(civitai_meta_raw, dict):
|
||||||
|
civitai_meta_raw["modelVersionIds"] = mvids
|
||||||
|
|
||||||
return (
|
return (
|
||||||
file_obj.read(),
|
file_obj.read(),
|
||||||
extension,
|
extension,
|
||||||
image_info.get("meta") if civitai_image_id and image_info else None,
|
civitai_meta_raw,
|
||||||
|
model_ver_id,
|
||||||
)
|
)
|
||||||
except RecipeDownloadError:
|
except RecipeDownloadError:
|
||||||
raise
|
raise
|
||||||
@@ -1351,7 +1424,7 @@ class RecipeManagementHandler:
|
|||||||
"Could not extract Civitai image ID from URL"
|
"Could not extract Civitai image ID from URL"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check for duplicate
|
# Check for duplicate (fast, before acquiring semaphore)
|
||||||
cache = await recipe_scanner.get_cached_data()
|
cache = await recipe_scanner.get_cached_data()
|
||||||
for recipe in getattr(cache, "raw_data", []):
|
for recipe in getattr(cache, "raw_data", []):
|
||||||
source = recipe.get("source_path")
|
source = recipe.get("source_path")
|
||||||
@@ -1365,82 +1438,8 @@ class RecipeManagementHandler:
|
|||||||
"already_exists": True,
|
"already_exists": True,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Download image and extract metadata
|
async with self._import_semaphore:
|
||||||
image_bytes, extension, civitai_meta = (
|
return await self._do_import_from_url(image_url, recipe_scanner)
|
||||||
await self._download_remote_media(image_url)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Extract embedded EXIF metadata
|
|
||||||
embedded_gen_params = {}
|
|
||||||
try:
|
|
||||||
with tempfile.NamedTemporaryFile(
|
|
||||||
suffix=extension, delete=False
|
|
||||||
) as temp_img:
|
|
||||||
temp_img.write(image_bytes)
|
|
||||||
temp_img_path = temp_img.name
|
|
||||||
|
|
||||||
try:
|
|
||||||
raw_embedded = ExifUtils.extract_image_metadata(temp_img_path)
|
|
||||||
if raw_embedded:
|
|
||||||
parser = (
|
|
||||||
self._analysis_service._recipe_parser_factory.create_parser(
|
|
||||||
raw_embedded
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if parser:
|
|
||||||
parsed_embedded = await parser.parse_metadata(
|
|
||||||
raw_embedded, recipe_scanner=recipe_scanner
|
|
||||||
)
|
|
||||||
if parsed_embedded and "gen_params" in parsed_embedded:
|
|
||||||
embedded_gen_params = parsed_embedded["gen_params"]
|
|
||||||
finally:
|
|
||||||
if os.path.exists(temp_img_path):
|
|
||||||
os.unlink(temp_img_path)
|
|
||||||
except Exception as exc:
|
|
||||||
self._logger.warning(
|
|
||||||
"Failed to extract embedded metadata: %s", exc
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build metadata
|
|
||||||
metadata: Dict[str, Any] = {
|
|
||||||
"base_model": "",
|
|
||||||
"loras": [],
|
|
||||||
"gen_params": embedded_gen_params or {},
|
|
||||||
"source_path": image_url,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Enrich via Civitai API
|
|
||||||
civitai_client = self._civitai_client_getter()
|
|
||||||
await RecipeEnricher.enrich_recipe(
|
|
||||||
recipe=metadata,
|
|
||||||
civitai_client=civitai_client,
|
|
||||||
request_params={},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Auto-generate name from prompt or fallback
|
|
||||||
prompt = (
|
|
||||||
metadata.get("gen_params", {}).get("prompt")
|
|
||||||
or metadata.get("gen_params", {}).get("positivePrompt")
|
|
||||||
or ""
|
|
||||||
)
|
|
||||||
if prompt:
|
|
||||||
name = " ".join(str(prompt).split()[:10])
|
|
||||||
else:
|
|
||||||
name = f"Civitai Image {image_id}"
|
|
||||||
|
|
||||||
# Parse tags from params if available
|
|
||||||
tags = self._parse_tags(request.query.get("tags"))
|
|
||||||
|
|
||||||
result = await self._persistence_service.save_recipe(
|
|
||||||
recipe_scanner=recipe_scanner,
|
|
||||||
image_bytes=image_bytes,
|
|
||||||
image_base64=None,
|
|
||||||
name=name,
|
|
||||||
tags=tags,
|
|
||||||
metadata=metadata,
|
|
||||||
extension=extension,
|
|
||||||
)
|
|
||||||
return web.json_response(result.payload, status=result.status)
|
|
||||||
except RecipeValidationError as exc:
|
except RecipeValidationError as exc:
|
||||||
return web.json_response({"error": str(exc)}, status=400)
|
return web.json_response({"error": str(exc)}, status=400)
|
||||||
except RecipeDownloadError as exc:
|
except RecipeDownloadError as exc:
|
||||||
@@ -1451,6 +1450,136 @@ class RecipeManagementHandler:
|
|||||||
)
|
)
|
||||||
return web.json_response({"error": str(exc)}, status=500)
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def _do_import_from_url(
|
||||||
|
self,
|
||||||
|
image_url: str,
|
||||||
|
recipe_scanner: Any,
|
||||||
|
) -> web.Response:
|
||||||
|
image_id = extract_civitai_image_id(image_url)
|
||||||
|
if not image_id:
|
||||||
|
raise RecipeValidationError(
|
||||||
|
"Could not extract Civitai image ID from URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
image_bytes, extension, civitai_meta_raw, model_version_id = (
|
||||||
|
await self._download_remote_media(image_url)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract embedded EXIF metadata
|
||||||
|
embedded_gen_params = {}
|
||||||
|
parsed_embedded = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
suffix=extension, delete=False
|
||||||
|
) as temp_img:
|
||||||
|
temp_img.write(image_bytes)
|
||||||
|
temp_img_path = temp_img.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_embedded = await asyncio.to_thread(
|
||||||
|
ExifUtils.extract_image_metadata, temp_img_path
|
||||||
|
)
|
||||||
|
if raw_embedded:
|
||||||
|
parser = (
|
||||||
|
self._analysis_service._recipe_parser_factory.create_parser(
|
||||||
|
raw_embedded
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if parser:
|
||||||
|
parsed_embedded = await parser.parse_metadata(
|
||||||
|
raw_embedded, recipe_scanner=recipe_scanner
|
||||||
|
)
|
||||||
|
if parsed_embedded and "gen_params" in parsed_embedded:
|
||||||
|
embedded_gen_params = parsed_embedded["gen_params"]
|
||||||
|
finally:
|
||||||
|
if os.path.exists(temp_img_path):
|
||||||
|
os.unlink(temp_img_path)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.warning(
|
||||||
|
"Failed to extract embedded metadata: %s", exc
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse CivitAI API meta to discover all resources from modelVersionIds.
|
||||||
|
# Run unconditionally — EXIF parsing succeeds for gen_params but misses
|
||||||
|
# LoRAs (modelVersionIds is NOT in the image EXIF).
|
||||||
|
civitai_parsed = None
|
||||||
|
if civitai_meta_raw:
|
||||||
|
civitai_inner_meta = civitai_meta_raw
|
||||||
|
if isinstance(civitai_meta_raw, dict) and "meta" in civitai_meta_raw:
|
||||||
|
civitai_inner_meta = civitai_meta_raw["meta"]
|
||||||
|
# Propagate modelVersionIds into unwrapped meta — it lives
|
||||||
|
# at the outer meta level in the CivitAI API response.
|
||||||
|
_mvids = civitai_meta_raw.get("modelVersionIds")
|
||||||
|
if _mvids and isinstance(civitai_inner_meta, dict):
|
||||||
|
civitai_inner_meta["modelVersionIds"] = _mvids
|
||||||
|
if isinstance(civitai_inner_meta, dict):
|
||||||
|
parser = self._analysis_service._recipe_parser_factory.create_parser(
|
||||||
|
civitai_inner_meta
|
||||||
|
)
|
||||||
|
if parser:
|
||||||
|
civitai_parsed = await parser.parse_metadata(
|
||||||
|
civitai_inner_meta, recipe_scanner=recipe_scanner
|
||||||
|
)
|
||||||
|
if civitai_parsed and "gen_params" in civitai_parsed:
|
||||||
|
# Merge: API gen_params override EXIF at field level,
|
||||||
|
# EXIF fills in fields the API doesn't have.
|
||||||
|
embedded_gen_params = {
|
||||||
|
**(embedded_gen_params or {}),
|
||||||
|
**civitai_parsed["gen_params"],
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata: Dict[str, Any] = {
|
||||||
|
"base_model": "",
|
||||||
|
"loras": [],
|
||||||
|
"gen_params": embedded_gen_params or {},
|
||||||
|
"source_path": image_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
if civitai_parsed:
|
||||||
|
civitai_loras = civitai_parsed.get("loras", [])
|
||||||
|
if civitai_loras and not metadata.get("loras"):
|
||||||
|
metadata["loras"] = civitai_loras
|
||||||
|
civitai_model = civitai_parsed.get("model")
|
||||||
|
if civitai_model and not metadata.get("checkpoint"):
|
||||||
|
metadata["checkpoint"] = civitai_model
|
||||||
|
elif parsed_embedded:
|
||||||
|
parsed_loras = parsed_embedded.get("loras")
|
||||||
|
if parsed_loras and not metadata.get("loras"):
|
||||||
|
metadata["loras"] = parsed_loras
|
||||||
|
parsed_model = parsed_embedded.get("model")
|
||||||
|
if parsed_model and not metadata.get("checkpoint"):
|
||||||
|
metadata["checkpoint"] = parsed_model
|
||||||
|
|
||||||
|
civitai_client = self._civitai_client_getter()
|
||||||
|
await RecipeEnricher.enrich_recipe(
|
||||||
|
recipe=metadata,
|
||||||
|
civitai_client=civitai_client,
|
||||||
|
request_params={},
|
||||||
|
prefetched_civitai_meta_raw=civitai_meta_raw,
|
||||||
|
prefetched_model_version_id=model_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
metadata.get("gen_params", {}).get("prompt")
|
||||||
|
or metadata.get("gen_params", {}).get("positivePrompt")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
if prompt:
|
||||||
|
name = " ".join(str(prompt).split()[:10])
|
||||||
|
else:
|
||||||
|
name = f"Civitai Image {image_id}"
|
||||||
|
|
||||||
|
result = await self._persistence_service.save_recipe(
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
image_bytes=image_bytes,
|
||||||
|
image_base64=None,
|
||||||
|
name=name,
|
||||||
|
tags=[],
|
||||||
|
metadata=metadata,
|
||||||
|
extension=extension,
|
||||||
|
)
|
||||||
|
return web.json_response(result.payload, status=result.status)
|
||||||
|
|
||||||
|
|
||||||
class RecipeAnalysisHandler:
|
class RecipeAnalysisHandler:
|
||||||
"""Analyze images to extract recipe metadata."""
|
"""Analyze images to extract recipe metadata."""
|
||||||
|
|||||||
121
py/services/auto_tag_service.py
Normal file
121
py/services/auto_tag_service.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""
|
||||||
|
Auto-tag extraction service for model cards.
|
||||||
|
|
||||||
|
Extracts implicit model attributes (HIGH/LOW, I2V/T2V/TI2V, Lightning, Turbo)
|
||||||
|
from filename, base_model, and CivitAI version name — no manual tagging required.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Dict, List, Set
|
||||||
|
|
||||||
|
# ── Tag category definitions ──────────────────────────────────────────
|
||||||
|
# Each category maps a display label to a regex pattern.
|
||||||
|
# Patterns are case-insensitive and matched against filename, base_model,
|
||||||
|
# and civitai version name.
|
||||||
|
|
||||||
|
# Use (?<![a-zA-Z0-9]) and (?![a-zA-Z0-9]) instead of \b because
|
||||||
|
# Python's \b treats underscore as a word character, so \bHIGH\b
|
||||||
|
# won't match '_HIGH_' in filenames.
|
||||||
|
_B = r"(?<![a-zA-Z0-9])" # left boundary
|
||||||
|
_E = r"(?![a-zA-Z0-9])" # right boundary
|
||||||
|
|
||||||
|
AUTO_TAG_CATEGORIES: Dict[str, str] = {
|
||||||
|
"HIGH": _B + r"HIGH" + _E,
|
||||||
|
"LOW": _B + r"(?<!F)LOW" + _E,
|
||||||
|
"I2V": _B + r"I2V" + _E,
|
||||||
|
"T2V": _B + r"T2V" + _E,
|
||||||
|
"TI2V": _B + r"TI2V" + _E,
|
||||||
|
"Lightning": _B + r"Lightning" + _E,
|
||||||
|
"Turbo": _B + r"Turbo" + _E,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tags that belong to the "mode" group (HIGH/LOW)
|
||||||
|
MODE_TAGS = {"HIGH", "LOW"}
|
||||||
|
|
||||||
|
# Tags that belong to the "video mode" group (I2V/T2V/TI2V)
|
||||||
|
VIDEO_MODE_TAGS = {"I2V", "T2V", "TI2V"}
|
||||||
|
|
||||||
|
# Tags that belong to the "speed/optimization" group
|
||||||
|
SPEED_TAGS = {"Lightning", "Turbo"}
|
||||||
|
|
||||||
|
# ── Display category groups (for settings UI) ─────────────────────────
|
||||||
|
|
||||||
|
AUTO_TAG_GROUPS = {
|
||||||
|
"mode": {"HIGH", "LOW"},
|
||||||
|
"video": {"I2V", "T2V", "TI2V"},
|
||||||
|
"speed": {"Lightning", "Turbo"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default enabled categories
|
||||||
|
DEFAULT_ENABLED_GROUPS = {"mode", "video"}
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_sources(model_data: Dict) -> List[str]:
|
||||||
|
"""Collect all text sources from model data for tag matching."""
|
||||||
|
sources: List[str] = []
|
||||||
|
|
||||||
|
file_name = model_data.get("file_name", "")
|
||||||
|
if file_name:
|
||||||
|
sources.append(file_name)
|
||||||
|
|
||||||
|
base_model = model_data.get("base_model", "")
|
||||||
|
if base_model:
|
||||||
|
sources.append(base_model)
|
||||||
|
|
||||||
|
civitai = model_data.get("civitai", {})
|
||||||
|
if isinstance(civitai, dict):
|
||||||
|
version_name = civitai.get("name", "")
|
||||||
|
if version_name:
|
||||||
|
sources.append(version_name)
|
||||||
|
|
||||||
|
return sources
|
||||||
|
|
||||||
|
|
||||||
|
def extract_auto_tags(model_data: Dict) -> List[str]:
|
||||||
|
"""Extract auto-detected tags from model metadata.
|
||||||
|
|
||||||
|
Matches predefined patterns against filename, base_model, and
|
||||||
|
CivitAI version name. Returns a sorted, deduplicated list of tag labels.
|
||||||
|
|
||||||
|
HIGH/LOW tags are only returned when the base_model indicates a Wan
|
||||||
|
family model — no other model architecture uses this distinction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_data: Model metadata dict with keys:
|
||||||
|
file_name, base_model, civitai (with optional 'name' field).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sorted list of unique auto-tag strings (e.g. ["I2V"]).
|
||||||
|
"""
|
||||||
|
sources = _collect_sources(model_data)
|
||||||
|
if not sources:
|
||||||
|
return []
|
||||||
|
|
||||||
|
base_model = model_data.get("base_model", "")
|
||||||
|
is_wan = "wan" in base_model.lower()
|
||||||
|
|
||||||
|
found: Set[str] = set()
|
||||||
|
|
||||||
|
for label, pattern in AUTO_TAG_CATEGORIES.items():
|
||||||
|
# HIGH/LOW are Wan-specific — skip for non-Wan to avoid noise
|
||||||
|
if label in ("HIGH", "LOW"):
|
||||||
|
if not is_wan:
|
||||||
|
continue
|
||||||
|
# Use case-insensitive character class + case-sensitive boundary,
|
||||||
|
# so "HighNoise" (camelCase) matches but "highlight" doesn't.
|
||||||
|
# Boundary: not followed by lowercase letter (= word has ended).
|
||||||
|
ci = "".join(f"[{c.lower()}{c.upper()}]" for c in label)
|
||||||
|
if label == "LOW":
|
||||||
|
regex = re.compile(r"(?<![Ff])" + ci + r"(?![a-z])")
|
||||||
|
else:
|
||||||
|
regex = re.compile(ci + r"(?![a-z])")
|
||||||
|
else:
|
||||||
|
regex = re.compile(pattern, re.IGNORECASE)
|
||||||
|
for source in sources:
|
||||||
|
if regex.search(source):
|
||||||
|
found.add(label)
|
||||||
|
break
|
||||||
|
|
||||||
|
return sorted(found)
|
||||||
@@ -77,6 +77,7 @@ class BaseModelService(ABC):
|
|||||||
base_models: list = None,
|
base_models: list = None,
|
||||||
model_types: list = None,
|
model_types: list = None,
|
||||||
tags: Optional[Dict[str, str]] = None,
|
tags: Optional[Dict[str, str]] = None,
|
||||||
|
auto_tags: Optional[Dict[str, str]] = None,
|
||||||
search_options: dict = None,
|
search_options: dict = None,
|
||||||
hash_filters: dict = None,
|
hash_filters: dict = None,
|
||||||
favorites_only: bool = False,
|
favorites_only: bool = False,
|
||||||
@@ -95,6 +96,11 @@ class BaseModelService(ABC):
|
|||||||
sorted_data = await self._fetch_with_usage_sort(sort_params)
|
sorted_data = await self._fetch_with_usage_sort(sort_params)
|
||||||
else:
|
else:
|
||||||
sorted_data = await self.cache_repository.fetch_sorted(sort_params)
|
sorted_data = await self.cache_repository.fetch_sorted(sort_params)
|
||||||
|
# Pre-compute auto_tags for every item — needed for both filtering
|
||||||
|
# and display. Computation is cheap (string regex on 2-3 fields).
|
||||||
|
from .auto_tag_service import extract_auto_tags
|
||||||
|
for item in sorted_data:
|
||||||
|
item["auto_tags"] = extract_auto_tags(item)
|
||||||
fetch_duration = time.perf_counter() - t0
|
fetch_duration = time.perf_counter() - t0
|
||||||
initial_count = len(sorted_data)
|
initial_count = len(sorted_data)
|
||||||
|
|
||||||
@@ -110,6 +116,7 @@ class BaseModelService(ABC):
|
|||||||
base_models=base_models,
|
base_models=base_models,
|
||||||
model_types=model_types,
|
model_types=model_types,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
|
auto_tags=auto_tags,
|
||||||
favorites_only=favorites_only,
|
favorites_only=favorites_only,
|
||||||
search_options=search_options,
|
search_options=search_options,
|
||||||
tag_logic=tag_logic,
|
tag_logic=tag_logic,
|
||||||
@@ -354,6 +361,7 @@ class BaseModelService(ABC):
|
|||||||
base_models: list = None,
|
base_models: list = None,
|
||||||
model_types: list = None,
|
model_types: list = None,
|
||||||
tags: Optional[Dict[str, str]] = None,
|
tags: Optional[Dict[str, str]] = None,
|
||||||
|
auto_tags: Optional[Dict[str, str]] = None,
|
||||||
favorites_only: bool = False,
|
favorites_only: bool = False,
|
||||||
search_options: dict = None,
|
search_options: dict = None,
|
||||||
tag_logic: str = "any",
|
tag_logic: str = "any",
|
||||||
@@ -367,6 +375,7 @@ class BaseModelService(ABC):
|
|||||||
base_models=base_models,
|
base_models=base_models,
|
||||||
model_types=model_types,
|
model_types=model_types,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
|
auto_tags=auto_tags,
|
||||||
favorites_only=favorites_only,
|
favorites_only=favorites_only,
|
||||||
search_options=normalized_options,
|
search_options=normalized_options,
|
||||||
tag_logic=tag_logic,
|
tag_logic=tag_logic,
|
||||||
@@ -908,6 +917,17 @@ class BaseModelService(ABC):
|
|||||||
)
|
)
|
||||||
if should_skip or metadata is None:
|
if should_skip or metadata is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Prune stale example-image metadata entries whose files no longer
|
||||||
|
# exist on disk (e.g. a user deleted the files manually).
|
||||||
|
from ..utils.example_images_metadata import MetadataUpdater
|
||||||
|
|
||||||
|
was_modified = await MetadataUpdater.prune_stale_example_images(metadata)
|
||||||
|
if was_modified:
|
||||||
|
asyncio.create_task(
|
||||||
|
MetadataManager.save_metadata(file_path, metadata)
|
||||||
|
)
|
||||||
|
|
||||||
return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
|
return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
|
||||||
|
|
||||||
async def get_model_description(self, file_path: str) -> Optional[str]:
|
async def get_model_description(self, file_path: str) -> Optional[str]:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import logging
|
|||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
from .base_model_service import BaseModelService
|
from .base_model_service import BaseModelService
|
||||||
|
from .auto_tag_service import extract_auto_tags
|
||||||
from ..utils.models import CheckpointMetadata
|
from ..utils.models import CheckpointMetadata
|
||||||
from ..config import config
|
from ..config import config
|
||||||
|
|
||||||
@@ -45,7 +46,8 @@ class CheckpointService(BaseModelService):
|
|||||||
"exclude": bool(checkpoint_data.get("exclude", False)),
|
"exclude": bool(checkpoint_data.get("exclude", False)),
|
||||||
"update_available": bool(checkpoint_data.get("update_available", False)),
|
"update_available": bool(checkpoint_data.get("update_available", False)),
|
||||||
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
|
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
|
||||||
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True)
|
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True),
|
||||||
|
"auto_tags": checkpoint_data.get("auto_tags") or extract_auto_tags(checkpoint_data),
|
||||||
}
|
}
|
||||||
|
|
||||||
def find_duplicate_hashes(self) -> Dict:
|
def find_duplicate_hashes(self) -> Dict:
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ class CivitaiClient:
|
|||||||
"GET",
|
"GET",
|
||||||
f"{self.base_url}/models",
|
f"{self.base_url}/models",
|
||||||
use_auth=True,
|
use_auth=True,
|
||||||
params={"ids": query},
|
params={"ids": query, "nsfw": "true"},
|
||||||
)
|
)
|
||||||
if not success:
|
if not success:
|
||||||
return None
|
return None
|
||||||
@@ -640,7 +640,7 @@ class CivitaiClient:
|
|||||||
"GET",
|
"GET",
|
||||||
f"{self.base_url}/models",
|
f"{self.base_url}/models",
|
||||||
use_auth=True,
|
use_auth=True,
|
||||||
params={"username": username},
|
params={"username": username, "nsfw": "true"},
|
||||||
)
|
)
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import logging
|
|||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
from .base_model_service import BaseModelService
|
from .base_model_service import BaseModelService
|
||||||
|
from .auto_tag_service import extract_auto_tags
|
||||||
from ..utils.models import EmbeddingMetadata
|
from ..utils.models import EmbeddingMetadata
|
||||||
from ..config import config
|
from ..config import config
|
||||||
|
|
||||||
@@ -45,7 +46,8 @@ class EmbeddingService(BaseModelService):
|
|||||||
"exclude": bool(embedding_data.get("exclude", False)),
|
"exclude": bool(embedding_data.get("exclude", False)),
|
||||||
"update_available": bool(embedding_data.get("update_available", False)),
|
"update_available": bool(embedding_data.get("update_available", False)),
|
||||||
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
|
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
|
||||||
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True)
|
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True),
|
||||||
|
"auto_tags": embedding_data.get("auto_tags") or extract_auto_tags(embedding_data),
|
||||||
}
|
}
|
||||||
|
|
||||||
def find_duplicate_hashes(self) -> Dict:
|
def find_duplicate_hashes(self) -> Dict:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from typing import Dict, List, Optional
|
|||||||
|
|
||||||
from .base_model_service import BaseModelService
|
from .base_model_service import BaseModelService
|
||||||
from .model_query import resolve_sub_type
|
from .model_query import resolve_sub_type
|
||||||
|
from .auto_tag_service import extract_auto_tags
|
||||||
from ..utils.models import LoraMetadata
|
from ..utils.models import LoraMetadata
|
||||||
from ..config import config
|
from ..config import config
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ class LoraService(BaseModelService):
|
|||||||
"civitai": self.filter_civitai_data(
|
"civitai": self.filter_civitai_data(
|
||||||
lora_data.get("civitai", {}), minimal=True
|
lora_data.get("civitai", {}), minimal=True
|
||||||
),
|
),
|
||||||
|
"auto_tags": lora_data.get("auto_tags") or extract_auto_tags(lora_data),
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ class FilterCriteria:
|
|||||||
folder_exclude: Optional[Sequence[str]] = None
|
folder_exclude: Optional[Sequence[str]] = None
|
||||||
base_models: Optional[Sequence[str]] = None
|
base_models: Optional[Sequence[str]] = None
|
||||||
tags: Optional[Dict[str, str]] = None
|
tags: Optional[Dict[str, str]] = None
|
||||||
|
auto_tags: Optional[Dict[str, str]] = None
|
||||||
favorites_only: bool = False
|
favorites_only: bool = False
|
||||||
search_options: Optional[Dict[str, Any]] = None
|
search_options: Optional[Dict[str, Any]] = None
|
||||||
model_types: Optional[Sequence[str]] = None
|
model_types: Optional[Sequence[str]] = None
|
||||||
@@ -359,10 +360,37 @@ class ModelFilterSet:
|
|||||||
]
|
]
|
||||||
model_types_duration = time.perf_counter() - t0
|
model_types_duration = time.perf_counter() - t0
|
||||||
|
|
||||||
|
auto_tags_duration = 0
|
||||||
|
auto_tag_filters = criteria.auto_tags or {}
|
||||||
|
if auto_tag_filters:
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
include_at = set()
|
||||||
|
exclude_at = set()
|
||||||
|
for tag, state in auto_tag_filters.items():
|
||||||
|
if not tag:
|
||||||
|
continue
|
||||||
|
if state == "exclude":
|
||||||
|
exclude_at.add(tag)
|
||||||
|
else:
|
||||||
|
include_at.add(tag)
|
||||||
|
|
||||||
|
if include_at:
|
||||||
|
items = [
|
||||||
|
item for item in items
|
||||||
|
if any(tag in include_at for tag in (item.get("auto_tags") or []))
|
||||||
|
]
|
||||||
|
|
||||||
|
if exclude_at:
|
||||||
|
items = [
|
||||||
|
item for item in items
|
||||||
|
if not any(tag in exclude_at for tag in (item.get("auto_tags") or []))
|
||||||
|
]
|
||||||
|
auto_tags_duration = time.perf_counter() - t0
|
||||||
|
|
||||||
duration = time.perf_counter() - overall_start
|
duration = time.perf_counter() - overall_start
|
||||||
if duration > 0.1: # Only log if it's potentially slow
|
if duration > 0.1: # Only log if it's potentially slow
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"ModelFilterSet.apply took %.3fs (sfw: %.3fs, fav: %.3fs, folder: %.3fs, base: %.3fs, tags: %.3fs, types: %.3fs). "
|
"ModelFilterSet.apply took %.3fs (sfw: %.3fs, fav: %.3fs, folder: %.3fs, base: %.3fs, tags: %.3fs, types: %.3fs, auto_tags: %.3fs). "
|
||||||
"Count: %d -> %d",
|
"Count: %d -> %d",
|
||||||
duration,
|
duration,
|
||||||
sfw_duration,
|
sfw_duration,
|
||||||
@@ -371,6 +399,7 @@ class ModelFilterSet:
|
|||||||
base_models_duration,
|
base_models_duration,
|
||||||
tags_duration,
|
tags_duration,
|
||||||
model_types_duration,
|
model_types_duration,
|
||||||
|
auto_tags_duration,
|
||||||
initial_count,
|
initial_count,
|
||||||
len(items),
|
len(items),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
@@ -14,6 +15,7 @@ from PIL import Image
|
|||||||
|
|
||||||
from ...utils.utils import calculate_recipe_fingerprint
|
from ...utils.utils import calculate_recipe_fingerprint
|
||||||
from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url
|
from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url
|
||||||
|
from ...recipes.enrichment import RecipeEnricher
|
||||||
from .errors import (
|
from .errors import (
|
||||||
RecipeDownloadError,
|
RecipeDownloadError,
|
||||||
RecipeNotFoundError,
|
RecipeNotFoundError,
|
||||||
@@ -170,9 +172,11 @@ class RecipeAnalysisService:
|
|||||||
await self._download_image(url, temp_path)
|
await self._download_image(url, temp_path)
|
||||||
|
|
||||||
if metadata is None and not is_video:
|
if metadata is None and not is_video:
|
||||||
metadata = self._exif_utils.extract_image_metadata(temp_path)
|
metadata = await asyncio.to_thread(
|
||||||
|
self._exif_utils.extract_image_metadata, temp_path
|
||||||
|
)
|
||||||
|
|
||||||
return await self._parse_metadata(
|
result = await self._parse_metadata(
|
||||||
metadata or {},
|
metadata or {},
|
||||||
recipe_scanner=recipe_scanner,
|
recipe_scanner=recipe_scanner,
|
||||||
image_path=temp_path,
|
image_path=temp_path,
|
||||||
@@ -180,6 +184,37 @@ class RecipeAnalysisService:
|
|||||||
is_video=is_video,
|
is_video=is_video,
|
||||||
extension=extension,
|
extension=extension,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if civitai_image_id and image_info and not result.payload.get("error"):
|
||||||
|
mvid = image_info.get("modelVersionId")
|
||||||
|
if not mvid:
|
||||||
|
mvids = image_info.get("modelVersionIds")
|
||||||
|
if isinstance(mvids, list) and mvids:
|
||||||
|
mvid = mvids[0]
|
||||||
|
|
||||||
|
recipe_for_enrich = {
|
||||||
|
"gen_params": result.payload.get("gen_params", {}),
|
||||||
|
"loras": result.payload.get("loras", []),
|
||||||
|
"base_model": result.payload.get("base_model", "") or "",
|
||||||
|
"checkpoint": result.payload.get("checkpoint") or result.payload.get("model"),
|
||||||
|
"source_path": url,
|
||||||
|
}
|
||||||
|
|
||||||
|
await RecipeEnricher.enrich_recipe(
|
||||||
|
recipe=recipe_for_enrich,
|
||||||
|
civitai_client=civitai_client,
|
||||||
|
request_params=None,
|
||||||
|
prefetched_civitai_meta_raw=image_info.get("meta"),
|
||||||
|
prefetched_model_version_id=mvid,
|
||||||
|
)
|
||||||
|
|
||||||
|
result.payload["gen_params"] = recipe_for_enrich["gen_params"]
|
||||||
|
if recipe_for_enrich.get("checkpoint"):
|
||||||
|
result.payload["checkpoint"] = recipe_for_enrich["checkpoint"]
|
||||||
|
if recipe_for_enrich.get("base_model"):
|
||||||
|
result.payload["base_model"] = recipe_for_enrich["base_model"]
|
||||||
|
|
||||||
|
return result
|
||||||
finally:
|
finally:
|
||||||
if temp_path:
|
if temp_path:
|
||||||
self._safe_cleanup(temp_path)
|
self._safe_cleanup(temp_path)
|
||||||
@@ -199,7 +234,9 @@ class RecipeAnalysisService:
|
|||||||
if not os.path.isfile(normalized_path):
|
if not os.path.isfile(normalized_path):
|
||||||
raise RecipeNotFoundError("File not found")
|
raise RecipeNotFoundError("File not found")
|
||||||
|
|
||||||
metadata = self._exif_utils.extract_image_metadata(normalized_path)
|
metadata = await asyncio.to_thread(
|
||||||
|
self._exif_utils.extract_image_metadata, normalized_path
|
||||||
|
)
|
||||||
if not metadata:
|
if not metadata:
|
||||||
return self._metadata_not_found_response(normalized_path)
|
return self._metadata_not_found_response(normalized_path)
|
||||||
|
|
||||||
|
|||||||
@@ -452,3 +452,111 @@ class MetadataUpdater:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error parsing image metadata: {e}", exc_info=True)
|
logger.error(f"Error parsing image metadata: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def prune_stale_example_images(metadata) -> bool:
|
||||||
|
"""Remove example-image metadata entries whose files no longer exist on disk.
|
||||||
|
|
||||||
|
Checks ``civitai.customImages`` (by ``id``) and ``civitai.images`` entries
|
||||||
|
that have an empty ``url`` (no remote fallback) against actual files in
|
||||||
|
the model's example-image folder. Stale entries are removed in-place so
|
||||||
|
the caller can persist the cleaned metadata afterwards.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
metadata: A ``BaseModelMetadata`` instance (modified in place).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if at least one entry was removed.
|
||||||
|
"""
|
||||||
|
from ..utils.example_images_paths import get_model_folder
|
||||||
|
|
||||||
|
model_hash = getattr(metadata, "sha256", None)
|
||||||
|
if not model_hash:
|
||||||
|
return False
|
||||||
|
|
||||||
|
model_folder = get_model_folder(model_hash)
|
||||||
|
if not model_folder:
|
||||||
|
return False
|
||||||
|
|
||||||
|
civitai = getattr(metadata, "civitai", None)
|
||||||
|
if not isinstance(civitai, dict):
|
||||||
|
return False
|
||||||
|
|
||||||
|
has_changes = False
|
||||||
|
|
||||||
|
custom_images = civitai.get("customImages")
|
||||||
|
if isinstance(custom_images, list) and custom_images:
|
||||||
|
stale: list[int] = []
|
||||||
|
|
||||||
|
for idx, img in enumerate(custom_images):
|
||||||
|
img_id = img.get("id", "")
|
||||||
|
if not img_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not os.path.isdir(model_folder):
|
||||||
|
stale.append(idx)
|
||||||
|
else:
|
||||||
|
found = False
|
||||||
|
try:
|
||||||
|
prefix = f"custom_{img_id}"
|
||||||
|
for fname in os.listdir(model_folder):
|
||||||
|
if fname.startswith(prefix) and os.path.isfile(
|
||||||
|
os.path.join(model_folder, fname)
|
||||||
|
):
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
stale.append(idx)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
stale.append(idx)
|
||||||
|
|
||||||
|
if stale:
|
||||||
|
for idx in reversed(stale):
|
||||||
|
custom_images.pop(idx)
|
||||||
|
has_changes = True
|
||||||
|
logger.info(
|
||||||
|
"Pruned %d stale custom image(s) for %s",
|
||||||
|
len(stale),
|
||||||
|
getattr(metadata, "model_name", model_hash),
|
||||||
|
)
|
||||||
|
|
||||||
|
images = civitai.get("images")
|
||||||
|
if isinstance(images, list) and images:
|
||||||
|
stale: list[int] = []
|
||||||
|
|
||||||
|
for idx, img in enumerate(images):
|
||||||
|
if img.get("url", ""):
|
||||||
|
# Has a remote fallback – keep it even if the local copy
|
||||||
|
# is gone.
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not os.path.isdir(model_folder):
|
||||||
|
stale.append(idx)
|
||||||
|
else:
|
||||||
|
found = False
|
||||||
|
try:
|
||||||
|
prefix = f"image_{idx}."
|
||||||
|
for fname in os.listdir(model_folder):
|
||||||
|
if fname.startswith(prefix):
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
stale.append(idx)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
stale.append(idx)
|
||||||
|
|
||||||
|
if stale:
|
||||||
|
for idx in reversed(stale):
|
||||||
|
images.pop(idx)
|
||||||
|
has_changes = True
|
||||||
|
logger.info(
|
||||||
|
"Pruned %d stale image entry(ies) for %s",
|
||||||
|
len(stale),
|
||||||
|
getattr(metadata, "model_name", model_hash),
|
||||||
|
)
|
||||||
|
|
||||||
|
return has_changes
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-lora-manager"
|
name = "comfyui-lora-manager"
|
||||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||||
version = "1.0.6"
|
version = "1.0.7"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
|||||||
@@ -507,21 +507,96 @@
|
|||||||
background: rgba(0,0,0,0.18); /* Optional: subtle background for contrast */
|
background: rgba(0,0,0,0.18); /* Optional: subtle background for contrast */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Version row — flex container for badges + version names */
|
||||||
|
.version-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge + version-name binding: they wrap as a single unit */
|
||||||
|
.badge-version-unit {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Medium density adjustments for version name */
|
/* Medium density adjustments for version name */
|
||||||
.medium-density .version-name {
|
.medium-density .version-name {
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.medium-density .badge-version-unit .version-name {
|
||||||
|
max-width: 90px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
/* Compact density adjustments for version name */
|
/* Compact density adjustments for version name */
|
||||||
.compact-density .version-name {
|
.compact-density .version-name {
|
||||||
font-size: 0.75em;
|
font-size: 0.75em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide civitai version name when setting is disabled */
|
.compact-density .badge-version-unit .version-name {
|
||||||
body.hide-card-version .civitai-version {
|
max-width: 70px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medium-density .version-row {
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* HIGH / LOW badges — shown inline before version name in card footer */
|
||||||
|
.hl-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.7em;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.1;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-badge--high {
|
||||||
|
color: oklch(75% 0.12 230);
|
||||||
|
background: oklch(55% 0.15 240 / 0.25);
|
||||||
|
border-color: oklch(60% 0.18 250 / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-badge--low {
|
||||||
|
color: oklch(78% 0.10 185);
|
||||||
|
background: oklch(50% 0.10 190 / 0.25);
|
||||||
|
border-color: oklch(55% 0.12 195 / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.medium-density .hl-badge {
|
||||||
|
font-size: 0.65em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-density .hl-badge {
|
||||||
|
font-size: 0.62em;
|
||||||
|
padding: 0px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide version-related elements when setting is disabled */
|
||||||
|
body.hide-card-version .civitai-version,
|
||||||
|
body.hide-card-version .hl-badge {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Compact density adjustments for version name */
|
||||||
|
.compact-density .version-name {
|
||||||
|
font-size: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
/* Prevent text selection on cards and interactive elements */
|
/* Prevent text selection on cards and interactive elements */
|
||||||
.model-card,
|
.model-card,
|
||||||
.model-card *,
|
.model-card *,
|
||||||
|
|||||||
@@ -4,15 +4,20 @@
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
border-bottom: 1px solid var(--lora-border);
|
border-bottom: 1px solid var(--lora-border);
|
||||||
padding-bottom: 10px;
|
padding-bottom: var(--space-2);
|
||||||
margin-bottom: 10px;
|
margin-bottom: var(--space-3);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-modal-header h2 {
|
.recipe-modal-header h2 {
|
||||||
font-size: 1.4em; /* Reduced from default h2 size */
|
margin: 0 0 var(--space-1);
|
||||||
line-height: 1.3;
|
padding: var(--space-1);
|
||||||
margin: 0;
|
border-radius: var(--border-radius-xs);
|
||||||
max-height: 2.6em; /* Limit to 2 lines */
|
font-size: 1.5em;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--text-color);
|
||||||
|
max-height: 2.8em;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
@@ -127,7 +132,7 @@
|
|||||||
/* Recipe Tags styles */
|
/* Recipe Tags styles */
|
||||||
.recipe-tags-container {
|
.recipe-tags-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-top: 6px;
|
margin-top: 0;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +230,62 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Recipe Header Actions */
|
||||||
|
.recipe-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-header-actions:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-source-url-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.03);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9em;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .recipe-source-url-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-source-url-btn:hover {
|
||||||
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-source-url-btn i {
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-height: 860px) {
|
||||||
|
.recipe-header-actions {
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Top Section: Preview and Gen Params */
|
/* Top Section: Preview and Gen Params */
|
||||||
.recipe-top-section {
|
.recipe-top-section {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -396,14 +457,54 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-gen-params h3 {
|
.gen-params-header-row {
|
||||||
margin-top: 0;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
font-size: 1.2em;
|
|
||||||
color: var(--text-color);
|
|
||||||
padding-bottom: var(--space-1);
|
padding-bottom: var(--space-1);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gen-params-header-row h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.2em;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline toggle for lora strip setting */
|
||||||
|
.lora-strip-toggle {
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-strip-toggle .inline-toggle-label {
|
||||||
|
font-size: 0.78em;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-strip-toggle:hover .inline-toggle-label {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-strip-toggle .toggle-switch {
|
||||||
|
width: 32px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-strip-toggle .toggle-slider:before {
|
||||||
|
height: 10px;
|
||||||
|
width: 10px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-strip-toggle .toggle-switch input:checked + .toggle-slider:before {
|
||||||
|
transform: translateX(16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gen-params-container {
|
.gen-params-container {
|
||||||
@@ -1043,13 +1144,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.recipe-modal-header {
|
.recipe-modal-header {
|
||||||
padding-bottom: 6px;
|
padding-bottom: var(--space-1);
|
||||||
margin-bottom: 8px;
|
margin-bottom: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-modal-header h2 {
|
.recipe-modal-header h2 {
|
||||||
font-size: 1.25em;
|
font-size: 1.3em;
|
||||||
max-height: 2.5em;
|
max-height: 2.4em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-tags-container {
|
.recipe-tags-container {
|
||||||
|
|||||||
@@ -978,6 +978,16 @@ export class BaseModelApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pageState.filters.autoTags && Object.keys(pageState.filters.autoTags).length > 0) {
|
||||||
|
Object.entries(pageState.filters.autoTags).forEach(([tag, state]) => {
|
||||||
|
if (state === 'include') {
|
||||||
|
params.append('auto_tag_include', tag);
|
||||||
|
} else if (state === 'exclude') {
|
||||||
|
params.append('auto_tag_exclude', tag);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) {
|
if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) {
|
||||||
// Check for empty wildcard marker - if present, no models should match
|
// Check for empty wildcard marker - if present, no models should match
|
||||||
const EMPTY_WILDCARD_MARKER = '__EMPTY_WILDCARD_RESULT__';
|
const EMPTY_WILDCARD_MARKER = '__EMPTY_WILDCARD_RESULT__';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata } from '../utils/uiHelpers.js';
|
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata } from '../utils/uiHelpers.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js';
|
import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||||
import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js';
|
import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js';
|
||||||
import { downloadManager } from '../managers/DownloadManager.js';
|
import { downloadManager } from '../managers/DownloadManager.js';
|
||||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||||
@@ -105,6 +105,7 @@ class RecipeModal {
|
|||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.setupCopyButtons();
|
this.setupCopyButtons();
|
||||||
|
this.setupStripLoraToggle();
|
||||||
this.setupPromptEditors();
|
this.setupPromptEditors();
|
||||||
// Set up tooltip positioning handlers after DOM is ready
|
// Set up tooltip positioning handlers after DOM is ready
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
@@ -382,6 +383,7 @@ class RecipeModal {
|
|||||||
|
|
||||||
this.syncGenerationParams(hydratedRecipe.gen_params);
|
this.syncGenerationParams(hydratedRecipe.gen_params);
|
||||||
this.syncResourcesSection(hydratedRecipe);
|
this.syncResourcesSection(hydratedRecipe);
|
||||||
|
this.syncSourceUrlAction();
|
||||||
|
|
||||||
// Show the modal
|
// Show the modal
|
||||||
modalManager.showModal('recipeModal');
|
modalManager.showModal('recipeModal');
|
||||||
@@ -514,6 +516,7 @@ class RecipeModal {
|
|||||||
} else {
|
} else {
|
||||||
this.updateSourceUrlDisplay(this.currentRecipe.source_path || '');
|
this.updateSourceUrlDisplay(this.currentRecipe.source_path || '');
|
||||||
}
|
}
|
||||||
|
this.syncSourceUrlAction();
|
||||||
}
|
}
|
||||||
|
|
||||||
getPreviewMediaUrl(recipe = {}) {
|
getPreviewMediaUrl(recipe = {}) {
|
||||||
@@ -581,6 +584,30 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncSourceUrlAction() {
|
||||||
|
const actionsContainer = document.getElementById('recipeHeaderActions');
|
||||||
|
if (!actionsContainer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
actionsContainer.innerHTML = '';
|
||||||
|
|
||||||
|
const sourcePath = this.currentRecipe?.source_path || '';
|
||||||
|
const isValidUrl = sourcePath.startsWith('http://') || sourcePath.startsWith('https://');
|
||||||
|
if (!isValidUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'recipe-source-url-btn';
|
||||||
|
btn.title = sourcePath;
|
||||||
|
btn.innerHTML = '<i class="fas fa-globe"></i> Open Source URL';
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
window.open(sourcePath, '_blank');
|
||||||
|
});
|
||||||
|
actionsContainer.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
syncTagsDisplay(tags) {
|
syncTagsDisplay(tags) {
|
||||||
const tagsContainer = document.getElementById('recipeTagsCompact');
|
const tagsContainer = document.getElementById('recipeTagsCompact');
|
||||||
if (!tagsContainer) {
|
if (!tagsContainer) {
|
||||||
@@ -1315,6 +1342,7 @@ class RecipeModal {
|
|||||||
// Update source URL in the UI
|
// Update source URL in the UI
|
||||||
this.commitField('source_path');
|
this.commitField('source_path');
|
||||||
this.updateSourceUrlDisplay(newSourceUrl, { forceInputSync: true });
|
this.updateSourceUrlDisplay(newSourceUrl, { forceInputSync: true });
|
||||||
|
this.syncSourceUrlAction();
|
||||||
|
|
||||||
// Update the current recipe object
|
// Update the current recipe object
|
||||||
this.currentRecipe.source_path = newSourceUrl;
|
this.currentRecipe.source_path = newSourceUrl;
|
||||||
@@ -1350,14 +1378,20 @@ class RecipeModal {
|
|||||||
|
|
||||||
if (copyPromptBtn) {
|
if (copyPromptBtn) {
|
||||||
copyPromptBtn.addEventListener('click', () => {
|
copyPromptBtn.addEventListener('click', () => {
|
||||||
const promptText = this.currentRecipe?.gen_params?.prompt || '';
|
let promptText = this.currentRecipe?.gen_params?.prompt || '';
|
||||||
|
if (this.shouldStripLoraOnCopy()) {
|
||||||
|
promptText = RecipeModal.stripLoraTags(promptText);
|
||||||
|
}
|
||||||
this.copyToClipboard(promptText, 'Prompt copied to clipboard');
|
this.copyToClipboard(promptText, 'Prompt copied to clipboard');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (copyNegativePromptBtn) {
|
if (copyNegativePromptBtn) {
|
||||||
copyNegativePromptBtn.addEventListener('click', () => {
|
copyNegativePromptBtn.addEventListener('click', () => {
|
||||||
const negativePromptText = this.currentRecipe?.gen_params?.negative_prompt || '';
|
let negativePromptText = this.currentRecipe?.gen_params?.negative_prompt || '';
|
||||||
|
if (this.shouldStripLoraOnCopy()) {
|
||||||
|
negativePromptText = RecipeModal.stripLoraTags(negativePromptText);
|
||||||
|
}
|
||||||
this.copyToClipboard(negativePromptText, 'Negative prompt copied to clipboard');
|
this.copyToClipboard(negativePromptText, 'Negative prompt copied to clipboard');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1377,6 +1411,43 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip <lora:...> tags from prompt text and clean up residual punctuation/whitespace.
|
||||||
|
* Handles both unescaped (<lora:...>) and HTML-escaped (<lora:...>) variants.
|
||||||
|
* Cleans up artifacts like leading ", ", double commas, and extra whitespace.
|
||||||
|
*/
|
||||||
|
static stripLoraTags(text) {
|
||||||
|
return text
|
||||||
|
.replace(/<lora:[^>]*>/gi, '')
|
||||||
|
.replace(/<lora:[^&]*>/gi, '')
|
||||||
|
.replace(/,(\s*,)+/g, ',')
|
||||||
|
.replace(/^,\s*/, '')
|
||||||
|
.replace(/,\s*$/, '')
|
||||||
|
.replace(/\s{2,}/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldStripLoraOnCopy() {
|
||||||
|
const toggle = document.getElementById('stripLoraOnCopyToggle');
|
||||||
|
return toggle ? toggle.checked : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setupStripLoraToggle() {
|
||||||
|
const toggle = document.getElementById('stripLoraOnCopyToggle');
|
||||||
|
if (!toggle) return;
|
||||||
|
|
||||||
|
const stored = getStorageItem('strip_lora_on_copy');
|
||||||
|
if (stored !== null) {
|
||||||
|
toggle.checked = stored === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle.addEventListener('change', () => {
|
||||||
|
const checked = toggle.checked;
|
||||||
|
setStorageItem('strip_lora_on_copy', checked);
|
||||||
|
state.global.settings.strip_lora_on_copy = checked;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch recipe syntax from backend and copy to clipboard
|
// Fetch recipe syntax from backend and copy to clipboard
|
||||||
async fetchAndCopyRecipeSyntax() {
|
async fetchAndCopyRecipeSyntax() {
|
||||||
if (!this.recipeId) {
|
if (!this.recipeId) {
|
||||||
|
|||||||
@@ -644,8 +644,23 @@ export function createModelCard(model, modelType) {
|
|||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<div class="model-info">
|
<div class="model-info">
|
||||||
<span class="model-name" title="${getDisplayName(model).replace(/"/g, '"')}">${getDisplayName(model)}</span>
|
<span class="model-name" title="${getDisplayName(model).replace(/"/g, '"')}">${getDisplayName(model)}</span>
|
||||||
<div>
|
<div class="version-row">
|
||||||
${model.civitai?.name ? `<span class="version-name civitai-version">${model.civitai.name}</span>` : ''}
|
${(() => {
|
||||||
|
const autoTags = model.auto_tags || [];
|
||||||
|
const hlTags = autoTags.filter(t => t === 'HIGH' || t === 'LOW');
|
||||||
|
const hasVersionName = model.civitai?.name;
|
||||||
|
if (!hlTags.length && !hasVersionName) return '';
|
||||||
|
const density = state.global.settings.display_density || 'default';
|
||||||
|
const shortLabels = density === 'medium' || density === 'compact';
|
||||||
|
const badges = hlTags.map(t => {
|
||||||
|
const cls = t === 'HIGH' ? 'hl-badge hl-badge--high' : 'hl-badge hl-badge--low';
|
||||||
|
const label = shortLabels ? (t === 'HIGH' ? 'H' : 'L') : t;
|
||||||
|
const titleAttr = shortLabels ? ` title="${t}"` : '';
|
||||||
|
return `<span class="${cls}"${titleAttr}>${label}</span>`;
|
||||||
|
}).join('');
|
||||||
|
const versionHtml = hasVersionName ? `<span class="version-name civitai-version">${model.civitai.name}</span>` : '';
|
||||||
|
return `<span class="badge-version-unit">${badges}${versionHtml}</span>`;
|
||||||
|
})()}
|
||||||
${hasUsageCount ? `<span class="version-name" title="${translate('modelCard.usage.timesUsed', {}, 'Times used')}">${model.usage_count}×</span>` : ''}
|
${hasUsageCount ? `<span class="version-name" title="${translate('modelCard.usage.timesUsed', {}, 'Times used')}">${model.usage_count}×</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -546,9 +546,23 @@ export class BulkManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const countElement = document.getElementById('bulkDeleteCount');
|
const count = state.selectedModels.size;
|
||||||
if (countElement) {
|
const isRecipes = state.currentPageType === 'recipes';
|
||||||
countElement.textContent = state.selectedModels.size;
|
const keyPrefix = isRecipes ? 'modals.bulkDeleteRecipes' : 'modals.bulkDelete';
|
||||||
|
|
||||||
|
const titleEl = document.querySelector('#bulkDeleteModal h2');
|
||||||
|
if (titleEl) {
|
||||||
|
titleEl.textContent = translate(`${keyPrefix}.title`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageEl = document.querySelector('#bulkDeleteModal .delete-message');
|
||||||
|
if (messageEl) {
|
||||||
|
messageEl.textContent = translate(`${keyPrefix}.message`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const countInfoEl = document.querySelector('#bulkDeleteModal .delete-model-info p');
|
||||||
|
if (countInfoEl) {
|
||||||
|
countInfoEl.innerHTML = `<span id="bulkDeleteCount">${count}</span> ${translate(`${keyPrefix}.countMessage`)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
modalManager.showModal('bulkDeleteModal');
|
modalManager.showModal('bulkDeleteModal');
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ export class FilterManager {
|
|||||||
// Initialize tag logic toggle
|
// Initialize tag logic toggle
|
||||||
this.initializeTagLogicToggle();
|
this.initializeTagLogicToggle();
|
||||||
|
|
||||||
|
// Create auto-tag filter section (I2V, T2V, TI2V, Lightning, Turbo)
|
||||||
|
this.createAutoTagFilters();
|
||||||
|
|
||||||
// Add click handler for filter button
|
// Add click handler for filter button
|
||||||
if (this.filterButton) {
|
if (this.filterButton) {
|
||||||
this.filterButton.addEventListener('click', () => {
|
this.filterButton.addEventListener('click', () => {
|
||||||
@@ -480,6 +483,58 @@ export class FilterManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AUTO_TAG_FILTER_TAGS = ['I2V', 'T2V', 'TI2V', 'Lightning', 'Turbo'];
|
||||||
|
|
||||||
|
createAutoTagFilters() {
|
||||||
|
const container = document.getElementById('autoTagFilterTags');
|
||||||
|
if (container) return;
|
||||||
|
|
||||||
|
const modelTypeSection = document.getElementById('modelTypeTags')?.closest('.filter-section');
|
||||||
|
if (!modelTypeSection) return;
|
||||||
|
|
||||||
|
const section = document.createElement('div');
|
||||||
|
section.className = 'filter-section';
|
||||||
|
section.innerHTML = `
|
||||||
|
<h4>${translate('header.filter.autoTags', {}, 'Auto Tags')}</h4>
|
||||||
|
<div class="filter-tags" id="autoTagFilterTags"></div>
|
||||||
|
`;
|
||||||
|
modelTypeSection.parentNode.insertBefore(section, modelTypeSection.nextSibling);
|
||||||
|
|
||||||
|
const tagsContainer = document.getElementById('autoTagFilterTags');
|
||||||
|
this.AUTO_TAG_FILTER_TAGS.forEach(tag => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'filter-tag auto-tag-filter';
|
||||||
|
el.dataset.autoTag = tag;
|
||||||
|
el.textContent = tag;
|
||||||
|
|
||||||
|
// Restore previous state
|
||||||
|
const state = (this.filters.autoTags && this.filters.autoTags[tag]) || 'none';
|
||||||
|
this._applyTriState(el, state);
|
||||||
|
|
||||||
|
el.addEventListener('click', async () => {
|
||||||
|
const current = (this.filters.autoTags && this.filters.autoTags[tag]) || 'none';
|
||||||
|
const next = current === 'none' ? 'include' : current === 'include' ? 'exclude' : 'none';
|
||||||
|
if (!this.filters.autoTags) this.filters.autoTags = {};
|
||||||
|
if (next === 'none') {
|
||||||
|
delete this.filters.autoTags[tag];
|
||||||
|
} else {
|
||||||
|
this.filters.autoTags[tag] = next;
|
||||||
|
}
|
||||||
|
this._applyTriState(el, next);
|
||||||
|
this.updateActiveFiltersCount();
|
||||||
|
await this.applyFilters(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
tagsContainer.appendChild(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyTriState(el, state) {
|
||||||
|
el.classList.remove('active', 'exclude');
|
||||||
|
if (state === 'include') el.classList.add('active');
|
||||||
|
else if (state === 'exclude') el.classList.add('exclude');
|
||||||
|
}
|
||||||
|
|
||||||
toggleFilterPanel() {
|
toggleFilterPanel() {
|
||||||
if (this.filterPanel) {
|
if (this.filterPanel) {
|
||||||
const isHidden = this.filterPanel.classList.contains('hidden');
|
const isHidden = this.filterPanel.classList.contains('hidden');
|
||||||
@@ -540,6 +595,13 @@ export class FilterManager {
|
|||||||
this.updateLicenseSelections();
|
this.updateLicenseSelections();
|
||||||
}
|
}
|
||||||
this.updateModelTypeSelections();
|
this.updateModelTypeSelections();
|
||||||
|
|
||||||
|
const autoTagEls = document.querySelectorAll('.auto-tag-filter');
|
||||||
|
autoTagEls.forEach(el => {
|
||||||
|
const tag = el.dataset.autoTag;
|
||||||
|
const state = (this.filters.autoTags && this.filters.autoTags[tag]) || 'none';
|
||||||
|
this._applyTriState(el, state);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateModelTypeSelections() {
|
updateModelTypeSelections() {
|
||||||
@@ -556,11 +618,12 @@ export class FilterManager {
|
|||||||
|
|
||||||
updateActiveFiltersCount() {
|
updateActiveFiltersCount() {
|
||||||
const tagFilterCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
|
const tagFilterCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
|
||||||
|
const autoTagFilterCount = this.filters.autoTags ? Object.keys(this.filters.autoTags).length : 0;
|
||||||
const licenseFilterCount = this.filters.license ? Object.keys(this.filters.license).length : 0;
|
const licenseFilterCount = this.filters.license ? Object.keys(this.filters.license).length : 0;
|
||||||
const modelTypeFilterCount = this.filters.modelTypes.length;
|
const modelTypeFilterCount = this.filters.modelTypes.length;
|
||||||
// Exclude EMPTY_WILDCARD_MARKER from base model count
|
// Exclude EMPTY_WILDCARD_MARKER from base model count
|
||||||
const baseModelCount = this.filters.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER).length;
|
const baseModelCount = this.filters.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER).length;
|
||||||
const totalActiveFilters = baseModelCount + tagFilterCount + licenseFilterCount + modelTypeFilterCount;
|
const totalActiveFilters = baseModelCount + tagFilterCount + autoTagFilterCount + licenseFilterCount + modelTypeFilterCount;
|
||||||
|
|
||||||
if (this.activeFiltersCount) {
|
if (this.activeFiltersCount) {
|
||||||
if (totalActiveFilters > 0) {
|
if (totalActiveFilters > 0) {
|
||||||
@@ -652,6 +715,7 @@ export class FilterManager {
|
|||||||
...this.filters,
|
...this.filters,
|
||||||
baseModel: [],
|
baseModel: [],
|
||||||
tags: {},
|
tags: {},
|
||||||
|
autoTags: {},
|
||||||
license: {},
|
license: {},
|
||||||
modelTypes: [],
|
modelTypes: [],
|
||||||
tagLogic: 'any'
|
tagLogic: 'any'
|
||||||
@@ -721,6 +785,7 @@ export class FilterManager {
|
|||||||
|
|
||||||
hasActiveFilters() {
|
hasActiveFilters() {
|
||||||
const tagCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
|
const tagCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
|
||||||
|
const autoTagCount = this.filters.autoTags ? Object.keys(this.filters.autoTags).length : 0;
|
||||||
const licenseCount = this.filters.license ? Object.keys(this.filters.license).length : 0;
|
const licenseCount = this.filters.license ? Object.keys(this.filters.license).length : 0;
|
||||||
const modelTypeCount = this.filters.modelTypes.length;
|
const modelTypeCount = this.filters.modelTypes.length;
|
||||||
// Exclude EMPTY_WILDCARD_MARKER from base model count
|
// Exclude EMPTY_WILDCARD_MARKER from base model count
|
||||||
@@ -728,6 +793,7 @@ export class FilterManager {
|
|||||||
return (
|
return (
|
||||||
baseModelCount > 0 ||
|
baseModelCount > 0 ||
|
||||||
tagCount > 0 ||
|
tagCount > 0 ||
|
||||||
|
autoTagCount > 0 ||
|
||||||
licenseCount > 0 ||
|
licenseCount > 0 ||
|
||||||
modelTypeCount > 0
|
modelTypeCount > 0
|
||||||
);
|
);
|
||||||
@@ -739,6 +805,7 @@ export class FilterManager {
|
|||||||
...source,
|
...source,
|
||||||
baseModel: Array.isArray(source.baseModel) ? [...source.baseModel] : [],
|
baseModel: Array.isArray(source.baseModel) ? [...source.baseModel] : [],
|
||||||
tags: this.normalizeTagFilters(source.tags),
|
tags: this.normalizeTagFilters(source.tags),
|
||||||
|
autoTags: this.normalizeTagFilters(source.autoTags),
|
||||||
license: this.shouldShowLicenseFilters() ? this.normalizeLicenseFilters(source.license) : {},
|
license: this.shouldShowLicenseFilters() ? this.normalizeLicenseFilters(source.license) : {},
|
||||||
modelTypes: this.normalizeModelTypeFilters(source.modelTypes),
|
modelTypes: this.normalizeModelTypeFilters(source.modelTypes),
|
||||||
tagLogic: source.tagLogic || 'any'
|
tagLogic: source.tagLogic || 'any'
|
||||||
@@ -822,6 +889,7 @@ export class FilterManager {
|
|||||||
...this.filters,
|
...this.filters,
|
||||||
baseModel: [...(this.filters.baseModel || [])],
|
baseModel: [...(this.filters.baseModel || [])],
|
||||||
tags: { ...(this.filters.tags || {}) },
|
tags: { ...(this.filters.tags || {}) },
|
||||||
|
autoTags: { ...(this.filters.autoTags || {}) },
|
||||||
license: { ...(this.filters.license || {}) },
|
license: { ...(this.filters.license || {}) },
|
||||||
modelTypes: [...(this.filters.modelTypes || [])],
|
modelTypes: [...(this.filters.modelTypes || [])],
|
||||||
tagLogic: this.filters.tagLogic || 'any'
|
tagLogic: this.filters.tagLogic || 'any'
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
|||||||
download_skip_base_models: [],
|
download_skip_base_models: [],
|
||||||
backup_auto_enabled: true,
|
backup_auto_enabled: true,
|
||||||
backup_retention_count: 5,
|
backup_retention_count: 5,
|
||||||
|
strip_lora_on_copy: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function createDefaultSettings() {
|
export function createDefaultSettings() {
|
||||||
|
|||||||
@@ -500,6 +500,18 @@ export function clearDynamicBaseModels() {
|
|||||||
dynamicBaseModelsTimestamp = null;
|
dynamicBaseModelsTimestamp = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const AUTO_TAG_GROUPS = {
|
||||||
|
mode: new Set(['HIGH', 'LOW']),
|
||||||
|
video: new Set(['I2V', 'T2V', 'TI2V']),
|
||||||
|
speed: new Set(['Lightning', 'Turbo']),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AUTO_TAG_GROUP_LABELS = {
|
||||||
|
mode: 'High / Low',
|
||||||
|
video: 'I2V / T2V / TI2V',
|
||||||
|
speed: 'Lightning / Turbo',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if dynamic base models cache is valid
|
* Check if dynamic base models cache is valid
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<!-- Header Actions: populated dynamically in RecipeModal.js -->
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions"></div>
|
||||||
<!-- Recipe Tags Container -->
|
<!-- Recipe Tags Container -->
|
||||||
<div class="recipe-tags-container">
|
<div class="recipe-tags-container">
|
||||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
||||||
@@ -22,7 +24,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<h3>Generation Parameters</h3>
|
<div class="gen-params-header-row">
|
||||||
|
<h3>Generation Parameters</h3>
|
||||||
|
<label class="inline-toggle-container lora-strip-toggle" title="When enabled, <lora:...> tags are removed from prompt text when copying">
|
||||||
|
<span class="inline-toggle-label">Strip <lora:></span>
|
||||||
|
<div class="toggle-switch">
|
||||||
|
<input type="checkbox" id="stripLoraOnCopyToggle">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<!-- Prompt -->
|
<!-- Prompt -->
|
||||||
|
|||||||
@@ -785,10 +785,16 @@ async def test_import_remote_recipe_merges_metadata(
|
|||||||
async def parse_metadata(self, raw, recipe_scanner=None):
|
async def parse_metadata(self, raw, recipe_scanner=None):
|
||||||
return json.loads(raw[len("Recipe metadata: ") :])
|
return json.loads(raw[len("Recipe metadata: ") :])
|
||||||
|
|
||||||
|
class MockApiParser:
|
||||||
|
async def parse_metadata(self, raw, recipe_scanner=None):
|
||||||
|
return {"gen_params": raw, "loras": []}
|
||||||
|
|
||||||
class MockFactory:
|
class MockFactory:
|
||||||
def create_parser(self, raw):
|
def create_parser(self, raw):
|
||||||
if raw.startswith("Recipe metadata: "):
|
if isinstance(raw, str) and raw.startswith("Recipe metadata: "):
|
||||||
return MockParser()
|
return MockParser()
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
return MockApiParser()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 4. Setup Harness and run test
|
# 4. Setup Harness and run test
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ async def test_get_model_versions_raises_on_other_errors(monkeypatch, downloader
|
|||||||
async def test_get_model_versions_bulk_success(monkeypatch, downloader):
|
async def test_get_model_versions_bulk_success(monkeypatch, downloader):
|
||||||
async def fake_make_request(method, url, use_auth=True, **kwargs):
|
async def fake_make_request(method, url, use_auth=True, **kwargs):
|
||||||
assert url.endswith("/models")
|
assert url.endswith("/models")
|
||||||
assert kwargs.get("params") == {"ids": "1,2"}
|
assert kwargs.get("params") == {"ids": "1,2", "nsfw": "true"}
|
||||||
return True, {
|
return True, {
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ async def test_repair_all_recipes_with_enriched_checkpoint_id(setup_scanner):
|
|||||||
recipe = {
|
recipe = {
|
||||||
"id": "r1",
|
"id": "r1",
|
||||||
"title": "Old Recipe",
|
"title": "Old Recipe",
|
||||||
"source_url": "https://civitai.com/images/12345",
|
"source_path": "https://civitai.com/images/12345",
|
||||||
"checkpoint": None,
|
"checkpoint": None,
|
||||||
"gen_params": {"prompt": ""}
|
"gen_params": {"prompt": ""}
|
||||||
}
|
}
|
||||||
@@ -127,7 +127,7 @@ async def test_repair_all_recipes_supports_civitai_red_source_url(setup_scanner)
|
|||||||
recipe = {
|
recipe = {
|
||||||
"id": "r1",
|
"id": "r1",
|
||||||
"title": "Red Recipe",
|
"title": "Red Recipe",
|
||||||
"source_url": "https://civitai.red/images/12345",
|
"source_path": "https://civitai.red/images/12345",
|
||||||
"checkpoint": None,
|
"checkpoint": None,
|
||||||
"gen_params": {"prompt": ""},
|
"gen_params": {"prompt": ""},
|
||||||
}
|
}
|
||||||
|
|||||||
151
tests/test_auto_tag_service.py
Normal file
151
tests/test_auto_tag_service.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "py"))
|
||||||
|
|
||||||
|
from services.auto_tag_service import extract_auto_tags, AUTO_TAG_CATEGORIES
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractAutoTags:
|
||||||
|
def test_file_name_high_i2v(self):
|
||||||
|
result = extract_auto_tags({
|
||||||
|
"file_name": "Shirt_lift_Wan2.2_14B_I2V_HIGH_v1.0",
|
||||||
|
"base_model": "Wan Video 2.2 I2V-A14B",
|
||||||
|
"civitai": {},
|
||||||
|
})
|
||||||
|
assert set(result) == {"HIGH", "I2V"}
|
||||||
|
|
||||||
|
def test_file_name_t2v_low(self):
|
||||||
|
result = extract_auto_tags({
|
||||||
|
"file_name": "my_wan_t2v_low_v2",
|
||||||
|
"base_model": "Wan 2.1",
|
||||||
|
"civitai": {},
|
||||||
|
})
|
||||||
|
assert set(result) == {"LOW", "T2V"}
|
||||||
|
|
||||||
|
def test_file_name_ti2v_high(self):
|
||||||
|
result = extract_auto_tags({
|
||||||
|
"file_name": "wan_ti2v_high_quality",
|
||||||
|
"base_model": "Wan 2.2",
|
||||||
|
"civitai": {},
|
||||||
|
})
|
||||||
|
assert set(result) == {"HIGH", "TI2V"}
|
||||||
|
|
||||||
|
def test_file_name_lightning_turbo(self):
|
||||||
|
result = extract_auto_tags({
|
||||||
|
"file_name": "sdxl_lightning_turbo_v3",
|
||||||
|
"base_model": "SDXL",
|
||||||
|
"civitai": {},
|
||||||
|
})
|
||||||
|
assert set(result) == {"Lightning", "Turbo"}
|
||||||
|
|
||||||
|
def test_base_model_source(self):
|
||||||
|
result = extract_auto_tags({
|
||||||
|
"file_name": "my_lora_v1",
|
||||||
|
"base_model": "Wan Video 2.2 I2V-A14B",
|
||||||
|
"civitai": {},
|
||||||
|
})
|
||||||
|
assert "I2V" in result
|
||||||
|
|
||||||
|
def test_civitai_name_source(self):
|
||||||
|
result = extract_auto_tags({
|
||||||
|
"file_name": "model_v1",
|
||||||
|
"base_model": "Wan",
|
||||||
|
"civitai": {"name": "HIGH Quality"},
|
||||||
|
})
|
||||||
|
assert "HIGH" in result
|
||||||
|
|
||||||
|
def test_no_false_match_flow(self):
|
||||||
|
result = extract_auto_tags({
|
||||||
|
"file_name": "flux_dev_model",
|
||||||
|
"base_model": "Flux.1 D",
|
||||||
|
"civitai": {},
|
||||||
|
})
|
||||||
|
assert "LOW" not in result
|
||||||
|
|
||||||
|
def test_no_false_match_glow(self):
|
||||||
|
result = extract_auto_tags({
|
||||||
|
"file_name": "glow_style_lora",
|
||||||
|
"base_model": "SDXL",
|
||||||
|
"civitai": {},
|
||||||
|
})
|
||||||
|
assert "LOW" not in result
|
||||||
|
|
||||||
|
def test_high_low_only_for_wan(self):
|
||||||
|
"""HIGH/LOW should not appear for non-Wan models even in filename."""
|
||||||
|
result = extract_auto_tags({
|
||||||
|
"file_name": "my_model_high_quality_v2",
|
||||||
|
"base_model": "Flux.1 D",
|
||||||
|
"civitai": {"name": "HIGH"},
|
||||||
|
})
|
||||||
|
assert "HIGH" not in result
|
||||||
|
assert "LOW" not in result
|
||||||
|
|
||||||
|
def test_no_distilled(self):
|
||||||
|
result = extract_auto_tags({
|
||||||
|
"file_name": "ltx-2.3-22b-distilled-lora-384",
|
||||||
|
"base_model": "LTXV 2.3",
|
||||||
|
"civitai": {},
|
||||||
|
})
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
result = extract_auto_tags({
|
||||||
|
"file_name": "generic_lora_v1",
|
||||||
|
"base_model": "SDXL",
|
||||||
|
"civitai": {},
|
||||||
|
})
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_missing_fields(self):
|
||||||
|
result = extract_auto_tags({})
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_dash_separated(self):
|
||||||
|
result = extract_auto_tags({
|
||||||
|
"file_name": "wan-i2v-high-v2",
|
||||||
|
"base_model": "Wan 2.2",
|
||||||
|
"civitai": {},
|
||||||
|
})
|
||||||
|
assert set(result) == {"HIGH", "I2V"}
|
||||||
|
|
||||||
|
def test_dot_separated(self):
|
||||||
|
result = extract_auto_tags({
|
||||||
|
"file_name": "wan.i2v.high.v2",
|
||||||
|
"base_model": "Wan 2.2",
|
||||||
|
"civitai": {},
|
||||||
|
})
|
||||||
|
assert set(result) == {"HIGH", "I2V"}
|
||||||
|
|
||||||
|
def test_case_insensitive(self):
|
||||||
|
result = extract_auto_tags({
|
||||||
|
"file_name": "WAN_i2v_High",
|
||||||
|
"base_model": "Wan 2.2",
|
||||||
|
"civitai": {},
|
||||||
|
})
|
||||||
|
assert set(result) == {"HIGH", "I2V"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestAutoTagCategories:
|
||||||
|
def test_all_patterns_compile(self):
|
||||||
|
import re
|
||||||
|
for label, pattern in AUTO_TAG_CATEGORIES.items():
|
||||||
|
re.compile(pattern, re.IGNORECASE)
|
||||||
|
|
||||||
|
def test_mode_group_tags(self):
|
||||||
|
from services.auto_tag_service import MODE_TAGS
|
||||||
|
assert "HIGH" in MODE_TAGS
|
||||||
|
assert "LOW" in MODE_TAGS
|
||||||
|
|
||||||
|
def test_video_group_tags(self):
|
||||||
|
from services.auto_tag_service import VIDEO_MODE_TAGS
|
||||||
|
assert "I2V" in VIDEO_MODE_TAGS
|
||||||
|
assert "T2V" in VIDEO_MODE_TAGS
|
||||||
|
assert "TI2V" in VIDEO_MODE_TAGS
|
||||||
|
|
||||||
|
def test_default_enabled_groups(self):
|
||||||
|
from services.auto_tag_service import DEFAULT_ENABLED_GROUPS
|
||||||
|
assert "mode" in DEFAULT_ENABLED_GROUPS
|
||||||
|
assert "video" in DEFAULT_ENABLED_GROUPS
|
||||||
|
assert "speed" not in DEFAULT_ENABLED_GROUPS
|
||||||
@@ -658,32 +658,34 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
|
|||||||
textEl.style.maxWidth = "140px";
|
textEl.style.maxWidth = "140px";
|
||||||
}
|
}
|
||||||
|
|
||||||
const countBadge = document.createElement("span");
|
if (tagData.items.length > 1) {
|
||||||
countBadge.className = "lm-trigger-count-badge";
|
const countBadge = document.createElement("span");
|
||||||
countBadge.textContent = `${groupState.activeChildren}/${groupState.totalChildren}`;
|
countBadge.className = "lm-trigger-count-badge";
|
||||||
Object.assign(countBadge.style, {
|
countBadge.textContent = `${groupState.activeChildren}/${groupState.totalChildren}`;
|
||||||
fontSize: "11px",
|
|
||||||
padding: "1px 6px",
|
|
||||||
borderRadius: "999px",
|
|
||||||
backgroundColor: "rgba(255,255,255,0.12)",
|
|
||||||
color: "inherit",
|
|
||||||
flexShrink: "0",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
minWidth: "42px",
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
lineHeight: "1",
|
|
||||||
fontVariantNumeric: "tabular-nums",
|
|
||||||
});
|
|
||||||
if (groupState.hasInactiveChildren) {
|
|
||||||
countBadge.classList.add("lm-trigger-count-badge--edited");
|
|
||||||
Object.assign(countBadge.style, {
|
Object.assign(countBadge.style, {
|
||||||
backgroundColor: "rgba(255,255,255,0.08)",
|
fontSize: "11px",
|
||||||
boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.28)",
|
padding: "1px 6px",
|
||||||
|
borderRadius: "999px",
|
||||||
|
backgroundColor: "rgba(255,255,255,0.12)",
|
||||||
|
color: "inherit",
|
||||||
|
flexShrink: "0",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
minWidth: "42px",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
lineHeight: "1",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
});
|
});
|
||||||
|
if (groupState.hasInactiveChildren) {
|
||||||
|
countBadge.classList.add("lm-trigger-count-badge--edited");
|
||||||
|
Object.assign(countBadge.style, {
|
||||||
|
backgroundColor: "rgba(255,255,255,0.08)",
|
||||||
|
boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.28)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
groupChip.appendChild(countBadge);
|
||||||
}
|
}
|
||||||
groupChip.appendChild(countBadge);
|
|
||||||
|
|
||||||
if (showStrengthInfo) {
|
if (showStrengthInfo) {
|
||||||
const strengthBadge = createStrengthBadge();
|
const strengthBadge = createStrengthBadge();
|
||||||
@@ -697,39 +699,43 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
|
|||||||
groupChip.title = activePreview ? `${tagData.text}\nActive: ${activePreview}` : tagData.text;
|
groupChip.title = activePreview ? `${tagData.text}\nActive: ${activePreview}` : tagData.text;
|
||||||
}
|
}
|
||||||
|
|
||||||
const editButton = document.createElement("button");
|
let editButton = null;
|
||||||
editButton.type = "button";
|
|
||||||
editButton.className = "lm-trigger-group-edit-button";
|
|
||||||
editButton.textContent = "⋯";
|
|
||||||
Object.assign(editButton.style, {
|
|
||||||
border: "none",
|
|
||||||
background: "transparent",
|
|
||||||
color: "inherit",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "14px",
|
|
||||||
lineHeight: "1",
|
|
||||||
padding: "0 2px",
|
|
||||||
marginLeft: "2px",
|
|
||||||
opacity: groupState.hasInactiveChildren ? "0.9" : "0.72",
|
|
||||||
flexShrink: "0",
|
|
||||||
});
|
|
||||||
editButton.title = "Edit group tags";
|
|
||||||
|
|
||||||
const openEditor = (event) => {
|
if (tagData.items.length > 1) {
|
||||||
event.preventDefault();
|
editButton = document.createElement("button");
|
||||||
event.stopPropagation();
|
editButton.type = "button";
|
||||||
toggleGroupEditor(widget, index, groupChip);
|
editButton.className = "lm-trigger-group-edit-button";
|
||||||
renderGroupEditor(widget, tagData, index);
|
editButton.textContent = "⋯";
|
||||||
};
|
Object.assign(editButton.style, {
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
color: "inherit",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "14px",
|
||||||
|
lineHeight: "1",
|
||||||
|
padding: "0 2px",
|
||||||
|
marginLeft: "2px",
|
||||||
|
opacity: groupState.hasInactiveChildren ? "0.9" : "0.72",
|
||||||
|
flexShrink: "0",
|
||||||
|
});
|
||||||
|
editButton.title = "Edit group tags";
|
||||||
|
|
||||||
editButton.addEventListener("click", openEditor);
|
const openEditor = (event) => {
|
||||||
groupChip.addEventListener("contextmenu", openEditor);
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
toggleGroupEditor(widget, index, groupChip);
|
||||||
|
renderGroupEditor(widget, tagData, index);
|
||||||
|
};
|
||||||
|
|
||||||
groupChip.appendChild(editButton);
|
editButton.addEventListener("click", openEditor);
|
||||||
|
groupChip.addEventListener("contextmenu", openEditor);
|
||||||
|
|
||||||
|
groupChip.appendChild(editButton);
|
||||||
|
}
|
||||||
|
|
||||||
groupChip.addEventListener("click", (e) => {
|
groupChip.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (e.target === editButton) {
|
if (editButton && e.target === editButton) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateWidgetValue(widget, (updatedTags) => {
|
updateWidgetValue(widget, (updatedTags) => {
|
||||||
@@ -740,7 +746,7 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
|
|||||||
|
|
||||||
if (showStrengthInfo) {
|
if (showStrengthInfo) {
|
||||||
groupChip.addEventListener("wheel", (e) => {
|
groupChip.addEventListener("wheel", (e) => {
|
||||||
if (e.target === editButton) {
|
if (editButton && e.target === editButton) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -303,6 +303,8 @@ app.registerExtension({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const groupMode = groupModeWidget?.value ?? false;
|
||||||
|
|
||||||
const updatedTags = node.tagWidget.value.map((tag) => {
|
const updatedTags = node.tagWidget.value.map((tag) => {
|
||||||
if (!Array.isArray(tag.items)) {
|
if (!Array.isArray(tag.items)) {
|
||||||
return {
|
return {
|
||||||
@@ -311,6 +313,15 @@ app.registerExtension({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In group mode, default_active only controls the group-level switch.
|
||||||
|
// Children's individual active states are managed exclusively via the group editor.
|
||||||
|
if (groupMode) {
|
||||||
|
return {
|
||||||
|
...tag,
|
||||||
|
active: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...tag,
|
...tag,
|
||||||
active: value,
|
active: value,
|
||||||
@@ -320,7 +331,6 @@ app.registerExtension({
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
node.tagWidget.value = updatedTags;
|
node.tagWidget.value = updatedTags;
|
||||||
node.applyTriggerHighlightState?.();
|
node.applyTriggerHighlightState?.();
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user