ui_extra_networks.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623
  1. import functools
  2. import os.path
  3. import urllib.parse
  4. from pathlib import Path
  5. from typing import Optional, Union
  6. from dataclasses import dataclass
  7. from modules import shared, ui_extra_networks_user_metadata, errors, extra_networks
  8. from modules.images import read_info_from_image, save_image_with_geninfo
  9. import gradio as gr
  10. import json
  11. import html
  12. from fastapi.exceptions import HTTPException
  13. from modules.generation_parameters_copypaste import image_from_url_text
  14. from modules.ui_components import ToolButton
  15. extra_pages = []
  16. allowed_dirs = set()
  17. default_allowed_preview_extensions = ["png", "jpg", "jpeg", "webp", "gif"]
  18. @functools.cache
  19. def allowed_preview_extensions_with_extra(extra_extensions=None):
  20. return set(default_allowed_preview_extensions) | set(extra_extensions or [])
  21. def allowed_preview_extensions():
  22. return allowed_preview_extensions_with_extra((shared.opts.samples_format, ))
  23. @dataclass
  24. class ExtraNetworksItem:
  25. """Wrapper for dictionaries representing ExtraNetworks items."""
  26. item: dict
  27. def get_tree(paths: Union[str, list[str]], items: dict[str, ExtraNetworksItem]) -> dict:
  28. """Recursively builds a directory tree.
  29. Args:
  30. paths: Path or list of paths to directories. These paths are treated as roots from which
  31. the tree will be built.
  32. items: A dictionary associating filepaths to an ExtraNetworksItem instance.
  33. Returns:
  34. The result directory tree.
  35. """
  36. if isinstance(paths, (str,)):
  37. paths = [paths]
  38. def _get_tree(_paths: list[str], _root: str):
  39. _res = {}
  40. for path in _paths:
  41. relpath = os.path.relpath(path, _root)
  42. if os.path.isdir(path):
  43. dir_items = os.listdir(path)
  44. # Ignore empty directories.
  45. if not dir_items:
  46. continue
  47. dir_tree = _get_tree([os.path.join(path, x) for x in dir_items], _root)
  48. # We only want to store non-empty folders in the tree.
  49. if dir_tree:
  50. _res[relpath] = dir_tree
  51. else:
  52. if path not in items:
  53. continue
  54. # Add the ExtraNetworksItem to the result.
  55. _res[relpath] = items[path]
  56. return _res
  57. res = {}
  58. # Handle each root directory separately.
  59. # Each root WILL have a key/value at the root of the result dict though
  60. # the value can be an empty dict if the directory is empty. We want these
  61. # placeholders for empty dirs so we can inform the user later.
  62. for path in paths:
  63. root = os.path.dirname(path)
  64. relpath = os.path.relpath(path, root)
  65. # Wrap the path in a list since that is what the `_get_tree` expects.
  66. res[relpath] = _get_tree([path], root)
  67. if res[relpath]:
  68. # We need to pull the inner path out one for these root dirs.
  69. res[relpath] = res[relpath][relpath]
  70. return res
  71. def register_page(page):
  72. """registers extra networks page for the UI; recommend doing it in on_before_ui() callback for extensions"""
  73. extra_pages.append(page)
  74. allowed_dirs.clear()
  75. allowed_dirs.update(set(sum([x.allowed_directories_for_previews() for x in extra_pages], [])))
  76. def fetch_file(filename: str = ""):
  77. from starlette.responses import FileResponse
  78. if not os.path.isfile(filename):
  79. raise HTTPException(status_code=404, detail="File not found")
  80. if not any(Path(x).absolute() in Path(filename).absolute().parents for x in allowed_dirs):
  81. raise ValueError(f"File cannot be fetched: {filename}. Must be in one of directories registered by extra pages.")
  82. ext = os.path.splitext(filename)[1].lower()[1:]
  83. if ext not in allowed_preview_extensions():
  84. raise ValueError(f"File cannot be fetched: {filename}. Extensions allowed: {allowed_preview_extensions()}.")
  85. # would profit from returning 304
  86. return FileResponse(filename, headers={"Accept-Ranges": "bytes"})
  87. def get_metadata(page: str = "", item: str = ""):
  88. from starlette.responses import JSONResponse
  89. page = next(iter([x for x in extra_pages if x.name == page]), None)
  90. if page is None:
  91. return JSONResponse({})
  92. metadata = page.metadata.get(item)
  93. if metadata is None:
  94. return JSONResponse({})
  95. return JSONResponse({"metadata": json.dumps(metadata, indent=4, ensure_ascii=False)})
  96. def get_single_card(page: str = "", tabname: str = "", name: str = ""):
  97. from starlette.responses import JSONResponse
  98. page = next(iter([x for x in extra_pages if x.name == page]), None)
  99. try:
  100. item = page.create_item(name, enable_filter=False)
  101. page.items[name] = item
  102. except Exception as e:
  103. errors.display(e, "creating item for extra network")
  104. item = page.items.get(name)
  105. page.read_user_metadata(item)
  106. item_html = page.create_item_html(tabname, item)
  107. return JSONResponse({"html": item_html})
  108. def add_pages_to_demo(app):
  109. app.add_api_route("/sd_extra_networks/thumb", fetch_file, methods=["GET"])
  110. app.add_api_route("/sd_extra_networks/metadata", get_metadata, methods=["GET"])
  111. app.add_api_route("/sd_extra_networks/get-single-card", get_single_card, methods=["GET"])
  112. def quote_js(s):
  113. s = s.replace('\\', '\\\\')
  114. s = s.replace('"', '\\"')
  115. return f'"{s}"'
  116. class ExtraNetworksPage:
  117. def __init__(self, title):
  118. self.title = title
  119. self.name = title.lower()
  120. self.id_page = self.name.replace(" ", "_")
  121. self.extra_networks_pane_template = shared.html("extra-networks-pane.html")
  122. self.card_page_template = shared.html("extra-networks-card.html")
  123. self.card_page_minimal_template = shared.html("extra-networks-card-minimal.html")
  124. self.allow_prompt = True
  125. self.allow_negative_prompt = False
  126. self.metadata = {}
  127. self.items = {}
  128. def refresh(self):
  129. pass
  130. def read_user_metadata(self, item):
  131. filename = item.get("filename", None)
  132. metadata = extra_networks.get_user_metadata(filename)
  133. desc = metadata.get("description", None)
  134. if desc is not None:
  135. item["description"] = desc
  136. item["user_metadata"] = metadata
  137. def link_preview(self, filename):
  138. quoted_filename = urllib.parse.quote(filename.replace('\\', '/'))
  139. mtime = os.path.getmtime(filename)
  140. return f"./sd_extra_networks/thumb?filename={quoted_filename}&mtime={mtime}"
  141. def search_terms_from_path(self, filename, possible_directories=None):
  142. abspath = os.path.abspath(filename)
  143. for parentdir in (possible_directories if possible_directories is not None else self.allowed_directories_for_previews()):
  144. parentdir = os.path.dirname(os.path.abspath(parentdir))
  145. if abspath.startswith(parentdir):
  146. return os.path.relpath(abspath, parentdir)
  147. return ""
  148. def create_item_html(self, tabname: str, item: dict, template: Optional[str] = None) -> str:
  149. """Generates HTML for a single ExtraNetworks Item
  150. Args:
  151. tabname: The name of the active tab.
  152. item: Dictionary containing item information.
  153. Returns:
  154. HTML string generated for this item.
  155. Can be empty if the item is not meant to be shown.
  156. """
  157. metadata = item.get("metadata")
  158. if metadata:
  159. self.metadata[item["name"]] = metadata
  160. if "user_metadata" not in item:
  161. self.read_user_metadata(item)
  162. preview = item.get("preview", None)
  163. height = f"height: {shared.opts.extra_networks_card_height}px;" if shared.opts.extra_networks_card_height else ''
  164. width = f"width: {shared.opts.extra_networks_card_width}px;" if shared.opts.extra_networks_card_width else ''
  165. background_image = f'<img src="{html.escape(preview)}" class="preview" loading="lazy">' if preview else ''
  166. onclick = item.get("onclick", None)
  167. if onclick is None:
  168. onclick = '"' + html.escape(f"""return cardClicked({quote_js(tabname)}, {item["prompt"]}, {"true" if self.allow_negative_prompt else "false"})""") + '"'
  169. copy_path_button = f"<div class='copy-path-button card-button' title='Copy path to clipboard' onclick='extraNetworksCopyCardPath(event, {quote_js(item['filename'])})' data-clipboard-text='{quote_js(item['filename'])}'></div>"
  170. metadata_button = ""
  171. metadata = item.get("metadata")
  172. if metadata:
  173. metadata_button = f"<div class='metadata-button card-button' title='Show internal metadata' onclick='extraNetworksRequestMetadata(event, {quote_js(self.name)}, {quote_js(html.escape(item['name']))})'></div>"
  174. edit_button = f"<div class='edit-button card-button' title='Edit metadata' onclick='extraNetworksEditUserMetadata(event, {quote_js(tabname)}, {quote_js(self.id_page)}, {quote_js(html.escape(item['name']))})'></div>"
  175. local_path = ""
  176. filename = item.get("filename", "")
  177. for reldir in self.allowed_directories_for_previews():
  178. absdir = os.path.abspath(reldir)
  179. if filename.startswith(absdir):
  180. local_path = filename[len(absdir):]
  181. # if this is true, the item must not be shown in the default view, and must instead only be
  182. # shown when searching for it
  183. if shared.opts.extra_networks_hidden_models == "Always":
  184. search_only = False
  185. else:
  186. search_only = "/." in local_path or "\\." in local_path
  187. if search_only and shared.opts.extra_networks_hidden_models == "Never":
  188. return ""
  189. sort_keys = " ".join([f'data-sort-{k}="{html.escape(str(v))}"' for k, v in item.get("sort_keys", {}).items()]).strip()
  190. search_terms_html = ""
  191. search_term_template = "<span style='{style}' class='{class}'>{search_term}</span>"
  192. for search_term in item.get("search_terms", []):
  193. search_terms_html += search_term_template.format(
  194. **{
  195. "style": "display: none;",
  196. "class": "search_terms" + (" search_only" if search_only else ""),
  197. "search_term": search_term,
  198. }
  199. )
  200. # Some items here might not be used depending on HTML template used.
  201. args = {
  202. "background_image": background_image,
  203. "card_clicked": onclick,
  204. "copy_path_button": copy_path_button,
  205. "description": (item.get("description") or "" if shared.opts.extra_networks_card_show_desc else ""),
  206. "edit_button": edit_button,
  207. "local_preview": quote_js(item["local_preview"]),
  208. "metadata_button": metadata_button,
  209. "name": html.escape(item["name"]),
  210. "prompt": item.get("prompt", None),
  211. "save_card_preview": '"' + html.escape(f"""return saveCardPreview(event, {quote_js(tabname)}, {quote_js(item["local_preview"])})""") + '"',
  212. "search_only": " search_only" if search_only else "",
  213. "search_terms": search_terms_html,
  214. "sort_keys": sort_keys,
  215. "style": f"'display: none; {height}{width}; font-size: {shared.opts.extra_networks_card_text_scale*100}%'",
  216. "tabname": quote_js(tabname),
  217. }
  218. if template:
  219. return template.format(**args)
  220. else:
  221. return self.card_page.format(**args)
  222. def create_tree_view_html(self, tabname: str) -> str:
  223. """Generates HTML for displaying folders in a tree view.
  224. Args:
  225. tabname: The name of the active tab.
  226. Returns:
  227. HTML string generated for this tree view.
  228. """
  229. res = ""
  230. # Generate HTML for the tree.
  231. roots = self.allowed_directories_for_previews()
  232. tree_items = {v["filename"]: ExtraNetworksItem(v) for v in self.items.values()}
  233. tree = get_tree([os.path.abspath(x) for x in roots], items=tree_items)
  234. if not tree:
  235. return res
  236. file_template = "<li class='file-item'>{card}</li>"
  237. dir_template = (
  238. "<details {attributes} class='folder-item'>"
  239. "<summary class='folder-item-summary' data-path='{data_path}' "
  240. "onclick='extraNetworksFolderClick(event, \"{tabname}_extra_search\");'>"
  241. "{folder_name}"
  242. "</summary>"
  243. "<ul class='folder-container'>{content}</ul>"
  244. "</details>"
  245. )
  246. def _build_tree(data: Optional[dict[str, ExtraNetworksItem]] = None) -> str:
  247. """Recursively builds HTML for a tree."""
  248. _res = ""
  249. if not data:
  250. return "<li style='list-style-type: \"⚠️\";'>DIRECTORY IS EMPTY</li>"
  251. for k, v in sorted(data.items(), key=lambda x: shared.natural_sort_key(x[0])):
  252. if isinstance(v, (ExtraNetworksItem,)):
  253. item_html = self.create_item_html(tabname, v.item, self.card_page_minimal_template)
  254. _res += file_template.format(**{"card": item_html})
  255. else:
  256. _res += dir_template.format(
  257. **{
  258. "attributes": "",
  259. "tabname": tabname,
  260. "folder_name": os.path.basename(k),
  261. "data_path": k,
  262. "content": _build_tree(v),
  263. }
  264. )
  265. return _res
  266. # Add each root directory to the tree.
  267. for k, v in sorted(tree.items(), key=lambda x: shared.natural_sort_key(x[0])):
  268. # If root is empty, append the "disabled" attribute to the template details tag.
  269. res += "<ul class='folder-container'>"
  270. res += dir_template.format(
  271. **{
  272. "attributes": "open" if v else "open",
  273. "tabname": tabname,
  274. "folder_name": os.path.basename(k),
  275. "data_path": k,
  276. "content": _build_tree(v),
  277. }
  278. )
  279. res += "</ul>"
  280. res += "</ul>"
  281. return res
  282. def create_card_view_html(self, tabname):
  283. res = ""
  284. self.items = {x["name"]: x for x in self.list_items()}
  285. for item in self.items.values():
  286. res += self.create_item_html(tabname, item, self.card_page_template)
  287. if res == "":
  288. dirs = "".join([f"<li>{x}</li>" for x in self.allowed_directories_for_previews()])
  289. res = shared.html("extra-networks-no-cards.html").format(dirs=dirs)
  290. return res
  291. def create_html(self, tabname):
  292. self.metadata = {}
  293. self.items = {x["name"]: x for x in self.list_items()}
  294. tree_view_html = self.create_tree_view_html(tabname)
  295. card_view_html = self.create_card_view_html(tabname)
  296. network_type_id = self.name.replace(" ", "_")
  297. return self.extra_networks_pane_template.format(
  298. **{
  299. "tabname": tabname,
  300. "network_type_id": network_type_id,
  301. "tree_html": tree_view_html,
  302. "items_html": card_view_html,
  303. }
  304. )
  305. def create_item(self, name, index=None):
  306. raise NotImplementedError()
  307. def list_items(self):
  308. raise NotImplementedError()
  309. def allowed_directories_for_previews(self):
  310. return []
  311. def get_sort_keys(self, path):
  312. """
  313. List of default keys used for sorting in the UI.
  314. """
  315. pth = Path(path)
  316. stat = pth.stat()
  317. return {
  318. "date_created": int(stat.st_ctime or 0),
  319. "date_modified": int(stat.st_mtime or 0),
  320. "name": pth.name.lower(),
  321. "path": str(pth.parent).lower(),
  322. }
  323. def find_preview(self, path):
  324. """
  325. Find a preview PNG for a given path (without extension) and call link_preview on it.
  326. """
  327. potential_files = sum([[path + "." + ext, path + ".preview." + ext] for ext in allowed_preview_extensions()], [])
  328. for file in potential_files:
  329. if os.path.isfile(file):
  330. return self.link_preview(file)
  331. return None
  332. def find_description(self, path):
  333. """
  334. Find and read a description file for a given path (without extension).
  335. """
  336. for file in [f"{path}.txt", f"{path}.description.txt"]:
  337. try:
  338. with open(file, "r", encoding="utf-8", errors="replace") as f:
  339. return f.read()
  340. except OSError:
  341. pass
  342. return None
  343. def create_user_metadata_editor(self, ui, tabname):
  344. return ui_extra_networks_user_metadata.UserMetadataEditor(ui, tabname, self)
  345. def initialize():
  346. extra_pages.clear()
  347. def register_default_pages():
  348. from modules.ui_extra_networks_textual_inversion import ExtraNetworksPageTextualInversion
  349. from modules.ui_extra_networks_hypernets import ExtraNetworksPageHypernetworks
  350. from modules.ui_extra_networks_checkpoints import ExtraNetworksPageCheckpoints
  351. register_page(ExtraNetworksPageTextualInversion())
  352. register_page(ExtraNetworksPageHypernetworks())
  353. register_page(ExtraNetworksPageCheckpoints())
  354. class ExtraNetworksUi:
  355. def __init__(self):
  356. self.pages = None
  357. """gradio HTML components related to extra networks' pages"""
  358. self.page_contents = None
  359. """HTML content of the above; empty initially, filled when extra pages have to be shown"""
  360. self.stored_extra_pages = None
  361. self.button_save_preview = None
  362. self.preview_target_filename = None
  363. self.tabname = None
  364. def pages_in_preferred_order(pages):
  365. tab_order = [x.lower().strip() for x in shared.opts.ui_extra_networks_tab_reorder.split(",")]
  366. def tab_name_score(name):
  367. name = name.lower()
  368. for i, possible_match in enumerate(tab_order):
  369. if possible_match in name:
  370. return i
  371. return len(pages)
  372. tab_scores = {page.name: (tab_name_score(page.name), original_index) for original_index, page in enumerate(pages)}
  373. return sorted(pages, key=lambda x: tab_scores[x.name])
  374. def create_ui(interface: gr.Blocks, unrelated_tabs, tabname):
  375. from modules.ui import switch_values_symbol
  376. ui = ExtraNetworksUi()
  377. ui.pages = []
  378. ui.pages_contents = []
  379. ui.user_metadata_editors = []
  380. ui.stored_extra_pages = pages_in_preferred_order(extra_pages.copy())
  381. ui.tabname = tabname
  382. related_tabs = []
  383. for page in ui.stored_extra_pages:
  384. with gr.Tab(page.title, elem_id=f"{tabname}_{page.id_page}", elem_classes=["extra-page"]) as tab:
  385. with gr.Column(elem_id=f"{tabname}_{page.id_page}_prompts", elem_classes=["extra-page-prompts"]):
  386. pass
  387. elem_id = f"{tabname}_{page.id_page}_cards_html"
  388. page_elem = gr.HTML('Loading...', elem_id=elem_id)
  389. ui.pages.append(page_elem)
  390. page_elem.change(
  391. fn=lambda: None,
  392. _js=f"function(){{applyExtraNetworkFilter({tabname}_extra_search); return []}}",
  393. inputs=[],
  394. outputs=[],
  395. )
  396. editor = page.create_user_metadata_editor(ui, tabname)
  397. editor.create_ui()
  398. ui.user_metadata_editors.append(editor)
  399. related_tabs.append(tab)
  400. edit_search = gr.Textbox('', show_label=False, elem_id=f"{tabname}_extra_search", elem_classes="search", placeholder="Search...", visible=False, interactive=True)
  401. dropdown_sort = gr.Dropdown(choices=['Path', 'Name', 'Date Created', 'Date Modified', ], value=shared.opts.extra_networks_card_order_field, elem_id=tabname+"_extra_sort", elem_classes="sort", multiselect=False, visible=False, show_label=False, interactive=True, label=tabname+"_extra_sort_order")
  402. button_sortorder = ToolButton(switch_values_symbol, elem_id=tabname+"_extra_sortorder", elem_classes=["sortorder"] + ([] if shared.opts.extra_networks_card_order == "Ascending" else ["sortReverse"]), visible=False, tooltip="Invert sort order")
  403. button_refresh = gr.Button('Refresh', elem_id=tabname+"_extra_refresh", visible=False)
  404. tab_controls = [
  405. edit_search,
  406. dropdown_sort,
  407. button_sortorder,
  408. button_refresh,
  409. ]
  410. ui.button_save_preview = gr.Button('Save preview', elem_id=tabname+"_save_preview", visible=False)
  411. ui.preview_target_filename = gr.Textbox('Preview save filename', elem_id=tabname+"_preview_filename", visible=False)
  412. for tab in unrelated_tabs:
  413. tab.select(
  414. fn=lambda: [gr.update(visible=False) for _ in tab_controls],
  415. _js=f"function(){{ extraNetworksUnrelatedTabSelected('{tabname}'); }}",
  416. inputs=[],
  417. outputs=tab_controls,
  418. show_progress=False,
  419. )
  420. for page, tab in zip(ui.stored_extra_pages, related_tabs):
  421. allow_prompt = "true" if page.allow_prompt else "false"
  422. allow_negative_prompt = "true" if page.allow_negative_prompt else "false"
  423. jscode = (
  424. "extraNetworksTabSelected("
  425. f"'{tabname}', "
  426. f"'{tabname}_{page.id_page}_prompts', "
  427. f"'{allow_prompt}', "
  428. f"'{allow_negative_prompt}'"
  429. ");"
  430. )
  431. tab.select(
  432. fn=lambda: [gr.update(visible=True) for _ in tab_controls],
  433. _js="function(){ " + jscode + " }",
  434. inputs=[],
  435. outputs=tab_controls,
  436. show_progress=False,
  437. )
  438. dropdown_sort.change(fn=lambda: None, _js="function(){ applyExtraNetworkSort('" + tabname + "'); }")
  439. def pages_html():
  440. if not ui.pages_contents:
  441. return refresh()
  442. return ui.pages_contents
  443. def refresh():
  444. for pg in ui.stored_extra_pages:
  445. pg.refresh()
  446. ui.pages_contents = [pg.create_html(ui.tabname) for pg in ui.stored_extra_pages]
  447. return ui.pages_contents
  448. interface.load(fn=pages_html, inputs=[], outputs=[*ui.pages])
  449. button_refresh.click(fn=refresh, inputs=[], outputs=ui.pages)
  450. return ui
  451. def path_is_parent(parent_path, child_path):
  452. parent_path = os.path.abspath(parent_path)
  453. child_path = os.path.abspath(child_path)
  454. return child_path.startswith(parent_path)
  455. def setup_ui(ui, gallery):
  456. def save_preview(index, images, filename):
  457. # this function is here for backwards compatibility and likely will be removed soon
  458. if len(images) == 0:
  459. print("There is no image in gallery to save as a preview.")
  460. return [page.create_html(ui.tabname) for page in ui.stored_extra_pages]
  461. index = int(index)
  462. index = 0 if index < 0 else index
  463. index = len(images) - 1 if index >= len(images) else index
  464. img_info = images[index if index >= 0 else 0]
  465. image = image_from_url_text(img_info)
  466. geninfo, items = read_info_from_image(image)
  467. is_allowed = False
  468. for extra_page in ui.stored_extra_pages:
  469. if any(path_is_parent(x, filename) for x in extra_page.allowed_directories_for_previews()):
  470. is_allowed = True
  471. break
  472. assert is_allowed, f'writing to {filename} is not allowed'
  473. save_image_with_geninfo(image, geninfo, filename)
  474. return [page.create_html(ui.tabname) for page in ui.stored_extra_pages]
  475. ui.button_save_preview.click(
  476. fn=save_preview,
  477. _js="function(x, y, z){return [selected_gallery_index(), y, z]}",
  478. inputs=[ui.preview_target_filename, gallery, ui.preview_target_filename],
  479. outputs=[*ui.pages]
  480. )
  481. for editor in ui.user_metadata_editors:
  482. editor.setup_ui(gallery)