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).