From b31faa42746f6fadd01a29202bfcea52c638e913 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Fri, 3 Apr 2026 00:25:30 +0200 Subject: [PATCH 01/45] feat: add ProjectResolution node Implements ProjectResolution with TDD: fetches a [width, height] pair from a resolution series by loop index, clamping out-of-bounds indices to the last entry and returning (512, 512) defaults on error or missing key. Also registers the node in mappings and updates TestNodeMappings count to 4. Co-Authored-By: Claude Sonnet 4.6 --- project_loader.py | 57 ++++++++++++++++++++++ tests/test_project_loader.py | 95 +++++++++++++++++++++++++++++++++++- 2 files changed, 150 insertions(+), 2 deletions(-) diff --git a/project_loader.py b/project_loader.py index 8827191..591cb21 100644 --- a/project_loader.py +++ b/project_loader.py @@ -293,15 +293,72 @@ class ProjectKey: return (str(val),) +class ProjectResolution: + """Fetches a (width, height) pair from a resolution series by loop index.""" + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "source_label": ("STRING", {"default": "", "multiline": False}), + "key_name": ("STRING", {"default": "resolutions", "multiline": False}), + "index": ("INT", {"default": 0, "min": 0, "max": 9999}), + }, + "optional": { + "manager_url": ("STRING", {"default": "http://localhost:8080", "multiline": False}), + "project_name": ("STRING", {"default": "", "multiline": False}), + "file_name": ("STRING", {"default": "", "multiline": False}), + "sequence_number": ("INT", {"default": 1, "min": 1, "max": 9999}), + }, + } + + RETURN_TYPES = ("INT", "INT") + RETURN_NAMES = ("width", "height") + FUNCTION = "fetch_resolution" + CATEGORY = "utils/json/project" + OUTPUT_NODE = False + + @classmethod + def IS_CHANGED(cls, **kwargs): + return float("nan") + + def fetch_resolution(self, source_label, key_name, index, + manager_url="http://localhost:8080", project_name="", + file_name="", sequence_number=1): + sequence_number = int(sequence_number) + logger.info("ProjectResolution.fetch_resolution: source=%s key=%s url=%s project=%s file=%s seq=%s index=%s", + source_label, key_name, manager_url, project_name, file_name, sequence_number, index) + # source_label is used by JS to identify which ProjectSource to sync + # config from. The actual config arrives via the optional widgets below. + data = _fetch_data(manager_url, project_name, file_name, sequence_number) + if data.get("error") in ("http_error", "network_error", "parse_error"): + logger.warning("ProjectResolution.fetch_resolution failed: %s", data.get("message")) + return (512, 512) + + series = data.get(key_name) + if not isinstance(series, list) or len(series) == 0: + logger.warning("ProjectResolution: key '%s' is not a resolution series", key_name) + return (512, 512) + + clamped = max(0, min(index, len(series) - 1)) + entry = series[clamped] + if not isinstance(entry, (list, tuple)) or len(entry) < 2: + logger.warning("ProjectResolution: entry at index %d is malformed: %r", clamped, entry) + return (512, 512) + + return (to_int(entry[0]), to_int(entry[1])) + + # --- Mappings --- PROJECT_NODE_CLASS_MAPPINGS = { "ProjectLoaderDynamic": ProjectLoaderDynamic, "ProjectSource": ProjectSource, "ProjectKey": ProjectKey, + "ProjectResolution": ProjectResolution, } PROJECT_NODE_DISPLAY_NAME_MAPPINGS = { "ProjectLoaderDynamic": "Project Loader (Dynamic)", "ProjectSource": "Project Source", "ProjectKey": "Project Key", + "ProjectResolution": "Project Resolution", } diff --git a/tests/test_project_loader.py b/tests/test_project_loader.py index 153b88a..01e4c2a 100644 --- a/tests/test_project_loader.py +++ b/tests/test_project_loader.py @@ -344,11 +344,102 @@ class TestProjectKey: assert ProjectKey.CATEGORY == "utils/json/project" +class TestProjectResolution: + def test_input_types(self): + from project_loader import ProjectResolution + inputs = ProjectResolution.INPUT_TYPES() + assert "source_label" in inputs["required"] + assert "key_name" in inputs["required"] + assert "index" in inputs["required"] + assert inputs["required"]["index"][0] == "INT" + + def test_two_outputs(self): + from project_loader import ProjectResolution + assert ProjectResolution.RETURN_TYPES == ("INT", "INT") + assert ProjectResolution.RETURN_NAMES == ("width", "height") + + def test_fetch_resolution_basic(self): + from project_loader import ProjectResolution + node = ProjectResolution() + data = {"resolutions": [[512, 512], [768, 1344], [1344, 768]]} + with patch("project_loader._fetch_data", return_value=data): + result = node.fetch_resolution( + source_label="src", key_name="resolutions", index=1, + manager_url="http://localhost:8080", project_name="p", + file_name="f", sequence_number=1, + ) + assert result == (768, 1344) + + def test_fetch_resolution_index_zero(self): + from project_loader import ProjectResolution + node = ProjectResolution() + data = {"resolutions": [[512, 512], [1024, 1024]]} + with patch("project_loader._fetch_data", return_value=data): + result = node.fetch_resolution( + source_label="src", key_name="resolutions", index=0, + manager_url="http://localhost:8080", project_name="p", + file_name="f", sequence_number=1, + ) + assert result == (512, 512) + + def test_fetch_resolution_clamps_on_out_of_bounds(self): + from project_loader import ProjectResolution + node = ProjectResolution() + data = {"resolutions": [[512, 512], [1024, 1024]]} + with patch("project_loader._fetch_data", return_value=data): + result = node.fetch_resolution( + source_label="src", key_name="resolutions", index=99, + manager_url="http://localhost:8080", project_name="p", + file_name="f", sequence_number=1, + ) + assert result == (1024, 1024) # last entry + + def test_fetch_resolution_missing_key_returns_defaults(self): + from project_loader import ProjectResolution + node = ProjectResolution() + with patch("project_loader._fetch_data", return_value={}): + result = node.fetch_resolution( + source_label="src", key_name="nonexistent", index=0, + manager_url="http://localhost:8080", project_name="p", + file_name="f", sequence_number=1, + ) + assert result == (512, 512) + + def test_fetch_resolution_network_error_returns_defaults(self): + from project_loader import ProjectResolution + node = ProjectResolution() + error_resp = {"error": "network_error", "message": "Connection refused"} + with patch("project_loader._fetch_data", return_value=error_resp): + result = node.fetch_resolution( + source_label="src", key_name="resolutions", index=0, + manager_url="http://localhost:8080", project_name="p", + file_name="f", sequence_number=1, + ) + assert result == (512, 512) + + def test_fetch_resolution_malformed_entry_returns_defaults(self): + from project_loader import ProjectResolution + node = ProjectResolution() + data = {"resolutions": [[512]]} # single-element, not a valid pair + with patch("project_loader._fetch_data", return_value=data): + result = node.fetch_resolution( + source_label="src", key_name="resolutions", index=0, + manager_url="http://localhost:8080", project_name="p", + file_name="f", sequence_number=1, + ) + assert result == (512, 512) + + def test_category(self): + from project_loader import ProjectResolution + assert ProjectResolution.CATEGORY == "utils/json/project" + + class TestNodeMappings: def test_mappings_exist(self): from project_loader import PROJECT_NODE_CLASS_MAPPINGS, PROJECT_NODE_DISPLAY_NAME_MAPPINGS assert "ProjectLoaderDynamic" in PROJECT_NODE_CLASS_MAPPINGS assert "ProjectSource" in PROJECT_NODE_CLASS_MAPPINGS assert "ProjectKey" in PROJECT_NODE_CLASS_MAPPINGS - assert len(PROJECT_NODE_CLASS_MAPPINGS) == 3 - assert len(PROJECT_NODE_DISPLAY_NAME_MAPPINGS) == 3 + assert "ProjectResolution" in PROJECT_NODE_CLASS_MAPPINGS + assert len(PROJECT_NODE_CLASS_MAPPINGS) == 4 + assert len(PROJECT_NODE_DISPLAY_NAME_MAPPINGS) == 4 From 31406cb092d62817aa9108988d10a2d5eb4ad045 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Fri, 3 Apr 2026 00:30:58 +0200 Subject: [PATCH 02/45] feat: resolution series editor in sequence card Co-Authored-By: Claude Sonnet 4.6 --- tab_batch_ng.py | 73 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index c0bd53c..8c8c5fa 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -409,6 +409,17 @@ def render_batch_processor(state: AppState): # Single sequence card # ====================================================================== +def _is_resolution_series(val) -> bool: + """Return True if val is a non-empty list of [width, height] numeric pairs.""" + if not isinstance(val, list) or len(val) == 0: + return False + return all( + isinstance(entry, (list, tuple)) and len(entry) == 2 + and all(isinstance(v, (int, float)) for v in entry) + for entry in val + ) + + def _render_sequence_card(i, seq, batch_list, data, file_path, state, src_cache, src_seq_select, standard_keys, refresh_list): @@ -552,6 +563,66 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, dict_textarea('Specific Negative', seq, 'negative').classes( 'w-full q-mt-sm').props('outlined rows=2') + # --- Resolution Series --- + res_keys = [k for k, v in seq.items() if _is_resolution_series(v)] + if res_keys: + ui.label('Resolution Series').classes('text-caption text-weight-bold q-mt-md') + for res_key in res_keys: + series: list = seq[res_key] + with ui.card().classes('w-full q-pa-sm q-mt-xs').props('flat bordered'): + with ui.row().classes('items-center q-mb-xs'): + ui.label(res_key).classes('text-caption col') + def del_series(k=res_key): + del seq[k] + commit() + ui.button(icon='delete', on_click=del_series).props( + 'flat dense round size=xs color=negative') + with ui.row().classes('text-caption text-grey q-mb-xs'): + ui.label('#').style('width:24px') + ui.label('Width').classes('col') + ui.label('Height').classes('col') + ui.label('').style('width:28px') + for idx, entry in enumerate(series): + with ui.row().classes('items-center w-full'): + ui.label(str(idx + 1)).classes('text-caption').style('width:24px') + w_inp = ui.number(value=int(entry[0]), min=1, step=1).classes( + 'col').props('outlined dense hide-bottom-space') + h_inp = ui.number(value=int(entry[1]), min=1, step=1).classes( + 'col').props('outlined dense hide-bottom-space') + + def _sync_wh(i=idx, k=res_key, wi=w_inp, hi=h_inp): + seq[k][i] = [ + int(wi.value) if wi.value else 512, + int(hi.value) if hi.value else 512, + ] + commit() + + w_inp.on('blur', lambda _, s=_sync_wh: s()) + h_inp.on('blur', lambda _, s=_sync_wh: s()) + + def del_row(i=idx, k=res_key): + if i < len(seq.get(k, [])): + seq[k].pop(i) + commit() + ui.button(icon='remove', on_click=del_row).props( + 'flat dense round size=xs') + + def add_row(k=res_key): + seq[k].append([512, 512]) + commit() + ui.button('+ Add row', icon='add', on_click=add_row).props( + 'flat dense size=sm').classes('q-mt-xs') + + with ui.expansion('Add Resolution Series', icon='straighten').classes('w-full q-mt-sm'): + new_res_key = ui.input('Key name', value='resolutions').props('outlined dense') + def add_res_series(): + k = new_res_key.value.strip() + if k and k not in seq: + seq[k] = [[512, 512], [1024, 1024]] + new_res_key.set_value('') + commit() + ui.button('Add', icon='add', on_click=add_res_series).props('outlined dense') + with splitter.after: # Mode dict_number('Mode', seq, 'mode').props('outlined').classes('w-full') @@ -645,7 +716,7 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, # --- Custom Parameters --- ui.label('Custom Parameters').classes('section-header q-mt-md') - custom_keys = [k for k in seq.keys() if k not in standard_keys] + custom_keys = [k for k in seq.keys() if k not in standard_keys and not _is_resolution_series(seq.get(k))] if custom_keys: for k in custom_keys: with ui.row().classes('w-full items-center'): From 281c04dd2eae3dc45ad39a2b748e0ad974ce914f Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Fri, 3 Apr 2026 00:36:48 +0200 Subject: [PATCH 03/45] feat: ProjectResolution JS extension for ComfyUI frontend Co-Authored-By: Claude Sonnet 4.6 --- web/project_resolution.js | 191 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 web/project_resolution.js diff --git a/web/project_resolution.js b/web/project_resolution.js new file mode 100644 index 0000000..51a19b0 --- /dev/null +++ b/web/project_resolution.js @@ -0,0 +1,191 @@ +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; + +app.registerExtension({ + name: "json.manager.project.resolution", + + async beforeQueuePrompt() { + if (!app.graph?._nodes) return; + for (const node of app.graph._nodes) { + if (node.type === "ProjectResolution" && node._syncFromSource) { + node._syncFromSource(); + } + } + }, + + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name !== "ProjectResolution") return; + + function hideWidget(widget) { + if (widget.origType === undefined) widget.origType = widget.type; + widget.type = "hidden"; + widget.hidden = true; + widget.computeSize = () => [0, -4]; + } + + function replaceWithCombo(node, name, values, callback) { + const idx = node.widgets?.findIndex(w => w.name === name); + if (idx === -1 || idx === undefined) return null; + const oldWidget = node.widgets[idx]; + const savedValue = oldWidget.value || ""; + const comboValues = values.length > 0 ? values : [""]; + if (savedValue && !comboValues.includes(savedValue)) { + comboValues.unshift(savedValue); + } + const defaultValue = savedValue || comboValues[0]; + node.widgets.splice(idx, 1); + const combo = node.addWidget("combo", name, defaultValue, callback, { values: comboValues }); + if (node.widgets.length > 1) { + node.widgets.splice(node.widgets.length - 1, 1); + node.widgets.splice(idx, 0, combo); + } + return combo; + } + + const origOnNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + origOnNodeCreated?.apply(this, arguments); + this._configured = false; + + // Hide synced config widgets — index stays visible, user wires it from loop node + for (const name of ["manager_url", "project_name", "file_name", "sequence_number"]) { + const w = this.widgets?.find(w => w.name === name); + if (w) hideWidget(w); + } + + const node = this; + const sourceLabels = this._getSourceLabels?.() || []; + const srcCombo = replaceWithCombo(this, "source_label", sourceLabels, function (value) { + node._syncFromSource(); + node._refreshKeys(); + }); + if (srcCombo) srcCombo.value = sourceLabels[0] || ""; + + const keyCombo = replaceWithCombo(this, "key_name", [], function (value) { + node.title = value ? `Resolution: ${value}` : "Project Resolution"; + app.graph?.setDirtyCanvas(true, true); + }); + if (keyCombo) keyCombo.value = ""; + + queueMicrotask(() => { + if (!this._configured) { + this.setSize(this.computeSize()); + } + }); + }; + + nodeType.prototype._getSourceLabels = function () { + const seen = new Set(); + const labels = []; + if (!this.graph) return labels; + for (const node of this.graph._nodes) { + if (node.type === "ProjectSource") { + const lw = node.widgets?.find(w => w.name === "label"); + if (lw?.value && !seen.has(lw.value)) { + seen.add(lw.value); + labels.push(lw.value); + } + } + } + return labels; + }; + + nodeType.prototype._findSource = function (label) { + if (!this.graph || !label) return null; + for (const node of this.graph._nodes) { + if (node.type === "ProjectSource") { + const lw = node.widgets?.find(w => w.name === "label"); + if (lw?.value === label) return node; + } + } + return null; + }; + + nodeType.prototype._syncFromSource = function () { + const srcWidget = this.widgets?.find(w => w.name === "source_label"); + const source = this._findSource(srcWidget?.value); + if (!source) return; + for (const name of ["manager_url", "project_name", "file_name", "sequence_number"]) { + const dst = this.widgets?.find(w => w.name === name); + const src = source.widgets?.find(w => w.name === name); + if (dst && src) dst.value = src.value; + } + }; + + nodeType.prototype._refreshKeys = async function () { + const urlW = this.widgets?.find(w => w.name === "manager_url"); + const projW = this.widgets?.find(w => w.name === "project_name"); + const fileW = this.widgets?.find(w => w.name === "file_name"); + const seqW = this.widgets?.find(w => w.name === "sequence_number"); + if (!urlW?.value || !projW?.value || !fileW?.value) return; + + try { + const resp = await api.fetchApi( + `/json_manager/get_project_keys?url=${encodeURIComponent(urlW.value)}&project=${encodeURIComponent(projW.value)}&file=${encodeURIComponent(fileW.value)}&seq=${seqW?.value || 1}` + ); + if (!resp.ok) return; + const data = await resp.json(); + if (data.error || !Array.isArray(data.keys)) return; + + const keyWidget = this.widgets?.find(w => w.name === "key_name"); + if (keyWidget) { + keyWidget.options.values = data.keys.length > 0 ? data.keys : [""]; + } + } catch (e) { + console.error("[ProjectResolution] Failed to refresh keys:", e); + } + }; + + const origOnMouseDown = nodeType.prototype.onMouseDown; + nodeType.prototype.onMouseDown = function (e, localPos, graphCanvas) { + origOnMouseDown?.apply(this, arguments); + const srcWidget = this.widgets?.find(w => w.name === "source_label"); + if (srcWidget) srcWidget.options.values = this._getSourceLabels(); + this._syncFromSource(); + }; + + const origOnConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function (info) { + origOnConfigure?.apply(this, arguments); + this._configured = true; + + for (const name of ["manager_url", "project_name", "file_name", "sequence_number"]) { + const w = this.widgets?.find(w => w.name === name); + if (w) hideWidget(w); + } + + const srcWidget = this.widgets?.find(w => w.name === "source_label"); + if (srcWidget && srcWidget.type !== "combo") { + const node = this; + replaceWithCombo(this, "source_label", this._getSourceLabels(), function (value) { + node._syncFromSource(); + node._refreshKeys(); + }); + } else if (srcWidget) { + srcWidget.options.values = this._getSourceLabels(); + } + + const keyWidget = this.widgets?.find(w => w.name === "key_name"); + if (keyWidget && keyWidget.type !== "combo") { + const node = this; + replaceWithCombo(this, "key_name", [], function (value) { + node.title = value ? `Resolution: ${value}` : "Project Resolution"; + app.graph?.setDirtyCanvas(true, true); + }); + } + + const finalKeyWidget = this.widgets?.find(w => w.name === "key_name"); + if (finalKeyWidget?.value) { + this.title = `Resolution: ${finalKeyWidget.value}`; + } + + this.setSize(this.computeSize()); + + const node = this; + queueMicrotask(() => { + node._syncFromSource(); + node._refreshKeys(); + }); + }; + }, +}); From 4b51d3c95d8115a61e3698c50754d2e99c472877 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Fri, 3 Apr 2026 01:07:59 +0200 Subject: [PATCH 04/45] =?UTF-8?q?feat:=20simplify=20resolutions=20UI=20?= =?UTF-8?q?=E2=80=94=20fixed=20key,=20index-labeled=20rows,=20single=20add?= =?UTF-8?q?=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- tab_batch_ng.py | 99 ++++++++++++++++--------------------------------- 1 file changed, 31 insertions(+), 68 deletions(-) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index 8c8c5fa..9ff24e2 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -409,16 +409,6 @@ def render_batch_processor(state: AppState): # Single sequence card # ====================================================================== -def _is_resolution_series(val) -> bool: - """Return True if val is a non-empty list of [width, height] numeric pairs.""" - if not isinstance(val, list) or len(val) == 0: - return False - return all( - isinstance(entry, (list, tuple)) and len(entry) == 2 - and all(isinstance(v, (int, float)) for v in entry) - for entry in val - ) - def _render_sequence_card(i, seq, batch_list, data, file_path, state, src_cache, src_seq_select, standard_keys, @@ -563,65 +553,38 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, dict_textarea('Specific Negative', seq, 'negative').classes( 'w-full q-mt-sm').props('outlined rows=2') - # --- Resolution Series --- - res_keys = [k for k, v in seq.items() if _is_resolution_series(v)] - if res_keys: - ui.label('Resolution Series').classes('text-caption text-weight-bold q-mt-md') - for res_key in res_keys: - series: list = seq[res_key] - with ui.card().classes('w-full q-pa-sm q-mt-xs').props('flat bordered'): - with ui.row().classes('items-center q-mb-xs'): - ui.label(res_key).classes('text-caption col') - def del_series(k=res_key): - del seq[k] - commit() - ui.button(icon='delete', on_click=del_series).props( - 'flat dense round size=xs color=negative') - with ui.row().classes('text-caption text-grey q-mb-xs'): - ui.label('#').style('width:24px') - ui.label('Width').classes('col') - ui.label('Height').classes('col') - ui.label('').style('width:28px') - for idx, entry in enumerate(series): - with ui.row().classes('items-center w-full'): - ui.label(str(idx + 1)).classes('text-caption').style('width:24px') - w_inp = ui.number(value=int(entry[0]), min=1, step=1).classes( - 'col').props('outlined dense hide-bottom-space') - h_inp = ui.number(value=int(entry[1]), min=1, step=1).classes( - 'col').props('outlined dense hide-bottom-space') + # --- Resolutions --- + ui.label('Resolutions').classes('text-caption text-weight-bold q-mt-md') + series: list = seq.setdefault('resolutions', []) + for idx, entry in enumerate(series): + with ui.row().classes('items-center w-full q-mt-xs'): + ui.label(str(idx)).classes('text-caption').style('min-width:20px') + w_inp = ui.number(value=int(entry[0]), min=1, step=1, label='W').classes( + 'col').props('outlined dense hide-bottom-space') + h_inp = ui.number(value=int(entry[1]), min=1, step=1, label='H').classes( + 'col').props('outlined dense hide-bottom-space') - def _sync_wh(i=idx, k=res_key, wi=w_inp, hi=h_inp): - seq[k][i] = [ - int(wi.value) if wi.value else 512, - int(hi.value) if hi.value else 512, - ] - commit() - - w_inp.on('blur', lambda _, s=_sync_wh: s()) - h_inp.on('blur', lambda _, s=_sync_wh: s()) - - def del_row(i=idx, k=res_key): - if i < len(seq.get(k, [])): - seq[k].pop(i) - commit() - ui.button(icon='remove', on_click=del_row).props( - 'flat dense round size=xs') - - def add_row(k=res_key): - seq[k].append([512, 512]) - commit() - ui.button('+ Add row', icon='add', on_click=add_row).props( - 'flat dense size=sm').classes('q-mt-xs') - - with ui.expansion('Add Resolution Series', icon='straighten').classes('w-full q-mt-sm'): - new_res_key = ui.input('Key name', value='resolutions').props('outlined dense') - def add_res_series(): - k = new_res_key.value.strip() - if k and k not in seq: - seq[k] = [[512, 512], [1024, 1024]] - new_res_key.set_value('') + def _sync_wh(i=idx, wi=w_inp, hi=h_inp): + seq['resolutions'][i] = [ + int(wi.value) if wi.value else 512, + int(hi.value) if hi.value else 512, + ] commit() - ui.button('Add', icon='add', on_click=add_res_series).props('outlined dense') + + w_inp.on('blur', lambda _, s=_sync_wh: s()) + h_inp.on('blur', lambda _, s=_sync_wh: s()) + + def del_row(i=idx): + if i < len(seq.get('resolutions', [])): + seq['resolutions'].pop(i) + commit() + ui.button(icon='remove', on_click=del_row).props('flat dense round size=xs') + + def add_resolution(): + seq.setdefault('resolutions', []).append([512, 512]) + commit() + ui.button(f'+ Add Resolution {len(series)}', icon='add', on_click=add_resolution).props( + 'flat dense size=sm').classes('q-mt-xs') with splitter.after: # Mode @@ -716,7 +679,7 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, # --- Custom Parameters --- ui.label('Custom Parameters').classes('section-header q-mt-md') - custom_keys = [k for k in seq.keys() if k not in standard_keys and not _is_resolution_series(seq.get(k))] + custom_keys = [k for k in seq.keys() if k not in standard_keys and k != 'resolutions'] if custom_keys: for k in custom_keys: with ui.row().classes('w-full items-center'): From f97f8a06163cc66d64f76256819fac220d473711 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Fri, 3 Apr 2026 01:11:07 +0200 Subject: [PATCH 05/45] =?UTF-8?q?feat:=20resolutions=20=E2=80=94=206=20fix?= =?UTF-8?q?ed=20slots,=20always=20visible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- tab_batch_ng.py | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index 9ff24e2..b9386a1 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -553,10 +553,13 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, dict_textarea('Specific Negative', seq, 'negative').classes( 'w-full q-mt-sm').props('outlined rows=2') - # --- Resolutions --- + # --- Resolutions (6 fixed slots) --- ui.label('Resolutions').classes('text-caption text-weight-bold q-mt-md') - series: list = seq.setdefault('resolutions', []) - for idx, entry in enumerate(series): + resolutions = seq.setdefault('resolutions', []) + while len(resolutions) < 6: + resolutions.append([512, 512]) + for idx in range(6): + entry = resolutions[idx] with ui.row().classes('items-center w-full q-mt-xs'): ui.label(str(idx)).classes('text-caption').style('min-width:20px') w_inp = ui.number(value=int(entry[0]), min=1, step=1, label='W').classes( @@ -574,18 +577,6 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, w_inp.on('blur', lambda _, s=_sync_wh: s()) h_inp.on('blur', lambda _, s=_sync_wh: s()) - def del_row(i=idx): - if i < len(seq.get('resolutions', [])): - seq['resolutions'].pop(i) - commit() - ui.button(icon='remove', on_click=del_row).props('flat dense round size=xs') - - def add_resolution(): - seq.setdefault('resolutions', []).append([512, 512]) - commit() - ui.button(f'+ Add Resolution {len(series)}', icon='add', on_click=add_resolution).props( - 'flat dense size=sm').classes('q-mt-xs') - with splitter.after: # Mode dict_number('Mode', seq, 'mode').props('outlined').classes('w-full') From 29be286eb14991b493f8ce6d8c053c6bdb4eab7a Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Fri, 3 Apr 2026 01:19:30 +0200 Subject: [PATCH 06/45] fix: move nodes to JSON Manager/project category Co-Authored-By: Claude Opus 4.6 --- project_loader.py | 8 ++++---- tests/test_project_loader.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/project_loader.py b/project_loader.py index 591cb21..9dbaff3 100644 --- a/project_loader.py +++ b/project_loader.py @@ -150,7 +150,7 @@ class ProjectLoaderDynamic: RETURN_TYPES = ("INT",) + tuple(any_type for _ in range(MAX_DYNAMIC_OUTPUTS)) RETURN_NAMES = ("total_sequences",) + tuple(f"output_{i}" for i in range(MAX_DYNAMIC_OUTPUTS)) FUNCTION = "load_dynamic" - CATEGORY = "utils/json/project" + CATEGORY = "JSON Manager/project" OUTPUT_NODE = False def load_dynamic(self, manager_url, project_name, file_name, sequence_number, @@ -224,7 +224,7 @@ class ProjectSource: RETURN_TYPES = ("INT", "STRING",) RETURN_NAMES = ("sequence_number", "file_name",) FUNCTION = "hold_config" - CATEGORY = "utils/json/project" + CATEGORY = "JSON Manager/project" OUTPUT_NODE = True def hold_config(self, manager_url, project_name, file_name, sequence_number, label): @@ -252,7 +252,7 @@ class ProjectKey: RETURN_TYPES = (any_type,) RETURN_NAMES = ("value",) FUNCTION = "fetch_key" - CATEGORY = "utils/json/project" + CATEGORY = "JSON Manager/project" OUTPUT_NODE = False @classmethod @@ -314,7 +314,7 @@ class ProjectResolution: RETURN_TYPES = ("INT", "INT") RETURN_NAMES = ("width", "height") FUNCTION = "fetch_resolution" - CATEGORY = "utils/json/project" + CATEGORY = "JSON Manager/project" OUTPUT_NODE = False @classmethod diff --git a/tests/test_project_loader.py b/tests/test_project_loader.py index 01e4c2a..09897d6 100644 --- a/tests/test_project_loader.py +++ b/tests/test_project_loader.py @@ -200,7 +200,7 @@ class TestProjectLoaderDynamic: assert "sequence_number" in inputs["required"] def test_category(self): - assert ProjectLoaderDynamic.CATEGORY == "utils/json/project" + assert ProjectLoaderDynamic.CATEGORY == "JSON Manager/project" class TestProjectSource: @@ -232,7 +232,7 @@ class TestProjectSource: def test_category(self): from project_loader import ProjectSource - assert ProjectSource.CATEGORY == "utils/json/project" + assert ProjectSource.CATEGORY == "JSON Manager/project" class TestProjectKey: @@ -341,7 +341,7 @@ class TestProjectKey: def test_category(self): from project_loader import ProjectKey - assert ProjectKey.CATEGORY == "utils/json/project" + assert ProjectKey.CATEGORY == "JSON Manager/project" class TestProjectResolution: @@ -431,7 +431,7 @@ class TestProjectResolution: def test_category(self): from project_loader import ProjectResolution - assert ProjectResolution.CATEGORY == "utils/json/project" + assert ProjectResolution.CATEGORY == "JSON Manager/project" class TestNodeMappings: From ca26da303c99fc794c93c23a0373552a896c7e74 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Fri, 3 Apr 2026 01:22:08 +0200 Subject: [PATCH 07/45] fix: persist resolutions on init and on every value change Co-Authored-By: Claude Opus 4.6 --- tab_batch_ng.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index b9386a1..7787efa 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -555,9 +555,12 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, # --- Resolutions (6 fixed slots) --- ui.label('Resolutions').classes('text-caption text-weight-bold q-mt-md') - resolutions = seq.setdefault('resolutions', []) - while len(resolutions) < 6: - resolutions.append([512, 512]) + if 'resolutions' not in seq or len(seq.get('resolutions', [])) < 6: + resolutions = seq.setdefault('resolutions', []) + while len(resolutions) < 6: + resolutions.append([512, 512]) + commit() + resolutions = seq['resolutions'] for idx in range(6): entry = resolutions[idx] with ui.row().classes('items-center w-full q-mt-xs'): @@ -575,7 +578,9 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, commit() w_inp.on('blur', lambda _, s=_sync_wh: s()) + w_inp.on('update:model-value', lambda _, s=_sync_wh: s()) h_inp.on('blur', lambda _, s=_sync_wh: s()) + h_inp.on('update:model-value', lambda _, s=_sync_wh: s()) with splitter.after: # Mode From cf8496ec08611ec0955c54e462ca0446d574028c Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Fri, 3 Apr 2026 01:24:58 +0200 Subject: [PATCH 08/45] fix: default key_name to 'resolutions' on new ProjectResolution node Co-Authored-By: Claude Opus 4.6 --- web/project_resolution.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/project_resolution.js b/web/project_resolution.js index 51a19b0..b6b95e9 100644 --- a/web/project_resolution.js +++ b/web/project_resolution.js @@ -65,7 +65,7 @@ app.registerExtension({ node.title = value ? `Resolution: ${value}` : "Project Resolution"; app.graph?.setDirtyCanvas(true, true); }); - if (keyCombo) keyCombo.value = ""; + if (keyCombo && !keyCombo.value) keyCombo.value = "resolutions"; queueMicrotask(() => { if (!this._configured) { From 062f7880a6164425269d0b09c67c48ce6bad392e Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Fri, 3 Apr 2026 01:33:29 +0200 Subject: [PATCH 09/45] fix: read sequence data directly from JSON file in API endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _get_data and _get_keys were querying the SQLite DB which only gets populated when db_enabled is on. JSON file is always the source of truth, so read from it directly — fixes missing keys (e.g. resolutions) when DB hasn't been synced. Co-Authored-By: Claude Sonnet 4.6 --- api_routes.py | 53 ++++++++++++++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/api_routes.py b/api_routes.py index 34f61ff..40815ab 100644 --- a/api_routes.py +++ b/api_routes.py @@ -1,16 +1,18 @@ -"""REST API endpoints for ComfyUI to query project data from SQLite. +"""REST API endpoints for ComfyUI to query project data from JSON files. All endpoints are read-only. Mounted on the NiceGUI/FastAPI server. """ import logging import time +from pathlib import Path from typing import Any from fastapi import HTTPException, Query from nicegui import app from db import ProjectDB +from utils import load_json, KEY_BATCH_DATA, KEY_SEQUENCE_NUMBER logger = logging.getLogger(__name__) @@ -54,34 +56,49 @@ def _list_sequences(name: str, file_name: str) -> dict[str, Any]: return {"sequences": seqs} -def _get_data(name: str, file_name: str, seq: int = Query(default=1)) -> dict[str, Any]: - t0 = time.perf_counter() +def _load_sequences(name: str, file_name: str) -> list[dict]: + """Load the batch_data list directly from the JSON file.""" db = _get_db() proj = db.get_project(name) if not proj: raise HTTPException(status_code=404, detail=f"Project '{name}' not found") - df = db.get_data_file_by_names(name, file_name) - if not df: + json_path = Path(proj["folder_path"]) / f"{file_name}.json" + if not json_path.exists(): raise HTTPException(status_code=404, detail=f"File '{file_name}' not found in project '{name}'") - data = db.get_sequence(df["id"], seq) - if data is None: + data, _ = load_json(json_path) + return data.get(KEY_BATCH_DATA, []) + + +def _get_data(name: str, file_name: str, seq: int = Query(default=1)) -> dict[str, Any]: + t0 = time.perf_counter() + sequences = _load_sequences(name, file_name) + match = next((s for s in sequences if int(s.get(KEY_SEQUENCE_NUMBER, 0)) == seq), None) + if match is None: raise HTTPException(status_code=404, detail=f"Sequence {seq} not found") logger.info("API _get_data %s/%s seq=%d (%d keys): %.3fs", - name, file_name, seq, len(data), time.perf_counter() - t0) - return data + name, file_name, seq, len(match), time.perf_counter() - t0) + return match def _get_keys(name: str, file_name: str, seq: int = Query(default=1)) -> dict[str, Any]: t0 = time.perf_counter() - db = _get_db() - proj = db.get_project(name) - if not proj: - raise HTTPException(status_code=404, detail=f"Project '{name}' not found") - df = db.get_data_file_by_names(name, file_name) - if not df: - raise HTTPException(status_code=404, detail=f"File '{file_name}' not found in project '{name}'") - keys, types = db.get_sequence_keys(df["id"], seq) - total = db.count_sequences(df["id"]) + sequences = _load_sequences(name, file_name) + match = next((s for s in sequences if int(s.get(KEY_SEQUENCE_NUMBER, 0)) == seq), None) + if match is None: + raise HTTPException(status_code=404, detail=f"Sequence {seq} not found") + keys = [k for k in match.keys() if k != KEY_SEQUENCE_NUMBER] + types = [] + for k in keys: + v = match[k] + if isinstance(v, bool): + types.append("BOOLEAN") + elif isinstance(v, int): + types.append("INT") + elif isinstance(v, float): + types.append("FLOAT") + else: + types.append("STRING") + total = len(sequences) logger.info("API _get_keys %s/%s seq=%d (%d keys): %.3fs", name, file_name, seq, len(keys), time.perf_counter() - t0) return {"keys": keys, "types": types, "total_sequences": total} From 55900e7c43ec85b4649a8fa23832f391eec5ca49 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Fri, 3 Apr 2026 11:27:11 +0200 Subject: [PATCH 10/45] feat: 8 resolution slots with per-slot seed + node outputs seed - Resolution entries expanded from 6 to 8 fixed slots - Each slot now stores [w, h, seed] (migrates old [w, h] entries to [w, h, 0]) - UI adds seed number input + casino randomize button per row - ProjectResolution node now outputs (width, height, seed) instead of (width, height) Co-Authored-By: Claude Sonnet 4.6 --- project_loader.py | 13 +++++---- tab_batch_ng.py | 56 ++++++++++++++++++++++++------------ tests/test_project_loader.py | 37 ++++++++++++++++-------- 3 files changed, 70 insertions(+), 36 deletions(-) diff --git a/project_loader.py b/project_loader.py index 9dbaff3..11bfb78 100644 --- a/project_loader.py +++ b/project_loader.py @@ -311,8 +311,8 @@ class ProjectResolution: }, } - RETURN_TYPES = ("INT", "INT") - RETURN_NAMES = ("width", "height") + RETURN_TYPES = ("INT", "INT", "INT") + RETURN_NAMES = ("width", "height", "seed") FUNCTION = "fetch_resolution" CATEGORY = "JSON Manager/project" OUTPUT_NODE = False @@ -332,20 +332,21 @@ class ProjectResolution: data = _fetch_data(manager_url, project_name, file_name, sequence_number) if data.get("error") in ("http_error", "network_error", "parse_error"): logger.warning("ProjectResolution.fetch_resolution failed: %s", data.get("message")) - return (512, 512) + return (512, 512, 0) series = data.get(key_name) if not isinstance(series, list) or len(series) == 0: logger.warning("ProjectResolution: key '%s' is not a resolution series", key_name) - return (512, 512) + return (512, 512, 0) clamped = max(0, min(index, len(series) - 1)) entry = series[clamped] if not isinstance(entry, (list, tuple)) or len(entry) < 2: logger.warning("ProjectResolution: entry at index %d is malformed: %r", clamped, entry) - return (512, 512) + return (512, 512, 0) - return (to_int(entry[0]), to_int(entry[1])) + seed = to_int(entry[2]) if len(entry) >= 3 else 0 + return (to_int(entry[0]), to_int(entry[1]), seed) # --- Mappings --- diff --git a/tab_batch_ng.py b/tab_batch_ng.py index 7787efa..647d470 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -553,34 +553,54 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, dict_textarea('Specific Negative', seq, 'negative').classes( 'w-full q-mt-sm').props('outlined rows=2') - # --- Resolutions (6 fixed slots) --- + # --- Resolutions (8 fixed slots) --- ui.label('Resolutions').classes('text-caption text-weight-bold q-mt-md') - if 'resolutions' not in seq or len(seq.get('resolutions', [])) < 6: - resolutions = seq.setdefault('resolutions', []) - while len(resolutions) < 6: - resolutions.append([512, 512]) + resolutions = seq.setdefault('resolutions', []) + changed = False + while len(resolutions) < 8: + resolutions.append([512, 512, 0]) + changed = True + # Migrate old [w, h] entries to [w, h, seed] + for i, entry in enumerate(resolutions): + if len(entry) < 3: + resolutions[i] = list(entry) + [0] + changed = True + if changed: commit() - resolutions = seq['resolutions'] - for idx in range(6): + for idx in range(8): entry = resolutions[idx] - with ui.row().classes('items-center w-full q-mt-xs'): - ui.label(str(idx)).classes('text-caption').style('min-width:20px') - w_inp = ui.number(value=int(entry[0]), min=1, step=1, label='W').classes( - 'col').props('outlined dense hide-bottom-space') - h_inp = ui.number(value=int(entry[1]), min=1, step=1, label='H').classes( - 'col').props('outlined dense hide-bottom-space') + with ui.row().classes('items-center w-full q-mt-xs no-wrap'): + ui.label(str(idx)).classes('text-caption').style('min-width:16px') + w_inp = ui.number(value=int(entry[0]), min=1, step=1, label='W').style( + 'width:70px').props('outlined dense hide-bottom-space') + h_inp = ui.number(value=int(entry[1]), min=1, step=1, label='H').style( + 'width:70px').props('outlined dense hide-bottom-space') + seed_inp = ui.number(value=int(entry[2]), min=0, step=1, label='Seed').style( + 'flex:1; min-width:60px').props('outlined dense hide-bottom-space') - def _sync_wh(i=idx, wi=w_inp, hi=h_inp): + def _sync_entry(i=idx, wi=w_inp, hi=h_inp, si=seed_inp): seq['resolutions'][i] = [ int(wi.value) if wi.value else 512, int(hi.value) if hi.value else 512, + int(si.value) if si.value else 0, ] commit() - w_inp.on('blur', lambda _, s=_sync_wh: s()) - w_inp.on('update:model-value', lambda _, s=_sync_wh: s()) - h_inp.on('blur', lambda _, s=_sync_wh: s()) - h_inp.on('update:model-value', lambda _, s=_sync_wh: s()) + def _randomize(si=seed_inp, i=idx): + import random + si.value = random.randint(0, 2**32 - 1) + seq['resolutions'][i][2] = int(si.value) + commit() + + ui.button(icon='casino', on_click=_randomize).props( + 'flat dense round').classes('q-ml-xs') + + w_inp.on('blur', lambda _, s=_sync_entry: s()) + w_inp.on('update:model-value', lambda _, s=_sync_entry: s()) + h_inp.on('blur', lambda _, s=_sync_entry: s()) + h_inp.on('update:model-value', lambda _, s=_sync_entry: s()) + seed_inp.on('blur', lambda _, s=_sync_entry: s()) + seed_inp.on('update:model-value', lambda _, s=_sync_entry: s()) with splitter.after: # Mode diff --git a/tests/test_project_loader.py b/tests/test_project_loader.py index 09897d6..9404d1c 100644 --- a/tests/test_project_loader.py +++ b/tests/test_project_loader.py @@ -353,46 +353,59 @@ class TestProjectResolution: assert "index" in inputs["required"] assert inputs["required"]["index"][0] == "INT" - def test_two_outputs(self): + def test_three_outputs(self): from project_loader import ProjectResolution - assert ProjectResolution.RETURN_TYPES == ("INT", "INT") - assert ProjectResolution.RETURN_NAMES == ("width", "height") + assert ProjectResolution.RETURN_TYPES == ("INT", "INT", "INT") + assert ProjectResolution.RETURN_NAMES == ("width", "height", "seed") def test_fetch_resolution_basic(self): from project_loader import ProjectResolution node = ProjectResolution() - data = {"resolutions": [[512, 512], [768, 1344], [1344, 768]]} + data = {"resolutions": [[512, 512, 0], [768, 1344, 12345], [1344, 768, 99]]} with patch("project_loader._fetch_data", return_value=data): result = node.fetch_resolution( source_label="src", key_name="resolutions", index=1, manager_url="http://localhost:8080", project_name="p", file_name="f", sequence_number=1, ) - assert result == (768, 1344) + assert result == (768, 1344, 12345) def test_fetch_resolution_index_zero(self): from project_loader import ProjectResolution node = ProjectResolution() - data = {"resolutions": [[512, 512], [1024, 1024]]} + data = {"resolutions": [[512, 512, 42], [1024, 1024, 0]]} with patch("project_loader._fetch_data", return_value=data): result = node.fetch_resolution( source_label="src", key_name="resolutions", index=0, manager_url="http://localhost:8080", project_name="p", file_name="f", sequence_number=1, ) - assert result == (512, 512) + assert result == (512, 512, 42) def test_fetch_resolution_clamps_on_out_of_bounds(self): from project_loader import ProjectResolution node = ProjectResolution() - data = {"resolutions": [[512, 512], [1024, 1024]]} + data = {"resolutions": [[512, 512, 0], [1024, 1024, 7]]} with patch("project_loader._fetch_data", return_value=data): result = node.fetch_resolution( source_label="src", key_name="resolutions", index=99, manager_url="http://localhost:8080", project_name="p", file_name="f", sequence_number=1, ) - assert result == (1024, 1024) # last entry + assert result == (1024, 1024, 7) # last entry + + def test_fetch_resolution_old_format_no_seed(self): + """Old [w, h] entries without seed should return seed=0.""" + from project_loader import ProjectResolution + node = ProjectResolution() + data = {"resolutions": [[576, 384], [960, 640]]} + with patch("project_loader._fetch_data", return_value=data): + result = node.fetch_resolution( + source_label="src", key_name="resolutions", index=0, + manager_url="http://localhost:8080", project_name="p", + file_name="f", sequence_number=1, + ) + assert result == (576, 384, 0) def test_fetch_resolution_missing_key_returns_defaults(self): from project_loader import ProjectResolution @@ -403,7 +416,7 @@ class TestProjectResolution: manager_url="http://localhost:8080", project_name="p", file_name="f", sequence_number=1, ) - assert result == (512, 512) + assert result == (512, 512, 0) def test_fetch_resolution_network_error_returns_defaults(self): from project_loader import ProjectResolution @@ -415,7 +428,7 @@ class TestProjectResolution: manager_url="http://localhost:8080", project_name="p", file_name="f", sequence_number=1, ) - assert result == (512, 512) + assert result == (512, 512, 0) def test_fetch_resolution_malformed_entry_returns_defaults(self): from project_loader import ProjectResolution @@ -427,7 +440,7 @@ class TestProjectResolution: manager_url="http://localhost:8080", project_name="p", file_name="f", sequence_number=1, ) - assert result == (512, 512) + assert result == (512, 512, 0) def test_category(self): from project_loader import ProjectResolution From fe8f91b477b2b1456376ac618365a8b2616eceef Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Fri, 3 Apr 2026 12:46:57 +0200 Subject: [PATCH 11/45] feat: show resolutions and custom fields in timeline preview _render_preview_fields was only rendering hardcoded known keys. Now adds a Resolutions section (W/H/Seed per slot) and a Custom Fields catch-all for any other keys not in the standard set. Co-Authored-By: Claude Sonnet 4.6 --- tab_timeline_ng.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tab_timeline_ng.py b/tab_timeline_ng.py index 3986a2e..408e427 100644 --- a/tab_timeline_ng.py +++ b/tab_timeline_ng.py @@ -602,6 +602,35 @@ def _render_preview_fields(item_data: dict): ui.input('Video Path', value=str(item_data.get('video file path', ''))).props('readonly outlined') + resolutions = item_data.get('resolutions') + if isinstance(resolutions, list) and resolutions: + with ui.expansion('Resolutions'): + with ui.grid(columns=4).classes('w-full'): + for i, entry in enumerate(resolutions): + if isinstance(entry, (list, tuple)) and len(entry) >= 2: + w, h = entry[0], entry[1] + seed = entry[2] if len(entry) >= 3 else 0 + ui.input(f'#{i} W', value=str(w)).props('readonly outlined dense') + ui.input(f'#{i} H', value=str(h)).props('readonly outlined dense') + ui.input(f'#{i} Seed', value=str(seed)).props('readonly outlined dense') + ui.label('') # grid spacer for 4th column + + known_keys = { + 'sequence_number', 'general_prompt', 'general_negative', 'current_prompt', 'prompt', + 'negative', 'camera', 'flf', 'seed', 'resolutions', + 'frame_to_skip', 'vace schedule', 'video file path', + } + # also skip lora keys + custom_keys = [ + k for k in item_data + if k not in known_keys and not k.startswith('lora ') + ] + if custom_keys: + with ui.expansion('Custom Fields'): + with ui.grid(columns=2).classes('w-full'): + for k in custom_keys: + ui.input(k, value=str(item_data[k])).props('readonly outlined dense') + def _truncate(val, max_len=60): """Truncate a value for display.""" From 20be3204b38798e040ae7bd389c5f7cd2c997a4b Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Fri, 3 Apr 2026 12:59:25 +0200 Subject: [PATCH 12/45] fix: await all commit() calls in sequence card event handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commit() is async but was called without await from sync handlers (clone_next, clone_end, clone_sub, delete, copy_source, del_custom, add_param, _sync_entry, _randomize) — causing saves and UI refreshes to silently never run. Made all handlers async and added await. Also fixed for i,entry loop shadowing the card's i parameter, which was causing _render_vace_settings to receive the wrong index. Removed unawaited render-time commit() in resolutions init block. Co-Authored-By: Claude Sonnet 4.6 --- tab_batch_ng.py | 56 ++++++++++++++++++++++--------------------------- 1 file changed, 25 insertions(+), 31 deletions(-) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index 647d470..a99c872 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -470,11 +470,11 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, ) if result is not None: s['name'] = result - commit('Renamed!') + await commit('Renamed!') ui.button('Rename', icon='edit', on_click=rename).props('outline') # Copy from source - def copy_source(idx=i, sn=seq_num): + async def copy_source(idx=i, sn=seq_num): item = copy.deepcopy(DEFAULTS) src_batch = src_cache['batch'] sel_idx = src_seq_select.value @@ -486,12 +486,12 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, item.pop(KEY_PROMPT_HISTORY, None) item.pop(KEY_HISTORY_TREE, None) batch_list[idx] = item - commit('Copied!') + await commit('Copied!') ui.button('Copy Src', icon='file_download', on_click=copy_source).props('outline') # Clone Next - def clone_next(idx=i, sn=seq_num, s=seq): + async def clone_next(idx=i, sn=seq_num, s=seq): new_seq = copy.deepcopy(s) new_seq[KEY_SEQUENCE_NUMBER] = max_main_seq_number(batch_list) + 1 if not is_subsegment(sn): @@ -499,21 +499,21 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, else: pos = idx + 1 batch_list.insert(pos, new_seq) - commit('Cloned to Next!') + await commit('Cloned to Next!') ui.button('Clone Next', icon='content_copy', on_click=clone_next).props('outline') # Clone End - def clone_end(s=seq): + async def clone_end(s=seq): new_seq = copy.deepcopy(s) new_seq[KEY_SEQUENCE_NUMBER] = max_main_seq_number(batch_list) + 1 batch_list.append(new_seq) - commit('Cloned to End!') + await commit('Cloned to End!') ui.button('Clone End', icon='vertical_align_bottom', on_click=clone_end).props('outline') # Clone Sub - def clone_sub(idx=i, sn=seq_num, s=seq): + async def clone_sub(idx=i, sn=seq_num, s=seq): new_seq = copy.deepcopy(s) p_seq = parent_of(sn) p_idx = idx @@ -525,17 +525,17 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, new_seq[KEY_SEQUENCE_NUMBER] = next_sub_segment_number(batch_list, p_seq) pos = find_insert_position(batch_list, p_idx, p_seq) batch_list.insert(pos, new_seq) - commit(f'Created {format_seq_label(new_seq[KEY_SEQUENCE_NUMBER])}!') + await commit(f'Created {format_seq_label(new_seq[KEY_SEQUENCE_NUMBER])}!') ui.button('Clone Sub', icon='link', on_click=clone_sub).props('outline') ui.element('div').classes('col') # Delete - def delete(idx=i): + async def delete(idx=i): if idx < len(batch_list): batch_list.pop(idx) - commit() + await commit() ui.button(icon='delete', on_click=delete).props('color=negative') @@ -556,17 +556,12 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, # --- Resolutions (8 fixed slots) --- ui.label('Resolutions').classes('text-caption text-weight-bold q-mt-md') resolutions = seq.setdefault('resolutions', []) - changed = False while len(resolutions) < 8: resolutions.append([512, 512, 0]) - changed = True - # Migrate old [w, h] entries to [w, h, seed] - for i, entry in enumerate(resolutions): - if len(entry) < 3: - resolutions[i] = list(entry) + [0] - changed = True - if changed: - commit() + # Migrate old [w, h] entries to [w, h, seed] (persisted on next real save) + for r_i in range(len(resolutions)): + if len(resolutions[r_i]) < 3: + resolutions[r_i] = list(resolutions[r_i]) + [0] for idx in range(8): entry = resolutions[idx] with ui.row().classes('items-center w-full q-mt-xs no-wrap'): @@ -578,19 +573,18 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, seed_inp = ui.number(value=int(entry[2]), min=0, step=1, label='Seed').style( 'flex:1; min-width:60px').props('outlined dense hide-bottom-space') - def _sync_entry(i=idx, wi=w_inp, hi=h_inp, si=seed_inp): - seq['resolutions'][i] = [ + async def _sync_entry(r=idx, wi=w_inp, hi=h_inp, si=seed_inp): + seq['resolutions'][r] = [ int(wi.value) if wi.value else 512, int(hi.value) if hi.value else 512, int(si.value) if si.value else 0, ] - commit() + await commit() - def _randomize(si=seed_inp, i=idx): - import random + async def _randomize(si=seed_inp, r=idx): si.value = random.randint(0, 2**32 - 1) - seq['resolutions'][i][2] = int(si.value) - commit() + seq['resolutions'][r][2] = int(si.value) + await commit() ui.button(icon='casino', on_click=_randomize).props( 'flat dense round').classes('q-ml-xs') @@ -702,9 +696,9 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, ui.input('Key', value=k).props('readonly outlined dense').classes('w-32') dict_input(ui.input, 'Value', seq, k).props('outlined dense').classes('col') - def del_custom(key=k): + async def del_custom(key=k): del seq[key] - commit() + await commit() ui.button(icon='delete', on_click=del_custom).props('flat dense color=negative') @@ -712,14 +706,14 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, new_k_input = ui.input('Key').props('outlined dense') new_v_input = ui.input('Value').props('outlined dense') - def add_param(): + async def add_param(): k = new_k_input.value v = new_v_input.value if k and k not in seq: seq[k] = v new_k_input.set_value('') new_v_input.set_value('') - commit() + await commit() ui.button('Add', on_click=add_param).props('flat') From f0e785afabebda79576c2b75aacf5857e15407e9 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 4 Apr 2026 00:00:56 +0200 Subject: [PATCH 13/45] docs: add BinaryIndexDecoder node design Co-Authored-By: Claude Sonnet 4.6 --- .../2026-04-04-binary-index-decoder-design.md | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 docs/plans/2026-04-04-binary-index-decoder-design.md diff --git a/docs/plans/2026-04-04-binary-index-decoder-design.md b/docs/plans/2026-04-04-binary-index-decoder-design.md new file mode 100644 index 0000000..dbb286a --- /dev/null +++ b/docs/plans/2026-04-04-binary-index-decoder-design.md @@ -0,0 +1,67 @@ +# BinaryIndexDecoder Node — Design + +## Summary + +A standalone ComfyUI utility node that converts an integer index into 3 boolean +outputs using binary (bit-field) encoding. Intended for use with loop counters to +gate multiple processing branches simultaneously. + +## Node Spec + +| Field | Value | +|---|---| +| Class name | `BinaryIndexDecoder` | +| Display name | `Binary Index Decoder` | +| Category | `JSON Manager/utils` | +| Function | `decode` | + +### Inputs + +| Name | Type | Default | Range | +|---|---|---|---| +| `index` | INT | 0 | 0–7 | + +### Outputs + +| Name | Type | +|---|---| +| `flag_0` | BOOLEAN | +| `flag_1` | BOOLEAN | +| `flag_2` | BOOLEAN | + +### Logic + +``` +flag_0 = bool((index >> 0) & 1) +flag_1 = bool((index >> 1) & 1) +flag_2 = bool((index >> 2) & 1) +``` + +### Truth table + +| index | flag_0 | flag_1 | flag_2 | +|---|---|---|---| +| 0 | F | F | F | +| 1 | T | F | F | +| 2 | F | T | F | +| 3 | T | T | F | +| 4 | F | F | T | +| 5 | T | F | T | +| 6 | F | T | T | +| 7 | T | T | T | + +## Implementation Notes + +- Lives in `project_loader.py` alongside other project nodes +- Added to `PROJECT_NODE_CLASS_MAPPINGS` and `PROJECT_NODE_DISPLAY_NAME_MAPPINGS` +- No JavaScript extension needed (no source sync, no dynamic widgets) +- No NiceGUI UI changes needed +- `IS_CHANGED` not needed (output is deterministic from input) + +## Testing + +9 tests in `tests/test_project_loader.py::TestBinaryIndexDecoder`: +- Input types include `index` as INT +- All 8 index values (0–7) produce correct boolean tuple +- Out-of-range index (e.g. 8) clamps to 0–7 or wraps gracefully +- `NodeMappings` test updated: 5 nodes, mappings length == 5 From c8cc397cc64a1f4b30aa9649627f411bd204a583 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 4 Apr 2026 00:01:36 +0200 Subject: [PATCH 14/45] docs: add BinaryIndexDecoder implementation plan Co-Authored-By: Claude Sonnet 4.6 --- .../2026-04-04-binary-index-decoder-plan.md | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 docs/plans/2026-04-04-binary-index-decoder-plan.md diff --git a/docs/plans/2026-04-04-binary-index-decoder-plan.md b/docs/plans/2026-04-04-binary-index-decoder-plan.md new file mode 100644 index 0000000..deac578 --- /dev/null +++ b/docs/plans/2026-04-04-binary-index-decoder-plan.md @@ -0,0 +1,166 @@ +# BinaryIndexDecoder Node — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a standalone ComfyUI node `BinaryIndexDecoder` that converts an integer index to 3 boolean outputs using binary (bit-field) encoding. + +**Architecture:** Single class in `project_loader.py`, no JS extension needed, no NiceGUI changes. Takes `index` INT, returns `(flag_0, flag_1, flag_2)` as BOOLEAN using bit-shift logic. Added to existing node mappings. + +**Tech Stack:** Python, ComfyUI node API, pytest + +--- + +### Task 1: Write failing tests for BinaryIndexDecoder + +**Files:** +- Modify: `tests/test_project_loader.py` (append new test class at end, before `TestNodeMappings`) +- Modify: `tests/test_project_loader.py` — update `TestNodeMappings.test_mappings_exist` to expect 5 nodes + +**Step 1: Add the test class** + +Append this class before `TestNodeMappings` in `tests/test_project_loader.py`: + +```python +class TestBinaryIndexDecoder: + def test_input_types(self): + from project_loader import BinaryIndexDecoder + inputs = BinaryIndexDecoder.INPUT_TYPES() + assert "index" in inputs["required"] + assert inputs["required"]["index"][0] == "INT" + + def test_three_boolean_outputs(self): + from project_loader import BinaryIndexDecoder + assert BinaryIndexDecoder.RETURN_TYPES == ("BOOLEAN", "BOOLEAN", "BOOLEAN") + assert BinaryIndexDecoder.RETURN_NAMES == ("flag_0", "flag_1", "flag_2") + + def test_category(self): + from project_loader import BinaryIndexDecoder + assert BinaryIndexDecoder.CATEGORY == "JSON Manager/utils" + + def test_index_0(self): + from project_loader import BinaryIndexDecoder + assert BinaryIndexDecoder().decode(0) == (False, False, False) + + def test_index_1(self): + from project_loader import BinaryIndexDecoder + assert BinaryIndexDecoder().decode(1) == (True, False, False) + + def test_index_2(self): + from project_loader import BinaryIndexDecoder + assert BinaryIndexDecoder().decode(2) == (False, True, False) + + def test_index_3(self): + from project_loader import BinaryIndexDecoder + assert BinaryIndexDecoder().decode(3) == (True, True, False) + + def test_index_4(self): + from project_loader import BinaryIndexDecoder + assert BinaryIndexDecoder().decode(4) == (False, False, True) + + def test_index_7(self): + from project_loader import BinaryIndexDecoder + assert BinaryIndexDecoder().decode(7) == (True, True, True) +``` + +Also update `TestNodeMappings.test_mappings_exist`: + +```python +def test_mappings_exist(self): + from project_loader import PROJECT_NODE_CLASS_MAPPINGS, PROJECT_NODE_DISPLAY_NAME_MAPPINGS + assert "ProjectLoaderDynamic" in PROJECT_NODE_CLASS_MAPPINGS + assert "ProjectSource" in PROJECT_NODE_CLASS_MAPPINGS + assert "ProjectKey" in PROJECT_NODE_CLASS_MAPPINGS + assert "ProjectResolution" in PROJECT_NODE_CLASS_MAPPINGS + assert "BinaryIndexDecoder" in PROJECT_NODE_CLASS_MAPPINGS + assert len(PROJECT_NODE_CLASS_MAPPINGS) == 5 + assert len(PROJECT_NODE_DISPLAY_NAME_MAPPINGS) == 5 +``` + +**Step 2: Run tests to verify they fail** + +```bash +python -m pytest tests/test_project_loader.py::TestBinaryIndexDecoder -v +``` + +Expected: FAIL with `ImportError: cannot import name 'BinaryIndexDecoder'` + +**Step 3: Commit the failing tests** + +```bash +git add tests/test_project_loader.py +git commit -m "test: add failing tests for BinaryIndexDecoder node" +``` + +--- + +### Task 2: Implement BinaryIndexDecoder + +**Files:** +- Modify: `project_loader.py` — add class after `ProjectResolution`, update mappings + +**Step 1: Add the class** + +Insert after the `ProjectResolution` class (before `# --- Mappings ---`) in `project_loader.py`: + +```python +class BinaryIndexDecoder: + """Decodes an integer index into 3 boolean flags using binary (bit-field) encoding. + + index 0 → (False, False, False) + index 1 → (True, False, False) # bit 0 + index 2 → (False, True, False) # bit 1 + index 3 → (True, True, False) # bits 0+1 + index 4 → (False, False, True) # bit 2 + ... + index 7 → (True, True, True) + """ + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "index": ("INT", {"default": 0, "min": 0, "max": 7}), + } + } + + RETURN_TYPES = ("BOOLEAN", "BOOLEAN", "BOOLEAN") + RETURN_NAMES = ("flag_0", "flag_1", "flag_2") + FUNCTION = "decode" + CATEGORY = "JSON Manager/utils" + OUTPUT_NODE = False + + def decode(self, index: int): + return ( + bool((index >> 0) & 1), + bool((index >> 1) & 1), + bool((index >> 2) & 1), + ) +``` + +**Step 2: Update mappings** + +In `PROJECT_NODE_CLASS_MAPPINGS`, add: +```python +"BinaryIndexDecoder": BinaryIndexDecoder, +``` + +In `PROJECT_NODE_DISPLAY_NAME_MAPPINGS`, add: +```python +"BinaryIndexDecoder": "Binary Index Decoder", +``` + +**Step 3: Run all tests** + +```bash +python -m pytest tests/test_project_loader.py -v +``` + +Expected: all tests PASS (42 existing + 10 new = 52 total) + +**Step 4: Commit** + +```bash +git add project_loader.py tests/test_project_loader.py +git commit -m "feat: add BinaryIndexDecoder node (INT index → 3 BOOLEANs, binary encoding)" +git push +``` From b75b177591a2141256e2328551a84c1f5c053dee Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 4 Apr 2026 00:02:43 +0200 Subject: [PATCH 15/45] test: add failing tests for BinaryIndexDecoder node Co-Authored-By: Claude Sonnet 4.6 --- tests/test_project_loader.py | 46 ++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/tests/test_project_loader.py b/tests/test_project_loader.py index 9404d1c..cd9ba09 100644 --- a/tests/test_project_loader.py +++ b/tests/test_project_loader.py @@ -447,6 +447,47 @@ class TestProjectResolution: assert ProjectResolution.CATEGORY == "JSON Manager/project" +class TestBinaryIndexDecoder: + def test_input_types(self): + from project_loader import BinaryIndexDecoder + inputs = BinaryIndexDecoder.INPUT_TYPES() + assert "index" in inputs["required"] + assert inputs["required"]["index"][0] == "INT" + + def test_three_boolean_outputs(self): + from project_loader import BinaryIndexDecoder + assert BinaryIndexDecoder.RETURN_TYPES == ("BOOLEAN", "BOOLEAN", "BOOLEAN") + assert BinaryIndexDecoder.RETURN_NAMES == ("flag_0", "flag_1", "flag_2") + + def test_category(self): + from project_loader import BinaryIndexDecoder + assert BinaryIndexDecoder.CATEGORY == "JSON Manager/utils" + + def test_index_0(self): + from project_loader import BinaryIndexDecoder + assert BinaryIndexDecoder().decode(0) == (False, False, False) + + def test_index_1(self): + from project_loader import BinaryIndexDecoder + assert BinaryIndexDecoder().decode(1) == (True, False, False) + + def test_index_2(self): + from project_loader import BinaryIndexDecoder + assert BinaryIndexDecoder().decode(2) == (False, True, False) + + def test_index_3(self): + from project_loader import BinaryIndexDecoder + assert BinaryIndexDecoder().decode(3) == (True, True, False) + + def test_index_4(self): + from project_loader import BinaryIndexDecoder + assert BinaryIndexDecoder().decode(4) == (False, False, True) + + def test_index_7(self): + from project_loader import BinaryIndexDecoder + assert BinaryIndexDecoder().decode(7) == (True, True, True) + + class TestNodeMappings: def test_mappings_exist(self): from project_loader import PROJECT_NODE_CLASS_MAPPINGS, PROJECT_NODE_DISPLAY_NAME_MAPPINGS @@ -454,5 +495,6 @@ class TestNodeMappings: assert "ProjectSource" in PROJECT_NODE_CLASS_MAPPINGS assert "ProjectKey" in PROJECT_NODE_CLASS_MAPPINGS assert "ProjectResolution" in PROJECT_NODE_CLASS_MAPPINGS - assert len(PROJECT_NODE_CLASS_MAPPINGS) == 4 - assert len(PROJECT_NODE_DISPLAY_NAME_MAPPINGS) == 4 + assert "BinaryIndexDecoder" in PROJECT_NODE_CLASS_MAPPINGS + assert len(PROJECT_NODE_CLASS_MAPPINGS) == 5 + assert len(PROJECT_NODE_DISPLAY_NAME_MAPPINGS) == 5 From 820cb426aaaed4768a115b720faf0d46bfb5734f Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 4 Apr 2026 00:04:15 +0200 Subject: [PATCH 16/45] test: add missing index 5 and 6 cases for BinaryIndexDecoder Co-Authored-By: Claude Sonnet 4.6 --- tests/test_project_loader.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_project_loader.py b/tests/test_project_loader.py index cd9ba09..956325d 100644 --- a/tests/test_project_loader.py +++ b/tests/test_project_loader.py @@ -483,6 +483,14 @@ class TestBinaryIndexDecoder: from project_loader import BinaryIndexDecoder assert BinaryIndexDecoder().decode(4) == (False, False, True) + def test_index_5(self): + from project_loader import BinaryIndexDecoder + assert BinaryIndexDecoder().decode(5) == (True, False, True) + + def test_index_6(self): + from project_loader import BinaryIndexDecoder + assert BinaryIndexDecoder().decode(6) == (False, True, True) + def test_index_7(self): from project_loader import BinaryIndexDecoder assert BinaryIndexDecoder().decode(7) == (True, True, True) From 4fe9a9c958bc1a27e0972bc077acd4474010645b Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 4 Apr 2026 00:05:15 +0200 Subject: [PATCH 17/45] =?UTF-8?q?feat:=20add=20BinaryIndexDecoder=20node?= =?UTF-8?q?=20(INT=20index=20=E2=86=92=203=20BOOLEANs,=20binary=20encoding?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- project_loader.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/project_loader.py b/project_loader.py index 11bfb78..2dc2a40 100644 --- a/project_loader.py +++ b/project_loader.py @@ -349,12 +349,47 @@ class ProjectResolution: return (to_int(entry[0]), to_int(entry[1]), seed) +class BinaryIndexDecoder: + """Decodes an integer index into 3 boolean flags using binary (bit-field) encoding. + + index 0 → (False, False, False) + index 1 → (True, False, False) # bit 0 + index 2 → (False, True, False) # bit 1 + index 3 → (True, True, False) # bits 0+1 + index 4 → (False, False, True) # bit 2 + ... + index 7 → (True, True, True) + """ + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "index": ("INT", {"default": 0, "min": 0, "max": 7}), + } + } + + RETURN_TYPES = ("BOOLEAN", "BOOLEAN", "BOOLEAN") + RETURN_NAMES = ("flag_0", "flag_1", "flag_2") + FUNCTION = "decode" + CATEGORY = "JSON Manager/utils" + OUTPUT_NODE = False + + def decode(self, index: int): + return ( + bool((index >> 0) & 1), + bool((index >> 1) & 1), + bool((index >> 2) & 1), + ) + + # --- Mappings --- PROJECT_NODE_CLASS_MAPPINGS = { "ProjectLoaderDynamic": ProjectLoaderDynamic, "ProjectSource": ProjectSource, "ProjectKey": ProjectKey, "ProjectResolution": ProjectResolution, + "BinaryIndexDecoder": BinaryIndexDecoder, } PROJECT_NODE_DISPLAY_NAME_MAPPINGS = { @@ -362,4 +397,5 @@ PROJECT_NODE_DISPLAY_NAME_MAPPINGS = { "ProjectSource": "Project Source", "ProjectKey": "Project Key", "ProjectResolution": "Project Resolution", + "BinaryIndexDecoder": "Binary Index Decoder", } From d33ce4da3899b056fc1921a96197d593d0496ece Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 4 Apr 2026 00:09:08 +0200 Subject: [PATCH 18/45] feat: rename 'reference path' to 'middle frame path' Updates DEFAULTS, standard_keys, UI label, and timeline known_keys. Adds _migrate_key_renames() called on load_json to auto-migrate existing JSON files with the old key name. Co-Authored-By: Claude Sonnet 4.6 --- tab_batch_ng.py | 4 ++-- tab_timeline_ng.py | 2 +- utils.py | 12 +++++++++++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index a99c872..6fefb8d 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -316,7 +316,7 @@ def render_batch_processor(state: AppState): 'seed', 'cfg', 'camera', 'flf', KEY_SEQUENCE_NUMBER, 'frame_to_skip', 'end_frame', 'transition', 'vace_length', 'input_a_frames', 'input_b_frames', 'reference switch', 'vace schedule', - 'reference path', 'video file path', 'reference image path', 'flf image path', + 'middle frame path', 'video file path', 'reference image path', 'flf image path', } standard_keys.update(lora_keys) @@ -632,7 +632,7 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, # Image paths with preview for img_label, img_key in [ ('Reference Image Path', 'reference image path'), - ('Reference Path', 'reference path'), + ('Middle Frame Path', 'middle frame path'), ('FLF Image Path', 'flf image path'), ]: with ui.row().classes('w-full items-center'): diff --git a/tab_timeline_ng.py b/tab_timeline_ng.py index 408e427..4ae0001 100644 --- a/tab_timeline_ng.py +++ b/tab_timeline_ng.py @@ -618,7 +618,7 @@ def _render_preview_fields(item_data: dict): known_keys = { 'sequence_number', 'general_prompt', 'general_negative', 'current_prompt', 'prompt', 'negative', 'camera', 'flf', 'seed', 'resolutions', - 'frame_to_skip', 'vace schedule', 'video file path', + 'frame_to_skip', 'vace schedule', 'video file path', 'middle frame path', } # also skip lora keys custom_keys = [ diff --git a/utils.py b/utils.py index ab5ed85..11165b8 100644 --- a/utils.py +++ b/utils.py @@ -46,7 +46,7 @@ DEFAULTS = { "reference switch": 1, "video file path": "", "reference image path": "", - "reference path": "", + "middle frame path": "", "flf image path": "", # --- LoRAs (name as STRING, strength as FLOAT) --- @@ -150,6 +150,15 @@ def save_snippets(snippets): json.dump(snippets, f, indent=4) os.replace(tmp, SNIPPETS_FILE) +def _migrate_key_renames(data: dict) -> None: + """Rename legacy keys to their current names.""" + for item in data.get(KEY_BATCH_DATA, []): + if not isinstance(item, dict): + continue + if 'reference path' in item and 'middle frame path' not in item: + item['middle frame path'] = item.pop('reference path') + + def _migrate_lora_keys(data: dict) -> None: """Split combined lora 'name:strength' into separate name and strength keys. @@ -208,6 +217,7 @@ def load_json(path: str | Path) -> tuple[dict[str, Any], float]: with open(path, 'r') as f: data = json.load(f) t1 = time.time() + _migrate_key_renames(data) _migrate_lora_keys(data) t2 = time.time() mtime = path.stat().st_mtime From a7a4794adbb2f662fabc5276b32545e324d46be8 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 4 Apr 2026 00:10:09 +0200 Subject: [PATCH 19/45] feat: rename 'flf image path' to 'end frame path' Updates DEFAULTS, standard_keys, UI label, timeline known_keys. Migration auto-renames old key on load. Co-Authored-By: Claude Sonnet 4.6 --- tab_batch_ng.py | 4 ++-- tab_timeline_ng.py | 2 +- utils.py | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index 6fefb8d..a3a12f4 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -316,7 +316,7 @@ def render_batch_processor(state: AppState): 'seed', 'cfg', 'camera', 'flf', KEY_SEQUENCE_NUMBER, 'frame_to_skip', 'end_frame', 'transition', 'vace_length', 'input_a_frames', 'input_b_frames', 'reference switch', 'vace schedule', - 'middle frame path', 'video file path', 'reference image path', 'flf image path', + 'middle frame path', 'video file path', 'reference image path', 'end frame path', } standard_keys.update(lora_keys) @@ -633,7 +633,7 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, for img_label, img_key in [ ('Reference Image Path', 'reference image path'), ('Middle Frame Path', 'middle frame path'), - ('FLF Image Path', 'flf image path'), + ('End Frame Path', 'end frame path'), ]: with ui.row().classes('w-full items-center'): inp = dict_input(ui.input, img_label, seq, img_key).classes( diff --git a/tab_timeline_ng.py b/tab_timeline_ng.py index 4ae0001..de6bb3e 100644 --- a/tab_timeline_ng.py +++ b/tab_timeline_ng.py @@ -618,7 +618,7 @@ def _render_preview_fields(item_data: dict): known_keys = { 'sequence_number', 'general_prompt', 'general_negative', 'current_prompt', 'prompt', 'negative', 'camera', 'flf', 'seed', 'resolutions', - 'frame_to_skip', 'vace schedule', 'video file path', 'middle frame path', + 'frame_to_skip', 'vace schedule', 'video file path', 'middle frame path', 'end frame path', } # also skip lora keys custom_keys = [ diff --git a/utils.py b/utils.py index 11165b8..dea6143 100644 --- a/utils.py +++ b/utils.py @@ -47,7 +47,7 @@ DEFAULTS = { "video file path": "", "reference image path": "", "middle frame path": "", - "flf image path": "", + "end frame path": "", # --- LoRAs (name as STRING, strength as FLOAT) --- "lora 1 high": "", @@ -157,6 +157,8 @@ def _migrate_key_renames(data: dict) -> None: continue if 'reference path' in item and 'middle frame path' not in item: item['middle frame path'] = item.pop('reference path') + if 'flf image path' in item and 'end frame path' not in item: + item['end frame path'] = item.pop('flf image path') def _migrate_lora_keys(data: dict) -> None: From 5bc2838b2108e92031d19b2b1838c2b529305eb8 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 4 Apr 2026 00:11:43 +0200 Subject: [PATCH 20/45] feat: rename 'reference image path' to 'start frame path' Updates DEFAULTS, standard_keys, UI label, timeline known_keys. Migration auto-renames old key on load. Co-Authored-By: Claude Sonnet 4.6 --- tab_batch_ng.py | 4 ++-- tab_timeline_ng.py | 2 +- utils.py | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index a3a12f4..44fb090 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -316,7 +316,7 @@ def render_batch_processor(state: AppState): 'seed', 'cfg', 'camera', 'flf', KEY_SEQUENCE_NUMBER, 'frame_to_skip', 'end_frame', 'transition', 'vace_length', 'input_a_frames', 'input_b_frames', 'reference switch', 'vace schedule', - 'middle frame path', 'video file path', 'reference image path', 'end frame path', + 'middle frame path', 'video file path', 'start frame path', 'end frame path', } standard_keys.update(lora_keys) @@ -631,7 +631,7 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, # Image paths with preview for img_label, img_key in [ - ('Reference Image Path', 'reference image path'), + ('Start Frame Path', 'start frame path'), ('Middle Frame Path', 'middle frame path'), ('End Frame Path', 'end frame path'), ]: diff --git a/tab_timeline_ng.py b/tab_timeline_ng.py index de6bb3e..548376c 100644 --- a/tab_timeline_ng.py +++ b/tab_timeline_ng.py @@ -618,7 +618,7 @@ def _render_preview_fields(item_data: dict): known_keys = { 'sequence_number', 'general_prompt', 'general_negative', 'current_prompt', 'prompt', 'negative', 'camera', 'flf', 'seed', 'resolutions', - 'frame_to_skip', 'vace schedule', 'video file path', 'middle frame path', 'end frame path', + 'frame_to_skip', 'vace schedule', 'video file path', 'middle frame path', 'end frame path', 'start frame path', } # also skip lora keys custom_keys = [ diff --git a/utils.py b/utils.py index dea6143..555d680 100644 --- a/utils.py +++ b/utils.py @@ -45,7 +45,7 @@ DEFAULTS = { "input_b_frames": 16, "reference switch": 1, "video file path": "", - "reference image path": "", + "start frame path": "", "middle frame path": "", "end frame path": "", @@ -159,6 +159,8 @@ def _migrate_key_renames(data: dict) -> None: item['middle frame path'] = item.pop('reference path') if 'flf image path' in item and 'end frame path' not in item: item['end frame path'] = item.pop('flf image path') + if 'reference image path' in item and 'start frame path' not in item: + item['start frame path'] = item.pop('reference image path') def _migrate_lora_keys(data: dict) -> None: From a5da8b26f4eb49b477500f08415fbac1e437ba97 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 4 Apr 2026 00:14:25 +0200 Subject: [PATCH 21/45] feat: add 'logic index' field mirroring end_frame Temporary field to ease node migration. Initializes to end_frame's value and stays in sync whenever end_frame changes. Co-Authored-By: Claude Sonnet 4.6 --- tab_batch_ng.py | 16 ++++++++++++++-- tab_timeline_ng.py | 1 + utils.py | 1 + 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index 44fb090..a3c955c 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -314,7 +314,7 @@ def render_batch_processor(state: AppState): standard_keys = { 'name', 'mode', 'general_prompt', 'general_negative', 'current_prompt', 'negative', 'prompt', 'seed', 'cfg', 'camera', 'flf', KEY_SEQUENCE_NUMBER, - 'frame_to_skip', 'end_frame', 'transition', 'vace_length', + 'frame_to_skip', 'end_frame', 'logic index', 'transition', 'vace_length', 'input_a_frames', 'input_b_frames', 'reference switch', 'vace schedule', 'middle frame path', 'video file path', 'start frame path', 'end frame path', } @@ -625,7 +625,19 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, dict_input(ui.input, 'Camera', seq, 'camera').props('outlined').classes('w-full') dict_input(ui.input, 'FLF', seq, 'flf').props('outlined').classes('w-full') - dict_number('End Frame', seq, 'end_frame').props('outlined').classes('w-full') + ef_input = dict_number('End Frame', seq, 'end_frame').props('outlined').classes('w-full') + # Initialize logic index to end_frame if not yet set + if 'logic index' not in seq: + seq['logic index'] = seq.get('end_frame', 0) + li_input = dict_number('Logic Index', seq, 'logic index').props('outlined').classes('w-full') + + def _mirror_to_logic_index(ef=ef_input, li=li_input, s=seq): + v = s.get('end_frame', 0) + s['logic index'] = v + li.set_value(v) + + ef_input.on('blur', lambda _, m=_mirror_to_logic_index: m()) + ef_input.on('update:model-value', lambda _, m=_mirror_to_logic_index: m()) dict_input(ui.input, 'Video File Path', seq, 'video file path').props( 'outlined input-style="direction: rtl"').classes('w-full') diff --git a/tab_timeline_ng.py b/tab_timeline_ng.py index 548376c..9c48d85 100644 --- a/tab_timeline_ng.py +++ b/tab_timeline_ng.py @@ -619,6 +619,7 @@ def _render_preview_fields(item_data: dict): 'sequence_number', 'general_prompt', 'general_negative', 'current_prompt', 'prompt', 'negative', 'camera', 'flf', 'seed', 'resolutions', 'frame_to_skip', 'vace schedule', 'video file path', 'middle frame path', 'end frame path', 'start frame path', + 'logic index', } # also skip lora keys custom_keys = [ diff --git a/utils.py b/utils.py index 555d680..dc92a7f 100644 --- a/utils.py +++ b/utils.py @@ -38,6 +38,7 @@ DEFAULTS = { # --- I2V / VACE Specifics --- "frame_to_skip": 81, "end_frame": 0, + "logic index": 0, "transition": "1-2", "vace_length": 49, "vace schedule": 1, From 932295ed27be53b2758504c2834fe0d651409259 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 4 Apr 2026 00:21:55 +0200 Subject: [PATCH 22/45] fix: replace direction:rtl with text-align:right on path inputs direction:rtl caused path characters to render in wrong order. text-align:right right-aligns the text (shows end of path) without breaking the character display order. Co-Authored-By: Claude Sonnet 4.6 --- tab_batch_ng.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index a3c955c..6b41879 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -639,7 +639,7 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, ef_input.on('blur', lambda _, m=_mirror_to_logic_index: m()) ef_input.on('update:model-value', lambda _, m=_mirror_to_logic_index: m()) dict_input(ui.input, 'Video File Path', seq, 'video file path').props( - 'outlined input-style="direction: rtl"').classes('w-full') + 'outlined input-style="text-align: right"').classes('w-full') # Image paths with preview for img_label, img_key in [ @@ -649,7 +649,7 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, ]: with ui.row().classes('w-full items-center'): inp = dict_input(ui.input, img_label, seq, img_key).classes( - 'col').props('outlined input-style="direction: rtl"') + 'col').props('outlined input-style="text-align: right"') img_path = Path(seq.get(img_key, '')) if seq.get(img_key) else None if (img_path and img_path.exists() and img_path.suffix.lower() in IMAGE_EXTENSIONS): From 735d90583311b8f2375b353b40bf55e5bf8bbcf4 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 4 Apr 2026 00:24:59 +0200 Subject: [PATCH 23/45] fix: move default DB path to project directory projects.db now lives next to main.py instead of ~/.comfyui_json_manager/ so it survives Docker container updates when the project dir is mounted. Co-Authored-By: Claude Sonnet 4.6 --- db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db.py b/db.py index 81e9461..377c367 100644 --- a/db.py +++ b/db.py @@ -9,7 +9,7 @@ from utils import load_json, KEY_BATCH_DATA, KEY_HISTORY_TREE logger = logging.getLogger(__name__) -DEFAULT_DB_PATH = Path.home() / ".comfyui_json_manager" / "projects.db" +DEFAULT_DB_PATH = Path(__file__).parent / "projects.db" SCHEMA_SQL = """ CREATE TABLE IF NOT EXISTS projects ( From 9ffdf6287d4ab3261cec58f7b9535fe65d448594 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 4 Apr 2026 00:26:17 +0200 Subject: [PATCH 24/45] fix: import folder scans project's folder_path not current_dir Was scanning state.current_dir which could differ from the project's actual folder, causing no JSON files to be found. Co-Authored-By: Claude Sonnet 4.6 --- tab_projects_ng.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tab_projects_ng.py b/tab_projects_ng.py index ddc1570..eca356f 100644 --- a/tab_projects_ng.py +++ b/tab_projects_ng.py @@ -216,8 +216,10 @@ def render_projects_tab(state: AppState): async def _import_folder(state: AppState, project_id: int, project_name: str, refresh_fn): - """Bulk import all .json files from current directory into a project.""" - json_files = sorted(state.current_dir.glob('*.json')) + """Bulk import all .json files from the project's folder_path into a project.""" + proj = state.db.get_project(project_name) + scan_dir = Path(proj['folder_path']) if proj else state.current_dir + json_files = sorted(scan_dir.glob('*.json')) json_files = [f for f in json_files if f.name not in ( '.editor_config.json', '.editor_snippets.json')] From 03dcb1c13a361e812f4aadb0f2b7b98608ba7be8 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 4 Apr 2026 00:41:03 +0200 Subject: [PATCH 25/45] feat: add tooltip to Logic Index explaining binary flag mapping Bit 0 = start frame, bit 1 = middle frame, bit 2 = end frame. Tooltip shows the full 0-7 truth table on hover. Co-Authored-By: Claude Sonnet 4.6 --- tab_batch_ng.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index 6b41879..f30aab0 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -630,6 +630,12 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, if 'logic index' not in seq: seq['logic index'] = seq.get('end_frame', 0) li_input = dict_number('Logic Index', seq, 'logic index').props('outlined').classes('w-full') + with li_input: + ui.tooltip( + 'Binary flags — bit 0: start frame | bit 1: middle frame | bit 2: end frame\n' + '0: none 1: start 2: middle 3: start+middle\n' + '4: end 5: start+end 6: middle+end 7: all' + ) def _mirror_to_logic_index(ef=ef_input, li=li_input, s=seq): v = s.get('end_frame', 0) From 2619d2c7e26cda45e2bd4cd282c4d3f671976f59 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 4 Apr 2026 11:33:36 +0200 Subject: [PATCH 26/45] feat: move resolutions into collapsible expansion above VACE Settings Co-Authored-By: Claude Sonnet 4.6 --- tab_batch_ng.py | 84 ++++++++++++++++++++++++------------------------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index f30aab0..b644c26 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -553,48 +553,6 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, dict_textarea('Specific Negative', seq, 'negative').classes( 'w-full q-mt-sm').props('outlined rows=2') - # --- Resolutions (8 fixed slots) --- - ui.label('Resolutions').classes('text-caption text-weight-bold q-mt-md') - resolutions = seq.setdefault('resolutions', []) - while len(resolutions) < 8: - resolutions.append([512, 512, 0]) - # Migrate old [w, h] entries to [w, h, seed] (persisted on next real save) - for r_i in range(len(resolutions)): - if len(resolutions[r_i]) < 3: - resolutions[r_i] = list(resolutions[r_i]) + [0] - for idx in range(8): - entry = resolutions[idx] - with ui.row().classes('items-center w-full q-mt-xs no-wrap'): - ui.label(str(idx)).classes('text-caption').style('min-width:16px') - w_inp = ui.number(value=int(entry[0]), min=1, step=1, label='W').style( - 'width:70px').props('outlined dense hide-bottom-space') - h_inp = ui.number(value=int(entry[1]), min=1, step=1, label='H').style( - 'width:70px').props('outlined dense hide-bottom-space') - seed_inp = ui.number(value=int(entry[2]), min=0, step=1, label='Seed').style( - 'flex:1; min-width:60px').props('outlined dense hide-bottom-space') - - async def _sync_entry(r=idx, wi=w_inp, hi=h_inp, si=seed_inp): - seq['resolutions'][r] = [ - int(wi.value) if wi.value else 512, - int(hi.value) if hi.value else 512, - int(si.value) if si.value else 0, - ] - await commit() - - async def _randomize(si=seed_inp, r=idx): - si.value = random.randint(0, 2**32 - 1) - seq['resolutions'][r][2] = int(si.value) - await commit() - - ui.button(icon='casino', on_click=_randomize).props( - 'flat dense round').classes('q-ml-xs') - - w_inp.on('blur', lambda _, s=_sync_entry: s()) - w_inp.on('update:model-value', lambda _, s=_sync_entry: s()) - h_inp.on('blur', lambda _, s=_sync_entry: s()) - h_inp.on('update:model-value', lambda _, s=_sync_entry: s()) - seed_inp.on('blur', lambda _, s=_sync_entry: s()) - seed_inp.on('update:model-value', lambda _, s=_sync_entry: s()) with splitter.after: # Mode @@ -663,6 +621,48 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, ui.image(str(img_path)).classes('w-full') ui.button(icon='visibility', on_click=dlg.open).props('flat dense') + # --- Resolutions (8 fixed slots) --- + resolutions = seq.setdefault('resolutions', []) + while len(resolutions) < 8: + resolutions.append([512, 512, 0]) + for r_i in range(len(resolutions)): + if len(resolutions[r_i]) < 3: + resolutions[r_i] = list(resolutions[r_i]) + [0] + with ui.expansion('Resolutions', icon='aspect_ratio').classes('w-full'): + for idx in range(8): + entry = resolutions[idx] + with ui.row().classes('items-center w-full q-mt-xs no-wrap'): + ui.label(str(idx)).classes('text-caption').style('min-width:16px') + w_inp = ui.number(value=int(entry[0]), min=1, step=1, label='W').style( + 'width:70px').props('outlined dense hide-bottom-space') + h_inp = ui.number(value=int(entry[1]), min=1, step=1, label='H').style( + 'width:70px').props('outlined dense hide-bottom-space') + seed_inp = ui.number(value=int(entry[2]), min=0, step=1, label='Seed').style( + 'flex:1; min-width:60px').props('outlined dense hide-bottom-space') + + async def _sync_entry(r=idx, wi=w_inp, hi=h_inp, si=seed_inp): + seq['resolutions'][r] = [ + int(wi.value) if wi.value else 512, + int(hi.value) if hi.value else 512, + int(si.value) if si.value else 0, + ] + await commit() + + async def _randomize(si=seed_inp, r=idx): + si.value = random.randint(0, 2**32 - 1) + seq['resolutions'][r][2] = int(si.value) + await commit() + + ui.button(icon='casino', on_click=_randomize).props( + 'flat dense round').classes('q-ml-xs') + + w_inp.on('blur', lambda _, s=_sync_entry: s()) + w_inp.on('update:model-value', lambda _, s=_sync_entry: s()) + h_inp.on('blur', lambda _, s=_sync_entry: s()) + h_inp.on('update:model-value', lambda _, s=_sync_entry: s()) + seed_inp.on('blur', lambda _, s=_sync_entry: s()) + seed_inp.on('update:model-value', lambda _, s=_sync_entry: s()) + # --- VACE Settings (full width) --- with ui.expansion('VACE Settings', icon='settings').classes('w-full'): _render_vace_settings(i, seq, batch_list, data, file_path, state, refresh_list) From fec843f80427a37784d47d160fb6c5a8a35d72e4 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 4 Apr 2026 11:38:30 +0200 Subject: [PATCH 27/45] feat: move frame paths to left column with strength + logic index switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each frame path row (start/middle/end) now has: - path input with preview - strength float (default 1.0) - switch linked to the corresponding logic index bit Switches and logic index are bidirectionally synced. end_frame → logic index → switches mirror chain preserved. Co-Authored-By: Claude Sonnet 4.6 --- tab_batch_ng.py | 68 ++++++++++++++++++++++++++++++++----------------- utils.py | 3 +++ 2 files changed, 47 insertions(+), 24 deletions(-) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index b644c26..97c3dbd 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -316,7 +316,10 @@ def render_batch_processor(state: AppState): 'seed', 'cfg', 'camera', 'flf', KEY_SEQUENCE_NUMBER, 'frame_to_skip', 'end_frame', 'logic index', 'transition', 'vace_length', 'input_a_frames', 'input_b_frames', 'reference switch', 'vace schedule', - 'middle frame path', 'video file path', 'start frame path', 'end frame path', + 'start frame path', 'start frame strength', + 'middle frame path', 'middle frame strength', + 'end frame path', 'end frame strength', + 'video file path', } standard_keys.update(lora_keys) @@ -542,6 +545,7 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, ui.separator() # --- Prompts + Settings (2-column) --- + frame_switches = [] # populated below, used for bidirectional sync with logic index with ui.splitter(value=66).classes('w-full') as splitter: with splitter.before: dict_textarea('General Prompt', seq, 'general_prompt').classes( @@ -553,6 +557,28 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, dict_textarea('Specific Negative', seq, 'negative').classes( 'w-full q-mt-sm').props('outlined rows=2') + # --- Frame paths (start / middle / end) --- + logic_val = int(seq.get('logic index', 0)) + for bit, img_label, img_key, str_key in [ + (0, 'Start Frame', 'start frame path', 'start frame strength'), + (1, 'Middle Frame', 'middle frame path', 'middle frame strength'), + (2, 'End Frame', 'end frame path', 'end frame strength'), + ]: + ui.label(img_label).classes('text-caption text-weight-bold q-mt-sm') + with ui.row().classes('w-full items-center no-wrap q-mt-xs'): + inp = dict_input(ui.input, 'Path', seq, img_key).classes( + 'col').props('outlined dense input-style="text-align: right"') + img_path = Path(seq.get(img_key, '')) if seq.get(img_key) else None + if (img_path and img_path.exists() and + img_path.suffix.lower() in IMAGE_EXTENSIONS): + with ui.dialog() as dlg, ui.card(): + ui.image(str(img_path)).classes('w-full') + ui.button(icon='visibility', on_click=dlg.open).props('flat dense') + str_inp = dict_number('Strength', seq, str_key, default=1.0, + step=0.05, format='%.2f').style( + 'width:80px').props('outlined dense') + sw = ui.switch(value=bool((logic_val >> bit) & 1)) + frame_switches.append(sw) with splitter.after: # Mode @@ -594,32 +620,26 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, '0: none 1: start 2: middle 3: start+middle\n' '4: end 5: start+end 6: middle+end 7: all' ) - - def _mirror_to_logic_index(ef=ef_input, li=li_input, s=seq): - v = s.get('end_frame', 0) - s['logic index'] = v - li.set_value(v) - - ef_input.on('blur', lambda _, m=_mirror_to_logic_index: m()) - ef_input.on('update:model-value', lambda _, m=_mirror_to_logic_index: m()) dict_input(ui.input, 'Video File Path', seq, 'video file path').props( 'outlined input-style="text-align: right"').classes('w-full') - # Image paths with preview - for img_label, img_key in [ - ('Start Frame Path', 'start frame path'), - ('Middle Frame Path', 'middle frame path'), - ('End Frame Path', 'end frame path'), - ]: - with ui.row().classes('w-full items-center'): - inp = dict_input(ui.input, img_label, seq, img_key).classes( - 'col').props('outlined input-style="text-align: right"') - img_path = Path(seq.get(img_key, '')) if seq.get(img_key) else None - if (img_path and img_path.exists() and - img_path.suffix.lower() in IMAGE_EXTENSIONS): - with ui.dialog() as dlg, ui.card(): - ui.image(str(img_path)).classes('w-full') - ui.button(icon='visibility', on_click=dlg.open).props('flat dense') + # Bidirectional sync: end_frame → logic index → switches, and switches → logic index + def _mirror_end_to_logic(li=li_input, switches=frame_switches, s=seq): + v = int(s.get('end_frame', 0)) + s['logic index'] = v + li.set_value(v) + for b, sw in enumerate(switches): + sw.set_value(bool((v >> b) & 1)) + + def _sync_switches_to_logic(li=li_input, switches=frame_switches, s=seq): + v = sum(int(sw.value) << b for b, sw in enumerate(switches)) + s['logic index'] = v + li.set_value(v) + + ef_input.on('blur', lambda _, m=_mirror_end_to_logic: m()) + ef_input.on('update:model-value', lambda _, m=_mirror_end_to_logic: m()) + for frame_sw in frame_switches: + frame_sw.on('update:model-value', lambda _, s=_sync_switches_to_logic: s()) # --- Resolutions (8 fixed slots) --- resolutions = seq.setdefault('resolutions', []) diff --git a/utils.py b/utils.py index dc92a7f..b9f558c 100644 --- a/utils.py +++ b/utils.py @@ -47,8 +47,11 @@ DEFAULTS = { "reference switch": 1, "video file path": "", "start frame path": "", + "start frame strength": 1.0, "middle frame path": "", + "middle frame strength": 1.0, "end frame path": "", + "end frame strength": 1.0, # --- LoRAs (name as STRING, strength as FLOAT) --- "lora 1 high": "", From f376fd562269c405337f30ab0c98e81238247eb9 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 4 Apr 2026 11:41:00 +0200 Subject: [PATCH 28/45] feat: show frame image preview on hover via thumbnail tooltip Replaces click-to-dialog with a small thumbnail that reveals the full image on hover. Co-Authored-By: Claude Sonnet 4.6 --- tab_batch_ng.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index 97c3dbd..75a9f5e 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -571,9 +571,14 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, img_path = Path(seq.get(img_key, '')) if seq.get(img_key) else None if (img_path and img_path.exists() and img_path.suffix.lower() in IMAGE_EXTENSIONS): - with ui.dialog() as dlg, ui.card(): - ui.image(str(img_path)).classes('w-full') - ui.button(icon='visibility', on_click=dlg.open).props('flat dense') + thumb = ui.image(str(img_path)).style( + 'width:36px; height:36px; object-fit:cover;' + ' border-radius:4px; cursor:default; flex-shrink:0') + with thumb: + with ui.tooltip().props('max-width=none').style( + 'background:transparent; padding:4px; box-shadow:none'): + ui.image(str(img_path)).style( + 'max-width:400px; max-height:400px; border-radius:6px') str_inp = dict_number('Strength', seq, str_key, default=1.0, step=0.05, format='%.2f').style( 'width:80px').props('outlined dense') From c7ca3ae277f5caff3c06eb16b97d7488e0c6b2af Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 4 Apr 2026 11:48:14 +0200 Subject: [PATCH 29/45] fix: replace broken hover tooltip with click-to-open dialog for image preview Quasar tooltip size constraints prevent large images from rendering. Thumbnail is now clickable (cursor:pointer) and opens a full-size dialog. Co-Authored-By: Claude Sonnet 4.6 --- tab_batch_ng.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index 75a9f5e..3b36180 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -571,14 +571,12 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, img_path = Path(seq.get(img_key, '')) if seq.get(img_key) else None if (img_path and img_path.exists() and img_path.suffix.lower() in IMAGE_EXTENSIONS): - thumb = ui.image(str(img_path)).style( + with ui.dialog() as img_dlg, ui.card().style('max-width:90vw'): + ui.image(str(img_path)).style('max-width:80vw; max-height:80vh') + ui.image(str(img_path)).style( 'width:36px; height:36px; object-fit:cover;' - ' border-radius:4px; cursor:default; flex-shrink:0') - with thumb: - with ui.tooltip().props('max-width=none').style( - 'background:transparent; padding:4px; box-shadow:none'): - ui.image(str(img_path)).style( - 'max-width:400px; max-height:400px; border-radius:6px') + ' border-radius:4px; cursor:pointer; flex-shrink:0' + ).on('click', img_dlg.open) str_inp = dict_number('Strength', seq, str_key, default=1.0, step=0.05, format='%.2f').style( 'width:80px').props('outlined dense') From 783f07e57a658e8b91920b07f46a81b4ba3c7d1c Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 4 Apr 2026 11:49:13 +0200 Subject: [PATCH 30/45] feat: make logic index read-only, driven solely by frame switches Removed end_frame mirror. Logic index is now entirely computed from the 3 frame switches (bit 0=start, bit 1=middle, bit 2=end). Co-Authored-By: Claude Sonnet 4.6 --- tab_batch_ng.py | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index 3b36180..b4a0398 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -613,10 +613,8 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, dict_input(ui.input, 'Camera', seq, 'camera').props('outlined').classes('w-full') dict_input(ui.input, 'FLF', seq, 'flf').props('outlined').classes('w-full') ef_input = dict_number('End Frame', seq, 'end_frame').props('outlined').classes('w-full') - # Initialize logic index to end_frame if not yet set - if 'logic index' not in seq: - seq['logic index'] = seq.get('end_frame', 0) - li_input = dict_number('Logic Index', seq, 'logic index').props('outlined').classes('w-full') + seq.setdefault('logic index', 0) + li_input = dict_number('Logic Index', seq, 'logic index').props('outlined readonly').classes('w-full') with li_input: ui.tooltip( 'Binary flags — bit 0: start frame | bit 1: middle frame | bit 2: end frame\n' @@ -626,21 +624,12 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, dict_input(ui.input, 'Video File Path', seq, 'video file path').props( 'outlined input-style="text-align: right"').classes('w-full') - # Bidirectional sync: end_frame → logic index → switches, and switches → logic index - def _mirror_end_to_logic(li=li_input, switches=frame_switches, s=seq): - v = int(s.get('end_frame', 0)) - s['logic index'] = v - li.set_value(v) - for b, sw in enumerate(switches): - sw.set_value(bool((v >> b) & 1)) - + # Switches → logic index (sole writer) def _sync_switches_to_logic(li=li_input, switches=frame_switches, s=seq): v = sum(int(sw.value) << b for b, sw in enumerate(switches)) s['logic index'] = v li.set_value(v) - ef_input.on('blur', lambda _, m=_mirror_end_to_logic: m()) - ef_input.on('update:model-value', lambda _, m=_mirror_end_to_logic: m()) for frame_sw in frame_switches: frame_sw.on('update:model-value', lambda _, s=_sync_switches_to_logic: s()) From 783da171e7462dd011c12c4ed1345084dde766cc Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 4 Apr 2026 11:54:50 +0200 Subject: [PATCH 31/45] fix: serve images via FastAPI endpoint to fix dialog preview NiceGUI's ui.image with a local file path fails to register static files when inside a ui.dialog, showing alt text instead of the image. Added /api/image-preview?path=... endpoint that streams the file via FileResponse, and updated frame path thumbnails to use this URL. Co-Authored-By: Claude Sonnet 4.6 --- api_routes.py | 9 +++++++++ tab_batch_ng.py | 6 ++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/api_routes.py b/api_routes.py index 40815ab..69d984d 100644 --- a/api_routes.py +++ b/api_routes.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import Any from fastapi import HTTPException, Query +from fastapi.responses import FileResponse from nicegui import app from db import ProjectDB @@ -30,6 +31,7 @@ def register_api_routes(db: ProjectDB) -> None: app.add_api_route("/api/projects/{name}/files/{file_name}/sequences", _list_sequences, methods=["GET"]) app.add_api_route("/api/projects/{name}/files/{file_name}/data", _get_data, methods=["GET"]) app.add_api_route("/api/projects/{name}/files/{file_name}/keys", _get_keys, methods=["GET"]) + app.add_api_route("/api/image-preview", _serve_image, methods=["GET"]) def _get_db() -> ProjectDB: @@ -102,3 +104,10 @@ def _get_keys(name: str, file_name: str, seq: int = Query(default=1)) -> dict[st logger.info("API _get_keys %s/%s seq=%d (%d keys): %.3fs", name, file_name, seq, len(keys), time.perf_counter() - t0) return {"keys": keys, "types": types, "total_sequences": total} + + +def _serve_image(path: str = Query(...)) -> FileResponse: + p = Path(path) + if not p.exists() or not p.is_file(): + raise HTTPException(status_code=404, detail="Image not found") + return FileResponse(str(p)) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index b4a0398..40fc8dd 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -6,6 +6,7 @@ import math import random import time from pathlib import Path +from urllib.parse import quote from nicegui import ui @@ -571,9 +572,10 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, img_path = Path(seq.get(img_key, '')) if seq.get(img_key) else None if (img_path and img_path.exists() and img_path.suffix.lower() in IMAGE_EXTENSIONS): + img_url = f'/api/image-preview?path={quote(str(img_path))}' with ui.dialog() as img_dlg, ui.card().style('max-width:90vw'): - ui.image(str(img_path)).style('max-width:80vw; max-height:80vh') - ui.image(str(img_path)).style( + ui.image(img_url).style('max-width:80vw; max-height:80vh') + ui.image(img_url).style( 'width:36px; height:36px; object-fit:cover;' ' border-radius:4px; cursor:pointer; flex-shrink:0' ).on('click', img_dlg.open) From 3065dd7e71990c3b80e20e07598b223524e7e06d Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 4 Apr 2026 11:59:44 +0200 Subject: [PATCH 32/45] fix: use raw tags to bypass q-img dialog rendering bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NiceGUI's ui.image (Quasar q-img) fails to display inside ui.dialog regardless of URL type — shows alt text instead of image. Switched both thumbnail and dialog content to plain HTML tags which the browser renders directly without component interference. Co-Authored-By: Claude Sonnet 4.6 --- tab_batch_ng.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index 40fc8dd..69ab195 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -573,11 +573,13 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, if (img_path and img_path.exists() and img_path.suffix.lower() in IMAGE_EXTENSIONS): img_url = f'/api/image-preview?path={quote(str(img_path))}' - with ui.dialog() as img_dlg, ui.card().style('max-width:90vw'): - ui.image(img_url).style('max-width:80vw; max-height:80vh') - ui.image(img_url).style( - 'width:36px; height:36px; object-fit:cover;' - ' border-radius:4px; cursor:pointer; flex-shrink:0' + with ui.dialog() as img_dlg, ui.card().style('max-width:90vw; padding:0'): + ui.html(f'') + ui.html( + f'' ).on('click', img_dlg.open) str_inp = dict_number('Strength', seq, str_key, default=1.0, step=0.05, format='%.2f').style( From 2277e6e4276e62e81b9467483dd3fbfae0e23354 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 4 Apr 2026 12:04:32 +0200 Subject: [PATCH 33/45] feat: highlight ProjectKey nodes sharing the same key_name on select Clicking any ProjectKey node temporarily highlights all other nodes in the workflow that share the same key_name with an amber color. Deselecting restores their original colors. Co-Authored-By: Claude Sonnet 4.6 --- web/project_key.js | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/web/project_key.js b/web/project_key.js index 325daca..04eeed2 100644 --- a/web/project_key.js +++ b/web/project_key.js @@ -201,6 +201,35 @@ app.registerExtension({ app.graph?.setDirtyCanvas(true, true); }; + // --- Highlight all ProjectKey nodes sharing the same key_name on select --- + nodeType.prototype.onSelected = function () { + const keyWidget = this.widgets?.find(w => w.name === "key_name"); + const myKey = keyWidget?.value; + if (!myKey || !this.graph) return; + for (const node of this.graph._nodes) { + if (node === this || node.type !== "ProjectKey") continue; + const kw = node.widgets?.find(w => w.name === "key_name"); + if (kw?.value !== myKey) continue; + node._savedColor = node.color; + node._savedBgColor = node.bgcolor; + node.color = "#c8a000"; + node.bgcolor = "#4a3800"; + } + app.graph?.setDirtyCanvas(true, true); + }; + + nodeType.prototype.onDeselected = function () { + if (!this.graph) return; + for (const node of this.graph._nodes) { + if (node.type !== "ProjectKey" || !("_savedColor" in node)) continue; + node.color = node._savedColor; + node.bgcolor = node._savedBgColor; + delete node._savedColor; + delete node._savedBgColor; + } + app.graph?.setDirtyCanvas(true, true); + }; + // --- Sync config on click (lazy, no key refresh to avoid race) --- const origOnMouseDown = nodeType.prototype.onMouseDown; nodeType.prototype.onMouseDown = function (e, localPos, graphCanvas) { From 410c80afc83d1aa121ece9dda809c042c077d85f Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 4 Apr 2026 12:07:37 +0200 Subject: [PATCH 34/45] feat: ProjectSource auto-fills active project from Manager Added /api/active-project endpoint that reads current_project from config. ProjectSource now hides the project_name widget and fetches the active project automatically on create, load, and click. Title shows "Source: