Add sub-segment support for VACE batch sequences

Sub-segments use parent*1000+index numbering (e.g. 2001 = Sub #2.1) so
ComfyUI nodes can reference them via integer sequence_number without
code changes. Adds clone-as-sub button, visual distinction in expander
labels, sort-by-number button, and fixes auto-numbering/frame_to_skip
shift to work correctly with sub-segments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 01:04:03 +01:00
parent f0ffeef731
commit adff3d0124

View File

@@ -4,6 +4,51 @@ import copy
from utils import DEFAULTS, save_json, load_json, KEY_BATCH_DATA, KEY_HISTORY_TREE, KEY_PROMPT_HISTORY, KEY_SEQUENCE_NUMBER from utils import DEFAULTS, save_json, load_json, KEY_BATCH_DATA, KEY_HISTORY_TREE, KEY_PROMPT_HISTORY, KEY_SEQUENCE_NUMBER
from history_tree import HistoryTree from history_tree import HistoryTree
SUB_SEGMENT_MULTIPLIER = 1000
def is_subsegment(seq_num):
"""Return True if seq_num is a sub-segment (>= 1000)."""
return int(seq_num) >= SUB_SEGMENT_MULTIPLIER
def parent_of(seq_num):
"""Return the parent segment number (or self if already a parent)."""
seq_num = int(seq_num)
return seq_num // SUB_SEGMENT_MULTIPLIER if is_subsegment(seq_num) else seq_num
def sub_index_of(seq_num):
"""Return the sub-index (0 if parent)."""
seq_num = int(seq_num)
return seq_num % SUB_SEGMENT_MULTIPLIER if is_subsegment(seq_num) else 0
def format_seq_label(seq_num):
"""Return display label: 'Sequence #3' or 'Sub #2.1'."""
seq_num = int(seq_num)
if is_subsegment(seq_num):
return f"Sub #{parent_of(seq_num)}.{sub_index_of(seq_num)}"
return f"Sequence #{seq_num}"
def next_sub_segment_number(batch_list, parent_seq_num):
"""Find the next available sub-segment number under a parent."""
parent_seq_num = int(parent_seq_num)
max_sub = 0
for s in batch_list:
sn = int(s.get(KEY_SEQUENCE_NUMBER, 0))
if is_subsegment(sn) and parent_of(sn) == parent_seq_num:
max_sub = max(max_sub, sub_index_of(sn))
return parent_seq_num * SUB_SEGMENT_MULTIPLIER + max_sub + 1
def find_insert_position(batch_list, parent_index, parent_seq_num):
"""Find the insert position after the parent's last existing sub-segment."""
parent_seq_num = int(parent_seq_num)
pos = parent_index + 1
while pos < len(batch_list):
sn = int(batch_list[pos].get(KEY_SEQUENCE_NUMBER, 0))
if is_subsegment(sn) and parent_of(sn) == parent_seq_num:
pos += 1
else:
break
return pos
def _render_mass_update(batch_list, data, file_path, key_prefix): def _render_mass_update(batch_list, data, file_path, key_prefix):
"""Render the mass update UI section.""" """Render the mass update UI section."""
with st.expander("🔄 Mass Update", expanded=False): with st.expander("🔄 Mass Update", expanded=False):
@@ -15,7 +60,7 @@ def _render_mass_update(batch_list, data, file_path, key_prefix):
source_idx = st.selectbox( source_idx = st.selectbox(
"Copy from sequence:", "Copy from sequence:",
range(len(batch_list)), range(len(batch_list)),
format_func=lambda i: f"Sequence #{batch_list[i].get('sequence_number', i+1)}", format_func=lambda i: format_seq_label(batch_list[i].get('sequence_number', i+1)),
key=f"{key_prefix}_mass_src" key=f"{key_prefix}_mass_src"
) )
source_seq = batch_list[source_idx] source_seq = batch_list[source_idx]
@@ -39,7 +84,7 @@ def _render_mass_update(batch_list, data, file_path, key_prefix):
continue continue
seq_num = seq.get("sequence_number", i + 1) seq_num = seq.get("sequence_number", i + 1)
with target_cols[col_idx % len(target_cols)]: with target_cols[col_idx % len(target_cols)]:
checked = select_all or st.checkbox(f"#{seq_num}", key=f"{key_prefix}_mass_t{i}") checked = select_all or st.checkbox(format_seq_label(seq_num), key=f"{key_prefix}_mass_t{i}")
if checked: if checked:
target_indices.append(i) target_indices.append(i)
col_idx += 1 col_idx += 1
@@ -131,7 +176,9 @@ def render_batch_processor(data, file_path, json_files, current_dir, selected_fi
def add_sequence(new_item): def add_sequence(new_item):
max_seq = 0 max_seq = 0
for s in batch_list: for s in batch_list:
if KEY_SEQUENCE_NUMBER in s: max_seq = max(max_seq, int(s[KEY_SEQUENCE_NUMBER])) sn = int(s.get(KEY_SEQUENCE_NUMBER, 0))
if not is_subsegment(sn):
max_seq = max(max_seq, sn)
new_item[KEY_SEQUENCE_NUMBER] = max_seq + 1 new_item[KEY_SEQUENCE_NUMBER] = max_seq + 1
for k in [KEY_PROMPT_HISTORY, KEY_HISTORY_TREE, "note", "loras"]: for k in [KEY_PROMPT_HISTORY, KEY_HISTORY_TREE, "note", "loras"]:
@@ -170,7 +217,15 @@ def render_batch_processor(data, file_path, json_files, current_dir, selected_fi
# --- RENDER LIST --- # --- RENDER LIST ---
st.markdown("---") st.markdown("---")
st.info(f"Batch contains {len(batch_list)} sequences.") info_col, reorder_col = st.columns([3, 1])
info_col.info(f"Batch contains {len(batch_list)} sequences.")
if reorder_col.button("🔢 Sort by Number", use_container_width=True, help="Reorder sequences by sequence number"):
batch_list.sort(key=lambda s: int(s.get(KEY_SEQUENCE_NUMBER, 0)))
data[KEY_BATCH_DATA] = batch_list
save_json(file_path, data)
st.session_state.ui_reset_token += 1
st.toast("Sorted by sequence number!", icon="🔢")
st.rerun()
# --- MASS UPDATE SECTION --- # --- MASS UPDATE SECTION ---
ui_reset_token = st.session_state.get("ui_reset_token", 0) ui_reset_token = st.session_state.get("ui_reset_token", 0)
@@ -192,7 +247,12 @@ def render_batch_processor(data, file_path, json_files, current_dir, selected_fi
seq_num = seq.get(KEY_SEQUENCE_NUMBER, i+1) seq_num = seq.get(KEY_SEQUENCE_NUMBER, i+1)
prefix = f"{selected_file_name}_seq{i}_v{st.session_state.ui_reset_token}" prefix = f"{selected_file_name}_seq{i}_v{st.session_state.ui_reset_token}"
with st.expander(f"🎬 Sequence #{seq_num}", expanded=False): if is_subsegment(seq_num):
expander_label = f"🔗 ↳ Sub #{parent_of(seq_num)}.{sub_index_of(seq_num)} ({int(seq_num)})"
else:
expander_label = f"🎬 Sequence #{seq_num}"
with st.expander(expander_label, expanded=False):
# --- ACTION ROW --- # --- ACTION ROW ---
act_c1, act_c2, act_c3, act_c4 = st.columns([1.2, 1.8, 1.2, 0.5]) act_c1, act_c2, act_c3, act_c4 = st.columns([1.2, 1.8, 1.2, 0.5])
@@ -214,11 +274,14 @@ def render_batch_processor(data, file_path, json_files, current_dir, selected_fi
# 2. Cloning Tools # 2. Cloning Tools
with act_c2: with act_c2:
cl_1, cl_2 = st.columns(2) cl_1, cl_2, cl_3 = st.columns(3)
if cl_1.button("👯 Next", key=f"{prefix}_c_next", help="Clone and insert below", use_container_width=True): if cl_1.button("👯 Next", key=f"{prefix}_c_next", help="Clone and insert below", use_container_width=True):
new_seq = copy.deepcopy(seq) new_seq = copy.deepcopy(seq)
max_sn = 0 max_sn = 0
for s in batch_list: max_sn = max(max_sn, int(s.get(KEY_SEQUENCE_NUMBER, 0))) for s in batch_list:
sn = int(s.get(KEY_SEQUENCE_NUMBER, 0))
if not is_subsegment(sn):
max_sn = max(max_sn, sn)
new_seq[KEY_SEQUENCE_NUMBER] = max_sn + 1 new_seq[KEY_SEQUENCE_NUMBER] = max_sn + 1
batch_list.insert(i + 1, new_seq) batch_list.insert(i + 1, new_seq)
data[KEY_BATCH_DATA] = batch_list data[KEY_BATCH_DATA] = batch_list
@@ -230,7 +293,10 @@ def render_batch_processor(data, file_path, json_files, current_dir, selected_fi
if cl_2.button("⏬ End", key=f"{prefix}_c_end", help="Clone and add to bottom", use_container_width=True): if cl_2.button("⏬ End", key=f"{prefix}_c_end", help="Clone and add to bottom", use_container_width=True):
new_seq = copy.deepcopy(seq) new_seq = copy.deepcopy(seq)
max_sn = 0 max_sn = 0
for s in batch_list: max_sn = max(max_sn, int(s.get(KEY_SEQUENCE_NUMBER, 0))) for s in batch_list:
sn = int(s.get(KEY_SEQUENCE_NUMBER, 0))
if not is_subsegment(sn):
max_sn = max(max_sn, sn)
new_seq[KEY_SEQUENCE_NUMBER] = max_sn + 1 new_seq[KEY_SEQUENCE_NUMBER] = max_sn + 1
batch_list.append(new_seq) batch_list.append(new_seq)
data[KEY_BATCH_DATA] = batch_list data[KEY_BATCH_DATA] = batch_list
@@ -239,6 +305,25 @@ def render_batch_processor(data, file_path, json_files, current_dir, selected_fi
st.toast("Cloned to End!", icon="") st.toast("Cloned to End!", icon="")
st.rerun() st.rerun()
if cl_3.button("🔗 Sub", key=f"{prefix}_c_sub", help="Clone as sub-segment", use_container_width=True):
new_seq = copy.deepcopy(seq)
p_seq_num = parent_of(seq_num)
# Find the parent's index in batch_list
p_idx = i
if is_subsegment(seq_num):
for pi, ps in enumerate(batch_list):
if int(ps.get(KEY_SEQUENCE_NUMBER, 0)) == p_seq_num:
p_idx = pi
break
new_seq[KEY_SEQUENCE_NUMBER] = next_sub_segment_number(batch_list, p_seq_num)
insert_pos = find_insert_position(batch_list, p_idx, p_seq_num)
batch_list.insert(insert_pos, new_seq)
data[KEY_BATCH_DATA] = batch_list
save_json(file_path, data)
st.session_state.ui_reset_token += 1
st.toast(f"Created {format_seq_label(new_seq[KEY_SEQUENCE_NUMBER])}!", icon="🔗")
st.rerun()
# 3. Promote # 3. Promote
with act_c3: with act_c3:
if st.button("↖️ Promote", key=f"{prefix}_prom", help="Save as Single File", use_container_width=True): if st.button("↖️ Promote", key=f"{prefix}_prom", help="Save as Single File", use_container_width=True):
@@ -270,7 +355,8 @@ def render_batch_processor(data, file_path, json_files, current_dir, selected_fi
seq["negative"] = st.text_area("Specific Negative", value=seq.get("negative", ""), height=60, key=f"{prefix}_sn") seq["negative"] = st.text_area("Specific Negative", value=seq.get("negative", ""), height=60, key=f"{prefix}_sn")
with c2: with c2:
seq[KEY_SEQUENCE_NUMBER] = st.number_input("Sequence Number", value=int(seq_num), key=f"{prefix}_sn_val") sn_label = f"Sequence Number (↳ Sub #{parent_of(seq_num)}.{sub_index_of(seq_num)})" if is_subsegment(seq_num) else "Sequence Number"
seq[KEY_SEQUENCE_NUMBER] = st.number_input(sn_label, value=int(seq_num), key=f"{prefix}_sn_val")
s_row1, s_row2 = st.columns([3, 1]) s_row1, s_row2 = st.columns([3, 1])
seed_key = f"{prefix}_seed" seed_key = f"{prefix}_seed"
@@ -302,12 +388,10 @@ def render_batch_processor(data, file_path, json_files, current_dir, selected_fi
fts_btn.write("") fts_btn.write("")
if fts_btn.button(delta_label, key=f"{prefix}_fts_shift", help="Apply delta to all following sequences", disabled=(delta == 0)): if fts_btn.button(delta_label, key=f"{prefix}_fts_shift", help="Apply delta to all following sequences", disabled=(delta == 0)):
if delta != 0: if delta != 0:
current_seq_num = int(seq.get(KEY_SEQUENCE_NUMBER, i + 1))
shifted = 0 shifted = 0
for s in batch_list: for j in range(i + 1, len(batch_list)):
if int(s.get(KEY_SEQUENCE_NUMBER, 0)) > current_seq_num: batch_list[j]["frame_to_skip"] = int(batch_list[j].get("frame_to_skip", 81)) + delta
s["frame_to_skip"] = int(s.get("frame_to_skip", 81)) + 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)
st.session_state.ui_reset_token += 1 st.session_state.ui_reset_token += 1