16 Commits

Author SHA1 Message Date
8911323832 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 <noreply@anthropic.com>
2026-02-27 22:15:56 +01:00
af5eafaf4d Right-align path inputs to show filename instead of directory prefix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 00:02:14 +01:00
29750acf58 Match Shift button height to input field
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 18:28:35 +01:00
da789e68ad Two-column VACE layout, inline mode reference button
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 18:26:45 +01:00
79755c286b Move VACE Settings to full-width section below splitter columns
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 18:23:37 +01:00
39a1b98924 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 <noreply@anthropic.com>
2026-02-26 18:11:11 +01:00
d3dbd4645a Remove Promote button (legacy single-file editor feature)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 18:05:19 +01:00
d795671763 Display LoRA strength with one decimal place (1.0 not 1)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 18:03:56 +01:00
9f141ba42f 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 <noreply@anthropic.com>
2026-02-26 18:02:24 +01:00
7931060d43 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 <noreply@anthropic.com>
2026-02-26 17:49:09 +01:00
3264845e68 Add dual-pane batch processor with independent file state
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 17:41:25 +01:00
fe2c6445ef Constrain main content area to 1200px max-width
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 17:29:08 +01:00
710a8407d2 Overhaul UI: new color palette, spacing, and visual hierarchy
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 <noreply@anthropic.com>
2026-02-26 17:27:02 +01:00
97748ab8ff 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 <noreply@anthropic.com>
2026-02-26 17:01:09 +01:00
b0125133f1 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 <noreply@anthropic.com>
2026-02-26 16:56:40 +01:00
a8c9a0376d 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 <noreply@anthropic.com>
2026-02-26 16:30:20 +01:00
6 changed files with 1089 additions and 796 deletions

220
main.py
View File

@@ -20,72 +20,136 @@ from tab_comfy_ng import render_comfy_monitor
def index(): def index():
# -- Streamlit dark theme -- # -- Streamlit dark theme --
ui.dark_mode(True) ui.dark_mode(True)
ui.colors(primary='#FF4B4B') ui.colors(primary='#F59E0B')
ui.add_head_html(
'<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">'
)
ui.add_css(''' 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 */ /* Backgrounds */
body.body--dark, body.body--dark,
.q-page.body--dark, .q-page.body--dark,
.body--dark .q-page { background: #0E1117 !important; } .body--dark .q-page { background: var(--bg-page) !important; }
.body--dark .q-drawer { background: #262730 !important; } .body--dark .q-drawer { background: var(--bg-surface-1) !important; }
.body--dark .q-card { background: #262730 !important; border-radius: 0.5rem; } .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-panels { background: transparent !important; }
.body--dark .q-tab-panel { background: transparent !important; } .body--dark .q-tab-panel { background: transparent !important; }
.body--dark .q-expansion-item { background: transparent !important; } .body--dark .q-expansion-item { background: transparent !important; }
/* Text */ /* Text */
.body--dark { color: #FAFAFA !important; } .body--dark { color: var(--text-primary) !important; }
.body--dark .q-field__label { color: rgba(250,250,250,0.6) !important; } .body--dark .q-field__label { color: var(--text-secondary) !important; }
.body--dark .text-caption { color: rgba(250,250,250,0.6) !important; } .body--dark .text-caption { color: var(--text-secondary) !important; }
.body--dark .text-subtitle1, .body--dark .text-subtitle1,
.body--dark .text-subtitle2 { color: #FAFAFA !important; } .body--dark .text-subtitle2 { color: var(--text-primary) !important; }
/* Inputs & textareas */ /* Inputs & textareas */
.body--dark .q-field--outlined .q-field__control { .body--dark .q-field--outlined .q-field__control {
background: #262730 !important; background: var(--bg-surface-3) !important;
border-radius: 0.5rem !important; border-radius: 0.5rem !important;
} }
.body--dark .q-field--outlined .q-field__control:before { .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; border-radius: 0.5rem !important;
} }
.body--dark .q-field--outlined.q-field--focused .q-field__control:after { .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__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 { .body--dark .q-drawer .q-field--outlined .q-field__control {
background: #0E1117 !important; background: var(--bg-page) !important;
} }
/* Buttons */ /* Buttons */
.body--dark .q-btn--standard { border-radius: 0.5rem !important; } .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 */ /* Tabs */
.body--dark .q-tab--active { color: #FF4B4B !important; } .body--dark .q-tab--active { color: var(--accent) !important; }
.body--dark .q-tab__indicator { background: #FF4B4B !important; } .body--dark .q-tab__indicator { background: var(--accent) !important; }
/* Separators */ /* Separators */
.body--dark .q-separator { background: rgba(250,250,250,0.2) !important; } .body--dark .q-separator { background: var(--border) !important; }
/* Expansion items */ /* 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; } .body--dark .q-item { border-radius: 0.5rem; }
/* Splitter */ /* 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 wrap */
.action-row { flex-wrap: wrap !important; gap: 4px !important; } .action-row { flex-wrap: wrap !important; gap: 8px !important; }
/* Notifications */ /* Notifications */
.body--dark .q-notification { border-radius: 0.5rem; } .body--dark .q-notification { border-radius: 0.5rem; }
/* Font */ /* 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);
}
/* 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() config = load_config()
@@ -94,6 +158,7 @@ def index():
current_dir=Path(config.get('last_dir', str(Path.cwd()))), current_dir=Path(config.get('last_dir', str(Path.cwd()))),
snippets=load_snippets(), snippets=load_snippets(),
) )
dual_pane = {'active': False, 'state': None}
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Define helpers FIRST (before sidebar, which needs them) # Define helpers FIRST (before sidebar, which needs them)
@@ -101,21 +166,23 @@ def index():
@ui.refreshable @ui.refreshable
def render_main_content(): def render_main_content():
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(): if not state.file_path or not state.file_path.exists():
ui.label('Select a file from the sidebar to begin.').classes( ui.label('Select a file from the sidebar to begin.').classes(
'text-subtitle1 q-pa-lg') 'text-subtitle1 q-pa-lg')
return 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('batch', label='Batch Processor')
ui.tab('timeline', label='Timeline') ui.tab('timeline', label='Timeline')
ui.tab('raw', label='Raw Editor') ui.tab('raw', label='Raw Editor')
with ui.tab_panels(tabs, value='batch').classes('w-full'): with ui.tab_panels(tabs, value='batch').classes('w-full'):
with ui.tab_panel('batch'): with ui.tab_panel('batch'):
render_batch_processor(state) _render_batch_tab_content()
with ui.tab_panel('timeline'): with ui.tab_panel('timeline'):
render_timeline_tab(state) render_timeline_tab(state)
with ui.tab_panel('raw'): with ui.tab_panel('raw'):
@@ -126,6 +193,66 @@ def index():
with ui.expansion('ComfyUI Monitor', icon='dns').classes('w-full'): with ui.expansion('ComfyUI Monitor', icon='dns').classes('w-full'):
render_comfy_monitor(state) 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_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_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_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')]
file_names = [f.name for f in json_files]
current_val = pane_state.file_path.name if pane_state.file_path else None
def on_select(e):
if not e.value:
return
fp = pane_state.current_dir / e.value
data, mtime = load_json(fp)
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(
file_names,
value=current_val,
label='File',
on_change=on_select,
).classes('w-full')
def load_file(file_name: str): def load_file(file_name: str):
"""Load a JSON file and refresh the main content.""" """Load a JSON file and refresh the main content."""
fp = state.current_dir / file_name fp = state.current_dir / file_name
@@ -148,8 +275,8 @@ def index():
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Sidebar (rendered AFTER helpers are attached) # 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) render_sidebar(state, dual_pane)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Main content area # Main content area
@@ -162,10 +289,11 @@ def index():
# Sidebar # Sidebar
# ====================================================================== # ======================================================================
def render_sidebar(state: AppState): def render_sidebar(state: AppState, dual_pane: dict):
ui.label('Navigator').classes('text-h6') ui.label('Navigator').classes('text-h6')
# --- Path input --- # --- Path input + Pin ---
with ui.card().classes('w-full q-pa-md q-mb-md'):
path_input = ui.input( path_input = ui.input(
'Current Path', 'Current Path',
value=str(state.current_dir), value=str(state.current_dir),
@@ -175,6 +303,11 @@ def render_sidebar(state: AppState):
p = resolve_path_case_insensitive(path_input.value) p = resolve_path_case_insensitive(path_input.value)
if p is not None and p.is_dir(): if p is not None and p.is_dir():
state.current_dir = p 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) state.config['last_dir'] = str(p)
save_config(state.current_dir, state.config['favorites'], state.config) save_config(state.current_dir, state.config['favorites'], state.config)
state.loaded_file = None state.loaded_file = None
@@ -188,7 +321,6 @@ def render_sidebar(state: AppState):
path_input.on('keydown.enter', lambda _: on_path_enter()) path_input.on('keydown.enter', lambda _: on_path_enter())
# --- Pin / Unpin ---
def pin_folder(): def pin_folder():
d = str(state.current_dir) d = str(state.current_dir)
if d not in state.config['favorites']: if d not in state.config['favorites']:
@@ -198,6 +330,10 @@ def render_sidebar(state: AppState):
ui.button('Pin Folder', icon='push_pin', on_click=pin_folder).classes('w-full') 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 @ui.refreshable
def render_favorites(): def render_favorites():
for fav in list(state.config['favorites']): for fav in list(state.config['favorites']):
@@ -213,6 +349,11 @@ def render_sidebar(state: AppState):
def _jump_to(fav: str): def _jump_to(fav: str):
state.current_dir = Path(fav) 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 state.config['last_dir'] = fav
save_config(state.current_dir, state.config['favorites'], state.config) save_config(state.current_dir, state.config['favorites'], state.config)
state.loaded_file = None state.loaded_file = None
@@ -230,10 +371,9 @@ def render_sidebar(state: AppState):
render_favorites() render_favorites()
ui.separator()
# --- Snippet Library --- # --- 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'): with ui.expansion('Add New Snippet'):
snip_name_input = ui.input('Name', placeholder='e.g. Cinematic').classes('w-full') snip_name_input = ui.input('Name', placeholder='e.g. Cinematic').classes('w-full')
@@ -281,11 +421,13 @@ def render_sidebar(state: AppState):
render_snippet_list() render_snippet_list()
ui.separator()
# --- File List --- # --- File List ---
with ui.card().classes('w-full q-pa-md q-mb-md'):
@ui.refreshable @ui.refreshable
def render_file_list(): 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 = 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')] json_files = [f for f in json_files if f.name not in ('.editor_config.json', '.editor_snippets.json')]
@@ -312,11 +454,13 @@ def render_sidebar(state: AppState):
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') ui.label('Select File').classes('subsection-header q-mt-sm')
file_names = [f.name for f in json_files] 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( ui.radio(
file_names, 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, on_change=lambda e: state._load_file(e.value) if e.value else None,
).classes('w-full') ).classes('w-full')
@@ -330,8 +474,6 @@ def render_sidebar(state: AppState):
render_file_list() render_file_list()
ui.separator()
# --- Comfy Monitor toggle --- # --- Comfy Monitor toggle ---
def on_monitor_toggle(e): def on_monitor_toggle(e):
state.show_comfy_monitor = e.value state.show_comfy_monitor = e.value

View File

@@ -1,5 +1,6 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, Callable
@dataclass @dataclass
@@ -15,3 +16,17 @@ class AppState:
timeline_selected_nodes: set = field(default_factory=set) timeline_selected_nodes: set = field(default_factory=set)
live_toggles: dict = field(default_factory=dict) live_toggles: dict = field(default_factory=dict)
show_comfy_monitor: bool = True 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)
def create_secondary(self) -> 'AppState':
return AppState(
config=self.config,
current_dir=self.current_dir,
snippets=self.snippets,
)

View File

@@ -13,6 +13,7 @@ from history_tree import HistoryTree
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.bmp', '.gif'} IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.bmp', '.gif'}
SUB_SEGMENT_MULTIPLIER = 1000 SUB_SEGMENT_MULTIPLIER = 1000
FRAME_TO_SKIP_DEFAULT = DEFAULTS['frame_to_skip']
VACE_MODES = [ VACE_MODES = [
'End Extend', 'Pre Extend', 'Middle Extend', 'Edge Extend', '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)) max_sub = max(max_sub, sub_index_of(sn))
return parent_seq_num * SUB_SEGMENT_MULTIPLIER + max_sub + 1 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): def find_insert_position(batch_list, parent_index, parent_seq_num):
parent_seq_num = int(parent_seq_num) parent_seq_num = int(parent_seq_num)
pos = parent_index + 1 pos = parent_index + 1
@@ -69,34 +79,56 @@ def find_insert_position(batch_list, parent_index, parent_seq_num):
# --- Helper for repetitive dict-bound inputs --- # --- Helper for repetitive dict-bound inputs ---
def dict_input(element_fn, label, seq, key, **kwargs): 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, '') val = seq.get(key, '')
if isinstance(val, (int, float)): if isinstance(val, (int, float)):
val = str(val) if element_fn != ui.number else val val = str(val) if element_fn != ui.number else val
el = element_fn(label, value=val, **kwargs) 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 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.""" """Number input bound to seq[key] via blur and model-value update."""
val = seq.get(key, 0) val = seq.get(key, default)
try: try:
# Try float first to handle "1.5" strings, then check if it's a clean int # Try float first to handle "1.5" strings, then check if it's a clean int
fval = float(val) fval = float(val)
val = int(fval) if fval == int(fval) else fval val = int(fval) if fval == int(fval) else fval
except (ValueError, TypeError): except (ValueError, TypeError, OverflowError):
val = 0 val = default
el = ui.number(label, value=val, **kwargs) 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 _sync(k=key, d=default):
v = el.value
if v is None:
v = d
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())
el.on('update:model-value', lambda _: _sync())
return el return el
def dict_textarea(label, seq, key, **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 = 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 return el
@@ -107,7 +139,10 @@ def dict_textarea(label, seq, key, **kwargs):
def render_batch_processor(state: AppState): def render_batch_processor(state: AppState):
data = state.data_cache data = state.data_cache
file_path = state.file_path 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: if not is_batch_file:
ui.label('This is a Single file. To use Batch mode, create a copy.').classes( ui.label('This is a Single file. To use Batch mode, create a copy.').classes(
@@ -138,6 +173,8 @@ def render_batch_processor(state: AppState):
batch_list = data.get(KEY_BATCH_DATA, []) batch_list = data.get(KEY_BATCH_DATA, [])
# Source file data for importing # Source file data for importing
with ui.card().classes('w-full q-pa-md q-mb-lg'):
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 = sorted(state.current_dir.glob('*.json'))
json_files = [f for f in json_files if f.name not in ( json_files = [f for f in json_files if f.name not in (
'.editor_config.json', '.editor_snippets.json')] '.editor_config.json', '.editor_snippets.json')]
@@ -171,16 +208,8 @@ def render_batch_processor(state: AppState):
src_file_select.on_value_change(lambda _: _update_src()) src_file_select.on_value_change(lambda _: _update_src())
_update_src() _update_src()
# --- Add New Sequence ---
ui.label('Add New Sequence').classes('text-subtitle1 q-mt-md')
def _add_sequence(new_item): def _add_sequence(new_item):
max_seq = 0 new_item[KEY_SEQUENCE_NUMBER] = max_main_seq_number(batch_list) + 1
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']: for k in [KEY_PROMPT_HISTORY, KEY_HISTORY_TREE, 'note', 'loras']:
new_item.pop(k, None) new_item.pop(k, None)
batch_list.append(new_item) batch_list.append(new_item)
@@ -188,7 +217,7 @@ def render_batch_processor(state: AppState):
save_json(file_path, data) save_json(file_path, data)
render_sequence_list.refresh() render_sequence_list.refresh()
with ui.row(): with ui.row().classes('q-mt-sm'):
def add_empty(): def add_empty():
_add_sequence(DEFAULTS.copy()) _add_sequence(DEFAULTS.copy())
@@ -205,8 +234,6 @@ def render_batch_processor(state: AppState):
ui.button('Add Empty', icon='add', on_click=add_empty) ui.button('Add Empty', icon='add', on_click=add_empty)
ui.button('From Source', icon='file_download', on_click=add_from_source) ui.button('From Source', icon='file_download', on_click=add_from_source)
ui.separator()
# --- Standard / LoRA / VACE key sets --- # --- Standard / LoRA / VACE key sets ---
lora_keys = ['lora 1 high', 'lora 1 low', 'lora 2 high', 'lora 2 low', lora_keys = ['lora 1 high', 'lora 1 low', 'lora 2 high', 'lora 2 low',
'lora 3 high', 'lora 3 low'] 'lora 3 high', 'lora 3 low']
@@ -237,6 +264,7 @@ def render_batch_processor(state: AppState):
ui.button('Sort by Number', icon='sort', on_click=sort_by_number).props('flat') ui.button('Sort by Number', icon='sort', on_click=sort_by_number).props('flat')
for i, seq in enumerate(batch_list): for i, seq in enumerate(batch_list):
with ui.card().classes('w-full q-mb-sm'):
_render_sequence_card( _render_sequence_card(
i, seq, batch_list, data, file_path, state, i, seq, batch_list, data, file_path, state,
_src_cache, src_seq_select, _src_cache, src_seq_select,
@@ -245,9 +273,8 @@ def render_batch_processor(state: AppState):
render_sequence_list() render_sequence_list()
ui.separator()
# --- Save & Snap --- # --- Save & Snap ---
with ui.card().classes('w-full q-pa-md q-mt-lg'):
with ui.row().classes('w-full items-end q-gutter-md'): with ui.row().classes('w-full items-end q-gutter-md'):
commit_input = ui.input('Change Note (Optional)', commit_input = ui.input('Change Note (Optional)',
placeholder='e.g. Added sequence 3').classes('col') placeholder='e.g. Added sequence 3').classes('col')
@@ -276,6 +303,13 @@ def render_batch_processor(state: AppState):
def _render_sequence_card(i, seq, batch_list, data, file_path, state, def _render_sequence_card(i, seq, batch_list, data, file_path, state,
src_cache, src_seq_select, standard_keys, src_cache, src_seq_select, standard_keys,
refresh_list): 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) seq_num = seq.get(KEY_SEQUENCE_NUMBER, i + 1)
if is_subsegment(seq_num): if is_subsegment(seq_num):
@@ -299,48 +333,31 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
item.pop(KEY_PROMPT_HISTORY, None) item.pop(KEY_PROMPT_HISTORY, None)
item.pop(KEY_HISTORY_TREE, None) item.pop(KEY_HISTORY_TREE, None)
batch_list[idx] = item batch_list[idx] = item
data[KEY_BATCH_DATA] = batch_list commit('Copied!')
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') ui.button('Copy Src', icon='file_download', on_click=copy_source).props('outline')
# Clone Next # Clone Next
def clone_next(idx=i, sn=seq_num, s=seq): def clone_next(idx=i, sn=seq_num, s=seq):
new_seq = copy.deepcopy(s) new_seq = copy.deepcopy(s)
max_sn = max( new_seq[KEY_SEQUENCE_NUMBER] = max_main_seq_number(batch_list) + 1
(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): if not is_subsegment(sn):
pos = find_insert_position(batch_list, idx, int(sn)) pos = find_insert_position(batch_list, idx, int(sn))
else: else:
pos = idx + 1 pos = idx + 1
batch_list.insert(pos, new_seq) batch_list.insert(pos, new_seq)
data[KEY_BATCH_DATA] = batch_list commit('Cloned to Next!')
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') ui.button('Clone Next', icon='content_copy', on_click=clone_next).props('outline')
# Clone End # Clone End
def clone_end(s=seq): def clone_end(s=seq):
new_seq = copy.deepcopy(s) new_seq = copy.deepcopy(s)
max_sn = max( new_seq[KEY_SEQUENCE_NUMBER] = max_main_seq_number(batch_list) + 1
(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) batch_list.append(new_seq)
data[KEY_BATCH_DATA] = batch_list commit('Cloned to End!')
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') ui.button('Clone End', icon='vertical_align_bottom', on_click=clone_end).props('outline')
# Clone Sub # Clone Sub
def clone_sub(idx=i, sn=seq_num, s=seq): def clone_sub(idx=i, sn=seq_num, s=seq):
@@ -355,37 +372,18 @@ 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) new_seq[KEY_SEQUENCE_NUMBER] = next_sub_segment_number(batch_list, p_seq)
pos = find_insert_position(batch_list, p_idx, p_seq) pos = find_insert_position(batch_list, p_idx, p_seq)
batch_list.insert(pos, new_seq) batch_list.insert(pos, new_seq)
data[KEY_BATCH_DATA] = batch_list commit(f'Created {format_seq_label(new_seq[KEY_SEQUENCE_NUMBER])}!')
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') ui.button('Clone Sub', icon='link', on_click=clone_sub).props('outline')
# Promote ui.element('div').classes('col')
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).props('dense')
# Delete # Delete
def delete(idx=i): def delete(idx=i):
batch_list.pop(idx) batch_list.pop(idx)
data[KEY_BATCH_DATA] = batch_list commit()
save_json(file_path, data)
refresh_list.refresh()
ui.button(icon='delete', on_click=delete).props('dense color=negative') ui.button(icon='delete', on_click=delete).props('color=negative')
ui.separator() ui.separator()
@@ -393,13 +391,13 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
with ui.splitter(value=66).classes('w-full') as splitter: with ui.splitter(value=66).classes('w-full') as splitter:
with splitter.before: with splitter.before:
dict_textarea('General Prompt', seq, 'general_prompt').classes( 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( 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( 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( 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: with splitter.after:
# Sequence number # Sequence number
@@ -422,17 +420,14 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
ui.button(icon='casino', on_click=randomize_seed).props('flat') ui.button(icon='casino', on_click=randomize_seed).props('flat')
# CFG # CFG
cfg_val = float(seq.get('cfg', DEFAULTS['cfg'])) dict_number('CFG', seq, 'cfg', default=DEFAULTS['cfg'],
cfg_input = ui.number('CFG', value=cfg_val, step=0.5, step=0.5, format='%.1f').props('outlined').classes('w-full')
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').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') 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_number('End Frame', seq, 'end_frame').props('outlined').classes('w-full')
dict_input(ui.input, 'Video File Path', seq, 'video file path').props( 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 # Image paths with preview
for img_label, img_key in [ for img_label, img_key in [
@@ -442,7 +437,7 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
]: ]:
with ui.row().classes('w-full items-center'): with ui.row().classes('w-full items-center'):
inp = dict_input(ui.input, img_label, seq, img_key).classes( 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 img_path = Path(seq.get(img_key, '')) if seq.get(img_key) else None
if (img_path and img_path.exists() and if (img_path and img_path.exists() and
img_path.suffix.lower() in IMAGE_EXTENSIONS): img_path.suffix.lower() in IMAGE_EXTENSIONS):
@@ -450,52 +445,67 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
ui.image(str(img_path)).classes('w-full') ui.image(str(img_path)).classes('w-full')
ui.button(icon='visibility', on_click=dlg.open).props('flat dense') ui.button(icon='visibility', on_click=dlg.open).props('flat dense')
# VACE Settings # --- VACE Settings (full width) ---
with ui.expansion('VACE Settings', icon='settings').classes('w-full'): with ui.expansion('VACE Settings', icon='settings').classes('w-full'):
_render_vace_settings(i, seq, batch_list, data, file_path, refresh_list) _render_vace_settings(i, seq, batch_list, data, file_path, refresh_list)
# --- LoRA Settings --- # --- LoRA Settings ---
with ui.expansion('LoRA Settings', icon='style').classes('w-full'): 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): 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')]: for tier, tier_label in [('high', 'High'), ('low', 'Low')]:
k = f'lora {lora_idx} {tier}' k = f'lora {lora_idx} {tier}'
raw = str(seq.get(k, '')) raw = str(seq.get(k, ''))
disp = raw.replace('<lora:', '').replace('>', '') inner = raw.replace('<lora:', '').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'): with ui.row().classes('w-full items-center q-gutter-sm'):
ui.label('<lora:').classes('text-caption font-mono') ui.label(f'L{lora_idx} {tier_label}').classes(
lora_input = ui.input( 'text-caption').style('min-width: 55px')
f'L{lora_idx} {tier_label}', name_input = ui.input(
value=disp, 'Name',
value=lora_name,
).classes('col').props('outlined dense') ).classes('col').props('outlined dense')
ui.label('>').classes('text-caption font-mono') strength_input = ui.number(
'Str',
value=lora_strength,
min=0, max=10, step=0.1,
format='%.1f',
).props('outlined dense').style('max-width: 80px')
def on_lora_blur(e, key=k): def _lora_sync(key=k, n_inp=name_input, s_inp=strength_input):
v = e.sender.value name = n_inp.value or ''
seq[key] = f'<lora:{v}>' if v else '' strength = s_inp.value if s_inp.value is not None else 1.0
seq[key] = f'<lora:{name}:{strength:.1f}>' 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 --- # --- Custom Parameters ---
ui.separator() ui.label('Custom Parameters').classes('section-header q-mt-md')
ui.label('Custom Parameters').classes('text-caption')
custom_keys = [k for k in seq.keys() if k not in standard_keys] custom_keys = [k for k in seq.keys() if k not in standard_keys]
if custom_keys: if custom_keys:
for k in custom_keys: for k in custom_keys:
with ui.row().classes('w-full items-center'): with ui.row().classes('w-full items-center'):
ui.input('Key', value=k).props('readonly outlined dense').classes('w-32') ui.input('Key', value=k).props('readonly outlined dense').classes('w-32')
val_input = ui.input('Value', value=str(seq[k])).props( dict_input(ui.input, 'Value', seq, k).props('outlined dense').classes('col')
'outlined dense').classes('col')
val_input.on('blur', lambda e, key=k: seq.__setitem__(key, e.sender.value))
def del_custom(key=k): def del_custom(key=k):
del seq[key] del seq[key]
save_json(file_path, data) commit()
refresh_list.refresh()
ui.button(icon='delete', on_click=del_custom).props('flat dense color=negative') ui.button(icon='delete', on_click=del_custom).props('flat dense color=negative')
@@ -508,10 +518,9 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
v = new_v_input.value v = new_v_input.value
if k and k not in seq: if k and k not in seq:
seq[k] = v seq[k] = v
save_json(file_path, data)
new_k_input.set_value('') new_k_input.set_value('')
new_v_input.set_value('') new_v_input.set_value('')
refresh_list.refresh() commit()
ui.button('Add', on_click=add_param).props('flat') ui.button('Add', on_click=add_param).props('flat')
@@ -521,13 +530,29 @@ 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): def _render_vace_settings(i, seq, batch_list, data, file_path, refresh_list):
# VACE Schedule (needed early for both columns)
sched_val = max(0, min(int(seq.get('vace schedule', 1)), len(VACE_MODES) - 1))
# Mode reference dialog
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)
with ui.row().classes('w-full q-gutter-md'):
# --- Left column ---
with ui.column().classes('col'):
# Frame to Skip + shift # Frame to Skip + shift
with ui.row().classes('w-full items-end'): with ui.row().classes('w-full items-end'):
fts_input = dict_number('Frame to Skip', seq, 'frame_to_skip').classes('col').props( fts_input = dict_number('Frame to Skip', seq, 'frame_to_skip').classes(
'outlined') '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))
_original_fts = int(seq.get('frame_to_skip', 81))
def shift_fts(idx=i, orig=_original_fts): def shift_fts(idx=i, orig=_original_fts):
new_fts = int(fts_input.value) if fts_input.value is not None else orig new_fts = int(fts_input.value) if fts_input.value is not None else orig
@@ -538,25 +563,26 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, refresh_list):
shifted = 0 shifted = 0
for j in range(idx + 1, len(batch_list)): for j in range(idx + 1, len(batch_list)):
batch_list[j]['frame_to_skip'] = int( 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 shifted += 1
data[KEY_BATCH_DATA] = batch_list data[KEY_BATCH_DATA] = batch_list
save_json(file_path, data) save_json(file_path, data)
ui.notify(f'Shifted {shifted} sequences by {delta:+d}', type='positive') ui.notify(f'Shifted {shifted} sequences by {delta:+d}', type='positive')
refresh_list.refresh() 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') dict_input(ui.input, 'Transition', seq, 'transition').props('outlined').classes(
'w-full q-mt-sm')
# VACE Schedule # 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 q-mt-sm'):
with ui.row().classes('w-full items-center'): vs_input = dict_number('VACE Schedule', seq, 'vace schedule', default=1,
vs_input = ui.number('VACE Schedule', value=sched_val, min=0, min=0, max=len(VACE_MODES) - 1).classes('col').props(
max=len(VACE_MODES) - 1).classes('col').props('outlined') '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') 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): def update_mode_label(e):
idx = int(e.sender.value) if e.sender.value is not None else 0 idx = int(e.sender.value) if e.sender.value is not None else 0
@@ -565,21 +591,12 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, refresh_list):
vs_input.on('update:model-value', update_mode_label) vs_input.on('update:model-value', update_mode_label)
# Mode reference # --- Right column ---
with ui.dialog() as ref_dlg, ui.card(): with ui.column().classes('col'):
table_md = ( ia_input = dict_number('Input A Frames', seq, 'input_a_frames').props(
'| # | Mode | Formula |\n|:--|:-----|:--------|\n' 'outlined').classes('w-full')
+ '\n'.join( ib_input = dict_number('Input B Frames', seq, 'input_b_frames').props(
f'| **{j}** | {VACE_MODES[j]} | `{VACE_FORMULAS[j]}` |' 'outlined').classes('w-full q-mt-sm')
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 # VACE Length + output calculation
input_a = int(seq.get('input_a_frames', 16)) input_a = int(seq.get('input_a_frames', 16))
@@ -594,11 +611,15 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, refresh_list):
else: else:
base_length = max(stored_total - input_a - input_b, 1) 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( vl_input = ui.number('VACE Length', value=base_length, min=1).classes(
'outlined') 'col').props('outlined')
output_label = ui.label(f'Output: {stored_total}').classes('text-bold') 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): def recalc_vace(*_args):
mi = int(vs_input.value) if vs_input.value is not None else 0 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 ia = int(ia_input.value) if ia_input.value is not None else 16
@@ -619,8 +640,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): for inp in (vs_input, ia_input, ib_input, vl_input):
inp.on('update:model-value', recalc_vace) inp.on('update:model-value', recalc_vace)
dict_number('Reference Switch', seq, 'reference switch').props('outlined')
# ====================================================================== # ======================================================================
# Mass Update # Mass Update
@@ -650,9 +669,10 @@ def _render_mass_update(batch_list, data, file_path, state: AppState, refresh_li
source_select.on_value_change(update_fields) source_select.on_value_change(update_fields)
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') select_all_cb = ui.checkbox('Select All')
target_checks = {} target_checks = {}
with ui.scroll_area().style('max-height: 250px'):
for idx, s in enumerate(batch_list): for idx, s in enumerate(batch_list):
sn = s.get(KEY_SEQUENCE_NUMBER, idx + 1) sn = s.get(KEY_SEQUENCE_NUMBER, idx + 1)
cb = ui.checkbox(format_seq_label(sn)) cb = ui.checkbox(format_seq_label(sn))

View File

@@ -55,7 +55,7 @@ def render_comfy_monitor(state: AppState):
# Add server section # Add server section
ui.separator() 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'): with ui.row().classes('w-full items-end'):
new_name = ui.input('Server Name', placeholder='e.g. Render Node 2').classes('col') 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') new_url = ui.input('URL', placeholder='http://192.168.1.50:8188').classes('col')
@@ -78,8 +78,8 @@ def render_comfy_monitor(state: AppState):
# --- Auto-poll timer (every 300s) --- # --- Auto-poll timer (every 300s) ---
# Store live_checkbox references so the timer can update them # Store live_checkbox references so the timer can update them
_live_checkboxes = state._live_checkboxes = getattr(state, '_live_checkboxes', {}) _live_checkboxes = state._live_checkboxes
_live_refreshables = state._live_refreshables = getattr(state, '_live_refreshables', {}) _live_refreshables = state._live_refreshables
def poll_all(): def poll_all():
timeout_val = config.get('monitor_timeout', 0) timeout_val = config.get('monitor_timeout', 0)
@@ -152,18 +152,18 @@ def _render_single_instance(state: AppState, instance_config: dict, index: int,
running_cnt = len(queue_data.get('queue_running', [])) running_cnt = len(queue_data.get('queue_running', []))
pending_cnt = len(queue_data.get('queue_pending', [])) 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('Status')
ui.label('Online' if running_cnt > 0 else 'Idle').classes( ui.label('Online' if running_cnt > 0 else 'Idle').classes(
'text-positive' if running_cnt > 0 else 'text-grey') '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('Pending')
ui.label(str(pending_cnt)) 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('Running')
ui.label(str(running_cnt)) ui.label(str(running_cnt))
else: 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('Status')
ui.label('Offline').classes('text-negative') ui.label('Offline').classes('text-negative')
ui.label(f'Could not connect to {comfy_url}').classes('text-negative') ui.label(f'Could not connect to {comfy_url}').classes('text-negative')
@@ -173,7 +173,8 @@ def _render_single_instance(state: AppState, instance_config: dict, index: int,
ui.button('Refresh Status', icon='refresh', on_click=refresh_status).props('flat dense') ui.button('Refresh Status', icon='refresh', on_click=refresh_status).props('flat dense')
# --- Live View --- # --- Live View ---
ui.label('Live View').classes('text-subtitle1 q-mt-md') 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}' toggle_key = f'live_toggle_{index}'
live_checkbox = ui.checkbox('Enable Live Preview', value=False) live_checkbox = ui.checkbox('Enable Live Preview', value=False)
@@ -230,7 +231,8 @@ def _render_single_instance(state: AppState, instance_config: dict, index: int,
render_live_view() render_live_view()
# --- Latest Output --- # --- Latest Output ---
ui.label('Latest Output').classes('text-subtitle1 q-mt-md') 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') img_container = ui.column().classes('w-full')
async def check_image(): async def check_image():

View File

@@ -11,7 +11,8 @@ def render_raw_editor(state: AppState):
data = state.data_cache data = state.data_cache
file_path = state.file_path 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 = ui.checkbox(
'Hide History (Safe Mode)', 'Hide History (Safe Mode)',
@@ -39,8 +40,6 @@ def render_raw_editor(state: AppState):
value=json_str, value=json_str,
).classes('w-full font-mono').props('outlined rows=30') ).classes('w-full font-mono').props('outlined rows=30')
ui.separator()
def do_save(): def do_save():
try: try:
input_data = json.loads(text_area.value) input_data = json.loads(text_area.value)
@@ -68,7 +67,7 @@ def render_raw_editor(state: AppState):
ui.button('Save Raw Changes', icon='save', on_click=do_save).props( ui.button('Save Raw Changes', icon='save', on_click=do_save).props(
'color=primary' 'color=primary'
).classes('w-full') ).classes('w-full q-mt-md')
hide_history.on_value_change(lambda _: render_editor.refresh()) hide_history.on_value_change(lambda _: render_editor.refresh())
render_editor() render_editor()

View File

@@ -8,39 +8,28 @@ from history_tree import HistoryTree
from utils import save_json, KEY_BATCH_DATA, KEY_HISTORY_TREE from utils import save_json, KEY_BATCH_DATA, KEY_HISTORY_TREE
def render_timeline_tab(state: AppState): def _delete_nodes(htree, data, file_path, node_ids):
data = state.data_cache """Delete nodes with backup, branch cleanup, and head fallback."""
file_path = state.file_path 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)
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) def _render_selection_picker(all_nodes, htree, state, refresh_fn):
"""Multi-select picker for batch-deleting timeline nodes."""
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] all_ids = [n['id'] for n in all_nodes]
def fmt_option(nid): def fmt_option(nid):
@@ -66,24 +55,26 @@ def render_timeline_tab(state: AppState):
with ui.row(): with ui.row():
def select_all(): def select_all():
state.timeline_selected_nodes = set(all_ids) state.timeline_selected_nodes = set(all_ids)
render_timeline.refresh() refresh_fn()
def deselect_all(): def deselect_all():
state.timeline_selected_nodes = set() state.timeline_selected_nodes = set()
render_timeline.refresh() refresh_fn()
ui.button('Select All', on_click=select_all).props('flat dense') ui.button('Select All', on_click=select_all).props('flat dense')
ui.button('Deselect All', on_click=deselect_all).props('flat dense') ui.button('Deselect All', on_click=deselect_all).props('flat dense')
# --- Graph views ---
mode = view_mode.value 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'): if mode in ('Horizontal', 'Vertical'):
direction = 'LR' if mode == 'Horizontal' else 'TB' direction = 'LR' if mode == 'Horizontal' else 'TB'
with ui.card().classes('w-full q-pa-md'):
try: try:
graph_dot = htree.generate_graph(direction=direction) graph_dot = htree.generate_graph(direction=direction)
_render_graphviz(graph_dot) _render_graphviz(graph_dot)
except Exception as e: except Exception as e:
ui.label(f'Graph Error: {e}').classes('text-negative') ui.label(f'Graph Error: {e}').classes('text-negative')
# --- Linear Log view ---
elif mode == 'Linear Log': elif mode == 'Linear Log':
ui.label('Chronological list of all snapshots.').classes('text-caption') ui.label('Chronological list of all snapshots.').classes('text-caption')
for n in all_nodes: for n in all_nodes:
@@ -92,16 +83,16 @@ def render_timeline_tab(state: AppState):
card_style = '' card_style = ''
if is_selected: 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: 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.card().classes('w-full q-mb-sm').style(card_style):
with ui.row().classes('w-full items-center'): with ui.row().classes('w-full items-center'):
if selection_mode.value: if selection_mode_on:
ui.checkbox( ui.checkbox(
'', '',
value=is_selected, value=is_selected,
on_change=lambda e, nid=n['id']: _toggle_select( on_change=lambda e, nid=n['id']: toggle_select_fn(
nid, e.value), nid, e.value),
) )
@@ -118,47 +109,34 @@ def render_timeline_tab(state: AppState):
ui.label( ui.label(
f'ID: {n["id"][:6]} - {ts}').classes('text-caption') f'ID: {n["id"][:6]} - {ts}').classes('text-caption')
if not is_head and not selection_mode.value: if not is_head and not selection_mode_on:
ui.button( ui.button(
'Restore', 'Restore',
icon='restore', icon='restore',
on_click=lambda node=n: _restore_and_refresh(node), on_click=lambda node=n: restore_fn(node),
).props('flat dense color=primary') ).props('flat dense color=primary')
# --- Batch Delete ---
if selection_mode.value and state.timeline_selected_nodes: 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()) valid = state.timeline_selected_nodes & set(htree.nodes.keys())
state.timeline_selected_nodes = valid state.timeline_selected_nodes = valid
count = len(valid) count = len(valid)
if count > 0: if count == 0:
return
ui.label( ui.label(
f'{count} node{"s" if count != 1 else ""} selected for deletion.' f'{count} node{"s" if count != 1 else ""} selected for deletion.'
).classes('text-warning q-mt-md') ).classes('text-warning q-mt-md')
def do_batch_delete(): def do_batch_delete():
if 'history_tree_backup' not in data: current_valid = state.timeline_selected_nodes & set(htree.nodes.keys())
data['history_tree_backup'] = [] _delete_nodes(htree, data, file_path, current_valid)
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() state.timeline_selected_nodes = set()
ui.notify( 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') type='positive')
render_timeline.refresh() refresh_fn()
ui.button( ui.button(
f'Delete {count} Node{"s" if count != 1 else ""}', f'Delete {count} Node{"s" if count != 1 else ""}',
@@ -166,87 +144,225 @@ def render_timeline_tab(state: AppState):
on_click=do_batch_delete, on_click=do_batch_delete,
).props('color=negative') ).props('color=negative')
ui.separator()
# --- Node selector + actions --- def _walk_branch_nodes(htree, tip_id):
ui.label('Manage Version').classes('text-subtitle1 q-mt-md') """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 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} def _find_active_branch(htree):
current_id = htree.head_id if htree.head_id in node_options else ( """Return branch name whose tip == head_id, or None if detached."""
all_nodes[0]['id'] if all_nodes else None) 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
selected_node_id = ui.select(
node_options, def _render_node_manager(all_nodes, htree, data, file_path, restore_fn, refresh_fn):
value=current_id, """Render branch-grouped node manager with restore, rename, delete, and preview."""
label='Select Version to Manage:', ui.label('Manage Version').classes('section-header')
# --- State that survives @ui.refreshable ---
active_branch = _find_active_branch(htree)
# 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': 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') ).classes('w-full')
with ui.row().classes('w-full items-end q-gutter-md'): # --- (b) Node list + (c) Actions panel ---
def restore_selected(): @ui.refreshable
nid = selected_node_id.value def render_branch_nodes():
if nid and nid in htree.nodes: branch_name = selected['branch']
_restore_and_refresh(htree.nodes[nid]) tip_id = htree.branches.get(branch_name)
nodes = _walk_branch_nodes(htree, tip_id) if tip_id else []
ui.button('Restore Version', icon='restore', if not nodes:
on_click=restore_selected).props('color=primary') ui.label('No nodes on this branch.').classes('text-caption q-pa-sm')
return
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']
card_style = ''
if is_selected:
card_style = 'border-left: 3px solid var(--primary);'
elif is_head:
card_style = 'border-left: 3px solid var(--accent);'
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')
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')
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')
def select_node(node_id=nid):
selected['node_id'] = node_id
render_branch_nodes.refresh()
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
with ui.row().classes('w-full items-end q-gutter-md'): rename_input = ui.input('Rename Label').classes('col').props('dense')
rename_input = ui.input('Rename Label').classes('col')
def rename_node(): def rename_node():
nid = selected_node_id.value if sel_id in htree.nodes and rename_input.value:
if nid and nid in htree.nodes and rename_input.value: htree.nodes[sel_id]['note'] = rename_input.value
htree.nodes[nid]['note'] = rename_input.value
data[KEY_HISTORY_TREE] = htree.to_dict() data[KEY_HISTORY_TREE] = htree.to_dict()
save_json(file_path, data) save_json(file_path, data)
ui.notify('Label updated', type='positive') ui.notify('Label updated', type='positive')
render_timeline.refresh() refresh_fn()
ui.button('Update Label', on_click=rename_node).props('flat') ui.button('Update Label', on_click=rename_node).props('flat dense')
# Danger zone # Danger zone
with ui.expansion('Danger Zone (Delete)', icon='warning').classes('w-full q-mt-md'): 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') ui.label('Deleting a node cannot be undone.').classes('text-warning')
def delete_selected(): def delete_selected():
nid = selected_node_id.value if sel_id in htree.nodes:
if nid and nid in htree.nodes: _delete_nodes(htree, data, file_path, {sel_id})
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') ui.notify('Node Deleted', type='positive')
render_timeline.refresh() refresh_fn()
ui.button('Delete This Node', icon='delete', ui.button('Delete This Node', icon='delete',
on_click=delete_selected).props('color=negative') on_click=delete_selected).props('color=negative dense')
# Data preview # Data preview
ui.separator() with ui.expansion('Data Preview', icon='preview').classes('w-full q-mt-sm'):
with ui.expansion('Data Preview', icon='preview').classes('w-full'): _render_data_preview(sel_id, htree)
render_branch_nodes()
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 q-mb-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 @ui.refreshable
def render_preview(): def render_timeline():
_render_data_preview(selected_node_id, htree) all_nodes = sorted(htree.nodes.values(), key=lambda x: x['timestamp'], reverse=True)
selected_node_id.on_value_change(lambda _: render_preview.refresh()) selected_nodes = state.timeline_selected_nodes if selection_mode.value else set()
render_preview()
if selection_mode.value:
_render_selection_picker(all_nodes, htree, state, render_timeline.refresh)
_render_graph_or_log(
view_mode.value, all_nodes, htree, selected_nodes,
selection_mode.value, _toggle_select, _restore_and_refresh)
if selection_mode.value and state.timeline_selected_nodes:
_render_batch_delete(htree, data, file_path, state, 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): def _toggle_select(nid, checked):
if checked: if checked:
@@ -281,7 +397,7 @@ def _render_graphviz(dot_source: str):
def _restore_node(data, node, htree, file_path, state: AppState): def _restore_node(data, node, htree, file_path, state: AppState):
"""Restore a history node as the current version.""" """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: if KEY_BATCH_DATA not in node_data and KEY_BATCH_DATA in data:
del data[KEY_BATCH_DATA] del data[KEY_BATCH_DATA]
data.update(node_data) data.update(node_data)
@@ -293,9 +409,8 @@ def _restore_node(data, node, htree, file_path, state: AppState):
ui.notify('Restored!', type='positive') 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.""" """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: if not nid or nid not in htree.nodes:
ui.label('No node selected.').classes('text-caption') ui.label('No node selected.').classes('text-caption')
return return