diff --git a/args_manager.py b/args_manager.py index df91077a..0848c056 100644 --- a/args_manager.py +++ b/args_manager.py @@ -6,6 +6,10 @@ import fcbh.cli_args as fcbh_cli fcbh_cli.parser.add_argument("--share", action='store_true', help="Set whether to share on Gradio.") fcbh_cli.parser.add_argument("--preset", type=str, default=None, help="Apply specified UI preset.") +fcbh_cli.parser.add_argument("--language", type=str, default=None, + help="Translate UI using json files in [language] folder. " + "For example, [--language example] will use [language/example.json] for translation.") + fcbh_cli.args = fcbh_cli.parser.parse_args() fcbh_cli.args.disable_cuda_malloc = True fcbh_cli.args.auto_launch = True diff --git a/fooocus_version.py b/fooocus_version.py index 62ce91c1..b3cd5b06 100644 --- a/fooocus_version.py +++ b/fooocus_version.py @@ -1 +1 @@ -version = '2.1.718' +version = '2.1.719' diff --git a/javascript/localization.js b/javascript/localization.js new file mode 100644 index 00000000..8f00c186 --- /dev/null +++ b/javascript/localization.js @@ -0,0 +1,205 @@ + +// localization = {} -- the dict with translations is created by the backend + +var ignore_ids_for_localization = { + setting_sd_hypernetwork: 'OPTION', + setting_sd_model_checkpoint: 'OPTION', + modelmerger_primary_model_name: 'OPTION', + modelmerger_secondary_model_name: 'OPTION', + modelmerger_tertiary_model_name: 'OPTION', + train_embedding: 'OPTION', + train_hypernetwork: 'OPTION', + txt2img_styles: 'OPTION', + img2img_styles: 'OPTION', + setting_random_artist_categories: 'OPTION', + setting_face_restoration_model: 'OPTION', + setting_realesrgan_enabled_models: 'OPTION', + extras_upscaler_1: 'OPTION', + extras_upscaler_2: 'OPTION', +}; + +var re_num = /^[.\d]+$/; +var re_emoji = /[\p{Extended_Pictographic}\u{1F3FB}-\u{1F3FF}\u{1F9B0}-\u{1F9B3}]/u; + +var original_lines = {}; +var translated_lines = {}; + +function hasLocalization() { + return window.localization && Object.keys(window.localization).length > 0; +} + +function textNodesUnder(el) { + var n, a = [], walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null, false); + while ((n = walk.nextNode())) a.push(n); + return a; +} + +function canBeTranslated(node, text) { + if (!text) return false; + if (!node.parentElement) return false; + + var parentType = node.parentElement.nodeName; + if (parentType == 'SCRIPT' || parentType == 'STYLE' || parentType == 'TEXTAREA') return false; + + if (parentType == 'OPTION' || parentType == 'SPAN') { + var pnode = node; + for (var level = 0; level < 4; level++) { + pnode = pnode.parentElement; + if (!pnode) break; + + if (ignore_ids_for_localization[pnode.id] == parentType) return false; + } + } + + if (re_num.test(text)) return false; + if (re_emoji.test(text)) return false; + return true; +} + +function getTranslation(text) { + if (!text) return undefined; + + if (translated_lines[text] === undefined) { + original_lines[text] = 1; + } + + var tl = localization[text]; + if (tl !== undefined) { + translated_lines[tl] = 1; + } + + return tl; +} + +function processTextNode(node) { + var text = node.textContent.trim(); + + if (!canBeTranslated(node, text)) return; + + var tl = getTranslation(text); + if (tl !== undefined) { + node.textContent = tl; + } +} + +function processNode(node) { + if (node.nodeType == 3) { + processTextNode(node); + return; + } + + if (node.title) { + let tl = getTranslation(node.title); + if (tl !== undefined) { + node.title = tl; + } + } + + if (node.placeholder) { + let tl = getTranslation(node.placeholder); + if (tl !== undefined) { + node.placeholder = tl; + } + } + + textNodesUnder(node).forEach(function(node) { + processTextNode(node); + }); +} + +function localizeWholePage() { + processNode(gradioApp()); + + function elem(comp) { + var elem_id = comp.props.elem_id ? comp.props.elem_id : "component-" + comp.id; + return gradioApp().getElementById(elem_id); + } + + for (var comp of window.gradio_config.components) { + if (comp.props.webui_tooltip) { + let e = elem(comp); + + let tl = e ? getTranslation(e.title) : undefined; + if (tl !== undefined) { + e.title = tl; + } + } + if (comp.props.placeholder) { + let e = elem(comp); + let textbox = e ? e.querySelector('[placeholder]') : null; + + let tl = textbox ? getTranslation(textbox.placeholder) : undefined; + if (tl !== undefined) { + textbox.placeholder = tl; + } + } + } +} + +function dumpTranslations() { + if (!hasLocalization()) { + // If we don't have any localization, + // we will not have traversed the app to find + // original_lines, so do that now. + localizeWholePage(); + } + var dumped = {}; + if (localization.rtl) { + dumped.rtl = true; + } + + for (const text in original_lines) { + if (dumped[text] !== undefined) continue; + dumped[text] = localization[text] || text; + } + + return dumped; +} + +function download_localization() { + var text = JSON.stringify(dumpTranslations(), null, 4); + + var element = document.createElement('a'); + element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text)); + element.setAttribute('download', "localization.json"); + element.style.display = 'none'; + document.body.appendChild(element); + + element.click(); + + document.body.removeChild(element); +} + +document.addEventListener("DOMContentLoaded", function() { + if (!hasLocalization()) { + return; + } + + onUiUpdate(function(m) { + m.forEach(function(mutation) { + mutation.addedNodes.forEach(function(node) { + processNode(node); + }); + }); + }); + + localizeWholePage(); + + if (localization.rtl) { // if the language is from right to left, + (new MutationObserver((mutations, observer) => { // wait for the style to load + mutations.forEach(mutation => { + mutation.addedNodes.forEach(node => { + if (node.tagName === 'STYLE') { + observer.disconnect(); + + for (const x of node.sheet.rules) { // find all rtl media rules + if (Array.from(x.media || []).includes('rtl')) { + x.media.appendMedium('all'); // enable them + } + } + } + }); + }); + })).observe(gradioApp(), {childList: true}); + } +}); diff --git a/javascript/script.js b/javascript/script.js index 1098affd..bdba94b0 100644 --- a/javascript/script.js +++ b/javascript/script.js @@ -1,5 +1,4 @@ // based on https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.6.0/script.js - function gradioApp() { const elems = document.getElementsByTagName('gradio-app'); const elem = elems.length == 0 ? document : elems[0]; @@ -12,22 +11,162 @@ function gradioApp() { return elem.shadowRoot ? elem.shadowRoot : elem; } -function playNotification() { - gradioApp().querySelector('#audio_notification audio')?.play(); +/** + * Get the currently selected top-level UI tab button (e.g. the button that says "Extras"). + */ +function get_uiCurrentTab() { + return gradioApp().querySelector('#tabs > .tab-nav > button.selected'); } -document.addEventListener('keydown', function(e) { - var handled = false; - if (e.key !== undefined) { - if ((e.key == "Enter" && (e.metaKey || e.ctrlKey || e.altKey))) handled = true; - } else if (e.keyCode !== undefined) { - if ((e.keyCode == 13 && (e.metaKey || e.ctrlKey || e.altKey))) handled = true; +/** + * Get the first currently visible top-level UI tab content (e.g. the div hosting the "txt2img" UI). + */ +function get_uiCurrentTabContent() { + return gradioApp().querySelector('#tabs > .tabitem[id^=tab_]:not([style*="display: none"])'); +} + +var uiUpdateCallbacks = []; +var uiAfterUpdateCallbacks = []; +var uiLoadedCallbacks = []; +var uiTabChangeCallbacks = []; +var optionsChangedCallbacks = []; +var uiAfterUpdateTimeout = null; +var uiCurrentTab = null; + +/** + * Register callback to be called at each UI update. + * The callback receives an array of MutationRecords as an argument. + */ +function onUiUpdate(callback) { + uiUpdateCallbacks.push(callback); +} + +/** + * Register callback to be called soon after UI updates. + * The callback receives no arguments. + * + * This is preferred over `onUiUpdate` if you don't need + * access to the MutationRecords, as your function will + * not be called quite as often. + */ +function onAfterUiUpdate(callback) { + uiAfterUpdateCallbacks.push(callback); +} + +/** + * Register callback to be called when the UI is loaded. + * The callback receives no arguments. + */ +function onUiLoaded(callback) { + uiLoadedCallbacks.push(callback); +} + +/** + * Register callback to be called when the UI tab is changed. + * The callback receives no arguments. + */ +function onUiTabChange(callback) { + uiTabChangeCallbacks.push(callback); +} + +/** + * Register callback to be called when the options are changed. + * The callback receives no arguments. + * @param callback + */ +function onOptionsChanged(callback) { + optionsChangedCallbacks.push(callback); +} + +function executeCallbacks(queue, arg) { + for (const callback of queue) { + try { + callback(arg); + } catch (e) { + console.error("error running callback", callback, ":", e); + } } - if (handled) { - var button = gradioApp().querySelector('button[id=generate_button]'); - if (button) { - button.click(); +} + +/** + * Schedule the execution of the callbacks registered with onAfterUiUpdate. + * The callbacks are executed after a short while, unless another call to this function + * is made before that time. IOW, the callbacks are executed only once, even + * when there are multiple mutations observed. + */ +function scheduleAfterUiUpdateCallbacks() { + clearTimeout(uiAfterUpdateTimeout); + uiAfterUpdateTimeout = setTimeout(function() { + executeCallbacks(uiAfterUpdateCallbacks); + }, 200); +} + +var executedOnLoaded = false; + +document.addEventListener("DOMContentLoaded", function() { + var mutationObserver = new MutationObserver(function(m) { + if (!executedOnLoaded && gradioApp().querySelector('#txt2img_prompt')) { + executedOnLoaded = true; + executeCallbacks(uiLoadedCallbacks); + } + + executeCallbacks(uiUpdateCallbacks, m); + scheduleAfterUiUpdateCallbacks(); + const newTab = get_uiCurrentTab(); + if (newTab && (newTab !== uiCurrentTab)) { + uiCurrentTab = newTab; + executeCallbacks(uiTabChangeCallbacks); + } + }); + mutationObserver.observe(gradioApp(), {childList: true, subtree: true}); +}); + +/** + * Add a ctrl+enter as a shortcut to start a generation + */ +document.addEventListener('keydown', function(e) { + const isEnter = e.key === 'Enter' || e.keyCode === 13; + const isModifierKey = e.metaKey || e.ctrlKey || e.altKey; + + const interruptButton = get_uiCurrentTabContent().querySelector('button[id$=_interrupt]'); + const generateButton = get_uiCurrentTabContent().querySelector('button[id$=_generate]'); + + if (isEnter && isModifierKey) { + if (interruptButton.style.display === 'block') { + interruptButton.click(); + setTimeout(function() { + generateButton.click(); + }, 500); + } else { + generateButton.click(); } e.preventDefault(); } }); + +/** + * checks that a UI element is not in another hidden element or tab content + */ +function uiElementIsVisible(el) { + if (el === document) { + return true; + } + + const computedStyle = getComputedStyle(el); + const isVisible = computedStyle.display !== 'none'; + + if (!isVisible) return false; + return uiElementIsVisible(el.parentNode); +} + +function uiElementInSight(el) { + const clRect = el.getBoundingClientRect(); + const windowHeight = window.innerHeight; + const isOnScreen = clRect.bottom > 0 && clRect.top < windowHeight; + + return isOnScreen; +} + +function playNotification() { + gradioApp().querySelector('#audio_notification audio')?.play(); +} diff --git a/language/example.json b/language/example.json new file mode 100644 index 00000000..9b792449 --- /dev/null +++ b/language/example.json @@ -0,0 +1,6 @@ +{ + "Generate": "生成", + "Input Image": "入力画像", + "Advanced": "고급", + "SAI 3D Model": "SAI 3D Modèle" +} diff --git a/modules/localization.py b/modules/localization.py new file mode 100644 index 00000000..4bc56265 --- /dev/null +++ b/modules/localization.py @@ -0,0 +1,25 @@ +import json +import os + + +localization_root = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'language') + + +def localization_js(filename): + data = {} + + if isinstance(filename, str): + full_name = os.path.abspath(os.path.join(localization_root, filename + '.json')) + if os.path.exists(full_name): + try: + with open(full_name, encoding='utf-8') as f: + data = json.load(f) + assert isinstance(data, dict) + for k, v in data.items(): + assert isinstance(k, str) + assert isinstance(v, str) + except Exception as e: + print(str(e)) + print(f'Failed to load localization file {full_name}') + + return f"window.localization = {json.dumps(data)}" diff --git a/modules/ui_gradio_extensions.py b/modules/ui_gradio_extensions.py index 9c90857b..194883fa 100644 --- a/modules/ui_gradio_extensions.py +++ b/modules/ui_gradio_extensions.py @@ -2,6 +2,10 @@ import os import gradio as gr +import args_manager + +from modules.localization import localization_js + GradioTemplateResponseOriginal = gr.routes.templates.TemplateResponse @@ -21,8 +25,11 @@ def webpath(fn): def javascript_html(): script_js_path = webpath('javascript/script.js') context_menus_js_path = webpath('javascript/contextMenus.js') - head = f'\n' + localization_js_path = webpath('javascript/localization.js') + head = f'\n' + head += f'\n' head += f'\n' + head += f'\n' return head diff --git a/readme.md b/readme.md index d3a72800..184c1e47 100644 --- a/readme.md +++ b/readme.md @@ -296,3 +296,36 @@ Special thanks to [twri](https://github.com/twri) and [3Diva](https://github.com ## Update Log The log is [here](update_log.md). + +# Localization/Translation/I18N + +**We need your help!** Please help with translating Fooocus to international languages. + +You can put json files in the `language` folder to translate the user interface. + +For example, below is the content of `Fooocus/language/example.json`: + +```json +{ + "Generate": "生成", + "Input Image": "入力画像", + "Advanced": "고급", + "SAI 3D Model": "SAI 3D Modèle" +} +``` + +If you add `--language example` arg, Fooocus will read `Fooocus/language/example.json` to translate the UI. + +For example, you can edit the ending line of Windows `run.bat` as + + .\python_embeded\python.exe -s Fooocus\entry_with_update.py --language example + +Or `run_anime.bat` as + + .\python_embeded\python.exe -s Fooocus\entry_with_update.py --language example --preset anime + +Or `run_realistic.bat` as + + .\python_embeded\python.exe -s Fooocus\entry_with_update.py --language example --preset realistic + +For practical translation, you may create your own file like `Fooocus/language/jp.json` or `Fooocus/language/cn.json` and then use flag `--language jp` or `--language cn`. Apparently, these files do not exist now. **We need your help to create these files!** diff --git a/update_log.md b/update_log.md index e67d38e4..255a4631 100644 --- a/update_log.md +++ b/update_log.md @@ -1,3 +1,7 @@ +# 2.1.719 + +* I18N + # 2.1.718 * Corrected handling dash in wildcard names, more wildcards (extended-color).