From f6d5ebfe3460ca5031b151fa0404cbe9ab2cc010 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Wed, 25 Feb 2026 10:53:47 +0100 Subject: [PATCH 01/26] Migrate web UI from Streamlit to NiceGUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the Streamlit-based UI (app.py + tab_*.py) with an event-driven NiceGUI implementation. This eliminates 135 session_state accesses, 35 st.rerun() calls, and the ui_reset_token hack. Key changes: - Add main.py as NiceGUI entry point with sidebar, tabs, and file navigation - Add state.py with AppState dataclass replacing st.session_state - Add tab_batch_ng.py (batch processor with blur-binding, VACE calc) - Add tab_timeline_ng.py (history tree with graphviz, batch delete) - Add tab_raw_ng.py (raw JSON editor) - Add tab_comfy_ng.py (ComfyUI monitor with polling timer) - Remove Streamlit dependency from utils.py (st.error → logger.error) - Remove Streamlit mock from tests/test_utils.py Co-Authored-By: Claude Opus 4.6 --- main.py | 272 +++++++++++++++++ state.py | 17 ++ tab_batch_ng.py | 700 ++++++++++++++++++++++++++++++++++++++++++++ tab_comfy_ng.py | 241 +++++++++++++++ tab_raw_ng.py | 74 +++++ tab_timeline_ng.py | 353 ++++++++++++++++++++++ tests/test_utils.py | 7 - utils.py | 4 +- 8 files changed, 1658 insertions(+), 10 deletions(-) create mode 100644 main.py create mode 100644 state.py create mode 100644 tab_batch_ng.py create mode 100644 tab_comfy_ng.py create mode 100644 tab_raw_ng.py create mode 100644 tab_timeline_ng.py diff --git a/main.py b/main.py new file mode 100644 index 0000000..b15ef4c --- /dev/null +++ b/main.py @@ -0,0 +1,272 @@ +from pathlib import Path + +from nicegui import ui + +from state import AppState +from utils import ( + load_config, save_config, load_snippets, save_snippets, + load_json, save_json, generate_templates, DEFAULTS, + KEY_BATCH_DATA, KEY_SEQUENCE_NUMBER, + resolve_path_case_insensitive, +) +from tab_batch_ng import render_batch_processor +from tab_timeline_ng import render_timeline_tab +from tab_raw_ng import render_raw_editor +from tab_comfy_ng import render_comfy_monitor + + +@ui.page('/') +def index(): + config = load_config() + state = AppState( + config=config, + current_dir=Path(config.get('last_dir', str(Path.cwd()))), + snippets=load_snippets(), + ) + + # ------------------------------------------------------------------ + # Define helpers FIRST (before sidebar, which needs them) + # ------------------------------------------------------------------ + + @ui.refreshable + def render_main_content(): + if not state.file_path or not state.file_path.exists(): + ui.label('Select a file from the sidebar to begin.').classes( + 'text-subtitle1 q-pa-lg') + return + + ui.label(f'Editing: {state.file_path.name}').classes('text-h5 q-mb-md') + + with ui.tabs().classes('w-full') as tabs: + ui.tab('batch', label='Batch Processor') + ui.tab('timeline', label='Timeline') + ui.tab('raw', label='Raw Editor') + + with ui.tab_panels(tabs, value='batch').classes('w-full'): + with ui.tab_panel('batch'): + render_batch_processor(state) + with ui.tab_panel('timeline'): + render_timeline_tab(state) + with ui.tab_panel('raw'): + render_raw_editor(state) + + if state.show_comfy_monitor: + ui.separator() + with ui.expansion('ComfyUI Monitor', icon='dns').classes('w-full'): + render_comfy_monitor(state) + + def load_file(file_name: str): + """Load a JSON file and refresh the main content.""" + fp = state.current_dir / file_name + if state.loaded_file == str(fp): + return + data, mtime = load_json(fp) + state.data_cache = data + state.last_mtime = mtime + state.loaded_file = str(fp) + state.file_path = fp + state.restored_indicator = None + if state._main_rendered: + render_main_content.refresh() + + # Attach helpers to state so sidebar can call them + state._load_file = load_file + state._render_main = render_main_content + state._main_rendered = False + + # ------------------------------------------------------------------ + # Sidebar (rendered AFTER helpers are attached) + # ------------------------------------------------------------------ + with ui.left_drawer().classes('q-pa-md').style('width: 350px'): + render_sidebar(state) + + # ------------------------------------------------------------------ + # Main content area + # ------------------------------------------------------------------ + render_main_content() + state._main_rendered = True + + +# ====================================================================== +# Sidebar +# ====================================================================== + +def render_sidebar(state: AppState): + ui.label('Navigator').classes('text-h6') + + # --- Path input --- + path_input = ui.input( + 'Current Path', + value=str(state.current_dir), + ).classes('w-full') + + def on_path_enter(): + p = resolve_path_case_insensitive(path_input.value) + if p is not None and p.is_dir(): + state.current_dir = p + state.config['last_dir'] = str(p) + save_config(state.current_dir, state.config['favorites']) + state.loaded_file = None + state.file_path = None + path_input.set_value(str(p)) + render_file_list.refresh() + # Auto-load inside render_file_list already refreshed main content + # if files exist; only refresh here for the empty-directory case. + if not state.loaded_file: + state._render_main.refresh() + + path_input.on('keydown.enter', lambda _: on_path_enter()) + + # --- Pin / Unpin --- + def pin_folder(): + d = str(state.current_dir) + if d not in state.config['favorites']: + state.config['favorites'].append(d) + save_config(state.current_dir, state.config['favorites']) + render_favorites.refresh() + + ui.button('Pin Folder', icon='push_pin', on_click=pin_folder).classes('w-full') + + @ui.refreshable + def render_favorites(): + for fav in list(state.config['favorites']): + with ui.row().classes('w-full items-center'): + ui.button( + fav, + on_click=lambda f=fav: _jump_to(f), + ).props('flat dense').classes('col') + ui.button( + icon='close', + on_click=lambda f=fav: _unpin(f), + ).props('flat dense color=negative') + + def _jump_to(fav: str): + state.current_dir = Path(fav) + state.config['last_dir'] = fav + save_config(state.current_dir, state.config['favorites']) + state.loaded_file = None + state.file_path = None + path_input.set_value(fav) + render_file_list.refresh() + if not state.loaded_file: + state._render_main.refresh() + + def _unpin(fav: str): + if fav in state.config['favorites']: + state.config['favorites'].remove(fav) + save_config(state.current_dir, state.config['favorites']) + render_favorites.refresh() + + render_favorites() + + ui.separator() + + # --- Snippet Library --- + ui.label('Snippet Library').classes('text-subtitle1 q-mt-md') + + with ui.expansion('Add New Snippet'): + snip_name_input = ui.input('Name', placeholder='e.g. Cinematic').classes('w-full') + snip_content_input = ui.textarea('Content', placeholder='4k, high quality...').classes('w-full') + + def save_snippet(): + name = snip_name_input.value + content = snip_content_input.value + if name and content: + state.snippets[name] = content + save_snippets(state.snippets) + snip_name_input.set_value('') + snip_content_input.set_value('') + ui.notify(f"Saved '{name}'") + render_snippet_list.refresh() + + ui.button('Save Snippet', on_click=save_snippet).classes('w-full') + + @ui.refreshable + def render_snippet_list(): + if not state.snippets: + return + ui.label('Click to copy snippet text:').classes('text-caption') + for name, content in list(state.snippets.items()): + with ui.row().classes('w-full items-center'): + async def copy_snippet(c=content): + await ui.run_javascript( + f'navigator.clipboard.writeText({c!r})', timeout=3.0) + ui.notify('Copied to clipboard') + + ui.button( + f'{name}', + on_click=copy_snippet, + ).props('flat dense').classes('col') + ui.button( + icon='delete', + on_click=lambda n=name: _del_snippet(n), + ).props('flat dense color=negative') + + def _del_snippet(name: str): + if name in state.snippets: + del state.snippets[name] + save_snippets(state.snippets) + render_snippet_list.refresh() + + render_snippet_list() + + ui.separator() + + # --- File List --- + @ui.refreshable + def render_file_list(): + json_files = sorted(state.current_dir.glob('*.json')) + json_files = [f for f in json_files if f.name not in ('.editor_config.json', '.editor_snippets.json')] + + if not json_files: + ui.label('No JSON files in this folder.').classes('text-caption') + ui.button('Generate Templates', on_click=lambda: _gen_templates()).classes('w-full') + return + + with ui.expansion('Create New JSON'): + new_fn_input = ui.input('Filename', placeholder='my_prompt_vace').classes('w-full') + + def create_new(): + fn = new_fn_input.value + if not fn: + return + if not fn.endswith('.json'): + fn += '.json' + path = state.current_dir / fn + first_item = DEFAULTS.copy() + first_item[KEY_SEQUENCE_NUMBER] = 1 + save_json(path, {KEY_BATCH_DATA: [first_item]}) + new_fn_input.set_value('') + render_file_list.refresh() + + ui.button('Create', on_click=create_new).classes('w-full') + + ui.label('Select File').classes('text-subtitle2 q-mt-sm') + file_names = [f.name for f in json_files] + ui.radio( + file_names, + value=file_names[0] if file_names else None, + on_change=lambda e: state._load_file(e.value) if e.value else None, + ).classes('w-full') + + # Auto-load first file if nothing loaded yet + if file_names and not state.loaded_file: + state._load_file(file_names[0]) + + def _gen_templates(): + generate_templates(state.current_dir) + render_file_list.refresh() + + render_file_list() + + ui.separator() + + # --- Comfy Monitor toggle --- + def on_monitor_toggle(e): + state.show_comfy_monitor = e.value + state._render_main.refresh() + + ui.checkbox('Show Comfy Monitor', value=True, on_change=on_monitor_toggle) + + +ui.run(title='AI Settings Manager', port=8080, reload=True) diff --git a/state.py b/state.py new file mode 100644 index 0000000..0f3d370 --- /dev/null +++ b/state.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass, field +from pathlib import Path + + +@dataclass +class AppState: + config: dict + current_dir: Path + loaded_file: str | None = None + last_mtime: float = 0 + data_cache: dict = field(default_factory=dict) + snippets: dict = field(default_factory=dict) + file_path: Path | None = None + restored_indicator: str | None = None + timeline_selected_nodes: set = field(default_factory=set) + live_toggles: dict = field(default_factory=dict) + show_comfy_monitor: bool = True diff --git a/tab_batch_ng.py b/tab_batch_ng.py new file mode 100644 index 0000000..bc2139d --- /dev/null +++ b/tab_batch_ng.py @@ -0,0 +1,700 @@ +import copy +import random +from pathlib import Path + +from nicegui import ui + +from state import AppState +from utils import ( + DEFAULTS, save_json, load_json, + KEY_BATCH_DATA, KEY_HISTORY_TREE, KEY_PROMPT_HISTORY, KEY_SEQUENCE_NUMBER, +) +from history_tree import HistoryTree + +IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.bmp', '.gif'} +SUB_SEGMENT_MULTIPLIER = 1000 + +VACE_MODES = [ + 'End Extend', 'Pre Extend', 'Middle Extend', 'Edge Extend', + 'Join Extend', 'Bidirectional Extend', 'Frame Interpolation', + 'Replace/Inpaint', 'Video Inpaint', 'Keyframe', +] +VACE_FORMULAS = [ + 'base + A', 'base + B', 'base + A + B', 'base + A + B', + 'base + A + B', 'base + A + B', '(B-1) * step', + 'snap(source)', 'snap(source)', 'base + A + B', +] + + +# --- Sub-segment helpers (same as original) --- + +def is_subsegment(seq_num): + return int(seq_num) >= SUB_SEGMENT_MULTIPLIER + +def parent_of(seq_num): + seq_num = int(seq_num) + return seq_num // SUB_SEGMENT_MULTIPLIER if is_subsegment(seq_num) else seq_num + +def sub_index_of(seq_num): + seq_num = int(seq_num) + return seq_num % SUB_SEGMENT_MULTIPLIER if is_subsegment(seq_num) else 0 + +def format_seq_label(seq_num): + seq_num = int(seq_num) + if is_subsegment(seq_num): + return f'Sub #{parent_of(seq_num)}.{sub_index_of(seq_num)}' + return f'Sequence #{seq_num}' + +def next_sub_segment_number(batch_list, parent_seq_num): + parent_seq_num = int(parent_seq_num) + max_sub = 0 + for s in batch_list: + sn = int(s.get(KEY_SEQUENCE_NUMBER, 0)) + if is_subsegment(sn) and parent_of(sn) == parent_seq_num: + max_sub = max(max_sub, sub_index_of(sn)) + return parent_seq_num * SUB_SEGMENT_MULTIPLIER + max_sub + 1 + +def find_insert_position(batch_list, parent_index, parent_seq_num): + parent_seq_num = int(parent_seq_num) + pos = parent_index + 1 + while pos < len(batch_list): + sn = int(batch_list[pos].get(KEY_SEQUENCE_NUMBER, 0)) + if is_subsegment(sn) and parent_of(sn) == parent_seq_num: + pos += 1 + else: + break + return pos + + +# --- Helper for repetitive dict-bound inputs --- + +def dict_input(element_fn, label, seq, key, **kwargs): + """Create an input element bound to seq[key] via blur event.""" + val = seq.get(key, '') + if isinstance(val, (int, float)): + val = str(val) if element_fn != ui.number else val + el = element_fn(label, value=val, **kwargs) + el.on('blur', lambda e, k=key: seq.__setitem__(k, e.sender.value)) + return el + + +def dict_number(label, seq, key, **kwargs): + """Number input bound to seq[key] via blur.""" + val = seq.get(key, 0) + try: + # Try float first to handle "1.5" strings, then check if it's a clean int + fval = float(val) + val = int(fval) if fval == int(fval) else fval + except (ValueError, TypeError): + val = 0 + el = ui.number(label, value=val, **kwargs) + el.on('blur', lambda e, k=key: seq.__setitem__( + k, e.sender.value if e.sender.value is not None else 0)) + return el + + +def dict_textarea(label, seq, key, **kwargs): + """Textarea bound to seq[key] via blur.""" + el = ui.textarea(label, value=seq.get(key, ''), **kwargs) + el.on('blur', lambda e, k=key: seq.__setitem__(k, e.sender.value)) + return el + + +# ====================================================================== +# Main render function +# ====================================================================== + +def render_batch_processor(state: AppState): + data = state.data_cache + file_path = state.file_path + is_batch_file = KEY_BATCH_DATA in data or isinstance(data, list) + + if not is_batch_file: + ui.label('This is a Single file. To use Batch mode, create a copy.').classes( + 'text-warning') + + def create_batch(): + new_name = f'batch_{file_path.name}' + new_path = file_path.parent / new_name + if new_path.exists(): + ui.notify(f'File {new_name} already exists!', type='warning') + return + first_item = data.copy() + first_item.pop(KEY_PROMPT_HISTORY, None) + first_item.pop(KEY_HISTORY_TREE, None) + first_item[KEY_SEQUENCE_NUMBER] = 1 + new_data = {KEY_BATCH_DATA: [first_item], KEY_HISTORY_TREE: {}, + KEY_PROMPT_HISTORY: []} + save_json(new_path, new_data) + ui.notify(f'Created {new_name}', type='positive') + + ui.button('Create Batch Copy', icon='content_copy', on_click=create_batch) + return + + if state.restored_indicator: + ui.label(f'Editing Restored Version: {state.restored_indicator}').classes( + 'text-info q-pa-sm') + + batch_list = data.get(KEY_BATCH_DATA, []) + + # Source file data for importing + json_files = sorted(state.current_dir.glob('*.json')) + json_files = [f for f in json_files if f.name not in ( + '.editor_config.json', '.editor_snippets.json')] + file_options = {f.name: f.name for f in json_files} + + src_file_select = ui.select( + file_options, + value=file_path.name, + label='Source File:', + ).classes('w-64') + + src_seq_select = ui.select([], label='Source Sequence:').classes('w-64') + + # Track loaded source data + _src_cache = {'data': None, 'batch': [], 'name': None} + + def _update_src(): + name = src_file_select.value + if name and name != _src_cache['name']: + src_data, _ = load_json(state.current_dir / name) + _src_cache['data'] = src_data + _src_cache['batch'] = src_data.get(KEY_BATCH_DATA, []) + _src_cache['name'] = name + if _src_cache['batch']: + opts = {i: format_seq_label(s.get(KEY_SEQUENCE_NUMBER, i+1)) + for i, s in enumerate(_src_cache['batch'])} + src_seq_select.options = opts + src_seq_select.set_value(0) + else: + src_seq_select.options = {} + + src_file_select.on_value_change(lambda _: _update_src()) + _update_src() + + # --- Add New Sequence --- + ui.label('Add New Sequence').classes('text-subtitle1 q-mt-md') + + def _add_sequence(new_item): + max_seq = 0 + for s in batch_list: + sn = int(s.get(KEY_SEQUENCE_NUMBER, 0)) + if not is_subsegment(sn): + max_seq = max(max_seq, sn) + new_item[KEY_SEQUENCE_NUMBER] = max_seq + 1 + for k in [KEY_PROMPT_HISTORY, KEY_HISTORY_TREE, 'note', 'loras']: + new_item.pop(k, None) + batch_list.append(new_item) + data[KEY_BATCH_DATA] = batch_list + save_json(file_path, data) + render_sequence_list.refresh() + + with ui.row(): + def add_empty(): + _add_sequence(DEFAULTS.copy()) + + def add_from_source(): + item = DEFAULTS.copy() + src_batch = _src_cache['batch'] + sel_idx = src_seq_select.value + if src_batch and sel_idx is not None: + item.update(src_batch[int(sel_idx)]) + elif _src_cache['data']: + item.update(_src_cache['data']) + _add_sequence(item) + + ui.button('Add Empty', icon='add', on_click=add_empty) + ui.button('From Source', icon='file_download', on_click=add_from_source) + + ui.separator() + + # --- Mass Update --- + _render_mass_update(batch_list, data, file_path, state) + + # --- Standard / LoRA / VACE key sets --- + lora_keys = ['lora 1 high', 'lora 1 low', 'lora 2 high', 'lora 2 low', + 'lora 3 high', 'lora 3 low'] + standard_keys = { + 'general_prompt', 'general_negative', 'current_prompt', 'negative', 'prompt', + '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', + } + standard_keys.update(lora_keys) + + def sort_by_number(): + batch_list.sort(key=lambda s: int(s.get(KEY_SEQUENCE_NUMBER, 0))) + data[KEY_BATCH_DATA] = batch_list + save_json(file_path, data) + ui.notify('Sorted by sequence number!', type='positive') + render_sequence_list.refresh() + + # --- Sequence list (count label + cards inside refreshable) --- + @ui.refreshable + def render_sequence_list(): + with ui.row().classes('w-full items-center'): + ui.label(f'Batch contains {len(batch_list)} sequences.') + ui.button('Sort by Number', icon='sort', on_click=sort_by_number).props('flat') + + for i, seq in enumerate(batch_list): + _render_sequence_card( + i, seq, batch_list, data, file_path, state, + _src_cache, src_seq_select, + standard_keys, render_sequence_list, + ) + + render_sequence_list() + + ui.separator() + + # --- Save & Snap --- + with ui.row().classes('w-full items-end q-gutter-md'): + commit_input = ui.input('Change Note (Optional)', + placeholder='e.g. Added sequence 3').classes('col') + + def save_and_snap(): + data[KEY_BATCH_DATA] = batch_list + tree_data = data.get(KEY_HISTORY_TREE, {}) + htree = HistoryTree(tree_data) + snapshot_payload = copy.deepcopy(data) + snapshot_payload.pop(KEY_HISTORY_TREE, None) + note = commit_input.value if commit_input.value else 'Batch Update' + htree.commit(snapshot_payload, note=note) + data[KEY_HISTORY_TREE] = htree.to_dict() + save_json(file_path, data) + state.restored_indicator = None + commit_input.set_value('') + ui.notify('Batch Saved & Snapshot Created!', type='positive') + + ui.button('Save & Snap', icon='save', on_click=save_and_snap).props('color=primary') + + +# ====================================================================== +# Single sequence card +# ====================================================================== + +def _render_sequence_card(i, seq, batch_list, data, file_path, state, + src_cache, src_seq_select, standard_keys, + refresh_list): + seq_num = seq.get(KEY_SEQUENCE_NUMBER, i + 1) + + if is_subsegment(seq_num): + label = f'Sub #{parent_of(seq_num)}.{sub_index_of(seq_num)} ({int(seq_num)})' + else: + label = f'Sequence #{seq_num}' + + with ui.expansion(label, icon='movie').classes('w-full'): + # --- Action row --- + with ui.row().classes('w-full q-gutter-sm'): + # Copy from source + def copy_source(idx=i, sn=seq_num): + item = DEFAULTS.copy() + src_batch = src_cache['batch'] + sel_idx = src_seq_select.value + if src_batch and sel_idx is not None: + item.update(src_batch[int(sel_idx)]) + elif src_cache['data']: + item.update(src_cache['data']) + item[KEY_SEQUENCE_NUMBER] = sn + item.pop(KEY_PROMPT_HISTORY, None) + item.pop(KEY_HISTORY_TREE, None) + batch_list[idx] = item + data[KEY_BATCH_DATA] = batch_list + save_json(file_path, data) + ui.notify('Copied!', type='positive') + refresh_list.refresh() + + ui.button('Copy Src', icon='file_download', on_click=copy_source).props('dense') + + # Clone Next + def clone_next(idx=i, sn=seq_num, s=seq): + new_seq = copy.deepcopy(s) + max_sn = max( + (int(x.get(KEY_SEQUENCE_NUMBER, 0)) + for x in batch_list if not is_subsegment(x.get(KEY_SEQUENCE_NUMBER, 0))), + default=0) + new_seq[KEY_SEQUENCE_NUMBER] = max_sn + 1 + if not is_subsegment(sn): + pos = find_insert_position(batch_list, idx, int(sn)) + else: + pos = idx + 1 + batch_list.insert(pos, new_seq) + data[KEY_BATCH_DATA] = batch_list + save_json(file_path, data) + ui.notify('Cloned to Next!', type='positive') + refresh_list.refresh() + + ui.button('Clone Next', icon='content_copy', on_click=clone_next).props('dense') + + # Clone End + def clone_end(s=seq): + new_seq = copy.deepcopy(s) + max_sn = max( + (int(x.get(KEY_SEQUENCE_NUMBER, 0)) + for x in batch_list if not is_subsegment(x.get(KEY_SEQUENCE_NUMBER, 0))), + default=0) + new_seq[KEY_SEQUENCE_NUMBER] = max_sn + 1 + batch_list.append(new_seq) + data[KEY_BATCH_DATA] = batch_list + save_json(file_path, data) + ui.notify('Cloned to End!', type='positive') + refresh_list.refresh() + + ui.button('Clone End', icon='vertical_align_bottom', on_click=clone_end).props('dense') + + # Clone Sub + def clone_sub(idx=i, sn=seq_num, s=seq): + new_seq = copy.deepcopy(s) + p_seq = parent_of(sn) + p_idx = idx + if is_subsegment(sn): + for pi, ps in enumerate(batch_list): + if int(ps.get(KEY_SEQUENCE_NUMBER, 0)) == p_seq: + p_idx = pi + break + 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) + data[KEY_BATCH_DATA] = batch_list + save_json(file_path, data) + ui.notify(f'Created {format_seq_label(new_seq[KEY_SEQUENCE_NUMBER])}!', + type='positive') + refresh_list.refresh() + + ui.button('Clone Sub', icon='link', on_click=clone_sub).props('dense') + + # Promote + def promote(idx=i, s=seq): + single_data = s.copy() + single_data[KEY_PROMPT_HISTORY] = data.get(KEY_PROMPT_HISTORY, []) + single_data[KEY_HISTORY_TREE] = data.get(KEY_HISTORY_TREE, {}) + single_data.pop(KEY_SEQUENCE_NUMBER, None) + save_json(file_path, single_data) + state.data_cache = single_data + ui.notify('Converted to Single!', type='positive') + # Full refresh so batch tab re-enters render_batch_processor + # and sees the file is now single (no KEY_BATCH_DATA) + state._render_main.refresh() + + ui.button('Promote', icon='north_west', on_click=promote).props('dense') + + # Delete + def delete(idx=i): + batch_list.pop(idx) + data[KEY_BATCH_DATA] = batch_list + save_json(file_path, data) + refresh_list.refresh() + + ui.button(icon='delete', on_click=delete).props('dense color=negative') + + ui.separator() + + # --- Prompts + Settings --- + with ui.row().classes('w-full q-gutter-md'): + # Left column: prompts + with ui.column().classes('col-8'): + dict_textarea('General Prompt', seq, 'general_prompt').classes( + 'w-full').props('outlined rows=2') + dict_textarea('General Negative', seq, 'general_negative').classes( + 'w-full').props('outlined rows=2') + dict_textarea('Specific Prompt', seq, 'current_prompt').classes( + 'w-full').props('outlined rows=10') + dict_textarea('Specific Negative', seq, 'negative').classes( + 'w-full').props('outlined rows=2') + + # Right column: settings + with ui.column().classes('col-4'): + # Sequence number + sn_label = ( + f'Seq Number (Sub #{parent_of(seq_num)}.{sub_index_of(seq_num)})' + if is_subsegment(seq_num) else 'Sequence Number' + ) + sn_input = dict_number(sn_label, seq, KEY_SEQUENCE_NUMBER) + sn_input.props('outlined') + + # Seed + randomize + with ui.row().classes('w-full items-end'): + seed_input = dict_number('Seed', seq, 'seed').classes('col').props('outlined') + + def randomize_seed(si=seed_input, s=seq): + new_seed = random.randint(0, 999999999999) + si.set_value(new_seed) + s['seed'] = new_seed + + ui.button(icon='casino', on_click=randomize_seed).props('flat') + + # CFG + cfg_val = float(seq.get('cfg', DEFAULTS['cfg'])) + cfg_input = ui.number('CFG', value=cfg_val, step=0.5, + format='%.1f').props('outlined') + cfg_input.on('blur', lambda e: seq.__setitem__( + 'cfg', e.sender.value if e.sender.value is not None else DEFAULTS['cfg'])) + + dict_input(ui.input, 'Camera', seq, 'camera').props('outlined') + dict_input(ui.input, 'FLF', seq, 'flf').props('outlined') + dict_number('End Frame', seq, 'end_frame').props('outlined') + dict_input(ui.input, 'Video File Path', seq, 'video file path').props('outlined') + + # Image paths with preview + for img_label, img_key in [ + ('Reference Image Path', 'reference image path'), + ('Reference Path', 'reference path'), + ('FLF Image Path', 'flf image path'), + ]: + with ui.row().classes('w-full items-center'): + inp = dict_input(ui.input, img_label, seq, img_key).classes( + 'col').props('outlined') + 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') + + # VACE Settings + with ui.expansion('VACE Settings', icon='settings').classes('w-full'): + _render_vace_settings(i, seq, batch_list, data, file_path, refresh_list) + + # --- LoRA Settings --- + with ui.expansion('LoRA Settings', icon='style').classes('w-full'): + with ui.row().classes('w-full q-gutter-md'): + for lora_idx in range(1, 4): + with ui.column().classes('col'): + ui.label(f'LoRA {lora_idx}').classes('text-subtitle2') + for tier, tier_label in [('high', 'High'), ('low', 'Low')]: + k = f'lora {lora_idx} {tier}' + raw = str(seq.get(k, '')) + disp = raw.replace('', '') + + with ui.row().classes('w-full items-center'): + ui.label('').classes('text-caption font-mono') + + def on_lora_blur(e, key=k): + v = e.sender.value + seq[key] = f'' if v else '' + + lora_input.on('blur', on_lora_blur) + + # --- Custom Parameters --- + ui.separator() + ui.label('Custom Parameters').classes('text-caption') + + custom_keys = [k for k in seq.keys() if k not in standard_keys] + if custom_keys: + for k in custom_keys: + with ui.row().classes('w-full items-center'): + ui.input('Key', value=k).props('readonly outlined dense').classes('w-32') + val_input = ui.input('Value', value=str(seq[k])).props( + 'outlined dense').classes('col') + val_input.on('blur', lambda e, key=k: seq.__setitem__(key, e.sender.value)) + + def del_custom(key=k): + del seq[key] + save_json(file_path, data) + refresh_list.refresh() + + ui.button(icon='delete', on_click=del_custom).props('flat dense color=negative') + + with ui.expansion('Add Parameter', icon='add').classes('w-full'): + new_k_input = ui.input('Key').props('outlined dense') + new_v_input = ui.input('Value').props('outlined dense') + + def add_param(): + k = new_k_input.value + v = new_v_input.value + if k and k not in seq: + seq[k] = v + save_json(file_path, data) + new_k_input.set_value('') + new_v_input.set_value('') + refresh_list.refresh() + + ui.button('Add', on_click=add_param).props('flat') + + +# ====================================================================== +# VACE Settings sub-section +# ====================================================================== + +def _render_vace_settings(i, seq, batch_list, data, file_path, refresh_list): + # Frame to Skip + shift + with ui.row().classes('w-full items-end'): + fts_input = dict_number('Frame to Skip', seq, 'frame_to_skip').classes('col').props( + 'outlined') + + # Capture original at render time; blur updates seq before click fires + _original_fts = int(seq.get('frame_to_skip', 81)) + + def shift_fts(idx=i, orig=_original_fts): + new_fts = int(fts_input.value) if fts_input.value is not None else orig + delta = new_fts - orig + if delta == 0: + ui.notify('No change to shift', type='info') + return + shifted = 0 + for j in range(idx + 1, len(batch_list)): + batch_list[j]['frame_to_skip'] = int( + batch_list[j].get('frame_to_skip', 81)) + delta + shifted += 1 + data[KEY_BATCH_DATA] = batch_list + save_json(file_path, data) + ui.notify(f'Shifted {shifted} sequences by {delta:+d}', type='positive') + refresh_list.refresh() + + ui.button('Shift', icon='arrow_downward', on_click=shift_fts).props('dense') + + dict_input(ui.input, 'Transition', seq, 'transition').props('outlined') + + # VACE Schedule + sched_val = max(0, min(int(seq.get('vace schedule', 1)), len(VACE_MODES) - 1)) + with ui.row().classes('w-full items-center'): + vs_input = ui.number('VACE Schedule', value=sched_val, min=0, + max=len(VACE_MODES) - 1).classes('col').props('outlined') + vs_input.on('blur', lambda e: seq.__setitem__( + 'vace schedule', int(e.sender.value) if e.sender.value is not None else 0)) + mode_label = ui.label(VACE_MODES[sched_val]).classes('text-caption') + + def update_mode_label(e): + idx = int(e.sender.value) if e.sender.value is not None else 0 + idx = max(0, min(idx, len(VACE_MODES) - 1)) + mode_label.set_text(VACE_MODES[idx]) + + vs_input.on('update:model-value', update_mode_label) + + # Mode reference + with ui.dialog() as ref_dlg, ui.card(): + table_md = ( + '| # | Mode | Formula |\n|:--|:-----|:--------|\n' + + '\n'.join( + f'| **{j}** | {VACE_MODES[j]} | `{VACE_FORMULAS[j]}` |' + for j in range(len(VACE_MODES))) + + '\n\n*All totals snapped to 4n+1 (1,5,9,...,49,...,81,...)*' + ) + ui.markdown(table_md) + ui.button('Mode Reference', icon='help', on_click=ref_dlg.open).props('flat dense') + + # Input A / B frames + ia_input = dict_number('Input A Frames', seq, 'input_a_frames').props('outlined') + ib_input = dict_number('Input B Frames', seq, 'input_b_frames').props('outlined') + + # VACE Length + output calculation + input_a = int(seq.get('input_a_frames', 16)) + input_b = int(seq.get('input_b_frames', 16)) + stored_total = int(seq.get('vace_length', 49)) + mode_idx = int(seq.get('vace schedule', 1)) + + if mode_idx == 0: + base_length = max(stored_total - input_a, 1) + elif mode_idx == 1: + base_length = max(stored_total - input_b, 1) + else: + base_length = max(stored_total - input_a - input_b, 1) + + with ui.row().classes('w-full items-center'): + vl_input = ui.number('VACE Length', value=base_length, min=1).classes('col').props( + 'outlined') + output_label = ui.label(f'Output: {stored_total}').classes('text-bold') + + def recalc_vace(*_args): + mi = int(vs_input.value) if vs_input.value is not None else 0 + ia = int(ia_input.value) if ia_input.value is not None else 16 + ib = int(ib_input.value) if ib_input.value is not None else 16 + nb = int(vl_input.value) if vl_input.value is not None else 1 + + if mi == 0: + raw = nb + ia + elif mi == 1: + raw = nb + ib + else: + raw = nb + ia + ib + + snapped = ((raw + 2) // 4) * 4 + 1 + seq['vace_length'] = snapped + output_label.set_text(f'Output: {snapped}') + + for inp in (vs_input, ia_input, ib_input, vl_input): + inp.on('update:model-value', recalc_vace) + + dict_number('Reference Switch', seq, 'reference switch').props('outlined') + + +# ====================================================================== +# Mass Update +# ====================================================================== + +def _render_mass_update(batch_list, data, file_path, state: AppState): + with ui.expansion('Mass Update', icon='sync').classes('w-full'): + if len(batch_list) < 2: + ui.label('Need at least 2 sequences for mass update.').classes('text-caption') + return + + source_options = {i: format_seq_label(s.get(KEY_SEQUENCE_NUMBER, i+1)) + for i, s in enumerate(batch_list)} + source_select = ui.select(source_options, value=0, + label='Copy from sequence:').classes('w-full') + + field_select = ui.select([], multiple=True, + label='Fields to copy:').classes('w-full') + + def update_fields(_=None): + idx = source_select.value + if idx is not None and 0 <= idx < len(batch_list): + src = batch_list[idx] + keys = [k for k in src.keys() if k != 'sequence_number'] + field_select.options = keys + + source_select.on_value_change(update_fields) + update_fields() + + ui.label('Apply to:').classes('text-subtitle2 q-mt-md') + select_all_cb = ui.checkbox('Select All') + target_checks = {} + for idx, s in enumerate(batch_list): + sn = s.get(KEY_SEQUENCE_NUMBER, idx + 1) + cb = ui.checkbox(format_seq_label(sn)) + target_checks[idx] = cb + + def on_select_all(e): + for cb in target_checks.values(): + cb.set_value(e.value) + + select_all_cb.on_value_change(on_select_all) + + def apply_mass_update(): + src_idx = source_select.value + if src_idx is None or src_idx >= len(batch_list): + ui.notify('Source sequence no longer exists', type='warning') + return + selected_keys = field_select.value or [] + if not selected_keys: + ui.notify('No fields selected', type='warning') + return + + source_seq = batch_list[src_idx] + targets = [idx for idx, cb in target_checks.items() + if cb.value and idx != src_idx and idx < len(batch_list)] + if not targets: + ui.notify('No target sequences selected', type='warning') + return + + for idx in targets: + for key in selected_keys: + batch_list[idx][key] = copy.deepcopy(source_seq.get(key)) + + data[KEY_BATCH_DATA] = batch_list + htree = HistoryTree(data.get(KEY_HISTORY_TREE, {})) + snapshot = copy.deepcopy(data) + snapshot.pop(KEY_HISTORY_TREE, None) + htree.commit(snapshot, f"Mass update: {', '.join(selected_keys)}") + data[KEY_HISTORY_TREE] = htree.to_dict() + save_json(file_path, data) + ui.notify(f'Updated {len(targets)} sequences', type='positive') + + ui.button('Apply Changes', icon='check', on_click=apply_mass_update).props( + 'color=primary') diff --git a/tab_comfy_ng.py b/tab_comfy_ng.py new file mode 100644 index 0000000..8499647 --- /dev/null +++ b/tab_comfy_ng.py @@ -0,0 +1,241 @@ +import html +import time +import urllib.parse + +import requests +from nicegui import ui + +from state import AppState +from utils import save_config + + +def render_comfy_monitor(state: AppState): + config = state.config + + # --- Global Monitor Settings --- + with ui.expansion('Monitor Settings', icon='settings').classes('w-full'): + with ui.row().classes('w-full items-end'): + viewer_input = ui.input( + 'Remote Browser URL', + value=config.get('viewer_url', ''), + placeholder='e.g., http://localhost:5800', + ).classes('col') + timeout_slider = ui.slider( + min=0, max=60, step=1, + value=config.get('monitor_timeout', 0), + ).classes('col') + ui.label().bind_text_from(timeout_slider, 'value', + backward=lambda v: f'Timeout: {v} min') + + def save_monitor_settings(): + config['viewer_url'] = viewer_input.value + config['monitor_timeout'] = int(timeout_slider.value) + save_config(state.current_dir, config['favorites'], config) + ui.notify('Monitor settings saved!', type='positive') + + ui.button('Save Monitor Settings', icon='save', on_click=save_monitor_settings) + + # --- Instance Management --- + if 'comfy_instances' not in config: + config['comfy_instances'] = [ + {'name': 'Main Server', 'url': 'http://192.168.1.100:8188'} + ] + + instances = config['comfy_instances'] + + @ui.refreshable + def render_instance_tabs(): + if not instances: + ui.label('No servers configured. Add one below.') + + for idx, inst in enumerate(instances): + with ui.expansion(inst.get('name', f'Server {idx+1}'), icon='dns').classes('w-full'): + _render_single_instance(state, inst, idx, instances, render_instance_tabs) + + # Add server section + ui.separator() + ui.label('Add New Server').classes('text-subtitle1') + with ui.row().classes('w-full items-end'): + new_name = ui.input('Server Name', placeholder='e.g. Render Node 2').classes('col') + new_url = ui.input('URL', placeholder='http://192.168.1.50:8188').classes('col') + + def add_instance(): + if new_name.value and new_url.value: + instances.append({'name': new_name.value, 'url': new_url.value}) + config['comfy_instances'] = instances + save_config(state.current_dir, config['favorites'], config) + ui.notify('Server Added!', type='positive') + new_name.set_value('') + new_url.set_value('') + render_instance_tabs.refresh() + else: + ui.notify('Please fill in both Name and URL.', type='warning') + + ui.button('Add Instance', icon='add', on_click=add_instance) + + render_instance_tabs() + + # --- Auto-poll timer (every 300s) --- + def poll_all(): + # Timeout checks for live toggles + timeout_val = config.get('monitor_timeout', 0) + if timeout_val > 0: + for key, start_time in list(state.live_toggles.items()): + if start_time and (time.time() - start_time) > (timeout_val * 60): + state.live_toggles[key] = None + + ui.timer(300, poll_all) + + +def _render_single_instance(state: AppState, instance_config: dict, index: int, + all_instances: list, refresh_fn): + config = state.config + url = instance_config.get('url', 'http://127.0.0.1:8188') + name = instance_config.get('name', f'Server {index+1}') + comfy_url = url.rstrip('/') + + # --- Settings popover --- + with ui.expansion('Settings', icon='settings'): + name_input = ui.input('Name', value=name).classes('w-full') + url_input = ui.input('URL', value=url).classes('w-full') + + def update_server(): + all_instances[index]['name'] = name_input.value + all_instances[index]['url'] = url_input.value + config['comfy_instances'] = all_instances + save_config(state.current_dir, config['favorites'], config) + ui.notify('Server config saved!', type='positive') + refresh_fn.refresh() + + def remove_server(): + all_instances.pop(index) + config['comfy_instances'] = all_instances + save_config(state.current_dir, config['favorites'], config) + ui.notify('Server removed', type='info') + refresh_fn.refresh() + + ui.button('Update & Save', icon='save', on_click=update_server).props('color=primary') + ui.button('Remove Server', icon='delete', on_click=remove_server).props('color=negative') + + # --- Status Dashboard --- + status_container = ui.row().classes('w-full items-center q-gutter-md') + + def refresh_status(): + status_container.clear() + with status_container: + try: + res = requests.get(f'{comfy_url}/queue', timeout=1.5) + queue_data = res.json() + running_cnt = len(queue_data.get('queue_running', [])) + pending_cnt = len(queue_data.get('queue_pending', [])) + + with ui.card().classes('q-pa-sm'): + ui.label('Status') + ui.label('Online' if running_cnt > 0 else 'Idle').classes( + 'text-positive' if running_cnt > 0 else 'text-grey') + with ui.card().classes('q-pa-sm'): + ui.label('Pending') + ui.label(str(pending_cnt)) + with ui.card().classes('q-pa-sm'): + ui.label('Running') + ui.label(str(running_cnt)) + except Exception: + with ui.card().classes('q-pa-sm'): + ui.label('Status') + ui.label('Offline').classes('text-negative') + ui.label(f'Could not connect to {comfy_url}').classes('text-negative') + + refresh_status() + ui.button('Refresh Status', icon='refresh', on_click=refresh_status).props('flat dense') + + # --- Live View --- + ui.label('Live View').classes('text-subtitle1 q-mt-md') + toggle_key = f'live_toggle_{index}' + + live_checkbox = ui.checkbox('Enable Live Preview', value=False) + + @ui.refreshable + def render_live_view(): + if not live_checkbox.value: + ui.label('Live Preview is disabled.').classes('text-caption') + return + + # Record start time + if toggle_key not in state.live_toggles or state.live_toggles.get(toggle_key) is None: + state.live_toggles[toggle_key] = time.time() + + timeout_val = config.get('monitor_timeout', 0) + if timeout_val > 0: + start = state.live_toggles.get(toggle_key, time.time()) + remaining = (timeout_val * 60) - (time.time() - start) + if remaining <= 0: + live_checkbox.set_value(False) + state.live_toggles[toggle_key] = None + ui.label('Preview timed out.').classes('text-caption') + return + ui.label(f'Auto-off in: {int(remaining)}s').classes('text-caption') + + iframe_h = ui.slider(min=600, max=2500, step=50, value=1000).classes('w-full') + ui.label().bind_text_from(iframe_h, 'value', backward=lambda v: f'Height: {v}px') + + viewer_base = config.get('viewer_url', '').strip() + parsed = urllib.parse.urlparse(viewer_base) + if viewer_base and parsed.scheme in ('http', 'https'): + safe_src = html.escape(viewer_base, quote=True) + ui.label(f'Viewing: {viewer_base}').classes('text-caption') + + iframe_container = ui.column().classes('w-full') + + def update_iframe(): + iframe_container.clear() + with iframe_container: + ui.html( + f'' + ) + + iframe_h.on_value_change(lambda _: update_iframe()) + update_iframe() + else: + ui.label('No valid viewer URL configured.').classes('text-warning') + + live_checkbox.on_value_change(lambda _: render_live_view.refresh()) + render_live_view() + + # --- Latest Output --- + ui.label('Latest Output').classes('text-subtitle1 q-mt-md') + img_container = ui.column().classes('w-full') + + def check_image(): + img_container.clear() + with img_container: + try: + hist_res = requests.get(f'{comfy_url}/history', timeout=2) + history = hist_res.json() + if not history: + ui.label('No history found.').classes('text-caption') + return + last_prompt_id = list(history.keys())[-1] + outputs = history[last_prompt_id].get('outputs', {}) + found_img = None + for node_output in outputs.values(): + if 'images' in node_output: + for img_info in node_output['images']: + if img_info['type'] == 'output': + found_img = img_info + break + if found_img: + break + if found_img: + img_name = found_img['filename'] + folder = found_img['subfolder'] + img_type = found_img['type'] + img_url = f'{comfy_url}/view?filename={img_name}&subfolder={folder}&type={img_type}' + ui.image(img_url).classes('w-full').style('max-width: 600px') + ui.label(f'Last Output: {img_name}').classes('text-caption') + else: + ui.label('Last run had no image output.').classes('text-caption') + except Exception as e: + ui.label(f'Error fetching image: {e}').classes('text-negative') + + ui.button('Check Latest Image', icon='image', on_click=check_image).props('flat') diff --git a/tab_raw_ng.py b/tab_raw_ng.py new file mode 100644 index 0000000..4b5277f --- /dev/null +++ b/tab_raw_ng.py @@ -0,0 +1,74 @@ +import copy +import json + +from nicegui import ui + +from state import AppState +from utils import save_json, get_file_mtime, KEY_HISTORY_TREE, KEY_PROMPT_HISTORY + + +def render_raw_editor(state: AppState): + data = state.data_cache + file_path = state.file_path + + ui.label(f'Raw Editor: {file_path.name}').classes('text-h6') + + hide_history = ui.checkbox( + 'Hide History (Safe Mode)', + value=True, + ) + + @ui.refreshable + def render_editor(): + # Prepare display data + if hide_history.value: + display_data = copy.deepcopy(data) + display_data.pop(KEY_HISTORY_TREE, None) + display_data.pop(KEY_PROMPT_HISTORY, None) + else: + display_data = data + + try: + json_str = json.dumps(display_data, indent=4, ensure_ascii=False) + except Exception as e: + ui.notify(f'Error serializing JSON: {e}', type='negative') + json_str = '{}' + + text_area = ui.textarea( + 'JSON Content', + value=json_str, + ).classes('w-full font-mono').props('outlined rows=30') + + ui.separator() + + def do_save(): + try: + input_data = json.loads(text_area.value) + + # Merge hidden history back in if safe mode + if hide_history.value: + if KEY_HISTORY_TREE in data: + input_data[KEY_HISTORY_TREE] = data[KEY_HISTORY_TREE] + if KEY_PROMPT_HISTORY in data: + input_data[KEY_PROMPT_HISTORY] = data[KEY_PROMPT_HISTORY] + + save_json(file_path, input_data) + + data.clear() + data.update(input_data) + state.last_mtime = get_file_mtime(file_path) + + ui.notify('Raw JSON Saved Successfully!', type='positive') + render_editor.refresh() + + except json.JSONDecodeError as e: + ui.notify(f'Invalid JSON Syntax: {e}', type='negative') + except Exception as e: + ui.notify(f'Unexpected Error: {e}', type='negative') + + ui.button('Save Raw Changes', icon='save', on_click=do_save).props( + 'color=primary' + ).classes('w-full') + + hide_history.on_value_change(lambda _: render_editor.refresh()) + render_editor() diff --git a/tab_timeline_ng.py b/tab_timeline_ng.py new file mode 100644 index 0000000..3ab3be3 --- /dev/null +++ b/tab_timeline_ng.py @@ -0,0 +1,353 @@ +import copy +import time + +from nicegui import ui + +from state import AppState +from history_tree import HistoryTree +from utils import save_json, KEY_BATCH_DATA, KEY_HISTORY_TREE + + +def render_timeline_tab(state: AppState): + data = state.data_cache + file_path = state.file_path + + tree_data = data.get(KEY_HISTORY_TREE, {}) + if not tree_data: + ui.label('No history timeline exists. Make some changes in the Editor first!').classes( + 'text-subtitle1 q-pa-md') + return + + htree = HistoryTree(tree_data) + + if state.restored_indicator: + ui.label(f'Editing Restored Version: {state.restored_indicator}').classes( + 'text-info q-pa-sm') + + # --- View mode + Selection toggle --- + with ui.row().classes('w-full items-center q-gutter-md'): + ui.label('Version History').classes('text-h6 col') + view_mode = ui.toggle( + ['Horizontal', 'Vertical', 'Linear Log'], + value='Horizontal', + ) + selection_mode = ui.switch('Select to Delete') + + @ui.refreshable + def render_timeline(): + # Rebuild node list inside refreshable so it's current after deletes + all_nodes = sorted(htree.nodes.values(), key=lambda x: x['timestamp'], reverse=True) + selected_nodes = state.timeline_selected_nodes if selection_mode.value else set() + + # --- Selection picker --- + if selection_mode.value: + all_ids = [n['id'] for n in all_nodes] + + def fmt_option(nid): + n = htree.nodes[nid] + ts = time.strftime('%b %d %H:%M', time.localtime(n['timestamp'])) + note = n.get('note', 'Step') + head = ' (HEAD)' if nid == htree.head_id else '' + return f'{note} - {ts} ({nid[:6]}){head}' + + options = {nid: fmt_option(nid) for nid in all_ids} + + def on_selection_change(e): + state.timeline_selected_nodes = set(e.value) if e.value else set() + + ui.select( + options, + value=list(state.timeline_selected_nodes), + multiple=True, + label='Select nodes to delete:', + on_change=on_selection_change, + ).classes('w-full') + + with ui.row(): + def select_all(): + state.timeline_selected_nodes = set(all_ids) + render_timeline.refresh() + def deselect_all(): + state.timeline_selected_nodes = set() + render_timeline.refresh() + ui.button('Select All', on_click=select_all).props('flat dense') + ui.button('Deselect All', on_click=deselect_all).props('flat dense') + + # --- Graph views --- + mode = view_mode.value + if mode in ('Horizontal', 'Vertical'): + direction = 'LR' if mode == 'Horizontal' else 'TB' + try: + graph_dot = htree.generate_graph(direction=direction) + _render_graphviz(graph_dot) + except Exception as e: + ui.label(f'Graph Error: {e}').classes('text-negative') + + # --- Linear Log view --- + elif mode == 'Linear Log': + ui.label('Chronological list of all snapshots.').classes('text-caption') + for n in all_nodes: + is_head = n['id'] == htree.head_id + is_selected = n['id'] in selected_nodes + + with ui.card().classes( + 'w-full q-mb-sm' + + (' bg-yellow-1' if is_head else '') + + (' bg-red-1' if is_selected else '') + ): + with ui.row().classes('w-full items-center'): + if selection_mode.value: + ui.checkbox( + '', + value=is_selected, + on_change=lambda e, nid=n['id']: _toggle_select( + nid, e.value), + ) + + icon = 'location_on' if is_head else 'circle' + ui.icon(icon).classes( + 'text-primary' if is_head else 'text-grey') + + with ui.column().classes('col'): + note = n.get('note', 'Step') + ts = time.strftime('%b %d %H:%M', + time.localtime(n['timestamp'])) + label = f'{note} (Current)' if is_head else note + ui.label(label).classes('text-bold') + ui.label( + f'ID: {n["id"][:6]} - {ts}').classes('text-caption') + + if not is_head and not selection_mode.value: + ui.button( + 'Restore', + icon='restore', + on_click=lambda node=n: _restore_and_refresh(node), + ).props('flat dense color=primary') + + # --- Batch Delete --- + if selection_mode.value and state.timeline_selected_nodes: + valid = state.timeline_selected_nodes & set(htree.nodes.keys()) + state.timeline_selected_nodes = valid + count = len(valid) + if count > 0: + ui.label( + f'{count} node{"s" if count != 1 else ""} selected for deletion.' + ).classes('text-warning q-mt-md') + + def do_batch_delete(): + if 'history_tree_backup' not in data: + data['history_tree_backup'] = [] + data['history_tree_backup'].append(copy.deepcopy(htree.to_dict())) + for nid in valid: + if nid in htree.nodes: + del htree.nodes[nid] + for b, tip in list(htree.branches.items()): + if tip in valid: + del htree.branches[b] + if htree.head_id in valid: + if htree.nodes: + fallback = sorted(htree.nodes.values(), + key=lambda x: x['timestamp'])[-1] + htree.head_id = fallback['id'] + else: + htree.head_id = None + data[KEY_HISTORY_TREE] = htree.to_dict() + save_json(file_path, data) + state.timeline_selected_nodes = set() + ui.notify( + f'Deleted {count} node{"s" if count != 1 else ""}!', + type='positive') + render_timeline.refresh() + + ui.button( + f'Delete {count} Node{"s" if count != 1 else ""}', + icon='delete', + on_click=do_batch_delete, + ).props('color=negative') + + ui.separator() + + # --- Node selector + actions --- + ui.label('Manage Version').classes('text-subtitle1 q-mt-md') + + def fmt_node(n): + ts = time.strftime('%b %d %H:%M', time.localtime(n['timestamp'])) + return f'{n.get("note", "Step")} - {ts} ({n["id"][:6]})' + + node_options = {n['id']: fmt_node(n) for n in all_nodes} + current_id = htree.head_id if htree.head_id in node_options else ( + all_nodes[0]['id'] if all_nodes else None) + + selected_node_id = ui.select( + node_options, + value=current_id, + label='Select Version to Manage:', + ).classes('w-full') + + with ui.row().classes('w-full items-end q-gutter-md'): + def restore_selected(): + nid = selected_node_id.value + if nid and nid in htree.nodes: + _restore_and_refresh(htree.nodes[nid]) + + ui.button('Restore Version', icon='restore', + on_click=restore_selected).props('color=primary') + + # Rename + with ui.row().classes('w-full items-end q-gutter-md'): + rename_input = ui.input('Rename Label').classes('col') + + def rename_node(): + nid = selected_node_id.value + if nid and nid in htree.nodes and rename_input.value: + htree.nodes[nid]['note'] = rename_input.value + data[KEY_HISTORY_TREE] = htree.to_dict() + save_json(file_path, data) + ui.notify('Label updated', type='positive') + render_timeline.refresh() + + ui.button('Update Label', on_click=rename_node).props('flat') + + # Danger zone + with ui.expansion('Danger Zone (Delete)', icon='warning').classes('w-full q-mt-md'): + ui.label('Deleting a node cannot be undone.').classes('text-warning') + + def delete_selected(): + nid = selected_node_id.value + if nid and nid in htree.nodes: + if 'history_tree_backup' not in data: + data['history_tree_backup'] = [] + data['history_tree_backup'].append( + copy.deepcopy(htree.to_dict())) + del htree.nodes[nid] + for b, tip in list(htree.branches.items()): + if tip == nid: + del htree.branches[b] + if htree.head_id == nid: + if htree.nodes: + fallback = sorted(htree.nodes.values(), + key=lambda x: x['timestamp'])[-1] + htree.head_id = fallback['id'] + else: + htree.head_id = None + data[KEY_HISTORY_TREE] = htree.to_dict() + save_json(file_path, data) + ui.notify('Node Deleted', type='positive') + render_timeline.refresh() + + ui.button('Delete This Node', icon='delete', + on_click=delete_selected).props('color=negative') + + # Data preview + ui.separator() + with ui.expansion('Data Preview', icon='preview').classes('w-full'): + @ui.refreshable + def render_preview(): + _render_data_preview(selected_node_id, htree) + selected_node_id.on_value_change(lambda _: render_preview.refresh()) + render_preview() + + def _toggle_select(nid, checked): + if checked: + state.timeline_selected_nodes.add(nid) + else: + state.timeline_selected_nodes.discard(nid) + render_timeline.refresh() + + def _restore_and_refresh(node): + _restore_node(data, node, htree, file_path, state) + # Refresh all tabs (batch, raw, timeline) so they pick up the restored data + state._render_main.refresh() + + view_mode.on_value_change(lambda _: render_timeline.refresh()) + selection_mode.on_value_change(lambda _: render_timeline.refresh()) + render_timeline() + + +def _render_graphviz(dot_source: str): + """Render graphviz DOT source as SVG using ui.html.""" + try: + import graphviz + src = graphviz.Source(dot_source) + svg = src.pipe(format='svg').decode('utf-8') + ui.html(f'
{svg}
') + except ImportError: + ui.label('Install graphviz Python package for graph rendering.').classes('text-warning') + ui.code(dot_source).classes('w-full') + except Exception as e: + ui.label(f'Graph rendering error: {e}').classes('text-negative') + + +def _restore_node(data, node, htree, file_path, state: AppState): + """Restore a history node as the current version.""" + node_data = node['data'] + if KEY_BATCH_DATA not in node_data and KEY_BATCH_DATA in data: + del data[KEY_BATCH_DATA] + data.update(node_data) + htree.head_id = node['id'] + data[KEY_HISTORY_TREE] = htree.to_dict() + save_json(file_path, data) + label = f"{node.get('note', 'Step')} ({node['id'][:4]})" + state.restored_indicator = label + ui.notify('Restored!', type='positive') + + +def _render_data_preview(selected_node_id, htree): + """Render a read-only preview of the selected node's data.""" + nid = selected_node_id.value + if not nid or nid not in htree.nodes: + ui.label('No node selected.').classes('text-caption') + return + + node_data = htree.nodes[nid]['data'] + batch_list = node_data.get(KEY_BATCH_DATA, []) + + if batch_list and isinstance(batch_list, list) and len(batch_list) > 0: + ui.label(f'This snapshot contains {len(batch_list)} sequences.').classes('text-caption') + for i, seq_data in enumerate(batch_list): + seq_num = seq_data.get('sequence_number', i + 1) + with ui.expansion(f'Sequence #{seq_num}', value=(i == 0)): + _render_preview_fields(seq_data) + else: + _render_preview_fields(node_data) + + +def _render_preview_fields(item_data: dict): + """Render read-only preview of prompts, settings, LoRAs.""" + with ui.grid(columns=2).classes('w-full'): + ui.textarea('General Positive', + value=item_data.get('general_prompt', '')).props('readonly outlined rows=3') + ui.textarea('General Negative', + value=item_data.get('general_negative', '')).props('readonly outlined rows=3') + val_sp = item_data.get('current_prompt', '') or item_data.get('prompt', '') + ui.textarea('Specific Positive', + value=val_sp).props('readonly outlined rows=3') + ui.textarea('Specific Negative', + value=item_data.get('negative', '')).props('readonly outlined rows=3') + + with ui.row().classes('w-full q-gutter-md'): + ui.input('Camera', value=str(item_data.get('camera', 'static'))).props('readonly outlined') + ui.input('FLF', value=str(item_data.get('flf', '0.0'))).props('readonly outlined') + ui.input('Seed', value=str(item_data.get('seed', '-1'))).props('readonly outlined') + + with ui.expansion('LoRA Configuration'): + with ui.row().classes('w-full q-gutter-md'): + for lora_idx in range(1, 4): + with ui.column(): + ui.input(f'L{lora_idx} Name', + value=item_data.get(f'lora {lora_idx} high', '')).props( + 'readonly outlined dense') + ui.input(f'L{lora_idx} Str', + value=str(item_data.get(f'lora {lora_idx} low', ''))).props( + 'readonly outlined dense') + + vace_keys = ['frame_to_skip', 'vace schedule', 'video file path'] + if any(k in item_data for k in vace_keys): + with ui.expansion('VACE / I2V Settings'): + with ui.row().classes('w-full q-gutter-md'): + ui.input('Skip Frames', + value=str(item_data.get('frame_to_skip', ''))).props('readonly outlined') + ui.input('Schedule', + value=str(item_data.get('vace schedule', ''))).props('readonly outlined') + ui.input('Video Path', + value=str(item_data.get('video file path', ''))).props('readonly outlined') diff --git a/tests/test_utils.py b/tests/test_utils.py index 2f3bc6f..af2201b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,15 +1,8 @@ import json -import os from pathlib import Path -from unittest.mock import patch import pytest -# Mock streamlit before importing utils -import sys -from unittest.mock import MagicMock -sys.modules.setdefault("streamlit", MagicMock()) - from utils import load_json, save_json, get_file_mtime, ALLOWED_BASE_DIR, DEFAULTS, resolve_path_case_insensitive diff --git a/utils.py b/utils.py index cd5722b..2e49007 100644 --- a/utils.py +++ b/utils.py @@ -5,8 +5,6 @@ import time from pathlib import Path from typing import Any -import streamlit as st - # --- Magic String Keys --- KEY_BATCH_DATA = "batch_data" KEY_HISTORY_TREE = "history_tree" @@ -145,7 +143,7 @@ def load_json(path: str | Path) -> tuple[dict[str, Any], float]: data = json.load(f) return data, path.stat().st_mtime except Exception as e: - st.error(f"Error loading JSON: {e}") + logger.error(f"Error loading JSON: {e}") return DEFAULTS.copy(), 0 def save_json(path: str | Path, data: dict[str, Any]) -> None: From d5fbfe765e8e7b51183af0ec686e06c025a9f87f Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Wed, 25 Feb 2026 11:30:44 +0100 Subject: [PATCH 02/26] Fix UI readability and clipping issues Add page/sidebar background contrast, wrap action button rows, ensure dark text in inputs, and improve timeline card highlight colors. Co-Authored-By: Claude Opus 4.6 --- main.py | 43 +++++++++++++++++++++++++++---------------- tab_batch_ng.py | 5 +++-- tab_timeline_ng.py | 4 ++-- 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/main.py b/main.py index b15ef4c..db13bff 100644 --- a/main.py +++ b/main.py @@ -17,6 +17,16 @@ from tab_comfy_ng import render_comfy_monitor @ui.page('/') def index(): + # -- Global styling for readability -- + ui.query('body').classes('bg-grey-2') + ui.add_css(''' + .q-expansion-item__content { padding: 4px 0; } + .action-row { flex-wrap: wrap !important; gap: 4px !important; } + .q-tab-panels { background: transparent !important; } + .q-textarea .q-field__native { color: #1a1a1a !important; } + .q-input .q-field__native { color: #1a1a1a !important; } + ''') + config = load_config() state = AppState( config=config, @@ -35,25 +45,26 @@ def index(): 'text-subtitle1 q-pa-lg') return - ui.label(f'Editing: {state.file_path.name}').classes('text-h5 q-mb-md') + with ui.card().classes('w-full q-pa-md'): + ui.label(f'Editing: {state.file_path.name}').classes('text-h5 q-mb-md') - with ui.tabs().classes('w-full') as tabs: - ui.tab('batch', label='Batch Processor') - ui.tab('timeline', label='Timeline') - ui.tab('raw', label='Raw Editor') + with ui.tabs().classes('w-full') as tabs: + ui.tab('batch', label='Batch Processor') + ui.tab('timeline', label='Timeline') + ui.tab('raw', label='Raw Editor') - with ui.tab_panels(tabs, value='batch').classes('w-full'): - with ui.tab_panel('batch'): - render_batch_processor(state) - with ui.tab_panel('timeline'): - render_timeline_tab(state) - with ui.tab_panel('raw'): - render_raw_editor(state) + with ui.tab_panels(tabs, value='batch').classes('w-full'): + with ui.tab_panel('batch'): + render_batch_processor(state) + with ui.tab_panel('timeline'): + render_timeline_tab(state) + with ui.tab_panel('raw'): + render_raw_editor(state) if state.show_comfy_monitor: - ui.separator() - with ui.expansion('ComfyUI Monitor', icon='dns').classes('w-full'): - render_comfy_monitor(state) + with ui.card().classes('w-full q-pa-md q-mt-md'): + with ui.expansion('ComfyUI Monitor', icon='dns').classes('w-full'): + render_comfy_monitor(state) def load_file(file_name: str): """Load a JSON file and refresh the main content.""" @@ -77,7 +88,7 @@ def index(): # ------------------------------------------------------------------ # Sidebar (rendered AFTER helpers are attached) # ------------------------------------------------------------------ - with ui.left_drawer().classes('q-pa-md').style('width: 350px'): + with ui.left_drawer().classes('q-pa-md bg-grey-1').style('width: 350px'): render_sidebar(state) # ------------------------------------------------------------------ diff --git a/tab_batch_ng.py b/tab_batch_ng.py index bc2139d..14bfc2c 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -284,9 +284,10 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, else: label = f'Sequence #{seq_num}' - with ui.expansion(label, icon='movie').classes('w-full'): + with ui.card().classes('w-full q-mb-sm'), \ + ui.expansion(label, icon='movie').classes('w-full'): # --- Action row --- - with ui.row().classes('w-full q-gutter-sm'): + with ui.row().classes('w-full q-gutter-sm action-row'): # Copy from source def copy_source(idx=i, sn=seq_num): item = DEFAULTS.copy() diff --git a/tab_timeline_ng.py b/tab_timeline_ng.py index 3ab3be3..91c9416 100644 --- a/tab_timeline_ng.py +++ b/tab_timeline_ng.py @@ -92,8 +92,8 @@ def render_timeline_tab(state: AppState): with ui.card().classes( 'w-full q-mb-sm' + - (' bg-yellow-1' if is_head else '') + - (' bg-red-1' if is_selected else '') + (' bg-amber-2' if is_head else '') + + (' bg-red-2' if is_selected else '') ): with ui.row().classes('w-full items-center'): if selection_mode.value: From 79a47e034ed738b6939c523a14bbc0cc06236972 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Wed, 25 Feb 2026 11:33:25 +0100 Subject: [PATCH 03/26] Switch to dark theme to match original Streamlit look Co-Authored-By: Claude Opus 4.6 --- main.py | 8 +++----- tab_timeline_ng.py | 4 ++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/main.py b/main.py index db13bff..994c9f0 100644 --- a/main.py +++ b/main.py @@ -17,14 +17,12 @@ from tab_comfy_ng import render_comfy_monitor @ui.page('/') def index(): - # -- Global styling for readability -- - ui.query('body').classes('bg-grey-2') + # -- Dark theme to match original Streamlit look -- + ui.dark_mode(True) ui.add_css(''' .q-expansion-item__content { padding: 4px 0; } .action-row { flex-wrap: wrap !important; gap: 4px !important; } .q-tab-panels { background: transparent !important; } - .q-textarea .q-field__native { color: #1a1a1a !important; } - .q-input .q-field__native { color: #1a1a1a !important; } ''') config = load_config() @@ -88,7 +86,7 @@ def index(): # ------------------------------------------------------------------ # Sidebar (rendered AFTER helpers are attached) # ------------------------------------------------------------------ - with ui.left_drawer().classes('q-pa-md bg-grey-1').style('width: 350px'): + with ui.left_drawer().classes('q-pa-md').style('width: 350px'): render_sidebar(state) # ------------------------------------------------------------------ diff --git a/tab_timeline_ng.py b/tab_timeline_ng.py index 91c9416..7435d1d 100644 --- a/tab_timeline_ng.py +++ b/tab_timeline_ng.py @@ -92,8 +92,8 @@ def render_timeline_tab(state: AppState): with ui.card().classes( 'w-full q-mb-sm' + - (' bg-amber-2' if is_head else '') + - (' bg-red-2' if is_selected else '') + (' bg-amber-9' if is_head else '') + + (' bg-red-9' if is_selected else '') ): with ui.row().classes('w-full items-center'): if selection_mode.value: From 3bbbdc827cc3c8d7215954f56869f2ef26c3e0ac Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Wed, 25 Feb 2026 11:34:41 +0100 Subject: [PATCH 04/26] Fix drawer JavaScript timeout by setting explicit initial value Co-Authored-By: Claude Opus 4.6 --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index 994c9f0..7806f36 100644 --- a/main.py +++ b/main.py @@ -86,7 +86,7 @@ def index(): # ------------------------------------------------------------------ # Sidebar (rendered AFTER helpers are attached) # ------------------------------------------------------------------ - with ui.left_drawer().classes('q-pa-md').style('width: 350px'): + with ui.left_drawer(value=True).classes('q-pa-md').style('width: 350px'): render_sidebar(state) # ------------------------------------------------------------------ From f48098c646796c1ef1598efaf3f1961b0a12888e Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Wed, 25 Feb 2026 11:38:34 +0100 Subject: [PATCH 05/26] Use splitter for 2-column sequence layout matching Streamlit Replaces row/col grid with a resizable splitter at 66/34 ratio, matching the original Streamlit st.columns([2, 1]) layout. Removes extra card wrapper from sequences to maximize content width. Co-Authored-By: Claude Opus 4.6 --- tab_batch_ng.py | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index 14bfc2c..ca18148 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -284,8 +284,7 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, else: label = f'Sequence #{seq_num}' - with ui.card().classes('w-full q-mb-sm'), \ - ui.expansion(label, icon='movie').classes('w-full'): + with ui.expansion(label, icon='movie').classes('w-full'): # --- Action row --- with ui.row().classes('w-full q-gutter-sm action-row'): # Copy from source @@ -391,10 +390,9 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, ui.separator() - # --- Prompts + Settings --- - with ui.row().classes('w-full q-gutter-md'): - # Left column: prompts - with ui.column().classes('col-8'): + # --- Prompts + Settings (2-column like Streamlit) --- + with ui.splitter(value=66).classes('w-full') as splitter: + with splitter.before: dict_textarea('General Prompt', seq, 'general_prompt').classes( 'w-full').props('outlined rows=2') dict_textarea('General Negative', seq, 'general_negative').classes( @@ -404,15 +402,14 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, dict_textarea('Specific Negative', seq, 'negative').classes( 'w-full').props('outlined rows=2') - # Right column: settings - with ui.column().classes('col-4'): + with splitter.after: # Sequence number sn_label = ( f'Seq Number (Sub #{parent_of(seq_num)}.{sub_index_of(seq_num)})' if is_subsegment(seq_num) else 'Sequence Number' ) sn_input = dict_number(sn_label, seq, KEY_SEQUENCE_NUMBER) - sn_input.props('outlined') + sn_input.props('outlined').classes('w-full') # Seed + randomize with ui.row().classes('w-full items-end'): @@ -428,14 +425,15 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, # CFG cfg_val = float(seq.get('cfg', DEFAULTS['cfg'])) cfg_input = ui.number('CFG', value=cfg_val, step=0.5, - format='%.1f').props('outlined') + format='%.1f').props('outlined').classes('w-full') cfg_input.on('blur', lambda e: seq.__setitem__( 'cfg', e.sender.value if e.sender.value is not None else DEFAULTS['cfg'])) - dict_input(ui.input, 'Camera', seq, 'camera').props('outlined') - dict_input(ui.input, 'FLF', seq, 'flf').props('outlined') - dict_number('End Frame', seq, 'end_frame').props('outlined') - dict_input(ui.input, 'Video File Path', seq, 'video file path').props('outlined') + 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') + dict_input(ui.input, 'Video File Path', seq, 'video file path').props( + 'outlined').classes('w-full') # Image paths with preview for img_label, img_key in [ From b6f31786c6b414744c7f5dc6894487fc8fb60bca Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Wed, 25 Feb 2026 14:09:00 +0100 Subject: [PATCH 06/26] Style NiceGUI to closely match Streamlit dark theme Exact Streamlit colors: #0E1117 background, #262730 secondary, #FF4B4B primary accent, #FAFAFA text, rgba borders. Match input styling, border-radius, sidebar width, tab indicators, and separator colors. Co-Authored-By: Claude Opus 4.6 --- main.py | 101 ++++++++++++++++++++++++++++++++++++--------- tab_timeline_ng.py | 11 ++--- 2 files changed, 87 insertions(+), 25 deletions(-) diff --git a/main.py b/main.py index 7806f36..ed8bef2 100644 --- a/main.py +++ b/main.py @@ -17,12 +17,74 @@ from tab_comfy_ng import render_comfy_monitor @ui.page('/') def index(): - # -- Dark theme to match original Streamlit look -- + # -- Streamlit dark theme -- ui.dark_mode(True) + ui.colors(primary='#FF4B4B') ui.add_css(''' - .q-expansion-item__content { padding: 4px 0; } + /* === Streamlit Dark Theme === */ + + /* Backgrounds */ + body.body--dark, + .q-page.body--dark, + .body--dark .q-page { background: #0E1117 !important; } + .body--dark .q-drawer { background: #262730 !important; } + .body--dark .q-card { background: #262730 !important; border-radius: 0.5rem; } + .body--dark .q-tab-panels { background: transparent !important; } + .body--dark .q-tab-panel { background: transparent !important; } + .body--dark .q-expansion-item { background: transparent !important; } + + /* Text */ + .body--dark { color: #FAFAFA !important; } + .body--dark .q-field__label { color: rgba(250,250,250,0.6) !important; } + .body--dark .text-caption { color: rgba(250,250,250,0.6) !important; } + .body--dark .text-subtitle1, + .body--dark .text-subtitle2 { color: #FAFAFA !important; } + + /* Inputs & textareas */ + .body--dark .q-field--outlined .q-field__control { + background: #262730 !important; + border-radius: 0.5rem !important; + } + .body--dark .q-field--outlined .q-field__control:before { + border-color: rgba(250,250,250,0.2) !important; + border-radius: 0.5rem !important; + } + .body--dark .q-field--outlined.q-field--focused .q-field__control:after { + border-color: #FF4B4B !important; + } + .body--dark .q-field__native, + .body--dark .q-field__input { color: #FAFAFA !important; } + + /* Sidebar inputs get main bg */ + .body--dark .q-drawer .q-field--outlined .q-field__control { + background: #0E1117 !important; + } + + /* Buttons */ + .body--dark .q-btn--standard { border-radius: 0.5rem !important; } + + /* Tabs */ + .body--dark .q-tab--active { color: #FF4B4B !important; } + .body--dark .q-tab__indicator { background: #FF4B4B !important; } + + /* Separators */ + .body--dark .q-separator { background: rgba(250,250,250,0.2) !important; } + + /* Expansion items */ + .body--dark .q-expansion-item__content { padding: 4px 0; } + .body--dark .q-item { border-radius: 0.5rem; } + + /* Splitter */ + .body--dark .q-splitter__separator { background: rgba(250,250,250,0.2) !important; } + + /* Action row wrap */ .action-row { flex-wrap: wrap !important; gap: 4px !important; } - .q-tab-panels { background: transparent !important; } + + /* Notifications */ + .body--dark .q-notification { border-radius: 0.5rem; } + + /* Font */ + body { font-family: "Source Sans Pro", "Source Sans 3", sans-serif !important; } ''') config = load_config() @@ -43,26 +105,25 @@ def index(): 'text-subtitle1 q-pa-lg') return - with ui.card().classes('w-full q-pa-md'): - ui.label(f'Editing: {state.file_path.name}').classes('text-h5 q-mb-md') + ui.label(f'Editing: {state.file_path.name}').classes('text-h5 q-mb-md') - with ui.tabs().classes('w-full') as tabs: - ui.tab('batch', label='Batch Processor') - ui.tab('timeline', label='Timeline') - ui.tab('raw', label='Raw Editor') + with ui.tabs().classes('w-full') as tabs: + ui.tab('batch', label='Batch Processor') + ui.tab('timeline', label='Timeline') + ui.tab('raw', label='Raw Editor') - with ui.tab_panels(tabs, value='batch').classes('w-full'): - with ui.tab_panel('batch'): - render_batch_processor(state) - with ui.tab_panel('timeline'): - render_timeline_tab(state) - with ui.tab_panel('raw'): - render_raw_editor(state) + with ui.tab_panels(tabs, value='batch').classes('w-full'): + with ui.tab_panel('batch'): + render_batch_processor(state) + with ui.tab_panel('timeline'): + render_timeline_tab(state) + with ui.tab_panel('raw'): + render_raw_editor(state) if state.show_comfy_monitor: - with ui.card().classes('w-full q-pa-md q-mt-md'): - with ui.expansion('ComfyUI Monitor', icon='dns').classes('w-full'): - render_comfy_monitor(state) + ui.separator() + with ui.expansion('ComfyUI Monitor', icon='dns').classes('w-full'): + render_comfy_monitor(state) def load_file(file_name: str): """Load a JSON file and refresh the main content.""" @@ -86,7 +147,7 @@ def index(): # ------------------------------------------------------------------ # Sidebar (rendered AFTER helpers are attached) # ------------------------------------------------------------------ - with ui.left_drawer(value=True).classes('q-pa-md').style('width: 350px'): + with ui.left_drawer(value=True).classes('q-pa-md').style('width: 300px'): render_sidebar(state) # ------------------------------------------------------------------ diff --git a/tab_timeline_ng.py b/tab_timeline_ng.py index 7435d1d..b1a9013 100644 --- a/tab_timeline_ng.py +++ b/tab_timeline_ng.py @@ -90,11 +90,12 @@ def render_timeline_tab(state: AppState): is_head = n['id'] == htree.head_id is_selected = n['id'] in selected_nodes - with ui.card().classes( - 'w-full q-mb-sm' + - (' bg-amber-9' if is_head else '') + - (' bg-red-9' if is_selected else '') - ): + card_style = '' + if is_selected: + card_style = 'background: #3d1f1f !important;' + elif is_head: + card_style = 'background: #1a2332 !important;' + with ui.card().classes('w-full q-mb-sm').style(card_style): with ui.row().classes('w-full items-center'): if selection_mode.value: ui.checkbox( From a0d58d8982c65d0bb2c31fd6b788b7c6ded66d33 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Wed, 25 Feb 2026 14:16:28 +0100 Subject: [PATCH 07/26] Fix multiple bugs found in code review - save_config calls now pass full config to preserve comfy settings - Mass update section moved inside refreshable to stay in sync - Deep copy source data to prevent shared mutable references - Clipboard copy uses json.dumps instead of repr() for safe JS - Comfy monitor uses async IO (run_in_executor) to avoid blocking - Auto-timeout now updates checkbox and refreshes live view UI - Image URLs properly URL-encoded with urllib.parse.urlencode Co-Authored-By: Claude Opus 4.6 --- main.py | 11 +++--- tab_batch_ng.py | 20 +++++------ tab_comfy_ng.py | 95 +++++++++++++++++++++++++++++++------------------ 3 files changed, 77 insertions(+), 49 deletions(-) diff --git a/main.py b/main.py index ed8bef2..c55b425 100644 --- a/main.py +++ b/main.py @@ -1,3 +1,4 @@ +import json from pathlib import Path from nicegui import ui @@ -175,7 +176,7 @@ def render_sidebar(state: AppState): if p is not None and p.is_dir(): state.current_dir = p state.config['last_dir'] = str(p) - save_config(state.current_dir, state.config['favorites']) + save_config(state.current_dir, state.config['favorites'], state.config) state.loaded_file = None state.file_path = None path_input.set_value(str(p)) @@ -192,7 +193,7 @@ def render_sidebar(state: AppState): d = str(state.current_dir) if d not in state.config['favorites']: state.config['favorites'].append(d) - save_config(state.current_dir, state.config['favorites']) + save_config(state.current_dir, state.config['favorites'], state.config) render_favorites.refresh() ui.button('Pin Folder', icon='push_pin', on_click=pin_folder).classes('w-full') @@ -213,7 +214,7 @@ def render_sidebar(state: AppState): def _jump_to(fav: str): state.current_dir = Path(fav) state.config['last_dir'] = fav - save_config(state.current_dir, state.config['favorites']) + save_config(state.current_dir, state.config['favorites'], state.config) state.loaded_file = None state.file_path = None path_input.set_value(fav) @@ -224,7 +225,7 @@ def render_sidebar(state: AppState): def _unpin(fav: str): if fav in state.config['favorites']: state.config['favorites'].remove(fav) - save_config(state.current_dir, state.config['favorites']) + save_config(state.current_dir, state.config['favorites'], state.config) render_favorites.refresh() render_favorites() @@ -260,7 +261,7 @@ def render_sidebar(state: AppState): with ui.row().classes('w-full items-center'): async def copy_snippet(c=content): await ui.run_javascript( - f'navigator.clipboard.writeText({c!r})', timeout=3.0) + f'navigator.clipboard.writeText({json.dumps(c)})', timeout=3.0) ui.notify('Copied to clipboard') ui.button( diff --git a/tab_batch_ng.py b/tab_batch_ng.py index ca18148..727c4b6 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -194,13 +194,13 @@ def render_batch_processor(state: AppState): _add_sequence(DEFAULTS.copy()) def add_from_source(): - item = DEFAULTS.copy() + item = copy.deepcopy(DEFAULTS) src_batch = _src_cache['batch'] sel_idx = src_seq_select.value if src_batch and sel_idx is not None: - item.update(src_batch[int(sel_idx)]) + item.update(copy.deepcopy(src_batch[int(sel_idx)])) elif _src_cache['data']: - item.update(_src_cache['data']) + item.update(copy.deepcopy(_src_cache['data'])) _add_sequence(item) ui.button('Add Empty', icon='add', on_click=add_empty) @@ -208,9 +208,6 @@ def render_batch_processor(state: AppState): ui.separator() - # --- Mass Update --- - _render_mass_update(batch_list, data, file_path, state) - # --- Standard / LoRA / VACE key sets --- lora_keys = ['lora 1 high', 'lora 1 low', 'lora 2 high', 'lora 2 low', 'lora 3 high', 'lora 3 low'] @@ -230,9 +227,12 @@ def render_batch_processor(state: AppState): ui.notify('Sorted by sequence number!', type='positive') render_sequence_list.refresh() - # --- Sequence list (count label + cards inside refreshable) --- + # --- Sequence list + mass update (inside refreshable so they stay in sync) --- @ui.refreshable def render_sequence_list(): + # Mass update (rebuilt on refresh so checkboxes match current sequences) + _render_mass_update(batch_list, data, file_path, state) + with ui.row().classes('w-full items-center'): ui.label(f'Batch contains {len(batch_list)} sequences.') ui.button('Sort by Number', icon='sort', on_click=sort_by_number).props('flat') @@ -289,13 +289,13 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, with ui.row().classes('w-full q-gutter-sm action-row'): # Copy from source def copy_source(idx=i, sn=seq_num): - item = DEFAULTS.copy() + item = copy.deepcopy(DEFAULTS) src_batch = src_cache['batch'] sel_idx = src_seq_select.value if src_batch and sel_idx is not None: - item.update(src_batch[int(sel_idx)]) + item.update(copy.deepcopy(src_batch[int(sel_idx)])) elif src_cache['data']: - item.update(src_cache['data']) + item.update(copy.deepcopy(src_cache['data'])) item[KEY_SEQUENCE_NUMBER] = sn item.pop(KEY_PROMPT_HISTORY, None) item.pop(KEY_HISTORY_TREE, None) diff --git a/tab_comfy_ng.py b/tab_comfy_ng.py index 8499647..2c23c61 100644 --- a/tab_comfy_ng.py +++ b/tab_comfy_ng.py @@ -1,3 +1,4 @@ +import asyncio import html import time import urllib.parse @@ -76,17 +77,33 @@ def render_comfy_monitor(state: AppState): render_instance_tabs() # --- Auto-poll timer (every 300s) --- + # Store live_checkbox references so the timer can update them + _live_checkboxes = state._live_checkboxes = getattr(state, '_live_checkboxes', {}) + _live_refreshables = state._live_refreshables = getattr(state, '_live_refreshables', {}) + def poll_all(): - # Timeout checks for live toggles timeout_val = config.get('monitor_timeout', 0) if timeout_val > 0: for key, start_time in list(state.live_toggles.items()): if start_time and (time.time() - start_time) > (timeout_val * 60): state.live_toggles[key] = None + if key in _live_checkboxes: + _live_checkboxes[key].set_value(False) + if key in _live_refreshables: + _live_refreshables[key].refresh() ui.timer(300, poll_all) +def _fetch_blocking(url, timeout=1.5): + """Run a blocking GET request; returns (response, error).""" + try: + res = requests.get(url, timeout=timeout) + return res, None + except Exception as e: + return None, e + + def _render_single_instance(state: AppState, instance_config: dict, index: int, all_instances: list, refresh_fn): config = state.config @@ -120,11 +137,13 @@ def _render_single_instance(state: AppState, instance_config: dict, index: int, # --- Status Dashboard --- status_container = ui.row().classes('w-full items-center q-gutter-md') - def refresh_status(): + async def refresh_status(): status_container.clear() + loop = asyncio.get_event_loop() + res, err = await loop.run_in_executor( + None, lambda: _fetch_blocking(f'{comfy_url}/queue')) with status_container: - try: - res = requests.get(f'{comfy_url}/queue', timeout=1.5) + if res is not None: queue_data = res.json() running_cnt = len(queue_data.get('queue_running', [])) pending_cnt = len(queue_data.get('queue_pending', [])) @@ -139,13 +158,14 @@ def _render_single_instance(state: AppState, instance_config: dict, index: int, with ui.card().classes('q-pa-sm'): ui.label('Running') ui.label(str(running_cnt)) - except Exception: + else: with ui.card().classes('q-pa-sm'): ui.label('Status') ui.label('Offline').classes('text-negative') ui.label(f'Could not connect to {comfy_url}').classes('text-negative') - refresh_status() + # Initial status fetch (non-blocking via button click handler pattern) + ui.timer(0.1, refresh_status, once=True) ui.button('Refresh Status', icon='refresh', on_click=refresh_status).props('flat dense') # --- Live View --- @@ -153,6 +173,8 @@ def _render_single_instance(state: AppState, instance_config: dict, index: int, toggle_key = f'live_toggle_{index}' live_checkbox = ui.checkbox('Enable Live Preview', value=False) + # Store reference so poll_all timer can disable it on timeout + state._live_checkboxes[toggle_key] = live_checkbox @ui.refreshable def render_live_view(): @@ -199,6 +221,7 @@ def _render_single_instance(state: AppState, instance_config: dict, index: int, else: ui.label('No valid viewer URL configured.').classes('text-warning') + state._live_refreshables[toggle_key] = render_live_view live_checkbox.on_value_change(lambda _: render_live_view.refresh()) render_live_view() @@ -206,36 +229,40 @@ def _render_single_instance(state: AppState, instance_config: dict, index: int, ui.label('Latest Output').classes('text-subtitle1 q-mt-md') img_container = ui.column().classes('w-full') - def check_image(): + async def check_image(): img_container.clear() + loop = asyncio.get_event_loop() + res, err = await loop.run_in_executor( + None, lambda: _fetch_blocking(f'{comfy_url}/history', timeout=2)) with img_container: - try: - hist_res = requests.get(f'{comfy_url}/history', timeout=2) - history = hist_res.json() - if not history: - ui.label('No history found.').classes('text-caption') - return - last_prompt_id = list(history.keys())[-1] - outputs = history[last_prompt_id].get('outputs', {}) - found_img = None - for node_output in outputs.values(): - if 'images' in node_output: - for img_info in node_output['images']: - if img_info['type'] == 'output': - found_img = img_info - break - if found_img: - break + if err is not None: + ui.label(f'Error fetching image: {err}').classes('text-negative') + return + history = res.json() + if not history: + ui.label('No history found.').classes('text-caption') + return + last_prompt_id = list(history.keys())[-1] + outputs = history[last_prompt_id].get('outputs', {}) + found_img = None + for node_output in outputs.values(): + if 'images' in node_output: + for img_info in node_output['images']: + if img_info['type'] == 'output': + found_img = img_info + break if found_img: - img_name = found_img['filename'] - folder = found_img['subfolder'] - img_type = found_img['type'] - img_url = f'{comfy_url}/view?filename={img_name}&subfolder={folder}&type={img_type}' - ui.image(img_url).classes('w-full').style('max-width: 600px') - ui.label(f'Last Output: {img_name}').classes('text-caption') - else: - ui.label('Last run had no image output.').classes('text-caption') - except Exception as e: - ui.label(f'Error fetching image: {e}').classes('text-negative') + break + if found_img: + params = urllib.parse.urlencode({ + 'filename': found_img['filename'], + 'subfolder': found_img['subfolder'], + 'type': found_img['type'], + }) + img_url = f'{comfy_url}/view?{params}' + ui.image(img_url).classes('w-full').style('max-width: 600px') + ui.label(f'Last Output: {found_img["filename"]}').classes('text-caption') + else: + ui.label('Last run had no image output.').classes('text-caption') ui.button('Check Latest Image', icon='image', on_click=check_image).props('flat') From 3928f4d225326fdf84e1a836f24f8a2bed3e4049 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Wed, 25 Feb 2026 14:22:40 +0100 Subject: [PATCH 08/26] Fix select options not pushing to browser and remaining shallow copies - Use set_options() instead of direct .options assignment (3 locations) so dropdown changes actually reach the browser - Wrap res.json() in try/except for non-JSON server responses - Deep copy in create_batch and promote to match rest of codebase Co-Authored-By: Claude Opus 4.6 --- tab_batch_ng.py | 15 +++++++-------- tab_comfy_ng.py | 12 ++++++++++-- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index 727c4b6..4d94d4f 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -119,7 +119,7 @@ def render_batch_processor(state: AppState): if new_path.exists(): ui.notify(f'File {new_name} already exists!', type='warning') return - first_item = data.copy() + first_item = copy.deepcopy(data) first_item.pop(KEY_PROMPT_HISTORY, None) first_item.pop(KEY_HISTORY_TREE, None) first_item[KEY_SEQUENCE_NUMBER] = 1 @@ -164,10 +164,9 @@ def render_batch_processor(state: AppState): if _src_cache['batch']: opts = {i: format_seq_label(s.get(KEY_SEQUENCE_NUMBER, i+1)) for i, s in enumerate(_src_cache['batch'])} - src_seq_select.options = opts - src_seq_select.set_value(0) + src_seq_select.set_options(opts, value=0) else: - src_seq_select.options = {} + src_seq_select.set_options({}) src_file_select.on_value_change(lambda _: _update_src()) _update_src() @@ -366,9 +365,9 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, # Promote def promote(idx=i, s=seq): - single_data = s.copy() - single_data[KEY_PROMPT_HISTORY] = data.get(KEY_PROMPT_HISTORY, []) - single_data[KEY_HISTORY_TREE] = data.get(KEY_HISTORY_TREE, {}) + single_data = copy.deepcopy(s) + single_data[KEY_PROMPT_HISTORY] = copy.deepcopy(data.get(KEY_PROMPT_HISTORY, [])) + single_data[KEY_HISTORY_TREE] = copy.deepcopy(data.get(KEY_HISTORY_TREE, {})) single_data.pop(KEY_SEQUENCE_NUMBER, None) save_json(file_path, single_data) state.data_cache = single_data @@ -646,7 +645,7 @@ def _render_mass_update(batch_list, data, file_path, state: AppState): if idx is not None and 0 <= idx < len(batch_list): src = batch_list[idx] keys = [k for k in src.keys() if k != 'sequence_number'] - field_select.options = keys + field_select.set_options(keys) source_select.on_value_change(update_fields) update_fields() diff --git a/tab_comfy_ng.py b/tab_comfy_ng.py index 2c23c61..d431ac8 100644 --- a/tab_comfy_ng.py +++ b/tab_comfy_ng.py @@ -144,7 +144,11 @@ def _render_single_instance(state: AppState, instance_config: dict, index: int, None, lambda: _fetch_blocking(f'{comfy_url}/queue')) with status_container: if res is not None: - queue_data = res.json() + try: + queue_data = res.json() + except (ValueError, Exception): + ui.label('Invalid response from server').classes('text-negative') + return running_cnt = len(queue_data.get('queue_running', [])) pending_cnt = len(queue_data.get('queue_pending', [])) @@ -238,7 +242,11 @@ def _render_single_instance(state: AppState, instance_config: dict, index: int, if err is not None: ui.label(f'Error fetching image: {err}').classes('text-negative') return - history = res.json() + try: + history = res.json() + except (ValueError, Exception): + ui.label('Invalid response from server').classes('text-negative') + return if not history: ui.label('No history found.').classes('text-caption') return From b7a7d8c37974f4c05b2714df00be63ad2491c70f Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Wed, 25 Feb 2026 14:28:46 +0100 Subject: [PATCH 09/26] Update README for NiceGUI migration Update badge, installation instructions, port references, and file structure to reflect the migration from Streamlit to NiceGUI. Co-Authored-By: Claude Opus 4.6 --- README.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index ef21b79..9a4e69b 100644 --- a/README.md +++ b/README.md @@ -21,13 +21,13 @@

License Python - Streamlit + NiceGUI ComfyUI

A visual dashboard for managing, versioning, and batch-processing JSON configuration files used in AI video generation workflows (I2V, VACE). Two parts: -1. **Streamlit Web Interface** — Dockerized editor for prompts, LoRAs, settings, and branching history +1. **NiceGUI Web Interface** — Dockerized editor for prompts, LoRAs, settings, and branching history 2. **ComfyUI Custom Nodes** — Read JSON files directly into workflows, including a dynamic node that auto-discovers keys --- @@ -86,12 +86,12 @@ Dynamic Node (New) ## Installation -### 1. Unraid / Docker (Streamlit Manager) +### 1. Unraid / Docker (NiceGUI Manager) ```bash # Repository: python:3.12-slim # Network: Bridge -# WebUI: http://[IP]:[PORT:8501] +# WebUI: http://[IP]:[PORT:8080] ``` **Path Mappings:** @@ -103,8 +103,8 @@ Dynamic Node (New) **Post Arguments:** ```bash /bin/sh -c "apt-get update && apt-get install -y graphviz && \ - pip install streamlit opencv-python-headless graphviz streamlit-agraph && \ - cd /app && streamlit run app.py --server.headless true --server.port 8501" + pip install nicegui graphviz requests && \ + cd /app && python main.py" ``` ### 2. ComfyUI (Custom Nodes) @@ -274,7 +274,7 @@ The **JSON Loader (Dynamic)** node reads your JSON file and automatically create ## Web Interface Usage -Navigate to your container's IP (e.g., `http://192.168.1.100:8501`). +Navigate to your container's IP (e.g., `http://192.168.1.100:8080`). **Path navigation** supports case-insensitive matching — typing `/media/P5/myFolder` will resolve to `/media/p5/MyFolder` automatically. @@ -315,13 +315,14 @@ ComfyUI-JSON-Manager/ ├── json_loader.py # All ComfyUI node classes + /json_manager/get_keys API ├── web/ │ └── json_dynamic.js # Frontend extension for Dynamic node (refresh, show/hide) -├── app.py # Streamlit main entry point & navigator +├── main.py # NiceGUI web UI entry point & navigator +├── state.py # Application state management ├── utils.py # I/O, config, defaults, case-insensitive path resolver ├── history_tree.py # Git-style branching engine -├── tab_batch.py # Batch processor UI -├── tab_timeline.py # Visual timeline UI -├── tab_comfy.py # ComfyUI server monitor -├── tab_raw.py # Raw JSON editor +├── tab_batch_ng.py # Batch processor UI (NiceGUI) +├── tab_timeline_ng.py # Visual timeline UI (NiceGUI) +├── tab_comfy_ng.py # ComfyUI server monitor (NiceGUI) +├── tab_raw_ng.py # Raw JSON editor (NiceGUI) └── tests/ ├── test_json_loader.py ├── test_utils.py From 9c171627d8114d676f1bc8e49feeca8fb88dc445 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Wed, 25 Feb 2026 15:54:12 +0100 Subject: [PATCH 10/26] Fix mass update not refreshing UI after applying changes Co-Authored-By: Claude Opus 4.6 --- tab_batch_ng.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index 4d94d4f..257dd97 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -230,7 +230,7 @@ def render_batch_processor(state: AppState): @ui.refreshable def render_sequence_list(): # Mass update (rebuilt on refresh so checkboxes match current sequences) - _render_mass_update(batch_list, data, file_path, state) + _render_mass_update(batch_list, data, file_path, state, render_sequence_list) with ui.row().classes('w-full items-center'): ui.label(f'Batch contains {len(batch_list)} sequences.') @@ -626,7 +626,7 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, refresh_list): # Mass Update # ====================================================================== -def _render_mass_update(batch_list, data, file_path, state: AppState): +def _render_mass_update(batch_list, data, file_path, state: AppState, refresh_list=None): with ui.expansion('Mass Update', icon='sync').classes('w-full'): if len(batch_list) < 2: ui.label('Need at least 2 sequences for mass update.').classes('text-caption') @@ -693,6 +693,8 @@ def _render_mass_update(batch_list, data, file_path, state: AppState): data[KEY_HISTORY_TREE] = htree.to_dict() save_json(file_path, data) ui.notify(f'Updated {len(targets)} sequences', type='positive') + if refresh_list: + refresh_list.refresh() ui.button('Apply Changes', icon='check', on_click=apply_mass_update).props( 'color=primary') From a8c9a0376dd0be12542b65d67cff3c5a62decfdc Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 26 Feb 2026 16:30:20 +0100 Subject: [PATCH 11/26] Fix number inputs saving whole numbers as floats in JSON NiceGUI's ui.number returns float values, so seeds, steps, dimensions etc. were being stored as floats (e.g. 42.0) instead of integers. Co-Authored-By: Claude Opus 4.6 --- tab_batch_ng.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index 257dd97..4a23c9d 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -88,8 +88,16 @@ def dict_number(label, seq, key, **kwargs): except (ValueError, TypeError): val = 0 el = ui.number(label, value=val, **kwargs) - el.on('blur', lambda e, k=key: seq.__setitem__( - k, e.sender.value if e.sender.value is not None else 0)) + + def _on_blur(e, k=key): + v = e.sender.value + if v is None: + v = 0 + elif isinstance(v, float) and v == int(v): + v = int(v) + seq[k] = v + + el.on('blur', _on_blur) return el From b0125133f1d7324afe2ced200e2731f903d51210 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 26 Feb 2026 16:56:40 +0100 Subject: [PATCH 12/26] Refactor for readability: declare state attrs, extract helpers, deduplicate - Declare dynamic attributes (_render_main, _load_file, etc.) in AppState dataclass instead of monkey-patching at runtime - Extract max_main_seq_number() and FRAME_TO_SKIP_DEFAULT in batch tab - Add commit() closure in _render_sequence_card to deduplicate save/notify/refresh - Add default param to dict_number(), replace hand-rolled CFG/VACE/custom bindings - Extract _delete_nodes() helper in timeline to deduplicate single/batch delete - Split 230-line render_timeline refreshable into 4 focused section helpers Co-Authored-By: Claude Opus 4.6 --- state.py | 8 + tab_batch_ng.py | 98 +++++------ tab_comfy_ng.py | 4 +- tab_timeline_ng.py | 411 +++++++++++++++++++++++---------------------- 4 files changed, 258 insertions(+), 263 deletions(-) diff --git a/state.py b/state.py index 0f3d370..5ce99b6 100644 --- a/state.py +++ b/state.py @@ -1,5 +1,6 @@ from dataclasses import dataclass, field from pathlib import Path +from typing import Any, Callable @dataclass @@ -15,3 +16,10 @@ class AppState: timeline_selected_nodes: set = field(default_factory=set) live_toggles: dict = field(default_factory=dict) show_comfy_monitor: bool = True + + # Set at runtime by main.py / tab_comfy_ng.py + _render_main: Any = None + _load_file: Callable | None = None + _main_rendered: bool = False + _live_checkboxes: dict = field(default_factory=dict) + _live_refreshables: dict = field(default_factory=dict) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index 4a23c9d..9120410 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -13,6 +13,7 @@ from history_tree import HistoryTree IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.bmp', '.gif'} SUB_SEGMENT_MULTIPLIER = 1000 +FRAME_TO_SKIP_DEFAULT = DEFAULTS['frame_to_skip'] VACE_MODES = [ 'End Extend', 'Pre Extend', 'Middle Extend', 'Edge Extend', @@ -54,6 +55,15 @@ def next_sub_segment_number(batch_list, parent_seq_num): max_sub = max(max_sub, sub_index_of(sn)) return parent_seq_num * SUB_SEGMENT_MULTIPLIER + max_sub + 1 +def max_main_seq_number(batch_list): + """Highest non-subsegment sequence number in the batch.""" + return max( + (int(x.get(KEY_SEQUENCE_NUMBER, 0)) + for x in batch_list if not is_subsegment(x.get(KEY_SEQUENCE_NUMBER, 0))), + default=0, + ) + + def find_insert_position(batch_list, parent_index, parent_seq_num): parent_seq_num = int(parent_seq_num) pos = parent_index + 1 @@ -78,21 +88,21 @@ def dict_input(element_fn, label, seq, key, **kwargs): return el -def dict_number(label, seq, key, **kwargs): +def dict_number(label, seq, key, default=0, **kwargs): """Number input bound to seq[key] via blur.""" - val = seq.get(key, 0) + val = seq.get(key, default) try: # Try float first to handle "1.5" strings, then check if it's a clean int fval = float(val) val = int(fval) if fval == int(fval) else fval except (ValueError, TypeError): - val = 0 + val = default el = ui.number(label, value=val, **kwargs) - def _on_blur(e, k=key): + def _on_blur(e, k=key, d=default): v = e.sender.value if v is None: - v = 0 + v = d elif isinstance(v, float) and v == int(v): v = int(v) seq[k] = v @@ -183,12 +193,7 @@ def render_batch_processor(state: AppState): ui.label('Add New Sequence').classes('text-subtitle1 q-mt-md') def _add_sequence(new_item): - max_seq = 0 - for s in batch_list: - sn = int(s.get(KEY_SEQUENCE_NUMBER, 0)) - if not is_subsegment(sn): - max_seq = max(max_seq, sn) - new_item[KEY_SEQUENCE_NUMBER] = max_seq + 1 + new_item[KEY_SEQUENCE_NUMBER] = max_main_seq_number(batch_list) + 1 for k in [KEY_PROMPT_HISTORY, KEY_HISTORY_TREE, 'note', 'loras']: new_item.pop(k, None) batch_list.append(new_item) @@ -284,6 +289,13 @@ def render_batch_processor(state: AppState): def _render_sequence_card(i, seq, batch_list, data, file_path, state, src_cache, src_seq_select, standard_keys, refresh_list): + def commit(message=None): + data[KEY_BATCH_DATA] = batch_list + save_json(file_path, data) + if message: + ui.notify(message, type='positive') + refresh_list.refresh() + seq_num = seq.get(KEY_SEQUENCE_NUMBER, i + 1) if is_subsegment(seq_num): @@ -307,46 +319,29 @@ 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 - data[KEY_BATCH_DATA] = batch_list - save_json(file_path, data) - ui.notify('Copied!', type='positive') - refresh_list.refresh() + commit('Copied!') ui.button('Copy Src', icon='file_download', on_click=copy_source).props('dense') # Clone Next def clone_next(idx=i, sn=seq_num, s=seq): new_seq = copy.deepcopy(s) - max_sn = max( - (int(x.get(KEY_SEQUENCE_NUMBER, 0)) - for x in batch_list if not is_subsegment(x.get(KEY_SEQUENCE_NUMBER, 0))), - default=0) - new_seq[KEY_SEQUENCE_NUMBER] = max_sn + 1 + new_seq[KEY_SEQUENCE_NUMBER] = max_main_seq_number(batch_list) + 1 if not is_subsegment(sn): pos = find_insert_position(batch_list, idx, int(sn)) else: pos = idx + 1 batch_list.insert(pos, new_seq) - data[KEY_BATCH_DATA] = batch_list - save_json(file_path, data) - ui.notify('Cloned to Next!', type='positive') - refresh_list.refresh() + commit('Cloned to Next!') ui.button('Clone Next', icon='content_copy', on_click=clone_next).props('dense') # Clone End def clone_end(s=seq): new_seq = copy.deepcopy(s) - max_sn = max( - (int(x.get(KEY_SEQUENCE_NUMBER, 0)) - for x in batch_list if not is_subsegment(x.get(KEY_SEQUENCE_NUMBER, 0))), - default=0) - new_seq[KEY_SEQUENCE_NUMBER] = max_sn + 1 + new_seq[KEY_SEQUENCE_NUMBER] = max_main_seq_number(batch_list) + 1 batch_list.append(new_seq) - data[KEY_BATCH_DATA] = batch_list - save_json(file_path, data) - ui.notify('Cloned to End!', type='positive') - refresh_list.refresh() + commit('Cloned to End!') ui.button('Clone End', icon='vertical_align_bottom', on_click=clone_end).props('dense') @@ -363,11 +358,7 @@ 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) - data[KEY_BATCH_DATA] = batch_list - save_json(file_path, data) - ui.notify(f'Created {format_seq_label(new_seq[KEY_SEQUENCE_NUMBER])}!', - type='positive') - refresh_list.refresh() + commit(f'Created {format_seq_label(new_seq[KEY_SEQUENCE_NUMBER])}!') ui.button('Clone Sub', icon='link', on_click=clone_sub).props('dense') @@ -389,9 +380,7 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, # Delete def delete(idx=i): batch_list.pop(idx) - data[KEY_BATCH_DATA] = batch_list - save_json(file_path, data) - refresh_list.refresh() + commit() ui.button(icon='delete', on_click=delete).props('dense color=negative') @@ -430,11 +419,8 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, ui.button(icon='casino', on_click=randomize_seed).props('flat') # CFG - cfg_val = float(seq.get('cfg', DEFAULTS['cfg'])) - cfg_input = ui.number('CFG', value=cfg_val, step=0.5, - format='%.1f').props('outlined').classes('w-full') - cfg_input.on('blur', lambda e: seq.__setitem__( - 'cfg', e.sender.value if e.sender.value is not None else DEFAULTS['cfg'])) + dict_number('CFG', seq, 'cfg', default=DEFAULTS['cfg'], + step=0.5, format='%.1f').props('outlined').classes('w-full') dict_input(ui.input, 'Camera', seq, 'camera').props('outlined').classes('w-full') dict_input(ui.input, 'FLF', seq, 'flf').props('outlined').classes('w-full') @@ -496,14 +482,11 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, for k in custom_keys: with ui.row().classes('w-full items-center'): ui.input('Key', value=k).props('readonly outlined dense').classes('w-32') - val_input = ui.input('Value', value=str(seq[k])).props( - 'outlined dense').classes('col') - val_input.on('blur', lambda e, key=k: seq.__setitem__(key, e.sender.value)) + dict_input(ui.input, 'Value', seq, k).props('outlined dense').classes('col') def del_custom(key=k): del seq[key] - save_json(file_path, data) - refresh_list.refresh() + commit() ui.button(icon='delete', on_click=del_custom).props('flat dense color=negative') @@ -516,10 +499,9 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, v = new_v_input.value if k and k not in seq: seq[k] = v - save_json(file_path, data) new_k_input.set_value('') new_v_input.set_value('') - refresh_list.refresh() + commit() ui.button('Add', on_click=add_param).props('flat') @@ -535,7 +517,7 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, refresh_list): 'outlined') # Capture original at render time; blur updates seq before click fires - _original_fts = int(seq.get('frame_to_skip', 81)) + _original_fts = int(seq.get('frame_to_skip', FRAME_TO_SKIP_DEFAULT)) def shift_fts(idx=i, orig=_original_fts): new_fts = int(fts_input.value) if fts_input.value is not None else orig @@ -546,7 +528,7 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, refresh_list): shifted = 0 for j in range(idx + 1, len(batch_list)): batch_list[j]['frame_to_skip'] = int( - batch_list[j].get('frame_to_skip', 81)) + delta + batch_list[j].get('frame_to_skip', FRAME_TO_SKIP_DEFAULT)) + delta shifted += 1 data[KEY_BATCH_DATA] = batch_list save_json(file_path, data) @@ -560,10 +542,8 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, refresh_list): # VACE Schedule sched_val = max(0, min(int(seq.get('vace schedule', 1)), len(VACE_MODES) - 1)) with ui.row().classes('w-full items-center'): - vs_input = ui.number('VACE Schedule', value=sched_val, min=0, - max=len(VACE_MODES) - 1).classes('col').props('outlined') - vs_input.on('blur', lambda e: seq.__setitem__( - 'vace schedule', int(e.sender.value) if e.sender.value is not None else 0)) + vs_input = dict_number('VACE Schedule', seq, 'vace schedule', + min=0, max=len(VACE_MODES) - 1).classes('col').props('outlined') mode_label = ui.label(VACE_MODES[sched_val]).classes('text-caption') def update_mode_label(e): diff --git a/tab_comfy_ng.py b/tab_comfy_ng.py index d431ac8..496d455 100644 --- a/tab_comfy_ng.py +++ b/tab_comfy_ng.py @@ -78,8 +78,8 @@ def render_comfy_monitor(state: AppState): # --- Auto-poll timer (every 300s) --- # Store live_checkbox references so the timer can update them - _live_checkboxes = state._live_checkboxes = getattr(state, '_live_checkboxes', {}) - _live_refreshables = state._live_refreshables = getattr(state, '_live_refreshables', {}) + _live_checkboxes = state._live_checkboxes + _live_refreshables = state._live_refreshables def poll_all(): timeout_val = config.get('monitor_timeout', 0) diff --git a/tab_timeline_ng.py b/tab_timeline_ng.py index b1a9013..9aaee0a 100644 --- a/tab_timeline_ng.py +++ b/tab_timeline_ng.py @@ -8,6 +8,207 @@ from history_tree import HistoryTree from utils import save_json, KEY_BATCH_DATA, KEY_HISTORY_TREE +def _delete_nodes(htree, data, file_path, node_ids): + """Delete nodes with backup, branch cleanup, and head fallback.""" + if 'history_tree_backup' not in data: + data['history_tree_backup'] = [] + data['history_tree_backup'].append(copy.deepcopy(htree.to_dict())) + for nid in node_ids: + htree.nodes.pop(nid, None) + for b, tip in list(htree.branches.items()): + if tip in node_ids: + del htree.branches[b] + if htree.head_id in node_ids: + if htree.nodes: + htree.head_id = sorted(htree.nodes.values(), + key=lambda x: x['timestamp'])[-1]['id'] + else: + htree.head_id = None + data[KEY_HISTORY_TREE] = htree.to_dict() + save_json(file_path, data) + + +def _render_selection_picker(all_nodes, htree, state, refresh_fn): + """Multi-select picker for batch-deleting timeline nodes.""" + all_ids = [n['id'] for n in all_nodes] + + def fmt_option(nid): + n = htree.nodes[nid] + ts = time.strftime('%b %d %H:%M', time.localtime(n['timestamp'])) + note = n.get('note', 'Step') + head = ' (HEAD)' if nid == htree.head_id else '' + return f'{note} - {ts} ({nid[:6]}){head}' + + options = {nid: fmt_option(nid) for nid in all_ids} + + def on_selection_change(e): + state.timeline_selected_nodes = set(e.value) if e.value else set() + + ui.select( + options, + value=list(state.timeline_selected_nodes), + multiple=True, + label='Select nodes to delete:', + on_change=on_selection_change, + ).classes('w-full') + + with ui.row(): + def select_all(): + state.timeline_selected_nodes = set(all_ids) + refresh_fn() + def deselect_all(): + state.timeline_selected_nodes = set() + refresh_fn() + ui.button('Select All', on_click=select_all).props('flat dense') + ui.button('Deselect All', on_click=deselect_all).props('flat dense') + + +def _render_graph_or_log(mode, all_nodes, htree, selected_nodes, + selection_mode_on, toggle_select_fn, restore_fn): + """Render graph visualization or linear log view.""" + if mode in ('Horizontal', 'Vertical'): + direction = 'LR' if mode == 'Horizontal' else 'TB' + try: + graph_dot = htree.generate_graph(direction=direction) + _render_graphviz(graph_dot) + except Exception as e: + ui.label(f'Graph Error: {e}').classes('text-negative') + + elif mode == 'Linear Log': + ui.label('Chronological list of all snapshots.').classes('text-caption') + for n in all_nodes: + is_head = n['id'] == htree.head_id + is_selected = n['id'] in selected_nodes + + card_style = '' + if is_selected: + card_style = 'background: #3d1f1f !important;' + elif is_head: + card_style = 'background: #1a2332 !important;' + with ui.card().classes('w-full q-mb-sm').style(card_style): + with ui.row().classes('w-full items-center'): + if selection_mode_on: + ui.checkbox( + '', + value=is_selected, + on_change=lambda e, nid=n['id']: toggle_select_fn( + nid, e.value), + ) + + icon = 'location_on' if is_head else 'circle' + ui.icon(icon).classes( + 'text-primary' if is_head else 'text-grey') + + with ui.column().classes('col'): + note = n.get('note', 'Step') + ts = time.strftime('%b %d %H:%M', + time.localtime(n['timestamp'])) + label = f'{note} (Current)' if is_head else note + ui.label(label).classes('text-bold') + ui.label( + f'ID: {n["id"][:6]} - {ts}').classes('text-caption') + + if not is_head and not selection_mode_on: + ui.button( + 'Restore', + icon='restore', + on_click=lambda node=n: restore_fn(node), + ).props('flat dense color=primary') + + +def _render_batch_delete(htree, data, file_path, state, refresh_fn): + """Render batch delete controls for selected timeline nodes.""" + valid = state.timeline_selected_nodes & set(htree.nodes.keys()) + state.timeline_selected_nodes = valid + count = len(valid) + if count == 0: + return + + ui.label( + f'{count} node{"s" if count != 1 else ""} selected for deletion.' + ).classes('text-warning q-mt-md') + + def do_batch_delete(): + _delete_nodes(htree, data, file_path, valid) + state.timeline_selected_nodes = set() + ui.notify( + f'Deleted {count} node{"s" if count != 1 else ""}!', + type='positive') + refresh_fn() + + ui.button( + f'Delete {count} Node{"s" if count != 1 else ""}', + icon='delete', + on_click=do_batch_delete, + ).props('color=negative') + + +def _render_node_manager(all_nodes, htree, data, file_path, restore_fn, refresh_fn): + """Render node selector with restore, rename, delete, and preview.""" + ui.label('Manage Version').classes('text-subtitle1 q-mt-md') + + def fmt_node(n): + ts = time.strftime('%b %d %H:%M', time.localtime(n['timestamp'])) + return f'{n.get("note", "Step")} - {ts} ({n["id"][:6]})' + + node_options = {n['id']: fmt_node(n) for n in all_nodes} + current_id = htree.head_id if htree.head_id in node_options else ( + all_nodes[0]['id'] if all_nodes else None) + + selected_node_id = ui.select( + node_options, + value=current_id, + label='Select Version to Manage:', + ).classes('w-full') + + with ui.row().classes('w-full items-end q-gutter-md'): + def restore_selected(): + nid = selected_node_id.value + if nid and nid in htree.nodes: + restore_fn(htree.nodes[nid]) + + ui.button('Restore Version', icon='restore', + on_click=restore_selected).props('color=primary') + + # Rename + with ui.row().classes('w-full items-end q-gutter-md'): + rename_input = ui.input('Rename Label').classes('col') + + def rename_node(): + nid = selected_node_id.value + if nid and nid in htree.nodes and rename_input.value: + htree.nodes[nid]['note'] = rename_input.value + data[KEY_HISTORY_TREE] = htree.to_dict() + save_json(file_path, data) + ui.notify('Label updated', type='positive') + refresh_fn() + + ui.button('Update Label', on_click=rename_node).props('flat') + + # Danger zone + with ui.expansion('Danger Zone (Delete)', icon='warning').classes('w-full q-mt-md'): + ui.label('Deleting a node cannot be undone.').classes('text-warning') + + def delete_selected(): + nid = selected_node_id.value + if nid and nid in htree.nodes: + _delete_nodes(htree, data, file_path, {nid}) + ui.notify('Node Deleted', type='positive') + refresh_fn() + + ui.button('Delete This Node', icon='delete', + on_click=delete_selected).props('color=negative') + + # Data preview + ui.separator() + with ui.expansion('Data Preview', icon='preview').classes('w-full'): + @ui.refreshable + def render_preview(): + _render_data_preview(selected_node_id, htree) + selected_node_id.on_value_change(lambda _: render_preview.refresh()) + render_preview() + + def render_timeline_tab(state: AppState): data = state.data_cache file_path = state.file_path @@ -35,218 +236,24 @@ def render_timeline_tab(state: AppState): @ui.refreshable def render_timeline(): - # Rebuild node list inside refreshable so it's current after deletes all_nodes = sorted(htree.nodes.values(), key=lambda x: x['timestamp'], reverse=True) selected_nodes = state.timeline_selected_nodes if selection_mode.value else set() - # --- Selection picker --- if selection_mode.value: - all_ids = [n['id'] for n in all_nodes] + _render_selection_picker(all_nodes, htree, state, render_timeline.refresh) - def fmt_option(nid): - n = htree.nodes[nid] - ts = time.strftime('%b %d %H:%M', time.localtime(n['timestamp'])) - note = n.get('note', 'Step') - head = ' (HEAD)' if nid == htree.head_id else '' - return f'{note} - {ts} ({nid[:6]}){head}' + _render_graph_or_log( + view_mode.value, all_nodes, htree, selected_nodes, + selection_mode.value, _toggle_select, _restore_and_refresh) - options = {nid: fmt_option(nid) for nid in all_ids} - - def on_selection_change(e): - state.timeline_selected_nodes = set(e.value) if e.value else set() - - ui.select( - options, - value=list(state.timeline_selected_nodes), - multiple=True, - label='Select nodes to delete:', - on_change=on_selection_change, - ).classes('w-full') - - with ui.row(): - def select_all(): - state.timeline_selected_nodes = set(all_ids) - render_timeline.refresh() - def deselect_all(): - state.timeline_selected_nodes = set() - render_timeline.refresh() - ui.button('Select All', on_click=select_all).props('flat dense') - ui.button('Deselect All', on_click=deselect_all).props('flat dense') - - # --- Graph views --- - mode = view_mode.value - if mode in ('Horizontal', 'Vertical'): - direction = 'LR' if mode == 'Horizontal' else 'TB' - try: - graph_dot = htree.generate_graph(direction=direction) - _render_graphviz(graph_dot) - except Exception as e: - ui.label(f'Graph Error: {e}').classes('text-negative') - - # --- Linear Log view --- - elif mode == 'Linear Log': - ui.label('Chronological list of all snapshots.').classes('text-caption') - for n in all_nodes: - is_head = n['id'] == htree.head_id - is_selected = n['id'] in selected_nodes - - card_style = '' - if is_selected: - card_style = 'background: #3d1f1f !important;' - elif is_head: - card_style = 'background: #1a2332 !important;' - with ui.card().classes('w-full q-mb-sm').style(card_style): - with ui.row().classes('w-full items-center'): - if selection_mode.value: - ui.checkbox( - '', - value=is_selected, - on_change=lambda e, nid=n['id']: _toggle_select( - nid, e.value), - ) - - icon = 'location_on' if is_head else 'circle' - ui.icon(icon).classes( - 'text-primary' if is_head else 'text-grey') - - with ui.column().classes('col'): - note = n.get('note', 'Step') - ts = time.strftime('%b %d %H:%M', - time.localtime(n['timestamp'])) - label = f'{note} (Current)' if is_head else note - ui.label(label).classes('text-bold') - ui.label( - f'ID: {n["id"][:6]} - {ts}').classes('text-caption') - - if not is_head and not selection_mode.value: - ui.button( - 'Restore', - icon='restore', - on_click=lambda node=n: _restore_and_refresh(node), - ).props('flat dense color=primary') - - # --- Batch Delete --- if selection_mode.value and state.timeline_selected_nodes: - valid = state.timeline_selected_nodes & set(htree.nodes.keys()) - state.timeline_selected_nodes = valid - count = len(valid) - if count > 0: - ui.label( - f'{count} node{"s" if count != 1 else ""} selected for deletion.' - ).classes('text-warning q-mt-md') - - def do_batch_delete(): - if 'history_tree_backup' not in data: - data['history_tree_backup'] = [] - data['history_tree_backup'].append(copy.deepcopy(htree.to_dict())) - for nid in valid: - if nid in htree.nodes: - del htree.nodes[nid] - for b, tip in list(htree.branches.items()): - if tip in valid: - del htree.branches[b] - if htree.head_id in valid: - if htree.nodes: - fallback = sorted(htree.nodes.values(), - key=lambda x: x['timestamp'])[-1] - htree.head_id = fallback['id'] - else: - htree.head_id = None - data[KEY_HISTORY_TREE] = htree.to_dict() - save_json(file_path, data) - state.timeline_selected_nodes = set() - ui.notify( - f'Deleted {count} node{"s" if count != 1 else ""}!', - type='positive') - render_timeline.refresh() - - ui.button( - f'Delete {count} Node{"s" if count != 1 else ""}', - icon='delete', - on_click=do_batch_delete, - ).props('color=negative') + _render_batch_delete(htree, data, file_path, state, render_timeline.refresh) ui.separator() - # --- Node selector + actions --- - ui.label('Manage Version').classes('text-subtitle1 q-mt-md') - - def fmt_node(n): - ts = time.strftime('%b %d %H:%M', time.localtime(n['timestamp'])) - return f'{n.get("note", "Step")} - {ts} ({n["id"][:6]})' - - node_options = {n['id']: fmt_node(n) for n in all_nodes} - current_id = htree.head_id if htree.head_id in node_options else ( - all_nodes[0]['id'] if all_nodes else None) - - selected_node_id = ui.select( - node_options, - value=current_id, - label='Select Version to Manage:', - ).classes('w-full') - - with ui.row().classes('w-full items-end q-gutter-md'): - def restore_selected(): - nid = selected_node_id.value - if nid and nid in htree.nodes: - _restore_and_refresh(htree.nodes[nid]) - - ui.button('Restore Version', icon='restore', - on_click=restore_selected).props('color=primary') - - # Rename - with ui.row().classes('w-full items-end q-gutter-md'): - rename_input = ui.input('Rename Label').classes('col') - - def rename_node(): - nid = selected_node_id.value - if nid and nid in htree.nodes and rename_input.value: - htree.nodes[nid]['note'] = rename_input.value - data[KEY_HISTORY_TREE] = htree.to_dict() - save_json(file_path, data) - ui.notify('Label updated', type='positive') - render_timeline.refresh() - - ui.button('Update Label', on_click=rename_node).props('flat') - - # Danger zone - with ui.expansion('Danger Zone (Delete)', icon='warning').classes('w-full q-mt-md'): - ui.label('Deleting a node cannot be undone.').classes('text-warning') - - def delete_selected(): - nid = selected_node_id.value - if nid and nid in htree.nodes: - if 'history_tree_backup' not in data: - data['history_tree_backup'] = [] - data['history_tree_backup'].append( - copy.deepcopy(htree.to_dict())) - del htree.nodes[nid] - for b, tip in list(htree.branches.items()): - if tip == nid: - del htree.branches[b] - if htree.head_id == nid: - if htree.nodes: - fallback = sorted(htree.nodes.values(), - key=lambda x: x['timestamp'])[-1] - htree.head_id = fallback['id'] - else: - htree.head_id = None - data[KEY_HISTORY_TREE] = htree.to_dict() - save_json(file_path, data) - ui.notify('Node Deleted', type='positive') - render_timeline.refresh() - - ui.button('Delete This Node', icon='delete', - on_click=delete_selected).props('color=negative') - - # Data preview - ui.separator() - with ui.expansion('Data Preview', icon='preview').classes('w-full'): - @ui.refreshable - def render_preview(): - _render_data_preview(selected_node_id, htree) - selected_node_id.on_value_change(lambda _: render_preview.refresh()) - render_preview() + _render_node_manager( + all_nodes, htree, data, file_path, + _restore_and_refresh, render_timeline.refresh) def _toggle_select(nid, checked): if checked: From 97748ab8ff0b3a7af2249889fa515a69bb4e71a2 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 26 Feb 2026 17:01:09 +0100 Subject: [PATCH 13/26] Fix VACE schedule default mismatch introduced in refactor dict_number() defaulted to 0 while mode_label used default of 1, causing visual inconsistency when 'vace schedule' key is missing. Co-Authored-By: Claude Opus 4.6 --- tab_batch_ng.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index 9120410..4c78ec1 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -542,7 +542,7 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, refresh_list): # VACE Schedule sched_val = max(0, min(int(seq.get('vace schedule', 1)), len(VACE_MODES) - 1)) with ui.row().classes('w-full items-center'): - vs_input = dict_number('VACE Schedule', seq, 'vace schedule', + vs_input = dict_number('VACE Schedule', seq, 'vace schedule', default=1, min=0, max=len(VACE_MODES) - 1).classes('col').props('outlined') mode_label = ui.label(VACE_MODES[sched_val]).classes('text-caption') From 710a8407d260278b16157fb47394cdc4d9e4a1d5 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 26 Feb 2026 17:27:02 +0100 Subject: [PATCH 14/26] Overhaul UI: new color palette, spacing, and visual hierarchy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace red accent with amber, add Inter font, introduce 4-level depth palette via CSS variables, expand padding/gaps, wrap sidebar and content sections in cards, add section/subsection header typography classes, and style scrollbars for dark theme. Pure visual changes — no functional or data-flow modifications. Co-Authored-By: Claude Opus 4.6 --- main.py | 387 ++++++++++++++++++++++++++------------------- tab_batch_ng.py | 207 ++++++++++++------------ tab_comfy_ng.py | 186 +++++++++++----------- tab_raw_ng.py | 107 +++++++------ tab_timeline_ng.py | 30 ++-- 5 files changed, 490 insertions(+), 427 deletions(-) diff --git a/main.py b/main.py index c55b425..9d7c007 100644 --- a/main.py +++ b/main.py @@ -20,72 +20,129 @@ from tab_comfy_ng import render_comfy_monitor def index(): # -- Streamlit dark theme -- ui.dark_mode(True) - ui.colors(primary='#FF4B4B') + ui.colors(primary='#F59E0B') + ui.add_head_html( + '' + ) ui.add_css(''' - /* === Streamlit Dark Theme === */ + /* === Dark Theme with Depth Palette === */ + :root { + --bg-page: #0B0E14; + --bg-surface-1: #13161E; + --bg-surface-2: #1A1E2A; + --bg-surface-3: #242836; + --border: rgba(255,255,255,0.08); + --text-primary: #EAECF0; + --text-secondary: rgba(234,236,240,0.55); + --accent: #F59E0B; + --accent-subtle: rgba(245,158,11,0.12); + --negative: #EF4444; + } /* Backgrounds */ body.body--dark, .q-page.body--dark, - .body--dark .q-page { background: #0E1117 !important; } - .body--dark .q-drawer { background: #262730 !important; } - .body--dark .q-card { background: #262730 !important; border-radius: 0.5rem; } + .body--dark .q-page { background: var(--bg-page) !important; } + .body--dark .q-drawer { background: var(--bg-surface-1) !important; } + .body--dark .q-card { + background: var(--bg-surface-2) !important; + border: 1px solid var(--border); + border-radius: 0.75rem; + } .body--dark .q-tab-panels { background: transparent !important; } .body--dark .q-tab-panel { background: transparent !important; } .body--dark .q-expansion-item { background: transparent !important; } /* Text */ - .body--dark { color: #FAFAFA !important; } - .body--dark .q-field__label { color: rgba(250,250,250,0.6) !important; } - .body--dark .text-caption { color: rgba(250,250,250,0.6) !important; } + .body--dark { color: var(--text-primary) !important; } + .body--dark .q-field__label { color: var(--text-secondary) !important; } + .body--dark .text-caption { color: var(--text-secondary) !important; } .body--dark .text-subtitle1, - .body--dark .text-subtitle2 { color: #FAFAFA !important; } + .body--dark .text-subtitle2 { color: var(--text-primary) !important; } /* Inputs & textareas */ .body--dark .q-field--outlined .q-field__control { - background: #262730 !important; + background: var(--bg-surface-3) !important; border-radius: 0.5rem !important; } .body--dark .q-field--outlined .q-field__control:before { - border-color: rgba(250,250,250,0.2) !important; + border-color: var(--border) !important; border-radius: 0.5rem !important; } .body--dark .q-field--outlined.q-field--focused .q-field__control:after { - border-color: #FF4B4B !important; + border-color: var(--accent) !important; } .body--dark .q-field__native, - .body--dark .q-field__input { color: #FAFAFA !important; } + .body--dark .q-field__input { color: var(--text-primary) !important; } - /* Sidebar inputs get main bg */ + /* Sidebar inputs get page bg */ .body--dark .q-drawer .q-field--outlined .q-field__control { - background: #0E1117 !important; + background: var(--bg-page) !important; } /* Buttons */ .body--dark .q-btn--standard { border-radius: 0.5rem !important; } + .body--dark .q-btn--outline { + transition: background 0.15s ease; + } + .body--dark .q-btn--outline:hover { + background: var(--accent-subtle) !important; + } /* Tabs */ - .body--dark .q-tab--active { color: #FF4B4B !important; } - .body--dark .q-tab__indicator { background: #FF4B4B !important; } + .body--dark .q-tab--active { color: var(--accent) !important; } + .body--dark .q-tab__indicator { background: var(--accent) !important; } /* Separators */ - .body--dark .q-separator { background: rgba(250,250,250,0.2) !important; } + .body--dark .q-separator { background: var(--border) !important; } /* Expansion items */ - .body--dark .q-expansion-item__content { padding: 4px 0; } + .body--dark .q-expansion-item__content { padding: 12px 16px; } .body--dark .q-item { border-radius: 0.5rem; } /* Splitter */ - .body--dark .q-splitter__separator { background: rgba(250,250,250,0.2) !important; } + .body--dark .q-splitter__separator { background: var(--border) !important; } + .body--dark .q-splitter__before, + .body--dark .q-splitter__after { padding: 0 8px; } /* Action row wrap */ - .action-row { flex-wrap: wrap !important; gap: 4px !important; } + .action-row { flex-wrap: wrap !important; gap: 8px !important; } /* Notifications */ .body--dark .q-notification { border-radius: 0.5rem; } /* Font */ - body { font-family: "Source Sans Pro", "Source Sans 3", sans-serif !important; } + body { font-family: "Inter", "Source Sans Pro", "Source Sans 3", sans-serif !important; } + + /* Surface utility classes (need .body--dark to beat .body--dark .q-card specificity) */ + .body--dark .surface-1 { background: var(--bg-surface-1) !important; } + .body--dark .surface-2 { background: var(--bg-surface-2) !important; } + .body--dark .surface-3 { background: var(--bg-surface-3) !important; } + + /* Typography utility classes */ + .section-header { + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary) !important; + } + .subsection-header { + font-size: 0.85rem; + font-weight: 500; + color: var(--text-primary) !important; + } + + /* Scrollbar */ + ::-webkit-scrollbar { width: 6px; height: 6px; } + ::-webkit-scrollbar-track { background: transparent; } + ::-webkit-scrollbar-thumb { + background: rgba(255,255,255,0.12); + border-radius: 3px; + } + ::-webkit-scrollbar-thumb:hover { + background: rgba(255,255,255,0.2); + } ''') config = load_config() @@ -106,9 +163,9 @@ def index(): 'text-subtitle1 q-pa-lg') return - ui.label(f'Editing: {state.file_path.name}').classes('text-h5 q-mb-md') + ui.label(f'Editing: {state.file_path.name}').classes('text-h5 q-mb-lg').style('font-weight: 600') - with ui.tabs().classes('w-full') as tabs: + with ui.tabs().classes('w-full').style('border-bottom: 1px solid var(--border)') as tabs: ui.tab('batch', label='Batch Processor') ui.tab('timeline', label='Timeline') ui.tab('raw', label='Raw Editor') @@ -148,7 +205,7 @@ def index(): # ------------------------------------------------------------------ # Sidebar (rendered AFTER helpers are attached) # ------------------------------------------------------------------ - with ui.left_drawer(value=True).classes('q-pa-md').style('width: 300px'): + with ui.left_drawer(value=True).classes('q-pa-md').style('width: 320px'): render_sidebar(state) # ------------------------------------------------------------------ @@ -165,172 +222,172 @@ def index(): def render_sidebar(state: AppState): ui.label('Navigator').classes('text-h6') - # --- Path input --- - path_input = ui.input( - 'Current Path', - value=str(state.current_dir), - ).classes('w-full') + # --- Path input + Pin --- + with ui.card().classes('w-full q-pa-md q-mb-md'): + path_input = ui.input( + 'Current Path', + value=str(state.current_dir), + ).classes('w-full') - def on_path_enter(): - p = resolve_path_case_insensitive(path_input.value) - if p is not None and p.is_dir(): - state.current_dir = p - state.config['last_dir'] = str(p) + def on_path_enter(): + p = resolve_path_case_insensitive(path_input.value) + if p is not None and p.is_dir(): + state.current_dir = p + state.config['last_dir'] = str(p) + save_config(state.current_dir, state.config['favorites'], state.config) + state.loaded_file = None + state.file_path = None + path_input.set_value(str(p)) + render_file_list.refresh() + # Auto-load inside render_file_list already refreshed main content + # if files exist; only refresh here for the empty-directory case. + if not state.loaded_file: + state._render_main.refresh() + + path_input.on('keydown.enter', lambda _: on_path_enter()) + + def pin_folder(): + d = str(state.current_dir) + if d not in state.config['favorites']: + state.config['favorites'].append(d) + save_config(state.current_dir, state.config['favorites'], state.config) + render_favorites.refresh() + + ui.button('Pin Folder', icon='push_pin', on_click=pin_folder).classes('w-full') + + # --- Favorites --- + with ui.card().classes('w-full q-pa-md q-mb-md'): + ui.label('Favorites').classes('section-header') + + @ui.refreshable + def render_favorites(): + for fav in list(state.config['favorites']): + with ui.row().classes('w-full items-center'): + ui.button( + fav, + on_click=lambda f=fav: _jump_to(f), + ).props('flat dense').classes('col') + ui.button( + icon='close', + on_click=lambda f=fav: _unpin(f), + ).props('flat dense color=negative') + + def _jump_to(fav: str): + state.current_dir = Path(fav) + state.config['last_dir'] = fav save_config(state.current_dir, state.config['favorites'], state.config) state.loaded_file = None state.file_path = None - path_input.set_value(str(p)) + path_input.set_value(fav) render_file_list.refresh() - # Auto-load inside render_file_list already refreshed main content - # if files exist; only refresh here for the empty-directory case. if not state.loaded_file: state._render_main.refresh() - path_input.on('keydown.enter', lambda _: on_path_enter()) + def _unpin(fav: str): + if fav in state.config['favorites']: + state.config['favorites'].remove(fav) + save_config(state.current_dir, state.config['favorites'], state.config) + render_favorites.refresh() - # --- Pin / Unpin --- - def pin_folder(): - d = str(state.current_dir) - if d not in state.config['favorites']: - state.config['favorites'].append(d) - save_config(state.current_dir, state.config['favorites'], state.config) - render_favorites.refresh() - - ui.button('Pin Folder', icon='push_pin', on_click=pin_folder).classes('w-full') - - @ui.refreshable - def render_favorites(): - for fav in list(state.config['favorites']): - with ui.row().classes('w-full items-center'): - ui.button( - fav, - on_click=lambda f=fav: _jump_to(f), - ).props('flat dense').classes('col') - ui.button( - icon='close', - on_click=lambda f=fav: _unpin(f), - ).props('flat dense color=negative') - - def _jump_to(fav: str): - state.current_dir = Path(fav) - state.config['last_dir'] = fav - save_config(state.current_dir, state.config['favorites'], state.config) - state.loaded_file = None - state.file_path = None - path_input.set_value(fav) - render_file_list.refresh() - if not state.loaded_file: - state._render_main.refresh() - - def _unpin(fav: str): - if fav in state.config['favorites']: - state.config['favorites'].remove(fav) - save_config(state.current_dir, state.config['favorites'], state.config) - render_favorites.refresh() - - render_favorites() - - ui.separator() + render_favorites() # --- Snippet Library --- - ui.label('Snippet Library').classes('text-subtitle1 q-mt-md') + with ui.card().classes('w-full q-pa-md q-mb-md'): + ui.label('Snippet Library').classes('section-header') - with ui.expansion('Add New Snippet'): - snip_name_input = ui.input('Name', placeholder='e.g. Cinematic').classes('w-full') - snip_content_input = ui.textarea('Content', placeholder='4k, high quality...').classes('w-full') + with ui.expansion('Add New Snippet'): + snip_name_input = ui.input('Name', placeholder='e.g. Cinematic').classes('w-full') + snip_content_input = ui.textarea('Content', placeholder='4k, high quality...').classes('w-full') - def save_snippet(): - name = snip_name_input.value - content = snip_content_input.value - if name and content: - state.snippets[name] = content + def save_snippet(): + name = snip_name_input.value + content = snip_content_input.value + if name and content: + state.snippets[name] = content + save_snippets(state.snippets) + snip_name_input.set_value('') + snip_content_input.set_value('') + ui.notify(f"Saved '{name}'") + render_snippet_list.refresh() + + ui.button('Save Snippet', on_click=save_snippet).classes('w-full') + + @ui.refreshable + def render_snippet_list(): + if not state.snippets: + return + ui.label('Click to copy snippet text:').classes('text-caption') + for name, content in list(state.snippets.items()): + with ui.row().classes('w-full items-center'): + async def copy_snippet(c=content): + await ui.run_javascript( + f'navigator.clipboard.writeText({json.dumps(c)})', timeout=3.0) + ui.notify('Copied to clipboard') + + ui.button( + f'{name}', + on_click=copy_snippet, + ).props('flat dense').classes('col') + ui.button( + icon='delete', + on_click=lambda n=name: _del_snippet(n), + ).props('flat dense color=negative') + + def _del_snippet(name: str): + if name in state.snippets: + del state.snippets[name] save_snippets(state.snippets) - snip_name_input.set_value('') - snip_content_input.set_value('') - ui.notify(f"Saved '{name}'") render_snippet_list.refresh() - ui.button('Save Snippet', on_click=save_snippet).classes('w-full') - - @ui.refreshable - def render_snippet_list(): - if not state.snippets: - return - ui.label('Click to copy snippet text:').classes('text-caption') - for name, content in list(state.snippets.items()): - with ui.row().classes('w-full items-center'): - async def copy_snippet(c=content): - await ui.run_javascript( - f'navigator.clipboard.writeText({json.dumps(c)})', timeout=3.0) - ui.notify('Copied to clipboard') - - ui.button( - f'{name}', - on_click=copy_snippet, - ).props('flat dense').classes('col') - ui.button( - icon='delete', - on_click=lambda n=name: _del_snippet(n), - ).props('flat dense color=negative') - - def _del_snippet(name: str): - if name in state.snippets: - del state.snippets[name] - save_snippets(state.snippets) - render_snippet_list.refresh() - - render_snippet_list() - - ui.separator() + render_snippet_list() # --- File List --- - @ui.refreshable - def render_file_list(): - json_files = sorted(state.current_dir.glob('*.json')) - json_files = [f for f in json_files if f.name not in ('.editor_config.json', '.editor_snippets.json')] + with ui.card().classes('w-full q-pa-md q-mb-md'): + @ui.refreshable + def render_file_list(): + json_files = sorted(state.current_dir.glob('*.json')) + json_files = [f for f in json_files if f.name not in ('.editor_config.json', '.editor_snippets.json')] - if not json_files: - ui.label('No JSON files in this folder.').classes('text-caption') - ui.button('Generate Templates', on_click=lambda: _gen_templates()).classes('w-full') - return + if not json_files: + ui.label('No JSON files in this folder.').classes('text-caption') + ui.button('Generate Templates', on_click=lambda: _gen_templates()).classes('w-full') + return - with ui.expansion('Create New JSON'): - new_fn_input = ui.input('Filename', placeholder='my_prompt_vace').classes('w-full') + with ui.expansion('Create New JSON'): + new_fn_input = ui.input('Filename', placeholder='my_prompt_vace').classes('w-full') - def create_new(): - fn = new_fn_input.value - if not fn: - return - if not fn.endswith('.json'): - fn += '.json' - path = state.current_dir / fn - first_item = DEFAULTS.copy() - first_item[KEY_SEQUENCE_NUMBER] = 1 - save_json(path, {KEY_BATCH_DATA: [first_item]}) - new_fn_input.set_value('') - render_file_list.refresh() + def create_new(): + fn = new_fn_input.value + if not fn: + return + if not fn.endswith('.json'): + fn += '.json' + path = state.current_dir / fn + first_item = DEFAULTS.copy() + first_item[KEY_SEQUENCE_NUMBER] = 1 + save_json(path, {KEY_BATCH_DATA: [first_item]}) + new_fn_input.set_value('') + render_file_list.refresh() - ui.button('Create', on_click=create_new).classes('w-full') + ui.button('Create', on_click=create_new).classes('w-full') - ui.label('Select File').classes('text-subtitle2 q-mt-sm') - file_names = [f.name for f in json_files] - ui.radio( - file_names, - value=file_names[0] if file_names else None, - on_change=lambda e: state._load_file(e.value) if e.value else None, - ).classes('w-full') + ui.label('Select File').classes('subsection-header q-mt-sm') + file_names = [f.name for f in json_files] + ui.radio( + file_names, + value=file_names[0] if file_names else None, + on_change=lambda e: state._load_file(e.value) if e.value else None, + ).classes('w-full') - # Auto-load first file if nothing loaded yet - if file_names and not state.loaded_file: - state._load_file(file_names[0]) + # Auto-load first file if nothing loaded yet + if file_names and not state.loaded_file: + state._load_file(file_names[0]) - def _gen_templates(): - generate_templates(state.current_dir) - render_file_list.refresh() + def _gen_templates(): + generate_templates(state.current_dir) + render_file_list.refresh() - render_file_list() - - ui.separator() + render_file_list() # --- Comfy Monitor toggle --- def on_monitor_toggle(e): diff --git a/tab_batch_ng.py b/tab_batch_ng.py index 4c78ec1..27e5ae9 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -156,69 +156,68 @@ def render_batch_processor(state: AppState): batch_list = data.get(KEY_BATCH_DATA, []) # Source file data for importing - json_files = sorted(state.current_dir.glob('*.json')) - json_files = [f for f in json_files if f.name not in ( - '.editor_config.json', '.editor_snippets.json')] - file_options = {f.name: f.name for f in json_files} + with ui.card().classes('w-full q-pa-md q-mb-lg'): + json_files = sorted(state.current_dir.glob('*.json')) + json_files = [f for f in json_files if f.name not in ( + '.editor_config.json', '.editor_snippets.json')] + file_options = {f.name: f.name for f in json_files} - src_file_select = ui.select( - file_options, - value=file_path.name, - label='Source File:', - ).classes('w-64') + src_file_select = ui.select( + file_options, + value=file_path.name, + label='Source File:', + ).classes('w-64') - src_seq_select = ui.select([], label='Source Sequence:').classes('w-64') + src_seq_select = ui.select([], label='Source Sequence:').classes('w-64') - # Track loaded source data - _src_cache = {'data': None, 'batch': [], 'name': None} + # Track loaded source data + _src_cache = {'data': None, 'batch': [], 'name': None} - def _update_src(): - name = src_file_select.value - if name and name != _src_cache['name']: - src_data, _ = load_json(state.current_dir / name) - _src_cache['data'] = src_data - _src_cache['batch'] = src_data.get(KEY_BATCH_DATA, []) - _src_cache['name'] = name - if _src_cache['batch']: - opts = {i: format_seq_label(s.get(KEY_SEQUENCE_NUMBER, i+1)) - for i, s in enumerate(_src_cache['batch'])} - src_seq_select.set_options(opts, value=0) - else: - src_seq_select.set_options({}) + def _update_src(): + name = src_file_select.value + if name and name != _src_cache['name']: + src_data, _ = load_json(state.current_dir / name) + _src_cache['data'] = src_data + _src_cache['batch'] = src_data.get(KEY_BATCH_DATA, []) + _src_cache['name'] = name + if _src_cache['batch']: + opts = {i: format_seq_label(s.get(KEY_SEQUENCE_NUMBER, i+1)) + for i, s in enumerate(_src_cache['batch'])} + src_seq_select.set_options(opts, value=0) + else: + src_seq_select.set_options({}) - src_file_select.on_value_change(lambda _: _update_src()) - _update_src() + src_file_select.on_value_change(lambda _: _update_src()) + _update_src() - # --- Add New Sequence --- - ui.label('Add New Sequence').classes('text-subtitle1 q-mt-md') + # --- Add New Sequence --- + ui.label('Add New Sequence').classes('section-header q-mt-md') - def _add_sequence(new_item): - new_item[KEY_SEQUENCE_NUMBER] = max_main_seq_number(batch_list) + 1 - for k in [KEY_PROMPT_HISTORY, KEY_HISTORY_TREE, 'note', 'loras']: - new_item.pop(k, None) - batch_list.append(new_item) - data[KEY_BATCH_DATA] = batch_list - save_json(file_path, data) - render_sequence_list.refresh() + def _add_sequence(new_item): + new_item[KEY_SEQUENCE_NUMBER] = max_main_seq_number(batch_list) + 1 + for k in [KEY_PROMPT_HISTORY, KEY_HISTORY_TREE, 'note', 'loras']: + new_item.pop(k, None) + batch_list.append(new_item) + data[KEY_BATCH_DATA] = batch_list + save_json(file_path, data) + render_sequence_list.refresh() - with ui.row(): - def add_empty(): - _add_sequence(DEFAULTS.copy()) + with ui.row(): + def add_empty(): + _add_sequence(DEFAULTS.copy()) - def add_from_source(): - item = copy.deepcopy(DEFAULTS) - src_batch = _src_cache['batch'] - sel_idx = src_seq_select.value - if src_batch and sel_idx is not None: - item.update(copy.deepcopy(src_batch[int(sel_idx)])) - elif _src_cache['data']: - item.update(copy.deepcopy(_src_cache['data'])) - _add_sequence(item) + def add_from_source(): + item = copy.deepcopy(DEFAULTS) + src_batch = _src_cache['batch'] + sel_idx = src_seq_select.value + if src_batch and sel_idx is not None: + item.update(copy.deepcopy(src_batch[int(sel_idx)])) + elif _src_cache['data']: + item.update(copy.deepcopy(_src_cache['data'])) + _add_sequence(item) - ui.button('Add Empty', icon='add', on_click=add_empty) - ui.button('From Source', icon='file_download', on_click=add_from_source) - - ui.separator() + ui.button('Add Empty', icon='add', on_click=add_empty) + ui.button('From Source', icon='file_download', on_click=add_from_source) # --- Standard / LoRA / VACE key sets --- lora_keys = ['lora 1 high', 'lora 1 low', 'lora 2 high', 'lora 2 low', @@ -250,36 +249,36 @@ def render_batch_processor(state: AppState): ui.button('Sort by Number', icon='sort', on_click=sort_by_number).props('flat') for i, seq in enumerate(batch_list): - _render_sequence_card( - i, seq, batch_list, data, file_path, state, - _src_cache, src_seq_select, - standard_keys, render_sequence_list, - ) + with ui.card().classes('w-full q-mb-sm'): + _render_sequence_card( + i, seq, batch_list, data, file_path, state, + _src_cache, src_seq_select, + standard_keys, render_sequence_list, + ) render_sequence_list() - ui.separator() - # --- Save & Snap --- - with ui.row().classes('w-full items-end q-gutter-md'): - commit_input = ui.input('Change Note (Optional)', - placeholder='e.g. Added sequence 3').classes('col') + with ui.card().classes('w-full q-pa-md q-mt-lg'): + with ui.row().classes('w-full items-end q-gutter-md'): + commit_input = ui.input('Change Note (Optional)', + placeholder='e.g. Added sequence 3').classes('col') - def save_and_snap(): - data[KEY_BATCH_DATA] = batch_list - tree_data = data.get(KEY_HISTORY_TREE, {}) - htree = HistoryTree(tree_data) - snapshot_payload = copy.deepcopy(data) - snapshot_payload.pop(KEY_HISTORY_TREE, None) - note = commit_input.value if commit_input.value else 'Batch Update' - htree.commit(snapshot_payload, note=note) - data[KEY_HISTORY_TREE] = htree.to_dict() - save_json(file_path, data) - state.restored_indicator = None - commit_input.set_value('') - ui.notify('Batch Saved & Snapshot Created!', type='positive') + def save_and_snap(): + data[KEY_BATCH_DATA] = batch_list + tree_data = data.get(KEY_HISTORY_TREE, {}) + htree = HistoryTree(tree_data) + snapshot_payload = copy.deepcopy(data) + snapshot_payload.pop(KEY_HISTORY_TREE, None) + note = commit_input.value if commit_input.value else 'Batch Update' + htree.commit(snapshot_payload, note=note) + data[KEY_HISTORY_TREE] = htree.to_dict() + save_json(file_path, data) + state.restored_indicator = None + commit_input.set_value('') + ui.notify('Batch Saved & Snapshot Created!', type='positive') - ui.button('Save & Snap', icon='save', on_click=save_and_snap).props('color=primary') + ui.button('Save & Snap', icon='save', on_click=save_and_snap).props('color=primary') # ====================================================================== @@ -321,7 +320,7 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, batch_list[idx] = item commit('Copied!') - ui.button('Copy Src', icon='file_download', on_click=copy_source).props('dense') + 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): @@ -334,7 +333,7 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, batch_list.insert(pos, new_seq) commit('Cloned to Next!') - ui.button('Clone Next', icon='content_copy', on_click=clone_next).props('dense') + ui.button('Clone Next', icon='content_copy', on_click=clone_next).props('outline') # Clone End def clone_end(s=seq): @@ -343,7 +342,7 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, batch_list.append(new_seq) commit('Cloned to End!') - ui.button('Clone End', icon='vertical_align_bottom', on_click=clone_end).props('dense') + 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): @@ -360,7 +359,10 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, batch_list.insert(pos, new_seq) commit(f'Created {format_seq_label(new_seq[KEY_SEQUENCE_NUMBER])}!') - ui.button('Clone Sub', icon='link', on_click=clone_sub).props('dense') + ui.button('Clone Sub', icon='link', on_click=clone_sub).props('outline') + + # Spacer before Promote + ui.element('div').classes('col') # Promote def promote(idx=i, s=seq): @@ -375,14 +377,17 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, # and sees the file is now single (no KEY_BATCH_DATA) state._render_main.refresh() - ui.button('Promote', icon='north_west', on_click=promote).props('dense') + ui.button('Promote', icon='north_west', on_click=promote) + + # Spacer before Delete + ui.element('div').classes('col') # Delete def delete(idx=i): batch_list.pop(idx) commit() - ui.button(icon='delete', on_click=delete).props('dense color=negative') + ui.button(icon='delete', on_click=delete).props('color=negative') ui.separator() @@ -390,13 +395,13 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, with ui.splitter(value=66).classes('w-full') as splitter: with splitter.before: dict_textarea('General Prompt', seq, 'general_prompt').classes( - 'w-full').props('outlined rows=2') + 'w-full q-mt-sm').props('outlined rows=2') dict_textarea('General Negative', seq, 'general_negative').classes( - 'w-full').props('outlined rows=2') + 'w-full q-mt-sm').props('outlined rows=2') dict_textarea('Specific Prompt', seq, 'current_prompt').classes( - 'w-full').props('outlined rows=10') + 'w-full q-mt-sm').props('outlined rows=10') dict_textarea('Specific Negative', seq, 'negative').classes( - 'w-full').props('outlined rows=2') + 'w-full q-mt-sm').props('outlined rows=2') with splitter.after: # Sequence number @@ -452,7 +457,7 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, with ui.expansion('LoRA Settings', icon='style').classes('w-full'): with ui.row().classes('w-full q-gutter-md'): for lora_idx in range(1, 4): - with ui.column().classes('col'): + with ui.card().classes('col q-pa-sm surface-3'): ui.label(f'LoRA {lora_idx}').classes('text-subtitle2') for tier, tier_label in [('high', 'High'), ('low', 'Low')]: k = f'lora {lora_idx} {tier}' @@ -474,8 +479,7 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, lora_input.on('blur', on_lora_blur) # --- Custom Parameters --- - ui.separator() - ui.label('Custom Parameters').classes('text-caption') + ui.label('Custom Parameters').classes('section-header q-mt-md') custom_keys = [k for k in seq.keys() if k not in standard_keys] if custom_keys: @@ -537,11 +541,11 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, refresh_list): ui.button('Shift', icon='arrow_downward', on_click=shift_fts).props('dense') - dict_input(ui.input, 'Transition', seq, 'transition').props('outlined') + dict_input(ui.input, 'Transition', seq, 'transition').props('outlined').classes('q-mt-sm') # VACE Schedule sched_val = max(0, min(int(seq.get('vace schedule', 1)), len(VACE_MODES) - 1)) - with ui.row().classes('w-full items-center'): + with ui.row().classes('w-full items-center q-mt-sm'): vs_input = dict_number('VACE Schedule', seq, 'vace schedule', default=1, min=0, max=len(VACE_MODES) - 1).classes('col').props('outlined') mode_label = ui.label(VACE_MODES[sched_val]).classes('text-caption') @@ -566,8 +570,8 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, refresh_list): ui.button('Mode Reference', icon='help', on_click=ref_dlg.open).props('flat dense') # Input A / B frames - ia_input = dict_number('Input A Frames', seq, 'input_a_frames').props('outlined') - ib_input = dict_number('Input B Frames', seq, 'input_b_frames').props('outlined') + ia_input = dict_number('Input A Frames', seq, 'input_a_frames').props('outlined').classes('q-mt-sm') + ib_input = dict_number('Input B Frames', seq, 'input_b_frames').props('outlined').classes('q-mt-sm') # VACE Length + output calculation input_a = int(seq.get('input_a_frames', 16)) @@ -582,7 +586,7 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, refresh_list): else: base_length = max(stored_total - input_a - input_b, 1) - with ui.row().classes('w-full items-center'): + with ui.row().classes('w-full items-center q-mt-sm'): vl_input = ui.number('VACE Length', value=base_length, min=1).classes('col').props( 'outlined') output_label = ui.label(f'Output: {stored_total}').classes('text-bold') @@ -607,7 +611,7 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, refresh_list): for inp in (vs_input, ia_input, ib_input, vl_input): inp.on('update:model-value', recalc_vace) - dict_number('Reference Switch', seq, 'reference switch').props('outlined') + dict_number('Reference Switch', seq, 'reference switch').props('outlined').classes('q-mt-sm') # ====================================================================== @@ -638,13 +642,14 @@ def _render_mass_update(batch_list, data, file_path, state: AppState, refresh_li source_select.on_value_change(update_fields) update_fields() - ui.label('Apply to:').classes('text-subtitle2 q-mt-md') + ui.label('Apply to:').classes('subsection-header q-mt-md') select_all_cb = ui.checkbox('Select All') target_checks = {} - for idx, s in enumerate(batch_list): - sn = s.get(KEY_SEQUENCE_NUMBER, idx + 1) - cb = ui.checkbox(format_seq_label(sn)) - target_checks[idx] = cb + with ui.scroll_area().style('max-height: 250px'): + for idx, s in enumerate(batch_list): + sn = s.get(KEY_SEQUENCE_NUMBER, idx + 1) + cb = ui.checkbox(format_seq_label(sn)) + target_checks[idx] = cb def on_select_all(e): for cb in target_checks.values(): diff --git a/tab_comfy_ng.py b/tab_comfy_ng.py index 496d455..7ae456d 100644 --- a/tab_comfy_ng.py +++ b/tab_comfy_ng.py @@ -55,7 +55,7 @@ def render_comfy_monitor(state: AppState): # Add server section ui.separator() - ui.label('Add New Server').classes('text-subtitle1') + ui.label('Add New Server').classes('section-header') with ui.row().classes('w-full items-end'): new_name = ui.input('Server Name', placeholder='e.g. Render Node 2').classes('col') new_url = ui.input('URL', placeholder='http://192.168.1.50:8188').classes('col') @@ -152,18 +152,18 @@ def _render_single_instance(state: AppState, instance_config: dict, index: int, running_cnt = len(queue_data.get('queue_running', [])) pending_cnt = len(queue_data.get('queue_pending', [])) - with ui.card().classes('q-pa-sm'): + with ui.card().classes('q-pa-md text-center').style('min-width: 100px'): ui.label('Status') ui.label('Online' if running_cnt > 0 else 'Idle').classes( 'text-positive' if running_cnt > 0 else 'text-grey') - with ui.card().classes('q-pa-sm'): + with ui.card().classes('q-pa-md text-center').style('min-width: 100px'): ui.label('Pending') ui.label(str(pending_cnt)) - with ui.card().classes('q-pa-sm'): + with ui.card().classes('q-pa-md text-center').style('min-width: 100px'): ui.label('Running') ui.label(str(running_cnt)) else: - with ui.card().classes('q-pa-sm'): + with ui.card().classes('q-pa-md text-center').style('min-width: 100px'): ui.label('Status') ui.label('Offline').classes('text-negative') ui.label(f'Could not connect to {comfy_url}').classes('text-negative') @@ -173,104 +173,106 @@ def _render_single_instance(state: AppState, instance_config: dict, index: int, ui.button('Refresh Status', icon='refresh', on_click=refresh_status).props('flat dense') # --- Live View --- - ui.label('Live View').classes('text-subtitle1 q-mt-md') - toggle_key = f'live_toggle_{index}' + with ui.card().classes('w-full q-pa-md q-mt-md'): + ui.label('Live View').classes('section-header') + toggle_key = f'live_toggle_{index}' - live_checkbox = ui.checkbox('Enable Live Preview', value=False) - # Store reference so poll_all timer can disable it on timeout - state._live_checkboxes[toggle_key] = live_checkbox + live_checkbox = ui.checkbox('Enable Live Preview', value=False) + # Store reference so poll_all timer can disable it on timeout + state._live_checkboxes[toggle_key] = live_checkbox - @ui.refreshable - def render_live_view(): - if not live_checkbox.value: - ui.label('Live Preview is disabled.').classes('text-caption') - return - - # Record start time - if toggle_key not in state.live_toggles or state.live_toggles.get(toggle_key) is None: - state.live_toggles[toggle_key] = time.time() - - timeout_val = config.get('monitor_timeout', 0) - if timeout_val > 0: - start = state.live_toggles.get(toggle_key, time.time()) - remaining = (timeout_val * 60) - (time.time() - start) - if remaining <= 0: - live_checkbox.set_value(False) - state.live_toggles[toggle_key] = None - ui.label('Preview timed out.').classes('text-caption') + @ui.refreshable + def render_live_view(): + if not live_checkbox.value: + ui.label('Live Preview is disabled.').classes('text-caption') return - ui.label(f'Auto-off in: {int(remaining)}s').classes('text-caption') - iframe_h = ui.slider(min=600, max=2500, step=50, value=1000).classes('w-full') - ui.label().bind_text_from(iframe_h, 'value', backward=lambda v: f'Height: {v}px') + # Record start time + if toggle_key not in state.live_toggles or state.live_toggles.get(toggle_key) is None: + state.live_toggles[toggle_key] = time.time() - viewer_base = config.get('viewer_url', '').strip() - parsed = urllib.parse.urlparse(viewer_base) - if viewer_base and parsed.scheme in ('http', 'https'): - safe_src = html.escape(viewer_base, quote=True) - ui.label(f'Viewing: {viewer_base}').classes('text-caption') + timeout_val = config.get('monitor_timeout', 0) + if timeout_val > 0: + start = state.live_toggles.get(toggle_key, time.time()) + remaining = (timeout_val * 60) - (time.time() - start) + if remaining <= 0: + live_checkbox.set_value(False) + state.live_toggles[toggle_key] = None + ui.label('Preview timed out.').classes('text-caption') + return + ui.label(f'Auto-off in: {int(remaining)}s').classes('text-caption') - iframe_container = ui.column().classes('w-full') + iframe_h = ui.slider(min=600, max=2500, step=50, value=1000).classes('w-full') + ui.label().bind_text_from(iframe_h, 'value', backward=lambda v: f'Height: {v}px') - def update_iframe(): - iframe_container.clear() - with iframe_container: - ui.html( - f'' - ) + viewer_base = config.get('viewer_url', '').strip() + parsed = urllib.parse.urlparse(viewer_base) + if viewer_base and parsed.scheme in ('http', 'https'): + safe_src = html.escape(viewer_base, quote=True) + ui.label(f'Viewing: {viewer_base}').classes('text-caption') - iframe_h.on_value_change(lambda _: update_iframe()) - update_iframe() - else: - ui.label('No valid viewer URL configured.').classes('text-warning') + iframe_container = ui.column().classes('w-full') - state._live_refreshables[toggle_key] = render_live_view - live_checkbox.on_value_change(lambda _: render_live_view.refresh()) - render_live_view() + def update_iframe(): + iframe_container.clear() + with iframe_container: + ui.html( + f'' + ) + + iframe_h.on_value_change(lambda _: update_iframe()) + update_iframe() + else: + ui.label('No valid viewer URL configured.').classes('text-warning') + + state._live_refreshables[toggle_key] = render_live_view + live_checkbox.on_value_change(lambda _: render_live_view.refresh()) + render_live_view() # --- Latest Output --- - ui.label('Latest Output').classes('text-subtitle1 q-mt-md') - img_container = ui.column().classes('w-full') + with ui.card().classes('w-full q-pa-md q-mt-md'): + ui.label('Latest Output').classes('section-header') + img_container = ui.column().classes('w-full') - async def check_image(): - img_container.clear() - loop = asyncio.get_event_loop() - res, err = await loop.run_in_executor( - None, lambda: _fetch_blocking(f'{comfy_url}/history', timeout=2)) - with img_container: - if err is not None: - ui.label(f'Error fetching image: {err}').classes('text-negative') - return - try: - history = res.json() - except (ValueError, Exception): - ui.label('Invalid response from server').classes('text-negative') - return - if not history: - ui.label('No history found.').classes('text-caption') - return - last_prompt_id = list(history.keys())[-1] - outputs = history[last_prompt_id].get('outputs', {}) - found_img = None - for node_output in outputs.values(): - if 'images' in node_output: - for img_info in node_output['images']: - if img_info['type'] == 'output': - found_img = img_info - break + async def check_image(): + img_container.clear() + loop = asyncio.get_event_loop() + res, err = await loop.run_in_executor( + None, lambda: _fetch_blocking(f'{comfy_url}/history', timeout=2)) + with img_container: + if err is not None: + ui.label(f'Error fetching image: {err}').classes('text-negative') + return + try: + history = res.json() + except (ValueError, Exception): + ui.label('Invalid response from server').classes('text-negative') + return + if not history: + ui.label('No history found.').classes('text-caption') + return + last_prompt_id = list(history.keys())[-1] + outputs = history[last_prompt_id].get('outputs', {}) + found_img = None + for node_output in outputs.values(): + if 'images' in node_output: + for img_info in node_output['images']: + if img_info['type'] == 'output': + found_img = img_info + break + if found_img: + break if found_img: - break - if found_img: - params = urllib.parse.urlencode({ - 'filename': found_img['filename'], - 'subfolder': found_img['subfolder'], - 'type': found_img['type'], - }) - img_url = f'{comfy_url}/view?{params}' - ui.image(img_url).classes('w-full').style('max-width: 600px') - ui.label(f'Last Output: {found_img["filename"]}').classes('text-caption') - else: - ui.label('Last run had no image output.').classes('text-caption') + params = urllib.parse.urlencode({ + 'filename': found_img['filename'], + 'subfolder': found_img['subfolder'], + 'type': found_img['type'], + }) + img_url = f'{comfy_url}/view?{params}' + ui.image(img_url).classes('w-full').style('max-width: 600px') + ui.label(f'Last Output: {found_img["filename"]}').classes('text-caption') + else: + ui.label('Last run had no image output.').classes('text-caption') - ui.button('Check Latest Image', icon='image', on_click=check_image).props('flat') + ui.button('Check Latest Image', icon='image', on_click=check_image).props('flat') diff --git a/tab_raw_ng.py b/tab_raw_ng.py index 4b5277f..39ec6f3 100644 --- a/tab_raw_ng.py +++ b/tab_raw_ng.py @@ -11,64 +11,63 @@ def render_raw_editor(state: AppState): data = state.data_cache file_path = state.file_path - ui.label(f'Raw Editor: {file_path.name}').classes('text-h6') + with ui.card().classes('w-full q-pa-md'): + ui.label(f'Raw Editor: {file_path.name}').classes('text-h6 q-mb-md') - hide_history = ui.checkbox( - 'Hide History (Safe Mode)', - value=True, - ) + hide_history = ui.checkbox( + 'Hide History (Safe Mode)', + value=True, + ) - @ui.refreshable - def render_editor(): - # Prepare display data - if hide_history.value: - display_data = copy.deepcopy(data) - display_data.pop(KEY_HISTORY_TREE, None) - display_data.pop(KEY_PROMPT_HISTORY, None) - else: - display_data = data + @ui.refreshable + def render_editor(): + # Prepare display data + if hide_history.value: + display_data = copy.deepcopy(data) + display_data.pop(KEY_HISTORY_TREE, None) + display_data.pop(KEY_PROMPT_HISTORY, None) + else: + display_data = data - try: - json_str = json.dumps(display_data, indent=4, ensure_ascii=False) - except Exception as e: - ui.notify(f'Error serializing JSON: {e}', type='negative') - json_str = '{}' - - text_area = ui.textarea( - 'JSON Content', - value=json_str, - ).classes('w-full font-mono').props('outlined rows=30') - - ui.separator() - - def do_save(): try: - input_data = json.loads(text_area.value) - - # Merge hidden history back in if safe mode - if hide_history.value: - if KEY_HISTORY_TREE in data: - input_data[KEY_HISTORY_TREE] = data[KEY_HISTORY_TREE] - if KEY_PROMPT_HISTORY in data: - input_data[KEY_PROMPT_HISTORY] = data[KEY_PROMPT_HISTORY] - - save_json(file_path, input_data) - - data.clear() - data.update(input_data) - state.last_mtime = get_file_mtime(file_path) - - ui.notify('Raw JSON Saved Successfully!', type='positive') - render_editor.refresh() - - except json.JSONDecodeError as e: - ui.notify(f'Invalid JSON Syntax: {e}', type='negative') + json_str = json.dumps(display_data, indent=4, ensure_ascii=False) except Exception as e: - ui.notify(f'Unexpected Error: {e}', type='negative') + ui.notify(f'Error serializing JSON: {e}', type='negative') + json_str = '{}' - ui.button('Save Raw Changes', icon='save', on_click=do_save).props( - 'color=primary' - ).classes('w-full') + text_area = ui.textarea( + 'JSON Content', + value=json_str, + ).classes('w-full font-mono').props('outlined rows=30') - hide_history.on_value_change(lambda _: render_editor.refresh()) - render_editor() + def do_save(): + try: + input_data = json.loads(text_area.value) + + # Merge hidden history back in if safe mode + if hide_history.value: + if KEY_HISTORY_TREE in data: + input_data[KEY_HISTORY_TREE] = data[KEY_HISTORY_TREE] + if KEY_PROMPT_HISTORY in data: + input_data[KEY_PROMPT_HISTORY] = data[KEY_PROMPT_HISTORY] + + save_json(file_path, input_data) + + data.clear() + data.update(input_data) + state.last_mtime = get_file_mtime(file_path) + + ui.notify('Raw JSON Saved Successfully!', type='positive') + render_editor.refresh() + + except json.JSONDecodeError as e: + ui.notify(f'Invalid JSON Syntax: {e}', type='negative') + except Exception as e: + ui.notify(f'Unexpected Error: {e}', type='negative') + + ui.button('Save Raw Changes', icon='save', on_click=do_save).props( + 'color=primary' + ).classes('w-full q-mt-md') + + hide_history.on_value_change(lambda _: render_editor.refresh()) + render_editor() diff --git a/tab_timeline_ng.py b/tab_timeline_ng.py index 9aaee0a..7f49b09 100644 --- a/tab_timeline_ng.py +++ b/tab_timeline_ng.py @@ -68,11 +68,12 @@ def _render_graph_or_log(mode, all_nodes, htree, selected_nodes, """Render graph visualization or linear log view.""" if mode in ('Horizontal', 'Vertical'): direction = 'LR' if mode == 'Horizontal' else 'TB' - try: - graph_dot = htree.generate_graph(direction=direction) - _render_graphviz(graph_dot) - except Exception as e: - ui.label(f'Graph Error: {e}').classes('text-negative') + with ui.card().classes('w-full q-pa-md'): + try: + graph_dot = htree.generate_graph(direction=direction) + _render_graphviz(graph_dot) + except Exception as e: + ui.label(f'Graph Error: {e}').classes('text-negative') elif mode == 'Linear Log': ui.label('Chronological list of all snapshots.').classes('text-caption') @@ -82,9 +83,9 @@ def _render_graph_or_log(mode, all_nodes, htree, selected_nodes, card_style = '' if is_selected: - card_style = 'background: #3d1f1f !important;' + card_style = 'background: rgba(239, 68, 68, 0.1) !important; border-left: 3px solid var(--negative);' elif is_head: - card_style = 'background: #1a2332 !important;' + card_style = 'background: var(--accent-subtle) !important; border-left: 3px solid var(--accent);' with ui.card().classes('w-full q-mb-sm').style(card_style): with ui.row().classes('w-full items-center'): if selection_mode_on: @@ -145,7 +146,7 @@ def _render_batch_delete(htree, data, file_path, state, refresh_fn): def _render_node_manager(all_nodes, htree, data, file_path, restore_fn, refresh_fn): """Render node selector with restore, rename, delete, and preview.""" - ui.label('Manage Version').classes('text-subtitle1 q-mt-md') + ui.label('Manage Version').classes('section-header') def fmt_node(n): ts = time.strftime('%b %d %H:%M', time.localtime(n['timestamp'])) @@ -186,7 +187,7 @@ def _render_node_manager(all_nodes, htree, data, file_path, restore_fn, refresh_ ui.button('Update Label', on_click=rename_node).props('flat') # Danger zone - with ui.expansion('Danger Zone (Delete)', icon='warning').classes('w-full q-mt-md'): + with ui.expansion('Danger Zone (Delete)', icon='warning').classes('w-full q-mt-md').style('border-left: 3px solid var(--negative)'): ui.label('Deleting a node cannot be undone.').classes('text-warning') def delete_selected(): @@ -226,7 +227,7 @@ def render_timeline_tab(state: AppState): 'text-info q-pa-sm') # --- View mode + Selection toggle --- - with ui.row().classes('w-full items-center q-gutter-md'): + with ui.row().classes('w-full items-center q-gutter-md q-mb-md'): ui.label('Version History').classes('text-h6 col') view_mode = ui.toggle( ['Horizontal', 'Vertical', 'Linear Log'], @@ -249,11 +250,10 @@ def render_timeline_tab(state: AppState): if selection_mode.value and state.timeline_selected_nodes: _render_batch_delete(htree, data, file_path, state, render_timeline.refresh) - ui.separator() - - _render_node_manager( - all_nodes, htree, data, file_path, - _restore_and_refresh, render_timeline.refresh) + with ui.card().classes('w-full q-pa-md q-mt-md'): + _render_node_manager( + all_nodes, htree, data, file_path, + _restore_and_refresh, render_timeline.refresh) def _toggle_select(nid, checked): if checked: From fe2c6445ef065448858148a510078024dbbf48fd Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 26 Feb 2026 17:29:08 +0100 Subject: [PATCH 15/26] Constrain main content area to 1200px max-width Co-Authored-By: Claude Opus 4.6 --- main.py | 41 +++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/main.py b/main.py index 9d7c007..250e2e1 100644 --- a/main.py +++ b/main.py @@ -158,30 +158,31 @@ def index(): @ui.refreshable def render_main_content(): - if not state.file_path or not state.file_path.exists(): - ui.label('Select a file from the sidebar to begin.').classes( - 'text-subtitle1 q-pa-lg') - return + with ui.column().classes('w-full q-pa-md').style('max-width: 1200px; margin: 0 auto'): + if not state.file_path or not state.file_path.exists(): + ui.label('Select a file from the sidebar to begin.').classes( + 'text-subtitle1 q-pa-lg') + return - ui.label(f'Editing: {state.file_path.name}').classes('text-h5 q-mb-lg').style('font-weight: 600') + ui.label(f'Editing: {state.file_path.name}').classes('text-h5 q-mb-lg').style('font-weight: 600') - with ui.tabs().classes('w-full').style('border-bottom: 1px solid var(--border)') as tabs: - ui.tab('batch', label='Batch Processor') - ui.tab('timeline', label='Timeline') - ui.tab('raw', label='Raw Editor') + with ui.tabs().classes('w-full').style('border-bottom: 1px solid var(--border)') as tabs: + ui.tab('batch', label='Batch Processor') + ui.tab('timeline', label='Timeline') + ui.tab('raw', label='Raw Editor') - with ui.tab_panels(tabs, value='batch').classes('w-full'): - with ui.tab_panel('batch'): - render_batch_processor(state) - with ui.tab_panel('timeline'): - render_timeline_tab(state) - with ui.tab_panel('raw'): - render_raw_editor(state) + with ui.tab_panels(tabs, value='batch').classes('w-full'): + with ui.tab_panel('batch'): + render_batch_processor(state) + with ui.tab_panel('timeline'): + render_timeline_tab(state) + with ui.tab_panel('raw'): + render_raw_editor(state) - if state.show_comfy_monitor: - ui.separator() - with ui.expansion('ComfyUI Monitor', icon='dns').classes('w-full'): - render_comfy_monitor(state) + if state.show_comfy_monitor: + ui.separator() + with ui.expansion('ComfyUI Monitor', icon='dns').classes('w-full'): + render_comfy_monitor(state) def load_file(file_name: str): """Load a JSON file and refresh the main content.""" From 3264845e6838dd251bc155b2a62e396e43188e8c Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 26 Feb 2026 17:41:25 +0100 Subject: [PATCH 16/26] Add dual-pane batch processor with independent file state Co-Authored-By: Claude Opus 4.6 --- main.py | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++--- state.py | 7 ++++++ 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/main.py b/main.py index 250e2e1..32b9fbe 100644 --- a/main.py +++ b/main.py @@ -143,6 +143,13 @@ def index(): ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); } + + /* Secondary pane teal accent */ + .pane-secondary .q-field--outlined.q-field--focused .q-field__control:after { + border-color: #06B6D4 !important; + } + .pane-secondary .q-btn.bg-primary { background-color: #06B6D4 !important; } + .pane-secondary .section-header { color: rgba(6,182,212,0.7) !important; } ''') config = load_config() @@ -151,6 +158,7 @@ def index(): current_dir=Path(config.get('last_dir', str(Path.cwd()))), snippets=load_snippets(), ) + dual_pane = {'active': False, 'state': None} # ------------------------------------------------------------------ # Define helpers FIRST (before sidebar, which needs them) @@ -158,7 +166,8 @@ def index(): @ui.refreshable def render_main_content(): - with ui.column().classes('w-full q-pa-md').style('max-width: 1200px; margin: 0 auto'): + max_w = '2400px' if dual_pane['active'] else '1200px' + with ui.column().classes('w-full q-pa-md').style(f'max-width: {max_w}; margin: 0 auto'): if not state.file_path or not state.file_path.exists(): ui.label('Select a file from the sidebar to begin.').classes( 'text-subtitle1 q-pa-lg') @@ -173,7 +182,7 @@ def index(): with ui.tab_panels(tabs, value='batch').classes('w-full'): with ui.tab_panel('batch'): - render_batch_processor(state) + _render_batch_tab_content() with ui.tab_panel('timeline'): render_timeline_tab(state) with ui.tab_panel('raw'): @@ -184,6 +193,62 @@ def index(): with ui.expansion('ComfyUI Monitor', icon='dns').classes('w-full'): render_comfy_monitor(state) + @ui.refreshable + def _render_batch_tab_content(): + def on_toggle(e): + dual_pane['active'] = e.value + if e.value and dual_pane['state'] is None: + s2 = state.create_secondary() + s2._render_main = _render_batch_tab_content + dual_pane['state'] = s2 + render_main_content.refresh() + + ui.switch('Dual Pane', value=dual_pane['active'], on_change=on_toggle) + + if not dual_pane['active']: + render_batch_processor(state) + else: + s2 = dual_pane['state'] + with ui.row().classes('w-full gap-4'): + with ui.column().classes('col'): + ui.label('Pane A').classes('section-header q-mb-sm') + render_batch_processor(state) + with ui.column().classes('col pane-secondary'): + ui.label('Pane B').classes('section-header q-mb-sm') + _render_secondary_file_selector(s2) + if s2.file_path and s2.file_path.exists(): + render_batch_processor(s2) + else: + ui.label('Select a file above to begin.').classes( + 'text-caption q-pa-md') + + def _render_secondary_file_selector(s2: AppState): + json_files = sorted(s2.current_dir.glob('*.json')) + json_files = [f for f in json_files if f.name not in ( + '.editor_config.json', '.editor_snippets.json')] + file_names = [f.name for f in json_files] + + current_val = s2.file_path.name if s2.file_path else None + + def on_select(e): + if not e.value: + return + fp = s2.current_dir / e.value + data, mtime = load_json(fp) + s2.data_cache = data + s2.last_mtime = mtime + s2.loaded_file = str(fp) + s2.file_path = fp + s2.restored_indicator = None + _render_batch_tab_content.refresh() + + ui.select( + file_names, + value=current_val, + label='File', + on_change=on_select, + ).classes('w-full') + def load_file(file_name: str): """Load a JSON file and refresh the main content.""" fp = state.current_dir / file_name @@ -207,7 +272,7 @@ def index(): # Sidebar (rendered AFTER helpers are attached) # ------------------------------------------------------------------ with ui.left_drawer(value=True).classes('q-pa-md').style('width: 320px'): - render_sidebar(state) + render_sidebar(state, dual_pane) # ------------------------------------------------------------------ # Main content area @@ -220,7 +285,7 @@ def index(): # Sidebar # ====================================================================== -def render_sidebar(state: AppState): +def render_sidebar(state: AppState, dual_pane: dict): ui.label('Navigator').classes('text-h6') # --- Path input + Pin --- @@ -234,6 +299,8 @@ def render_sidebar(state: AppState): p = resolve_path_case_insensitive(path_input.value) if p is not None and p.is_dir(): state.current_dir = p + if dual_pane['state']: + dual_pane['state'].current_dir = state.current_dir state.config['last_dir'] = str(p) save_config(state.current_dir, state.config['favorites'], state.config) state.loaded_file = None @@ -275,6 +342,8 @@ def render_sidebar(state: AppState): def _jump_to(fav: str): state.current_dir = Path(fav) + if dual_pane['state']: + dual_pane['state'].current_dir = state.current_dir state.config['last_dir'] = fav save_config(state.current_dir, state.config['favorites'], state.config) state.loaded_file = None diff --git a/state.py b/state.py index 5ce99b6..e4aeab4 100644 --- a/state.py +++ b/state.py @@ -23,3 +23,10 @@ class AppState: _main_rendered: bool = False _live_checkboxes: dict = field(default_factory=dict) _live_refreshables: dict = field(default_factory=dict) + + def create_secondary(self) -> 'AppState': + return AppState( + config=self.config, + current_dir=self.current_dir, + snippets=self.snippets, + ) From 7931060d43849d468635c1e18f06bc5a22917c07 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 26 Feb 2026 17:49:09 +0100 Subject: [PATCH 17/26] Fix number inputs not syncing to dict until blur dict_number() only wrote to seq[key] on blur, so changing a value (e.g. via spinner arrows) and immediately clicking Save could race the save ahead of the blur on the server. Now also syncs on update:model-value so the dict is always current. Co-Authored-By: Claude Opus 4.6 --- tab_batch_ng.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index 27e5ae9..ab3d3cc 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -89,7 +89,7 @@ def dict_input(element_fn, label, seq, key, **kwargs): def dict_number(label, seq, key, default=0, **kwargs): - """Number input bound to seq[key] via blur.""" + """Number input bound to seq[key] via blur and model-value update.""" val = seq.get(key, default) try: # Try float first to handle "1.5" strings, then check if it's a clean int @@ -99,15 +99,16 @@ def dict_number(label, seq, key, default=0, **kwargs): val = default el = ui.number(label, value=val, **kwargs) - def _on_blur(e, k=key, d=default): - v = e.sender.value + def _sync(k=key, d=default): + v = el.value if v is None: v = d elif isinstance(v, float) and v == int(v): v = int(v) seq[k] = v - el.on('blur', _on_blur) + el.on('blur', lambda _: _sync()) + el.on('update:model-value', lambda _: _sync()) return el From 9f141ba42f6da408b3fb7305463e5e4c2d409234 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 26 Feb 2026 18:02:24 +0100 Subject: [PATCH 18/26] Fix input sync bugs, improve LoRA UX, and harden edge cases - Sync dict_input/dict_textarea/LoRA inputs on update:model-value (not just blur) to prevent silent data loss on quick saves - Split LoRA into name + strength fields, default strength to 1.0 - Stack LoRAs one per line instead of 3-card row - Collapse "Add New Sequence from Source File" into expansion - Add file selector to Pane A in dual-pane mode - Clear secondary pane state on directory change - Fix file radio resetting to first file on refresh - Handle bare-list JSON files and inf/nan edge cases Co-Authored-By: Claude Opus 4.6 --- main.py | 31 +++++--- tab_batch_ng.py | 190 ++++++++++++++++++++++++++++-------------------- 2 files changed, 131 insertions(+), 90 deletions(-) diff --git a/main.py b/main.py index 32b9fbe..f99f00e 100644 --- a/main.py +++ b/main.py @@ -212,34 +212,35 @@ def index(): with ui.row().classes('w-full gap-4'): with ui.column().classes('col'): ui.label('Pane A').classes('section-header q-mb-sm') + _render_pane_file_selector(state) render_batch_processor(state) with ui.column().classes('col pane-secondary'): ui.label('Pane B').classes('section-header q-mb-sm') - _render_secondary_file_selector(s2) + _render_pane_file_selector(s2) if s2.file_path and s2.file_path.exists(): render_batch_processor(s2) else: ui.label('Select a file above to begin.').classes( 'text-caption q-pa-md') - def _render_secondary_file_selector(s2: AppState): - json_files = sorted(s2.current_dir.glob('*.json')) + def _render_pane_file_selector(pane_state: AppState): + json_files = sorted(pane_state.current_dir.glob('*.json')) json_files = [f for f in json_files if f.name not in ( '.editor_config.json', '.editor_snippets.json')] file_names = [f.name for f in json_files] - current_val = s2.file_path.name if s2.file_path else None + current_val = pane_state.file_path.name if pane_state.file_path else None def on_select(e): if not e.value: return - fp = s2.current_dir / e.value + fp = pane_state.current_dir / e.value data, mtime = load_json(fp) - s2.data_cache = data - s2.last_mtime = mtime - s2.loaded_file = str(fp) - s2.file_path = fp - s2.restored_indicator = None + pane_state.data_cache = data + pane_state.last_mtime = mtime + pane_state.loaded_file = str(fp) + pane_state.file_path = fp + pane_state.restored_indicator = None _render_batch_tab_content.refresh() ui.select( @@ -301,6 +302,9 @@ def render_sidebar(state: AppState, dual_pane: dict): state.current_dir = p if dual_pane['state']: dual_pane['state'].current_dir = state.current_dir + dual_pane['state'].file_path = None + dual_pane['state'].loaded_file = None + dual_pane['state'].data_cache = {} state.config['last_dir'] = str(p) save_config(state.current_dir, state.config['favorites'], state.config) state.loaded_file = None @@ -344,6 +348,9 @@ def render_sidebar(state: AppState, dual_pane: dict): state.current_dir = Path(fav) if dual_pane['state']: dual_pane['state'].current_dir = state.current_dir + dual_pane['state'].file_path = None + dual_pane['state'].loaded_file = None + dual_pane['state'].data_cache = {} state.config['last_dir'] = fav save_config(state.current_dir, state.config['favorites'], state.config) state.loaded_file = None @@ -443,9 +450,11 @@ def render_sidebar(state: AppState, dual_pane: dict): ui.label('Select File').classes('subsection-header q-mt-sm') file_names = [f.name for f in json_files] + current = Path(state.loaded_file).name if state.loaded_file else None + selected = current if current in file_names else (file_names[0] if file_names else None) ui.radio( file_names, - value=file_names[0] if file_names else None, + value=selected, on_change=lambda e: state._load_file(e.value) if e.value else None, ).classes('w-full') diff --git a/tab_batch_ng.py b/tab_batch_ng.py index ab3d3cc..a41fab3 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -79,12 +79,17 @@ def find_insert_position(batch_list, parent_index, parent_seq_num): # --- Helper for repetitive dict-bound inputs --- def dict_input(element_fn, label, seq, key, **kwargs): - """Create an input element bound to seq[key] via blur event.""" + """Create an input element bound to seq[key] via blur and model-value update.""" val = seq.get(key, '') if isinstance(val, (int, float)): val = str(val) if element_fn != ui.number else val el = element_fn(label, value=val, **kwargs) - el.on('blur', lambda e, k=key: seq.__setitem__(k, e.sender.value)) + + def _sync(k=key): + seq[k] = el.value + + el.on('blur', lambda _: _sync()) + el.on('update:model-value', lambda _: _sync()) return el @@ -95,7 +100,7 @@ def dict_number(label, seq, key, default=0, **kwargs): # Try float first to handle "1.5" strings, then check if it's a clean int fval = float(val) val = int(fval) if fval == int(fval) else fval - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): val = default el = ui.number(label, value=val, **kwargs) @@ -103,8 +108,11 @@ def dict_number(label, seq, key, default=0, **kwargs): v = el.value if v is None: v = d - elif isinstance(v, float) and v == int(v): - v = int(v) + elif isinstance(v, float): + try: + v = int(v) if v == int(v) else v + except (OverflowError, ValueError): + v = d seq[k] = v el.on('blur', lambda _: _sync()) @@ -113,9 +121,14 @@ def dict_number(label, seq, key, default=0, **kwargs): def dict_textarea(label, seq, key, **kwargs): - """Textarea bound to seq[key] via blur.""" + """Textarea bound to seq[key] via blur and model-value update.""" el = ui.textarea(label, value=seq.get(key, ''), **kwargs) - el.on('blur', lambda e, k=key: seq.__setitem__(k, e.sender.value)) + + def _sync(k=key): + seq[k] = el.value + + el.on('blur', lambda _: _sync()) + el.on('update:model-value', lambda _: _sync()) return el @@ -126,7 +139,10 @@ def dict_textarea(label, seq, key, **kwargs): def render_batch_processor(state: AppState): data = state.data_cache file_path = state.file_path - is_batch_file = KEY_BATCH_DATA in data or isinstance(data, list) + if isinstance(data, list): + data = {KEY_BATCH_DATA: data} + state.data_cache = data + is_batch_file = KEY_BATCH_DATA in data if not is_batch_file: ui.label('This is a Single file. To use Batch mode, create a copy.').classes( @@ -158,67 +174,65 @@ def render_batch_processor(state: AppState): # Source file data for importing with ui.card().classes('w-full q-pa-md q-mb-lg'): - json_files = sorted(state.current_dir.glob('*.json')) - json_files = [f for f in json_files if f.name not in ( - '.editor_config.json', '.editor_snippets.json')] - file_options = {f.name: f.name for f in json_files} + with ui.expansion('Add New Sequence from Source File', icon='playlist_add').classes('w-full'): + json_files = sorted(state.current_dir.glob('*.json')) + json_files = [f for f in json_files if f.name not in ( + '.editor_config.json', '.editor_snippets.json')] + file_options = {f.name: f.name for f in json_files} - src_file_select = ui.select( - file_options, - value=file_path.name, - label='Source File:', - ).classes('w-64') + src_file_select = ui.select( + file_options, + value=file_path.name, + label='Source File:', + ).classes('w-64') - src_seq_select = ui.select([], label='Source Sequence:').classes('w-64') + src_seq_select = ui.select([], label='Source Sequence:').classes('w-64') - # Track loaded source data - _src_cache = {'data': None, 'batch': [], 'name': None} + # Track loaded source data + _src_cache = {'data': None, 'batch': [], 'name': None} - def _update_src(): - name = src_file_select.value - if name and name != _src_cache['name']: - src_data, _ = load_json(state.current_dir / name) - _src_cache['data'] = src_data - _src_cache['batch'] = src_data.get(KEY_BATCH_DATA, []) - _src_cache['name'] = name - if _src_cache['batch']: - opts = {i: format_seq_label(s.get(KEY_SEQUENCE_NUMBER, i+1)) - for i, s in enumerate(_src_cache['batch'])} - src_seq_select.set_options(opts, value=0) - else: - src_seq_select.set_options({}) + def _update_src(): + name = src_file_select.value + if name and name != _src_cache['name']: + src_data, _ = load_json(state.current_dir / name) + _src_cache['data'] = src_data + _src_cache['batch'] = src_data.get(KEY_BATCH_DATA, []) + _src_cache['name'] = name + if _src_cache['batch']: + opts = {i: format_seq_label(s.get(KEY_SEQUENCE_NUMBER, i+1)) + for i, s in enumerate(_src_cache['batch'])} + src_seq_select.set_options(opts, value=0) + else: + src_seq_select.set_options({}) - src_file_select.on_value_change(lambda _: _update_src()) - _update_src() + src_file_select.on_value_change(lambda _: _update_src()) + _update_src() - # --- Add New Sequence --- - ui.label('Add New Sequence').classes('section-header q-mt-md') + def _add_sequence(new_item): + new_item[KEY_SEQUENCE_NUMBER] = max_main_seq_number(batch_list) + 1 + for k in [KEY_PROMPT_HISTORY, KEY_HISTORY_TREE, 'note', 'loras']: + new_item.pop(k, None) + batch_list.append(new_item) + data[KEY_BATCH_DATA] = batch_list + save_json(file_path, data) + render_sequence_list.refresh() - def _add_sequence(new_item): - new_item[KEY_SEQUENCE_NUMBER] = max_main_seq_number(batch_list) + 1 - for k in [KEY_PROMPT_HISTORY, KEY_HISTORY_TREE, 'note', 'loras']: - new_item.pop(k, None) - batch_list.append(new_item) - data[KEY_BATCH_DATA] = batch_list - save_json(file_path, data) - render_sequence_list.refresh() + with ui.row().classes('q-mt-sm'): + def add_empty(): + _add_sequence(DEFAULTS.copy()) - with ui.row(): - def add_empty(): - _add_sequence(DEFAULTS.copy()) + def add_from_source(): + item = copy.deepcopy(DEFAULTS) + src_batch = _src_cache['batch'] + sel_idx = src_seq_select.value + if src_batch and sel_idx is not None: + item.update(copy.deepcopy(src_batch[int(sel_idx)])) + elif _src_cache['data']: + item.update(copy.deepcopy(_src_cache['data'])) + _add_sequence(item) - def add_from_source(): - item = copy.deepcopy(DEFAULTS) - src_batch = _src_cache['batch'] - sel_idx = src_seq_select.value - if src_batch and sel_idx is not None: - item.update(copy.deepcopy(src_batch[int(sel_idx)])) - elif _src_cache['data']: - item.update(copy.deepcopy(_src_cache['data'])) - _add_sequence(item) - - ui.button('Add Empty', icon='add', on_click=add_empty) - ui.button('From Source', icon='file_download', on_click=add_from_source) + ui.button('Add Empty', icon='add', on_click=add_empty) + ui.button('From Source', icon='file_download', on_click=add_from_source) # --- Standard / LoRA / VACE key sets --- lora_keys = ['lora 1 high', 'lora 1 low', 'lora 2 high', 'lora 2 low', @@ -456,28 +470,46 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, # --- LoRA Settings --- with ui.expansion('LoRA Settings', icon='style').classes('w-full'): - with ui.row().classes('w-full q-gutter-md'): - for lora_idx in range(1, 4): - with ui.card().classes('col q-pa-sm surface-3'): - ui.label(f'LoRA {lora_idx}').classes('text-subtitle2') - for tier, tier_label in [('high', 'High'), ('low', 'Low')]: - k = f'lora {lora_idx} {tier}' - raw = str(seq.get(k, '')) - disp = raw.replace('', '') + for lora_idx in range(1, 4): + for tier, tier_label in [('high', 'High'), ('low', 'Low')]: + k = f'lora {lora_idx} {tier}' + raw = str(seq.get(k, '')) + inner = raw.replace('', '') + # Split "name:strength" or just "name" + if ':' in inner: + parts = inner.rsplit(':', 1) + lora_name = parts[0] + try: + lora_strength = float(parts[1]) + except ValueError: + lora_name = inner + lora_strength = 1.0 + else: + lora_name = inner + lora_strength = 1.0 - with ui.row().classes('w-full items-center'): - ui.label('').classes('text-caption font-mono') + with ui.row().classes('w-full items-center q-gutter-sm'): + ui.label(f'L{lora_idx} {tier_label}').classes( + 'text-caption').style('min-width: 55px') + name_input = ui.input( + 'Name', + value=lora_name, + ).classes('col').props('outlined dense') + strength_input = ui.number( + 'Str', + value=lora_strength, + min=0, max=10, step=0.1, + ).props('outlined dense').style('max-width: 80px') - def on_lora_blur(e, key=k): - v = e.sender.value - seq[key] = f'' if v else '' + def _lora_sync(key=k, n_inp=name_input, s_inp=strength_input): + name = n_inp.value or '' + strength = s_inp.value if s_inp.value is not None else 1.0 + seq[key] = f'' if name else '' - lora_input.on('blur', on_lora_blur) + name_input.on('blur', lambda _, s=_lora_sync: s()) + name_input.on('update:model-value', lambda _, s=_lora_sync: s()) + strength_input.on('blur', lambda _, s=_lora_sync: s()) + strength_input.on('update:model-value', lambda _, s=_lora_sync: s()) # --- Custom Parameters --- ui.label('Custom Parameters').classes('section-header q-mt-md') From d7956717631d2824821aec30bc15a815dba54b66 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 26 Feb 2026 18:03:56 +0100 Subject: [PATCH 19/26] Display LoRA strength with one decimal place (1.0 not 1) Co-Authored-By: Claude Opus 4.6 --- tab_batch_ng.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index a41fab3..d1b7c00 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -499,12 +499,13 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, 'Str', value=lora_strength, min=0, max=10, step=0.1, + format='%.1f', ).props('outlined dense').style('max-width: 80px') def _lora_sync(key=k, n_inp=name_input, s_inp=strength_input): name = n_inp.value or '' strength = s_inp.value if s_inp.value is not None else 1.0 - seq[key] = f'' if name else '' + seq[key] = f'' if name else '' name_input.on('blur', lambda _, s=_lora_sync: s()) name_input.on('update:model-value', lambda _, s=_lora_sync: s()) From d3dbd4645ac7b0ee1503c9065905018a0007e5bd Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 26 Feb 2026 18:05:19 +0100 Subject: [PATCH 20/26] Remove Promote button (legacy single-file editor feature) Co-Authored-By: Claude Opus 4.6 --- tab_batch_ng.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index d1b7c00..0cd7973 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -376,25 +376,6 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, ui.button('Clone Sub', icon='link', on_click=clone_sub).props('outline') - # Spacer before Promote - ui.element('div').classes('col') - - # Promote - def promote(idx=i, s=seq): - single_data = copy.deepcopy(s) - single_data[KEY_PROMPT_HISTORY] = copy.deepcopy(data.get(KEY_PROMPT_HISTORY, [])) - single_data[KEY_HISTORY_TREE] = copy.deepcopy(data.get(KEY_HISTORY_TREE, {})) - single_data.pop(KEY_SEQUENCE_NUMBER, None) - save_json(file_path, single_data) - state.data_cache = single_data - ui.notify('Converted to Single!', type='positive') - # Full refresh so batch tab re-enters render_batch_processor - # and sees the file is now single (no KEY_BATCH_DATA) - state._render_main.refresh() - - ui.button('Promote', icon='north_west', on_click=promote) - - # Spacer before Delete ui.element('div').classes('col') # Delete From 39a1b98924e8dd59b284ba90951b24c8bc1a3cdf Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 26 Feb 2026 18:11:11 +0100 Subject: [PATCH 21/26] Fix history snapshot corruption, missing dir crash, stale batch delete - Deep-copy node data on restore to prevent edits from mutating stored history snapshots - Guard glob calls against non-existent current_dir - Read current selection at delete time instead of using stale render-time capture Co-Authored-By: Claude Opus 4.6 --- main.py | 6 ++++++ tab_timeline_ng.py | 7 ++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/main.py b/main.py index f99f00e..bb73de7 100644 --- a/main.py +++ b/main.py @@ -224,6 +224,9 @@ def index(): 'text-caption q-pa-md') def _render_pane_file_selector(pane_state: AppState): + if not pane_state.current_dir.exists(): + ui.label('Directory not found.').classes('text-warning') + return json_files = sorted(pane_state.current_dir.glob('*.json')) json_files = [f for f in json_files if f.name not in ( '.editor_config.json', '.editor_snippets.json')] @@ -422,6 +425,9 @@ def render_sidebar(state: AppState, dual_pane: dict): with ui.card().classes('w-full q-pa-md q-mb-md'): @ui.refreshable def render_file_list(): + if not state.current_dir.exists(): + ui.label('Directory not found.').classes('text-warning') + return json_files = sorted(state.current_dir.glob('*.json')) json_files = [f for f in json_files if f.name not in ('.editor_config.json', '.editor_snippets.json')] diff --git a/tab_timeline_ng.py b/tab_timeline_ng.py index 7f49b09..6c0c004 100644 --- a/tab_timeline_ng.py +++ b/tab_timeline_ng.py @@ -130,10 +130,11 @@ def _render_batch_delete(htree, data, file_path, state, refresh_fn): ).classes('text-warning q-mt-md') def do_batch_delete(): - _delete_nodes(htree, data, file_path, valid) + current_valid = state.timeline_selected_nodes & set(htree.nodes.keys()) + _delete_nodes(htree, data, file_path, current_valid) state.timeline_selected_nodes = set() ui.notify( - f'Deleted {count} node{"s" if count != 1 else ""}!', + f'Deleted {len(current_valid)} node{"s" if len(current_valid) != 1 else ""}!', type='positive') refresh_fn() @@ -288,7 +289,7 @@ def _render_graphviz(dot_source: str): def _restore_node(data, node, htree, file_path, state: AppState): """Restore a history node as the current version.""" - node_data = node['data'] + node_data = copy.deepcopy(node['data']) if KEY_BATCH_DATA not in node_data and KEY_BATCH_DATA in data: del data[KEY_BATCH_DATA] data.update(node_data) From 79755c286bfcca72b0032f0fb6b6a1c02178d10f Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 26 Feb 2026 18:23:37 +0100 Subject: [PATCH 22/26] Move VACE Settings to full-width section below splitter columns Co-Authored-By: Claude Opus 4.6 --- tab_batch_ng.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index 0cd7973..36864b5 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -445,9 +445,9 @@ 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') - # VACE Settings - with ui.expansion('VACE Settings', icon='settings').classes('w-full'): - _render_vace_settings(i, seq, batch_list, data, file_path, refresh_list) + # --- VACE Settings (full width) --- + with ui.expansion('VACE Settings', icon='settings').classes('w-full'): + _render_vace_settings(i, seq, batch_list, data, file_path, refresh_list) # --- LoRA Settings --- with ui.expansion('LoRA Settings', icon='style').classes('w-full'): From da789e68ad72636730588e7de335972621ed9799 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 26 Feb 2026 18:26:45 +0100 Subject: [PATCH 23/26] Two-column VACE layout, inline mode reference button Co-Authored-By: Claude Opus 4.6 --- tab_batch_ng.py | 135 ++++++++++++++++++++++++++---------------------- 1 file changed, 73 insertions(+), 62 deletions(-) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index 36864b5..5cee4ee 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -530,49 +530,10 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, # ====================================================================== def _render_vace_settings(i, seq, batch_list, data, file_path, refresh_list): - # Frame to Skip + shift - with ui.row().classes('w-full items-end'): - fts_input = dict_number('Frame to Skip', seq, 'frame_to_skip').classes('col').props( - 'outlined') - - # Capture original at render time; blur updates seq before click fires - _original_fts = int(seq.get('frame_to_skip', FRAME_TO_SKIP_DEFAULT)) - - def shift_fts(idx=i, orig=_original_fts): - new_fts = int(fts_input.value) if fts_input.value is not None else orig - delta = new_fts - orig - if delta == 0: - ui.notify('No change to shift', type='info') - return - shifted = 0 - for j in range(idx + 1, len(batch_list)): - batch_list[j]['frame_to_skip'] = int( - batch_list[j].get('frame_to_skip', FRAME_TO_SKIP_DEFAULT)) + delta - shifted += 1 - data[KEY_BATCH_DATA] = batch_list - save_json(file_path, data) - ui.notify(f'Shifted {shifted} sequences by {delta:+d}', type='positive') - refresh_list.refresh() - - ui.button('Shift', icon='arrow_downward', on_click=shift_fts).props('dense') - - dict_input(ui.input, 'Transition', seq, 'transition').props('outlined').classes('q-mt-sm') - - # VACE Schedule + # VACE Schedule (needed early for both columns) sched_val = max(0, min(int(seq.get('vace schedule', 1)), len(VACE_MODES) - 1)) - with ui.row().classes('w-full items-center q-mt-sm'): - vs_input = dict_number('VACE Schedule', seq, 'vace schedule', default=1, - min=0, max=len(VACE_MODES) - 1).classes('col').props('outlined') - mode_label = ui.label(VACE_MODES[sched_val]).classes('text-caption') - def update_mode_label(e): - idx = int(e.sender.value) if e.sender.value is not None else 0 - idx = max(0, min(idx, len(VACE_MODES) - 1)) - mode_label.set_text(VACE_MODES[idx]) - - vs_input.on('update:model-value', update_mode_label) - - # Mode reference + # Mode reference dialog with ui.dialog() as ref_dlg, ui.card(): table_md = ( '| # | Mode | Formula |\n|:--|:-----|:--------|\n' @@ -582,30 +543,82 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, refresh_list): + '\n\n*All totals snapped to 4n+1 (1,5,9,...,49,...,81,...)*' ) ui.markdown(table_md) - ui.button('Mode Reference', icon='help', on_click=ref_dlg.open).props('flat dense') - # Input A / B frames - ia_input = dict_number('Input A Frames', seq, 'input_a_frames').props('outlined').classes('q-mt-sm') - ib_input = dict_number('Input B Frames', seq, 'input_b_frames').props('outlined').classes('q-mt-sm') + with ui.row().classes('w-full q-gutter-md'): + # --- Left column --- + with ui.column().classes('col'): + # Frame to Skip + shift + with ui.row().classes('w-full items-end'): + fts_input = dict_number('Frame to Skip', seq, 'frame_to_skip').classes( + 'col').props('outlined') - # VACE Length + output calculation - input_a = int(seq.get('input_a_frames', 16)) - input_b = int(seq.get('input_b_frames', 16)) - stored_total = int(seq.get('vace_length', 49)) - mode_idx = int(seq.get('vace schedule', 1)) + _original_fts = int(seq.get('frame_to_skip', FRAME_TO_SKIP_DEFAULT)) - if mode_idx == 0: - base_length = max(stored_total - input_a, 1) - elif mode_idx == 1: - base_length = max(stored_total - input_b, 1) - else: - base_length = max(stored_total - input_a - input_b, 1) + def shift_fts(idx=i, orig=_original_fts): + new_fts = int(fts_input.value) if fts_input.value is not None else orig + delta = new_fts - orig + if delta == 0: + ui.notify('No change to shift', type='info') + return + shifted = 0 + for j in range(idx + 1, len(batch_list)): + batch_list[j]['frame_to_skip'] = int( + batch_list[j].get('frame_to_skip', FRAME_TO_SKIP_DEFAULT)) + delta + shifted += 1 + data[KEY_BATCH_DATA] = batch_list + save_json(file_path, data) + ui.notify(f'Shifted {shifted} sequences by {delta:+d}', type='positive') + refresh_list.refresh() - with ui.row().classes('w-full items-center q-mt-sm'): - vl_input = ui.number('VACE Length', value=base_length, min=1).classes('col').props( - 'outlined') - output_label = ui.label(f'Output: {stored_total}').classes('text-bold') + ui.button('Shift', icon='arrow_downward', on_click=shift_fts).props('dense') + dict_input(ui.input, 'Transition', seq, 'transition').props('outlined').classes( + 'w-full q-mt-sm') + + # VACE Schedule + with ui.row().classes('w-full items-center q-mt-sm'): + vs_input = dict_number('VACE Schedule', seq, 'vace schedule', default=1, + min=0, max=len(VACE_MODES) - 1).classes('col').props( + 'outlined') + mode_label = ui.label(VACE_MODES[sched_val]).classes('text-caption') + ui.button(icon='help', on_click=ref_dlg.open).props('flat dense round') + + def update_mode_label(e): + idx = int(e.sender.value) if e.sender.value is not None else 0 + idx = max(0, min(idx, len(VACE_MODES) - 1)) + mode_label.set_text(VACE_MODES[idx]) + + vs_input.on('update:model-value', update_mode_label) + + # --- Right column --- + with ui.column().classes('col'): + ia_input = dict_number('Input A Frames', seq, 'input_a_frames').props( + 'outlined').classes('w-full') + ib_input = dict_number('Input B Frames', seq, 'input_b_frames').props( + 'outlined').classes('w-full q-mt-sm') + + # VACE Length + output calculation + input_a = int(seq.get('input_a_frames', 16)) + input_b = int(seq.get('input_b_frames', 16)) + stored_total = int(seq.get('vace_length', 49)) + mode_idx = int(seq.get('vace schedule', 1)) + + if mode_idx == 0: + base_length = max(stored_total - input_a, 1) + elif mode_idx == 1: + base_length = max(stored_total - input_b, 1) + else: + base_length = max(stored_total - input_a - input_b, 1) + + with ui.row().classes('w-full items-center q-mt-sm'): + vl_input = ui.number('VACE Length', value=base_length, min=1).classes( + 'col').props('outlined') + output_label = ui.label(f'Output: {stored_total}').classes('text-bold') + + dict_number('Reference Switch', seq, 'reference switch').props( + 'outlined').classes('w-full q-mt-sm') + + # Recalculate VACE output when any input changes def recalc_vace(*_args): mi = int(vs_input.value) if vs_input.value is not None else 0 ia = int(ia_input.value) if ia_input.value is not None else 16 @@ -626,8 +639,6 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, refresh_list): for inp in (vs_input, ia_input, ib_input, vl_input): inp.on('update:model-value', recalc_vace) - dict_number('Reference Switch', seq, 'reference switch').props('outlined').classes('q-mt-sm') - # ====================================================================== # Mass Update From 29750acf587a8d3c5e21ccb1dcaf211020bffc9e Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 26 Feb 2026 18:28:35 +0100 Subject: [PATCH 24/26] Match Shift button height to input field Co-Authored-By: Claude Opus 4.6 --- tab_batch_ng.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index 5cee4ee..217aaab 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -570,7 +570,8 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, refresh_list): ui.notify(f'Shifted {shifted} sequences by {delta:+d}', type='positive') refresh_list.refresh() - ui.button('Shift', icon='arrow_downward', on_click=shift_fts).props('dense') + ui.button('Shift', icon='arrow_downward', on_click=shift_fts).props( + 'outline').style('height: 40px') dict_input(ui.input, 'Transition', seq, 'transition').props('outlined').classes( 'w-full q-mt-sm') From af5eafaf4d2fe40096447ff2577d2c88d6aad302 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Fri, 27 Feb 2026 00:02:14 +0100 Subject: [PATCH 25/26] Right-align path inputs to show filename instead of directory prefix Co-Authored-By: Claude Opus 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 217aaab..5b0135e 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -427,7 +427,7 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, dict_input(ui.input, 'FLF', seq, 'flf').props('outlined').classes('w-full') dict_number('End Frame', seq, 'end_frame').props('outlined').classes('w-full') dict_input(ui.input, 'Video File Path', seq, 'video file path').props( - 'outlined').classes('w-full') + 'outlined input-style="direction: rtl"').classes('w-full') # Image paths with preview for img_label, img_key in [ @@ -437,7 +437,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') + 'col').props('outlined input-style="direction: rtl"') 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 891132383214f182140a965931e089dd7d070bc8 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Fri, 27 Feb 2026 22:15:56 +0100 Subject: [PATCH 26/26] Branch-grouped navigation for timeline node manager Replace flat dropdown with branch selector showing node counts, scrollable node list with HEAD/tip badges, and inline actions panel. Co-Authored-By: Claude Opus 4.6 --- tab_timeline_ng.py | 209 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 158 insertions(+), 51 deletions(-) diff --git a/tab_timeline_ng.py b/tab_timeline_ng.py index 6c0c004..05c935f 100644 --- a/tab_timeline_ng.py +++ b/tab_timeline_ng.py @@ -145,70 +145,178 @@ def _render_batch_delete(htree, data, file_path, state, refresh_fn): ).props('color=negative') +def _walk_branch_nodes(htree, tip_id): + """Walk parent pointers from tip, returning nodes newest-first.""" + nodes = [] + current = tip_id + while current and current in htree.nodes: + nodes.append(htree.nodes[current]) + current = htree.nodes[current].get('parent') + return nodes + + +def _find_active_branch(htree): + """Return branch name whose tip == head_id, or None if detached.""" + if not htree.head_id: + return None + for b_name, tip_id in htree.branches.items(): + if tip_id == htree.head_id: + return b_name + return None + + def _render_node_manager(all_nodes, htree, data, file_path, restore_fn, refresh_fn): - """Render node selector with restore, rename, delete, and preview.""" + """Render branch-grouped node manager with restore, rename, delete, and preview.""" ui.label('Manage Version').classes('section-header') - def fmt_node(n): - ts = time.strftime('%b %d %H:%M', time.localtime(n['timestamp'])) - return f'{n.get("note", "Step")} - {ts} ({n["id"][:6]})' + # --- State that survives @ui.refreshable --- + active_branch = _find_active_branch(htree) - node_options = {n['id']: fmt_node(n) for n in all_nodes} - current_id = htree.head_id if htree.head_id in node_options else ( - all_nodes[0]['id'] if all_nodes else None) + # Default branch: active branch, or branch whose ancestry contains HEAD + default_branch = active_branch + if not default_branch and htree.head_id: + for b_name, tip_id in htree.branches.items(): + for n in _walk_branch_nodes(htree, tip_id): + if n['id'] == htree.head_id: + default_branch = b_name + break + if default_branch: + break + if not default_branch and htree.branches: + default_branch = next(iter(htree.branches)) - selected_node_id = ui.select( - node_options, - value=current_id, - label='Select Version to Manage:', + selected = {'node_id': htree.head_id, 'branch': default_branch} + + # --- (a) Branch selector --- + def fmt_branch(b_name): + count = len(_walk_branch_nodes(htree, htree.branches.get(b_name))) + suffix = ' (active)' if b_name == active_branch else '' + return f'{b_name} ({count} nodes){suffix}' + + branch_options = {b: fmt_branch(b) for b in htree.branches} + + def on_branch_change(e): + selected['branch'] = e.value + tip = htree.branches.get(e.value) + if tip: + selected['node_id'] = tip + render_branch_nodes.refresh() + + ui.select( + branch_options, + value=selected['branch'], + label='Branch:', + on_change=on_branch_change, ).classes('w-full') - with ui.row().classes('w-full items-end q-gutter-md'): - def restore_selected(): - nid = selected_node_id.value - if nid and nid in htree.nodes: - restore_fn(htree.nodes[nid]) + # --- (b) Node list + (c) Actions panel --- + @ui.refreshable + def render_branch_nodes(): + branch_name = selected['branch'] + tip_id = htree.branches.get(branch_name) + nodes = _walk_branch_nodes(htree, tip_id) if tip_id else [] - ui.button('Restore Version', icon='restore', - on_click=restore_selected).props('color=primary') + if not nodes: + ui.label('No nodes on this branch.').classes('text-caption q-pa-sm') + return - # Rename - with ui.row().classes('w-full items-end q-gutter-md'): - rename_input = ui.input('Rename Label').classes('col') + with ui.scroll_area().classes('w-full').style('max-height: 350px'): + for n in nodes: + nid = n['id'] + is_head = nid == htree.head_id + is_tip = nid == tip_id + is_selected = nid == selected['node_id'] - def rename_node(): - nid = selected_node_id.value - if nid and nid in htree.nodes and rename_input.value: - htree.nodes[nid]['note'] = rename_input.value - data[KEY_HISTORY_TREE] = htree.to_dict() - save_json(file_path, data) - ui.notify('Label updated', type='positive') - refresh_fn() + card_style = '' + if is_selected: + card_style = 'border-left: 3px solid var(--primary);' + elif is_head: + card_style = 'border-left: 3px solid var(--accent);' - ui.button('Update Label', on_click=rename_node).props('flat') + with ui.card().classes('w-full q-mb-xs q-pa-xs').style(card_style): + with ui.row().classes('w-full items-center no-wrap'): + icon = 'location_on' if is_head else 'circle' + icon_size = 'sm' if is_head else 'xs' + ui.icon(icon, size=icon_size).classes( + 'text-primary' if is_head else 'text-grey') - # Danger zone - with ui.expansion('Danger Zone (Delete)', icon='warning').classes('w-full q-mt-md').style('border-left: 3px solid var(--negative)'): - ui.label('Deleting a node cannot be undone.').classes('text-warning') + with ui.column().classes('col q-ml-xs').style('min-width: 0'): + note = n.get('note', 'Step') + ts = time.strftime('%b %d %H:%M', + time.localtime(n['timestamp'])) + label_text = note + lbl = ui.label(label_text).classes('text-body2 ellipsis') + if is_head: + lbl.classes('text-bold') + ui.label(f'{ts} \u2022 {nid[:6]}').classes( + 'text-caption text-grey') - def delete_selected(): - nid = selected_node_id.value - if nid and nid in htree.nodes: - _delete_nodes(htree, data, file_path, {nid}) - ui.notify('Node Deleted', type='positive') - refresh_fn() + if is_head: + ui.badge('HEAD', color='amber').props('dense') + if is_tip and not is_head: + ui.badge('tip', color='green', outline=True).props('dense') - ui.button('Delete This Node', icon='delete', - on_click=delete_selected).props('color=negative') + def select_node(node_id=nid): + selected['node_id'] = node_id + render_branch_nodes.refresh() - # Data preview - ui.separator() - with ui.expansion('Data Preview', icon='preview').classes('w-full'): - @ui.refreshable - def render_preview(): - _render_data_preview(selected_node_id, htree) - selected_node_id.on_value_change(lambda _: render_preview.refresh()) - render_preview() + ui.button(icon='check_circle', on_click=select_node).props( + 'flat dense round size=sm' + ).tooltip('Select this node') + + # --- (c) Actions panel --- + sel_id = selected['node_id'] + if not sel_id or sel_id not in htree.nodes: + return + + sel_node = htree.nodes[sel_id] + sel_note = sel_node.get('note', 'Step') + is_head = sel_id == htree.head_id + + ui.separator().classes('q-my-sm') + ui.label(f'Selected: {sel_note} ({sel_id[:6]})').classes( + 'text-caption text-bold') + + with ui.row().classes('w-full items-end q-gutter-sm'): + if not is_head: + def restore_selected(): + if sel_id in htree.nodes: + restore_fn(htree.nodes[sel_id]) + ui.button('Restore', icon='restore', + on_click=restore_selected).props('color=primary dense') + + # Rename + rename_input = ui.input('Rename Label').classes('col').props('dense') + + def rename_node(): + if sel_id in htree.nodes and rename_input.value: + htree.nodes[sel_id]['note'] = rename_input.value + data[KEY_HISTORY_TREE] = htree.to_dict() + save_json(file_path, data) + ui.notify('Label updated', type='positive') + refresh_fn() + + ui.button('Update Label', on_click=rename_node).props('flat dense') + + # Danger zone + with ui.expansion('Danger Zone', icon='warning').classes( + 'w-full q-mt-sm').style('border-left: 3px solid var(--negative)'): + ui.label('Deleting a node cannot be undone.').classes('text-warning') + + def delete_selected(): + if sel_id in htree.nodes: + _delete_nodes(htree, data, file_path, {sel_id}) + ui.notify('Node Deleted', type='positive') + refresh_fn() + + ui.button('Delete This Node', icon='delete', + on_click=delete_selected).props('color=negative dense') + + # Data preview + with ui.expansion('Data Preview', icon='preview').classes('w-full q-mt-sm'): + _render_data_preview(sel_id, htree) + + render_branch_nodes() def render_timeline_tab(state: AppState): @@ -301,9 +409,8 @@ def _restore_node(data, node, htree, file_path, state: AppState): ui.notify('Restored!', type='positive') -def _render_data_preview(selected_node_id, htree): +def _render_data_preview(nid, htree): """Render a read-only preview of the selected node's data.""" - nid = selected_node_id.value if not nid or nid not in htree.nodes: ui.label('No node selected.').classes('text-caption') return