diff --git a/history_tree.py b/history_tree.py index 093d028..4e1f911 100644 --- a/history_tree.py +++ b/history_tree.py @@ -18,7 +18,10 @@ class HistoryTree: def _migrate_legacy(self, old_list: list[dict[str, Any]]) -> None: parent = None for item in reversed(old_list): - node_id = str(uuid.uuid4())[:8] + for _ in range(10): + node_id = str(uuid.uuid4())[:8] + if node_id not in self.nodes: + break self.nodes[node_id] = { "id": node_id, "parent": parent, "timestamp": time.time(), "data": item, "note": item.get("note", "Legacy Import") @@ -28,7 +31,13 @@ class HistoryTree: self.head_id = parent def commit(self, data: dict[str, Any], note: str = "Snapshot") -> str: - new_id = str(uuid.uuid4())[:8] + # Generate unique node ID with collision check + for _ in range(10): + new_id = str(uuid.uuid4())[:8] + if new_id not in self.nodes: + break + else: + raise ValueError("Failed to generate unique node ID after 10 attempts") # Cycle detection: walk parent chain from head to verify no cycle if self.head_id: @@ -39,7 +48,7 @@ class HistoryTree: raise ValueError(f"Cycle detected in history tree at node {current}") visited.add(current) node = self.nodes.get(current) - current = node["parent"] if node else None + current = node.get("parent") if node else None active_branch = None for b_name, tip_id in self.branches.items(): @@ -115,8 +124,12 @@ class HistoryTree: # Build reverse lookup: node_id -> branch name (walk each branch ancestry) node_to_branch: dict[str, str] = {} for b_name, tip_id in self.branches.items(): + visited = set() current = tip_id while current and current in self.nodes: + if current in visited: + break + visited.add(current) if current not in node_to_branch: node_to_branch[current] = b_name current = self.nodes[current].get('parent') @@ -192,11 +205,18 @@ class HistoryTree: + '>' ) - safe_tooltip = full_note.replace('\\', '\\\\').replace('"', '\\"') - dot.append(f' "{nid}" [label={label}, tooltip="{safe_tooltip}"];') + safe_tooltip = (full_note + .replace('\\', '\\\\') + .replace('"', '\\"') + .replace('\n', ' ') + .replace('\r', '') + .replace(']', ']')) + safe_nid = nid.replace('"', '_') + dot.append(f' "{safe_nid}" [label={label}, tooltip="{safe_tooltip}"];') - if n["parent"] and n["parent"] in self.nodes: - dot.append(f' "{n["parent"]}" -> "{nid}";') + if n.get("parent") and n["parent"] in self.nodes: + safe_parent = n["parent"].replace('"', '_') + dot.append(f' "{safe_parent}" -> "{safe_nid}";') dot.append("}") return "\n".join(dot) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index f905cb3..16a4069 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -1,5 +1,6 @@ import copy import json +import math import random from pathlib import Path @@ -143,6 +144,8 @@ def dict_number(label, seq, key, default=0, **kwargs): try: # Try float first to handle "1.5" strings, then check if it's a clean int fval = float(val) + if not math.isfinite(fval): + fval = float(default) val = int(fval) if fval == int(fval) else fval except (ValueError, TypeError, OverflowError): val = default @@ -153,10 +156,13 @@ def dict_number(label, seq, key, default=0, **kwargs): if v is None: v = d elif isinstance(v, float): - try: - v = int(v) if v == int(v) else v - except (OverflowError, ValueError): + if not math.isfinite(v): v = d + else: + try: + v = int(v) if v == int(v) else v + except (OverflowError, ValueError): + v = d seq[k] = v el.on('blur', lambda _: _sync()) @@ -336,7 +342,11 @@ def render_batch_processor(state: AppState): snapshot_payload = copy.deepcopy(data) snapshot_payload.pop(KEY_HISTORY_TREE, None) note = commit_input.value if commit_input.value else _auto_change_note(htree, batch_list) - htree.commit(snapshot_payload, note=note) + try: + htree.commit(snapshot_payload, note=note) + except ValueError as e: + ui.notify(f'Save failed: {e}', type='negative') + return data[KEY_HISTORY_TREE] = htree.to_dict() save_json(file_path, data) if state.db_enabled and state.current_project and state.db: @@ -635,18 +645,18 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, state, refresh_li fts_input = dict_number('Frame to Skip', seq, 'frame_to_skip').classes( 'col').props('outlined') - _original_fts = int(seq.get('frame_to_skip', FRAME_TO_SKIP_DEFAULT)) + _original_fts = _safe_int(seq.get('frame_to_skip', FRAME_TO_SKIP_DEFAULT), 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 + new_fts = _safe_int(fts_input.value, 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 + batch_list[j]['frame_to_skip'] = _safe_int( + batch_list[j].get('frame_to_skip', FRAME_TO_SKIP_DEFAULT), FRAME_TO_SKIP_DEFAULT) + delta shifted += 1 data[KEY_BATCH_DATA] = batch_list save_json(file_path, data) @@ -670,7 +680,7 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, state, refresh_li 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 = _safe_int(e.sender.value, 0) idx = max(0, min(idx, len(VACE_MODES) - 1)) mode_label.set_text(VACE_MODES[idx]) @@ -706,10 +716,10 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, state, refresh_li # 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 - 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 + mi = _safe_int(vs_input.value, 0) + ia = _safe_int(ia_input.value, 16) + ib = _safe_int(ib_input.value, 16) + nb = _safe_int(vl_input.value, 1) if mi == 0: raw = nb + ia @@ -794,7 +804,11 @@ def _render_mass_update(batch_list, data, file_path, state: AppState, refresh_li 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)}") + try: + htree.commit(snapshot, f"Mass update: {', '.join(selected_keys)}") + except ValueError as e: + ui.notify(f'Mass update failed: {e}', type='negative') + return data[KEY_HISTORY_TREE] = htree.to_dict() save_json(file_path, data) if state.db_enabled and state.current_project and state.db: diff --git a/tab_comfy_ng.py b/tab_comfy_ng.py index 7ae456d..3c4c359 100644 --- a/tab_comfy_ng.py +++ b/tab_comfy_ng.py @@ -139,7 +139,7 @@ def _render_single_instance(state: AppState, instance_config: dict, index: int, async def refresh_status(): status_container.clear() - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() res, err = await loop.run_in_executor( None, lambda: _fetch_blocking(f'{comfy_url}/queue')) with status_container: @@ -237,7 +237,7 @@ def _render_single_instance(state: AppState, instance_config: dict, index: int, async def check_image(): img_container.clear() - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() res, err = await loop.run_in_executor( None, lambda: _fetch_blocking(f'{comfy_url}/history', timeout=2)) with img_container: diff --git a/tab_timeline_ng.py b/tab_timeline_ng.py index 521d4da..bc618ed 100644 --- a/tab_timeline_ng.py +++ b/tab_timeline_ng.py @@ -1,4 +1,5 @@ import copy +import json import time from nicegui import ui @@ -9,16 +10,37 @@ from utils import save_json, sync_to_db, KEY_BATCH_DATA, KEY_HISTORY_TREE def _delete_nodes(htree, data, file_path, node_ids): - """Delete nodes with backup, branch cleanup, and head fallback.""" + """Delete nodes with backup, branch cleanup, re-parenting, and head fallback.""" if 'history_tree_backup' not in data: data['history_tree_backup'] = [] data['history_tree_backup'].append(copy.deepcopy(htree.to_dict())) data['history_tree_backup'] = data['history_tree_backup'][-10:] + # Save deleted node parents before removal (needed for branch re-pointing) + deleted_parents = {} + for nid in node_ids: + deleted_node = htree.nodes.get(nid) + if deleted_node: + deleted_parents[nid] = deleted_node.get('parent') + # Re-parent children of deleted nodes — walk up to find a surviving ancestor + for nid in node_ids: + surviving_parent = deleted_parents.get(nid) + while surviving_parent in node_ids: + surviving_parent = deleted_parents.get(surviving_parent) + for child in htree.nodes.values(): + if child.get('parent') == nid: + child['parent'] = surviving_parent for nid in node_ids: htree.nodes.pop(nid, None) + # Re-point branches whose tip was deleted to a surviving ancestor for b, tip in list(htree.branches.items()): if tip in node_ids: - del htree.branches[b] + new_tip = deleted_parents.get(tip) + while new_tip in node_ids: + new_tip = deleted_parents.get(new_tip) + if new_tip and new_tip in htree.nodes: + htree.branches[b] = new_tip + else: + del htree.branches[b] if htree.head_id in node_ids: if htree.nodes: htree.head_id = sorted(htree.nodes.values(), @@ -153,8 +175,12 @@ def _render_batch_delete(htree, data, file_path, state, refresh_fn): def _walk_branch_nodes(htree, tip_id): """Walk parent pointers from tip, returning nodes newest-first.""" nodes = [] + visited = set() current = tip_id while current and current in htree.nodes: + if current in visited: + break + visited.add(current) nodes.append(htree.nodes[current]) current = htree.nodes[current].get('parent') return nodes @@ -173,10 +199,14 @@ def _find_active_branch(htree): def _find_branch_for_node(htree, node_id): """Return the branch name whose ancestry contains node_id, or None.""" for b_name, tip_id in htree.branches.items(): + visited = set() current = tip_id while current and current in htree.nodes: + if current in visited: + break if current == node_id: return b_name + visited.add(current) current = htree.nodes[current].get('parent') return None @@ -311,6 +341,10 @@ def _render_node_manager(all_nodes, htree, data, file_path, restore_fn, refresh_ _delete_nodes(htree, data, file_path, {sel_id}) if state and state.db_enabled and state.current_project and state.db: sync_to_db(state.db, state.current_project, file_path, data) + # Reset selection if branch was removed + if selected['branch'] not in htree.branches: + selected['branch'] = next(iter(htree.branches), None) + selected['node_id'] = htree.head_id ui.notify('Node Deleted', type='positive') refresh_fn() @@ -434,7 +468,7 @@ def _render_graphviz(dot_source: str, selected_node_id: str | None = None): src = graphviz.Source(dot_source) svg = src.pipe(format='svg').decode('utf-8') - sel_escaped = selected_node_id.replace("'", "\\'") if selected_node_id else '' + sel_escaped = json.dumps(selected_node_id or '')[1:-1] # strip quotes, get JS-safe content # CSS inline (allowed), JS via run_javascript (script tags blocked) css = '''