From 87ed2f1dfba6839a5f5b77a147451de06bea89e5 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Mon, 2 Feb 2026 13:10:23 +0100 Subject: [PATCH] Merge timeline tabs into single polished tab with adaptive scaling Combine stable and WIP timeline tabs into one with all features: view switcher, restore/rename/delete, and data preview panel. Add adaptive graph spacing based on node count, show full dates and branch names on node labels, increase label truncation to 25 chars, and drop streamlit-agraph dependency. Co-Authored-By: Claude Opus 4.5 --- app.py | 13 +-- history_tree.py | 71 ++++++++++----- tab_timeline.py | 213 +++++++++++++++++++++++++++----------------- tab_timeline_wip.py | 191 --------------------------------------- 4 files changed, 181 insertions(+), 307 deletions(-) delete mode 100644 tab_timeline_wip.py diff --git a/app.py b/app.py index 8600ec6..55fd44f 100644 --- a/app.py +++ b/app.py @@ -11,7 +11,6 @@ from utils import ( from tab_single import render_single_editor from tab_batch import render_batch_processor from tab_timeline import render_timeline_tab -from tab_timeline_wip import render_timeline_wip from tab_comfy import render_comfy_monitor from tab_raw import render_raw_editor @@ -197,10 +196,9 @@ if selected_file_name: # --- CONTROLLED NAVIGATION --- # Removed "๐Ÿ”Œ Comfy Monitor" from this list tabs_list = [ - "๐Ÿ“ Single Editor", - "๐Ÿš€ Batch Processor", - "๐Ÿ•’ Timeline", - "๐Ÿงช Interactive Timeline", + "๐Ÿ“ Single Editor", + "๐Ÿš€ Batch Processor", + "๐Ÿ•’ Timeline", "๐Ÿ’ป Raw Editor" ] @@ -226,10 +224,7 @@ if selected_file_name: elif current_tab == "๐Ÿ•’ Timeline": render_timeline_tab(data, file_path) - - elif current_tab == "๐Ÿงช Interactive Timeline": - render_timeline_wip(data, file_path) - + elif current_tab == "๐Ÿ’ป Raw Editor": render_raw_editor(data, file_path) diff --git a/history_tree.py b/history_tree.py index 2b3f53b..bc43b13 100644 --- a/history_tree.py +++ b/history_tree.py @@ -75,55 +75,78 @@ class HistoryTree: Generates Graphviz source. direction: "LR" (Horizontal) or "TB" (Vertical) """ + node_count = len(self.nodes) + if node_count <= 5: + nodesep, ranksep = 0.5, 0.6 + elif node_count <= 15: + nodesep, ranksep = 0.3, 0.4 + else: + nodesep, ranksep = 0.15, 0.25 + + # Build reverse lookup: branch tip -> branch name(s) + tip_to_branches: dict[str, list[str]] = {} + for b_name, tip_id in self.branches.items(): + if tip_id: + tip_to_branches.setdefault(tip_id, []).append(b_name) + dot = [ 'digraph History {', - f' rankdir={direction};', # Dynamic Direction - ' bgcolor="white";', - ' splines=ortho;', - - # TIGHT SPACING - ' nodesep=0.2;', - ' ranksep=0.3;', - - # GLOBAL STYLES - ' node [shape=plain, fontname="Arial"];', + f' rankdir={direction};', + ' bgcolor="white";', + ' splines=ortho;', + f' nodesep={nodesep};', + f' ranksep={ranksep};', + ' node [shape=plain, fontname="Arial"];', ' edge [color="#888888", arrowsize=0.6, penwidth=1.0];' ] - + sorted_nodes = sorted(self.nodes.values(), key=lambda x: x["timestamp"]) - + for n in sorted_nodes: nid = n["id"] full_note = n.get('note', 'Step') - - display_note = (full_note[:15] + '..') if len(full_note) > 15 else full_note - + + display_note = (full_note[:25] + '..') if len(full_note) > 25 else full_note + + ts = time.strftime('%b %d %H:%M', time.localtime(n['timestamp'])) + + # Branch label for tip nodes + branch_label = "" + if nid in tip_to_branches: + branch_label = ", ".join(tip_to_branches[nid]) + # COLORS bg_color = "#f9f9f9" border_color = "#999999" border_width = "1" - + if nid == self.head_id: - bg_color = "#fff6cd" # Yellow for Current + bg_color = "#fff6cd" border_color = "#eebb00" border_width = "2" elif nid in self.branches.values(): - bg_color = "#e6ffe6" # Green for Tips + bg_color = "#e6ffe6" border_color = "#66aa66" # HTML LABEL + rows = [ + f'{display_note}', + f'{ts} โ€ข {nid[:4]}', + ] + if branch_label: + rows.append(f'{branch_label}') + label = ( f'<' - f'' - f'' - f'
{display_note}
{nid[:4]}
>' + + "".join(rows) + + '>' ) - + safe_tooltip = full_note.replace('"', "'") dot.append(f' "{nid}" [label={label}, tooltip="{safe_tooltip}"];') - + if n["parent"] and n["parent"] in self.nodes: dot.append(f' "{n["parent"]}" -> "{nid}";') - + dot.append("}") return "\n".join(dot) diff --git a/tab_timeline.py b/tab_timeline.py index e319ac1..c91e265 100644 --- a/tab_timeline.py +++ b/tab_timeline.py @@ -1,11 +1,10 @@ import streamlit as st import copy -import json -import graphviz import time from history_tree import HistoryTree from utils import save_json, KEY_BATCH_DATA, KEY_HISTORY_TREE + def render_timeline_tab(data, file_path): tree_data = data.get(KEY_HISTORY_TREE, {}) if not tree_data: @@ -20,14 +19,18 @@ def render_timeline_tab(data, file_path): # --- VIEW SWITCHER --- c_title, c_view = st.columns([2, 1]) c_title.subheader("๐Ÿ•ฐ๏ธ Version History") - + view_mode = c_view.radio( - "View Mode", - ["๐ŸŒณ Horizontal", "๐ŸŒฒ Vertical", "๐Ÿ“œ Linear Log"], + "View Mode", + ["๐ŸŒณ Horizontal", "๐ŸŒฒ Vertical", "๐Ÿ“œ Linear Log"], horizontal=True, label_visibility="collapsed" ) + # --- Build sorted node list (shared by all views) --- + all_nodes = list(htree.nodes.values()) + all_nodes.sort(key=lambda x: x["timestamp"], reverse=True) + # --- RENDER GRAPH VIEWS --- if view_mode in ["๐ŸŒณ Horizontal", "๐ŸŒฒ Vertical"]: direction = "LR" if view_mode == "๐ŸŒณ Horizontal" else "TB" @@ -36,13 +39,11 @@ def render_timeline_tab(data, file_path): st.graphviz_chart(graph_dot, use_container_width=True) except Exception as e: st.error(f"Graph Error: {e}") - + # --- RENDER LINEAR LOG VIEW --- elif view_mode == "๐Ÿ“œ Linear Log": st.caption("A simple chronological list of all snapshots.") - all_nodes = list(htree.nodes.values()) - all_nodes.sort(key=lambda x: x["timestamp"], reverse=True) - + for n in all_nodes: is_head = (n["id"] == htree.head_id) with st.container(): @@ -51,41 +52,26 @@ def render_timeline_tab(data, file_path): st.markdown("### ๐Ÿ“" if is_head else "### โšซ") with c2: note_txt = n.get('note', 'Step') - ts = time.strftime('%H:%M:%S', time.localtime(n['timestamp'])) + ts = time.strftime('%b %d %H:%M', time.localtime(n['timestamp'])) if is_head: st.markdown(f"**{note_txt}** (Current)") else: st.write(f"**{note_txt}**") - st.caption(f"ID: {n['id'][:6]} โ€ข Time: {ts}") + st.caption(f"ID: {n['id'][:6]} โ€ข {ts}") with c3: if not is_head: if st.button("โช", key=f"log_rst_{n['id']}", help="Restore this version"): - # --- FIX: Cleanup 'batch_data' if restoring a Single File --- - if KEY_BATCH_DATA not in n["data"] and KEY_BATCH_DATA in data: - del data[KEY_BATCH_DATA] - # ------------------------------------------------------------- - - data.update(n["data"]) - htree.head_id = n['id'] - data[KEY_HISTORY_TREE] = htree.to_dict() - save_json(file_path, data) - st.session_state.ui_reset_token += 1 - label = f"{n.get('note')} ({n['id'][:4]})" - st.session_state.restored_indicator = label - st.toast(f"Restored!", icon="๐Ÿ”„") - st.rerun() + _restore_node(data, n, htree, file_path) st.divider() st.markdown("---") - # --- ACTIONS & SELECTION --- + # --- NODE SELECTOR --- col_sel, col_act = st.columns([3, 1]) - - all_nodes = list(htree.nodes.values()) - all_nodes.sort(key=lambda x: x["timestamp"], reverse=True) - + def fmt_node(n): - return f"{n.get('note', 'Step')} ({n['id']})" + ts = time.strftime('%b %d %H:%M', time.localtime(n['timestamp'])) + return f"{n.get('note', 'Step')} โ€ข {ts} ({n['id'][:6]})" with col_sel: current_idx = 0 @@ -93,66 +79,127 @@ def render_timeline_tab(data, file_path): if n["id"] == htree.head_id: current_idx = i break - + selected_node = st.selectbox( - "Select Version to Manage:", - all_nodes, + "Select Version to Manage:", + all_nodes, format_func=fmt_node, index=current_idx ) - if selected_node: - node_data = selected_node["data"] - - # --- ACTIONS --- - with col_act: - st.write(""); st.write("") - if st.button("โช Restore Version", type="primary", use_container_width=True): - # --- FIX: Cleanup 'batch_data' if restoring a Single File --- - if KEY_BATCH_DATA not in node_data and KEY_BATCH_DATA in data: - del data[KEY_BATCH_DATA] - # ------------------------------------------------------------- + if not selected_node: + return - data.update(node_data) - htree.head_id = selected_node['id'] + node_data = selected_node["data"] + + # --- RESTORE --- + with col_act: + st.write(""); st.write("") + if st.button("โช Restore Version", type="primary", use_container_width=True): + _restore_node(data, selected_node, htree, file_path) + + # --- RENAME --- + rn_col1, rn_col2 = st.columns([3, 1]) + new_label = rn_col1.text_input("Rename Label", value=selected_node.get("note", "")) + if rn_col2.button("Update Label"): + selected_node["note"] = new_label + data[KEY_HISTORY_TREE] = htree.to_dict() + save_json(file_path, data) + st.rerun() + + # --- DANGER ZONE --- + st.markdown("---") + with st.expander("โš ๏ธ Danger Zone (Delete)"): + st.warning("Deleting a node cannot be undone.") + if st.button("๐Ÿ—‘๏ธ Delete This Node", type="primary"): + if selected_node['id'] 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[selected_node['id']] + for b, tip in list(htree.branches.items()): + if tip == selected_node['id']: + del htree.branches[b] + if htree.head_id == selected_node['id']: + 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) - st.session_state.ui_reset_token += 1 - label = f"{selected_node.get('note')} ({selected_node['id'][:4]})" - st.session_state.restored_indicator = label - st.toast(f"Restored!", icon="๐Ÿ”„") + st.toast("Node Deleted", icon="๐Ÿ—‘๏ธ") st.rerun() - # --- RENAME --- - rn_col1, rn_col2 = st.columns([3, 1]) - new_label = rn_col1.text_input("Rename Label", value=selected_node.get("note", "")) - if rn_col2.button("Update Label"): - selected_node["note"] = new_label - data[KEY_HISTORY_TREE] = htree.to_dict() - save_json(file_path, data) - st.rerun() + # --- DATA PREVIEW --- + st.markdown("---") + with st.expander("๐Ÿ” Data Preview", expanded=False): + batch_list = node_data.get(KEY_BATCH_DATA, []) - # --- DANGER ZONE --- - st.markdown("---") - with st.expander("โš ๏ธ Danger Zone (Delete)"): - st.warning("Deleting a node cannot be undone.") - if st.button("๐Ÿ—‘๏ธ Delete This Node", type="primary"): - if selected_node['id'] in htree.nodes: - # Backup current tree state before destructive operation - if "history_tree_backup" not in data: - data["history_tree_backup"] = [] - data["history_tree_backup"].append(copy.deepcopy(htree.to_dict())) - del htree.nodes[selected_node['id']] - for b, tip in list(htree.branches.items()): - if tip == selected_node['id']: - del htree.branches[b] - if htree.head_id == selected_node['id']: - 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) - st.toast("Node Deleted", icon="๐Ÿ—‘๏ธ") - st.rerun() + if batch_list and isinstance(batch_list, list) and len(batch_list) > 0: + st.info(f"๐Ÿ“š This snapshot contains {len(batch_list)} sequences.") + for i, seq_data in enumerate(batch_list): + seq_num = seq_data.get("sequence_number", i + 1) + with st.expander(f"๐ŸŽฌ Sequence #{seq_num}", expanded=(i == 0)): + prefix = f"p_{selected_node['id']}_s{i}" + _render_preview_fields(seq_data, prefix) + else: + prefix = f"p_{selected_node['id']}_single" + _render_preview_fields(node_data, prefix) + + +def _restore_node(data, node, htree, file_path): + """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) + st.session_state.ui_reset_token += 1 + label = f"{node.get('note')} ({node['id'][:4]})" + st.session_state.restored_indicator = label + st.toast("Restored!", icon="๐Ÿ”„") + st.rerun() + + +def _render_preview_fields(item_data, prefix): + """Render a read-only preview of prompts, settings, and LoRAs.""" + # Prompts + p_col1, p_col2 = st.columns(2) + with p_col1: + st.text_area("General Positive", value=item_data.get("general_prompt", ""), height=80, disabled=True, key=f"{prefix}_gp") + val_sp = item_data.get("current_prompt", "") or item_data.get("prompt", "") + st.text_area("Specific Positive", value=val_sp, height=80, disabled=True, key=f"{prefix}_sp") + with p_col2: + st.text_area("General Negative", value=item_data.get("general_negative", ""), height=80, disabled=True, key=f"{prefix}_gn") + st.text_area("Specific Negative", value=item_data.get("negative", ""), height=80, disabled=True, key=f"{prefix}_sn") + + # Settings + s_col1, s_col2, s_col3 = st.columns(3) + s_col1.text_input("Camera", value=str(item_data.get("camera", "static")), disabled=True, key=f"{prefix}_cam") + s_col2.text_input("FLF", value=str(item_data.get("flf", "0.0")), disabled=True, key=f"{prefix}_flf") + s_col3.text_input("Seed", value=str(item_data.get("seed", "-1")), disabled=True, key=f"{prefix}_seed") + + # LoRAs + with st.expander("๐Ÿ’Š LoRA Configuration", expanded=False): + l1, l2, l3 = st.columns(3) + with l1: + st.text_input("L1 Name", value=item_data.get("lora 1 high", ""), disabled=True, key=f"{prefix}_l1h") + st.text_input("L1 Str", value=str(item_data.get("lora 1 low", "")), disabled=True, key=f"{prefix}_l1l") + with l2: + st.text_input("L2 Name", value=item_data.get("lora 2 high", ""), disabled=True, key=f"{prefix}_l2h") + st.text_input("L2 Str", value=str(item_data.get("lora 2 low", "")), disabled=True, key=f"{prefix}_l2l") + with l3: + st.text_input("L3 Name", value=item_data.get("lora 3 high", ""), disabled=True, key=f"{prefix}_l3h") + st.text_input("L3 Str", value=str(item_data.get("lora 3 low", "")), disabled=True, key=f"{prefix}_l3l") + + # VACE + vace_keys = ["frame_to_skip", "vace schedule", "video file path"] + if any(k in item_data for k in vace_keys): + with st.expander("๐ŸŽž๏ธ VACE / I2V Settings", expanded=False): + v1, v2, v3 = st.columns(3) + v1.text_input("Skip Frames", value=str(item_data.get("frame_to_skip", "")), disabled=True, key=f"{prefix}_fts") + v2.text_input("Schedule", value=str(item_data.get("vace schedule", "")), disabled=True, key=f"{prefix}_vsc") + v3.text_input("Video Path", value=str(item_data.get("video file path", "")), disabled=True, key=f"{prefix}_vid") diff --git a/tab_timeline_wip.py b/tab_timeline_wip.py deleted file mode 100644 index 419676c..0000000 --- a/tab_timeline_wip.py +++ /dev/null @@ -1,191 +0,0 @@ -import streamlit as st -import json -from history_tree import HistoryTree -from utils import save_json, KEY_BATCH_DATA, KEY_HISTORY_TREE - -try: - from streamlit_agraph import agraph, Node, Edge, Config - _HAS_AGRAPH = True -except ImportError: - _HAS_AGRAPH = False - -def render_timeline_wip(data, file_path): - if not _HAS_AGRAPH: - st.error("The `streamlit-agraph` package is required for this tab. Install it with: `pip install streamlit-agraph`") - return - tree_data = data.get(KEY_HISTORY_TREE, {}) - if not tree_data: - st.info("No history timeline exists.") - return - - htree = HistoryTree(tree_data) - - # --- 1. BUILD GRAPH --- - nodes = [] - edges = [] - - sorted_nodes = sorted(htree.nodes.values(), key=lambda x: x["timestamp"]) - - for n in sorted_nodes: - nid = n["id"] - note = n.get('note', 'Step') - short_note = (note[:15] + '..') if len(note) > 15 else note - - color = "#ffffff" - border = "#666666" - - if nid == htree.head_id: - color = "#fff6cd" - border = "#eebb00" - - if nid in htree.branches.values(): - if color == "#ffffff": - color = "#e6ffe6" - border = "#44aa44" - - nodes.append(Node( - id=nid, - label=f"{short_note}\n({nid[:4]})", - size=25, - shape="box", - color=color, - borderWidth=1, - borderColor=border, - font={'color': 'black', 'face': 'Arial', 'size': 14} - )) - - if n["parent"] and n["parent"] in htree.nodes: - edges.append(Edge( - source=n["parent"], - target=nid, - color="#aaaaaa", - type="STRAIGHT" - )) - - # --- UPDATED CONFIGURATION --- - config = Config( - width="100%", - # Increased height from 400px to 600px for better visibility - height="600px", - directed=True, - physics=False, - hierarchical=True, - layout={ - "hierarchical": { - "enabled": True, - # Increased separation to widen the tree structure - "levelSeparation": 200, # Was 150 - "nodeSpacing": 150, # Was 100 - "treeSpacing": 150, # Was 100 - "direction": "LR", - "sortMethod": "directed" - } - } - ) - - st.subheader("โœจ Interactive Timeline") - st.caption("Click a node to view its settings below.") - - # --- FIX: REMOVED 'key' ARGUMENT --- - selected_id = agraph(nodes=nodes, edges=edges, config=config) - - st.markdown("---") - - # --- 2. DETERMINE TARGET --- - target_node_id = selected_id if selected_id else htree.head_id - - if target_node_id and target_node_id in htree.nodes: - selected_node = htree.nodes[target_node_id] - node_data = selected_node["data"] - - # Header - c_h1, c_h2 = st.columns([3, 1]) - c_h1.markdown(f"### ๐Ÿ“„ Previewing: {selected_node.get('note', 'Step')}") - c_h1.caption(f"ID: {target_node_id}") - - # Restore Button - with c_h2: - st.write(""); st.write("") - if st.button("โช Restore This Version", type="primary", use_container_width=True, key=f"rst_{target_node_id}"): - # --- FIX: Cleanup 'batch_data' if restoring a Single File --- - 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 = target_node_id - - data[KEY_HISTORY_TREE] = htree.to_dict() - save_json(file_path, data) - - st.session_state.ui_reset_token += 1 - label = f"{selected_node.get('note')} ({target_node_id[:4]})" - st.session_state.restored_indicator = label - - st.toast(f"Restored {target_node_id}!", icon="๐Ÿ”„") - st.rerun() - - # --- 3. PREVIEW LOGIC (BATCH VS SINGLE) --- - - # Helper to render one set of inputs - def render_preview_fields(item_data, prefix): - # A. Prompts - p_col1, p_col2 = st.columns(2) - with p_col1: - val_gp = item_data.get("general_prompt", "") - st.text_area("General Positive", value=val_gp, height=80, disabled=True, key=f"{prefix}_gp") - - val_sp = item_data.get("current_prompt", "") or item_data.get("prompt", "") - st.text_area("Specific Positive", value=val_sp, height=80, disabled=True, key=f"{prefix}_sp") - with p_col2: - val_gn = item_data.get("general_negative", "") - st.text_area("General Negative", value=val_gn, height=80, disabled=True, key=f"{prefix}_gn") - - val_sn = item_data.get("negative", "") - st.text_area("Specific Negative", value=val_sn, height=80, disabled=True, key=f"{prefix}_sn") - - # B. Settings - s_col1, s_col2, s_col3 = st.columns(3) - s_col1.text_input("Camera", value=str(item_data.get("camera", "static")), disabled=True, key=f"{prefix}_cam") - s_col2.text_input("FLF", value=str(item_data.get("flf", "0.0")), disabled=True, key=f"{prefix}_flf") - s_col3.text_input("Seed", value=str(item_data.get("seed", "-1")), disabled=True, key=f"{prefix}_seed") - - # C. LoRAs - with st.expander("๐Ÿ’Š LoRA Configuration", expanded=False): - l1, l2, l3 = st.columns(3) - with l1: - st.text_input("L1 Name", value=item_data.get("lora 1 high", ""), disabled=True, key=f"{prefix}_l1h") - st.text_input("L1 Str", value=str(item_data.get("lora 1 low", "")), disabled=True, key=f"{prefix}_l1l") - with l2: - st.text_input("L2 Name", value=item_data.get("lora 2 high", ""), disabled=True, key=f"{prefix}_l2h") - st.text_input("L2 Str", value=str(item_data.get("lora 2 low", "")), disabled=True, key=f"{prefix}_l2l") - with l3: - st.text_input("L3 Name", value=item_data.get("lora 3 high", ""), disabled=True, key=f"{prefix}_l3h") - st.text_input("L3 Str", value=str(item_data.get("lora 3 low", "")), disabled=True, key=f"{prefix}_l3l") - - # D. VACE - vace_keys = ["frame_to_skip", "vace schedule", "video file path"] - has_vace = any(k in item_data for k in vace_keys) - if has_vace: - with st.expander("๐ŸŽž๏ธ VACE / I2V Settings", expanded=False): - v1, v2, v3 = st.columns(3) - v1.text_input("Skip Frames", value=str(item_data.get("frame_to_skip", "")), disabled=True, key=f"{prefix}_fts") - v2.text_input("Schedule", value=str(item_data.get("vace schedule", "")), disabled=True, key=f"{prefix}_vsc") - v3.text_input("Video Path", value=str(item_data.get("video file path", "")), disabled=True, key=f"{prefix}_vid") - - # --- DETECT BATCH VS SINGLE --- - batch_list = node_data.get(KEY_BATCH_DATA, []) - - if batch_list and isinstance(batch_list, list) and len(batch_list) > 0: - st.info(f"๐Ÿ“š This snapshot contains {len(batch_list)} sequences.") - - for i, seq_data in enumerate(batch_list): - seq_num = seq_data.get("sequence_number", i+1) - with st.expander(f"๐ŸŽฌ Sequence #{seq_num}", expanded=(i==0)): - # Unique prefix for every sequence in every node - prefix = f"p_{target_node_id}_s{i}" - render_preview_fields(seq_data, prefix) - else: - # Single File Preview - prefix = f"p_{target_node_id}_single" - render_preview_fields(node_data, prefix) \ No newline at end of file