Refactor stream_editor.py for batch file support

Refactor stream_editor.py to improve code structure and readability. Added batch file handling and updated configuration loading.
This commit is contained in:
2025-12-31 13:38:35 +01:00
committed by GitHub
parent 5e3fdd627a
commit f21c901d81

View File

@@ -17,8 +17,8 @@ DEFAULTS = {
"flf": 0,
"seed": 0,
"frame_to_skip": 81,
"input_a_frames": 0, # INT
"input_b_frames": 0, # INT
"input_a_frames": 0,
"input_b_frames": 0,
"reference path": "",
"reference switch": 1,
"vace schedule": 1,
@@ -39,15 +39,12 @@ DEFAULTS = {
"prompt_history": []
}
# Only these two types exist now
GENERIC_TEMPLATES = ["prompt_i2v.json", "prompt_vace_extend.json"]
GENERIC_TEMPLATES = ["prompt_i2v.json", "prompt_vace_extend.json", "batch_i2v.json", "batch_vace.json"]
# --- Helper Functions ---
def load_config():
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE, 'r') as f:
return json.load(f)
try: with open(CONFIG_FILE, 'r') as f: return json.load(f)
except: pass
return {"last_dir": str(Path.cwd()), "favorites": []}
@@ -57,9 +54,7 @@ def save_config(current_dir, favorites):
def load_snippets():
if SNIPPETS_FILE.exists():
try:
with open(SNIPPETS_FILE, 'r') as f:
return json.load(f)
try: with open(SNIPPETS_FILE, 'r') as f: return json.load(f)
except: pass
return {}
@@ -77,42 +72,45 @@ def load_json(path):
return data, get_file_mtime(path)
def save_json(path, data):
clean_data = {k: v for k, v in data.items() if k in DEFAULTS or k == "prompt_history"}
# For batch files, we might be saving a list or a dict containing a list
# For single files, we save the dict
if path.exists():
try:
with open(path, 'r') as f:
existing = json.load(f)
existing.update(clean_data)
clean_data = existing
if isinstance(existing, dict) and isinstance(data, dict):
existing.update(data)
data = existing
except: pass
with open(path, 'w') as f:
json.dump(clean_data, f, indent=4)
json.dump(data, f, indent=4)
return get_file_mtime(path)
def generate_templates(directory):
for filename in GENERIC_TEMPLATES:
path = directory / filename
data = DEFAULTS.copy()
if "vace" in filename:
data.update({"frame_to_skip": 81, "vace schedule": 1, "video file path": ""})
elif "i2v" in filename:
data.update({"reference image path": "", "flf image path": ""})
if "batch" in filename:
# Batch template is a list of sequence objects
data = {"batch_data": []} # Root object to hold list
else:
data = DEFAULTS.copy()
if "vace" in filename:
data.update({"frame_to_skip": 81, "vace schedule": 1, "video file path": ""})
elif "i2v" in filename:
data.update({"reference image path": "", "flf image path": ""})
save_json(path, data)
# --- Initialization ---
if 'config' not in st.session_state:
st.session_state.config = load_config()
st.session_state.current_dir = Path(st.session_state.config.get("last_dir", Path.cwd()))
if 'snippets' not in st.session_state:
st.session_state.snippets = load_snippets()
if 'loaded_file' not in st.session_state:
st.session_state.loaded_file = None
if 'last_mtime' not in st.session_state:
st.session_state.last_mtime = 0
if 'edit_history_idx' not in st.session_state:
st.session_state.edit_history_idx = None
if 'snippets' not in st.session_state: st.session_state.snippets = load_snippets()
if 'loaded_file' not in st.session_state: st.session_state.loaded_file = None
if 'last_mtime' not in st.session_state: st.session_state.last_mtime = 0
if 'edit_history_idx' not in st.session_state: st.session_state.edit_history_idx = None
# Cache for single editor data to copy from
if 'single_editor_cache' not in st.session_state: st.session_state.single_editor_cache = DEFAULTS.copy()
# --- Sidebar ---
with st.sidebar:
@@ -143,7 +141,7 @@ with st.sidebar:
st.subheader("🧩 Snippet Library")
with st.expander("Add New Snippet"):
snip_name = st.text_input("Name", placeholder="e.g. Cinematic")
snip_content = st.text_area("Content", placeholder="4k, high quality, dramatic lighting...")
snip_content = st.text_area("Content", placeholder="4k, high quality...")
if st.button("Save Snippet"):
if snip_name and snip_content:
st.session_state.snippets[snip_name] = snip_content
@@ -169,27 +167,32 @@ with st.sidebar:
json_files = [f for f in json_files if f.name != ".editor_config.json" and f.name != ".editor_snippets.json"]
if not json_files:
if st.button("Generate Templates (I2V / VACE)"):
if st.button("Generate Templates"):
generate_templates(st.session_state.current_dir)
st.rerun()
with st.expander("Create New JSON"):
new_filename = st.text_input("Filename", placeholder="my_prompt_vace")
is_batch = st.checkbox("Is Batch File?")
if st.button("Create"):
if not new_filename.endswith(".json"): new_filename += ".json"
path = st.session_state.current_dir / new_filename
data = DEFAULTS.copy()
if "vace" in new_filename: data.update({"frame_to_skip": 81, "vace schedule": 1, "video file path": ""})
elif "i2v" in new_filename: data.update({"reference image path": "", "flf image path": ""})
if is_batch:
data = {"batch_data": []}
else:
data = DEFAULTS.copy()
if "vace" in new_filename: data.update({"frame_to_skip": 81, "vace schedule": 1, "video file path": ""})
elif "i2v" in new_filename: data.update({"reference image path": "", "flf image path": ""})
save_json(path, data)
st.rerun()
selected_file_name = st.radio("Select File", [f.name for f in json_files])
# --- Main Editor Area ---
# --- Load Logic ---
if selected_file_name:
file_path = st.session_state.current_dir / selected_file_name
# Reload check
if st.session_state.loaded_file != str(file_path):
data, mtime = load_json(file_path)
st.session_state.data_cache = data
@@ -202,255 +205,222 @@ if selected_file_name:
data = st.session_state.data_cache
st.title(f"Editing: {selected_file_name}")
# Determine File Type
is_batch_file = "batch_data" in data or isinstance(data, list)
col1, col2 = st.columns([2, 1])
with col1:
# --- GENERAL SECTION (Collapsible) ---
with st.expander("🌍 General Prompts (Global Layer)", expanded=False):
gen_prompt = st.text_area("General Prompt", value=data.get("general_prompt", ""), height=100)
gen_negative = st.text_area("General Negative", value=data.get("general_negative", DEFAULTS["general_negative"]), height=100)
# --- TABS ---
tab_single, tab_batch = st.tabs(["📝 Single Editor", "🚀 Batch Processor"])
# --- SPECIFIC SECTION ---
st.write("📝 **Specific Prompts**")
current_prompt_val = data.get("current_prompt", "")
if 'append_prompt' in st.session_state:
current_prompt_val = (current_prompt_val.strip() + ", " + st.session_state.append_prompt).strip(', ')
del st.session_state.append_prompt
new_prompt = st.text_area("Specific Prompt", value=current_prompt_val, height=150)
new_negative = st.text_area("Specific Negative", value=data.get("negative", ""), height=100)
# --- SEED ---
col_seed_val, col_seed_btn = st.columns([4, 1])
with col_seed_btn:
st.write("")
st.write("")
if st.button("🎲 Randomize"):
st.session_state.rand_seed = random.randint(0, 999999999999)
st.rerun()
with col_seed_val:
seed_val = st.session_state.get('rand_seed', int(data.get("seed", 0)))
new_seed = st.number_input("Seed", value=seed_val, step=1, min_value=0, format="%d")
data["seed"] = new_seed
st.subheader("LoRAs")
st.code("<lora::1.0>", language="text")
l_col1, l_col2 = st.columns(2)
loras = {}
keys = ["lora 1 high", "lora 1 low", "lora 2 high", "lora 2 low", "lora 3 high", "lora 3 low"]
for i, k in enumerate(keys):
with (l_col1 if i % 2 == 0 else l_col2):
loras[k] = st.text_input(k, value=data.get(k, ""))
st.subheader("Settings")
spec_fields = {}
fname = selected_file_name
# --- DYNAMIC FIELD GENERATION ---
# Shared fields
spec_fields["camera"] = st.text_input("camera", value=str(data.get("camera", DEFAULTS["camera"])))
spec_fields["flf"] = st.text_input("flf", value=str(data.get("flf", DEFAULTS["flf"])))
if "vace" in fname:
# Integers
spec_fields["frame_to_skip"] = st.number_input("frame_to_skip", value=int(data.get("frame_to_skip", 81)))
spec_fields["input_a_frames"] = st.number_input("input_a_frames", value=int(data.get("input_a_frames", 0)))
spec_fields["input_b_frames"] = st.number_input("input_b_frames", value=int(data.get("input_b_frames", 0)))
spec_fields["reference switch"] = st.number_input("reference switch", value=int(data.get("reference switch", 1)))
spec_fields["vace schedule"] = st.number_input("vace schedule", value=int(data.get("vace schedule", 1)))
# Strings
for f in ["reference path", "video file path", "reference image path"]:
spec_fields[f] = st.text_input(f, value=str(data.get(f, "")))
elif "i2v" in fname:
for f in ["reference image path", "flf image path", "video file path"]:
spec_fields[f] = st.text_input(f, value=str(data.get(f, "")))
with col2:
st.subheader("Actions")
current_disk_mtime = get_file_mtime(file_path)
is_conflict = current_disk_mtime > st.session_state.last_mtime
if is_conflict:
st.error("⚠️ CONFLICT: File changed on disk!")
c_col1, c_col2 = st.columns(2)
if c_col1.button("Force Overwrite", type="primary"):
data.update({
"current_prompt": new_prompt, "negative": new_negative,
"general_prompt": gen_prompt, "general_negative": gen_negative,
"seed": new_seed
})
data.update(loras)
data.update(spec_fields)
st.session_state.last_mtime = save_json(file_path, data)
st.session_state.data_cache = data
st.toast("Forced overwrite success!", icon="⚠️")
st.rerun()
if c_col2.button("Reload File"):
st.session_state.loaded_file = None
st.rerun()
# ==============================================================================
# TAB 1: SINGLE EDITOR
# ==============================================================================
with tab_single:
if is_batch_file:
st.info("This is a batch file. Switch to the 'Batch Processor' tab to edit sequences.")
else:
if st.button("💾 Update File", use_container_width=True):
data.update({
"current_prompt": new_prompt, "negative": new_negative,
# --- UI LAYOUT ---
col1, col2 = st.columns([2, 1])
with col1:
with st.expander("🌍 General Prompts (Global Layer)", expanded=False):
gen_prompt = st.text_area("General Prompt", value=data.get("general_prompt", ""), height=100)
gen_negative = st.text_area("General Negative", value=data.get("general_negative", DEFAULTS["general_negative"]), height=100)
st.write("📝 **Specific Prompts**")
current_prompt_val = data.get("current_prompt", "")
if 'append_prompt' in st.session_state:
current_prompt_val = (current_prompt_val.strip() + ", " + st.session_state.append_prompt).strip(', ')
del st.session_state.append_prompt
new_prompt = st.text_area("Specific Prompt", value=current_prompt_val, height=150)
new_negative = st.text_area("Specific Negative", value=data.get("negative", ""), height=100)
col_seed_val, col_seed_btn = st.columns([4, 1])
with col_seed_btn:
st.write("")
st.write("")
if st.button("🎲 Randomize"):
st.session_state.rand_seed = random.randint(0, 999999999999)
st.rerun()
with col_seed_val:
seed_val = st.session_state.get('rand_seed', int(data.get("seed", 0)))
new_seed = st.number_input("Seed", value=seed_val, step=1, min_value=0, format="%d")
data["seed"] = new_seed
st.subheader("LoRAs")
st.code("<lora::1.0>", language="text")
l_col1, l_col2 = st.columns(2)
loras = {}
keys = ["lora 1 high", "lora 1 low", "lora 2 high", "lora 2 low", "lora 3 high", "lora 3 low"]
for i, k in enumerate(keys):
with (l_col1 if i % 2 == 0 else l_col2):
loras[k] = st.text_input(k, value=data.get(k, ""))
st.subheader("Settings")
spec_fields = {}
spec_fields["camera"] = st.text_input("camera", value=str(data.get("camera", DEFAULTS["camera"])))
spec_fields["flf"] = st.text_input("flf", value=str(data.get("flf", DEFAULTS["flf"])))
if "vace" in selected_file_name:
spec_fields["frame_to_skip"] = st.number_input("frame_to_skip", value=int(data.get("frame_to_skip", 81)))
spec_fields["input_a_frames"] = st.number_input("input_a_frames", value=int(data.get("input_a_frames", 0)))
spec_fields["input_b_frames"] = st.number_input("input_b_frames", value=int(data.get("input_b_frames", 0)))
spec_fields["reference switch"] = st.number_input("reference switch", value=int(data.get("reference switch", 1)))
spec_fields["vace schedule"] = st.number_input("vace schedule", value=int(data.get("vace schedule", 1)))
for f in ["reference path", "video file path", "reference image path"]:
spec_fields[f] = st.text_input(f, value=str(data.get(f, "")))
elif "i2v" in selected_file_name:
for f in ["reference image path", "flf image path", "video file path"]:
spec_fields[f] = st.text_input(f, value=str(data.get(f, "")))
with col2:
# Store current state for "Copy to Batch" feature
current_state = {
"general_prompt": gen_prompt, "general_negative": gen_negative,
"seed": new_seed
})
data.update(loras)
data.update(spec_fields)
st.session_state.last_mtime = save_json(file_path, data)
st.session_state.data_cache = data
st.toast("File updated successfully!", icon="")
"current_prompt": new_prompt, "negative": new_negative,
"seed": new_seed, **loras, **spec_fields
}
st.session_state.single_editor_cache = current_state
st.subheader("Actions")
current_disk_mtime = get_file_mtime(file_path)
is_conflict = current_disk_mtime > st.session_state.last_mtime
if is_conflict:
st.error("⚠️ CONFLICT: Disk change detected!")
c1, c2 = st.columns(2)
if c1.button("Force Save"):
data.update(current_state)
st.session_state.last_mtime = save_json(file_path, data)
st.session_state.data_cache = data
st.toast("Saved!", icon="⚠️")
st.rerun()
if c2.button("Reload"):
st.session_state.loaded_file = None
st.rerun()
else:
if st.button("💾 Update File", use_container_width=True):
data.update(current_state)
st.session_state.last_mtime = save_json(file_path, data)
st.session_state.data_cache = data
st.toast("Updated!", icon="")
st.markdown("---")
# History Logic
archive_note = st.text_input("Archive Note")
if st.button("📦 Snapshot to History", use_container_width=True):
entry = {
"note": archive_note if archive_note else f"Snapshot",
**current_state
}
if "prompt_history" not in data: data["prompt_history"] = []
data["prompt_history"].insert(0, entry)
data.update(entry)
st.session_state.last_mtime = save_json(file_path, data)
st.session_state.data_cache = data
st.toast("Archived!", icon="📦")
st.rerun()
st.markdown("---")
st.subheader("History")
history = data.get("prompt_history", [])
for idx, h in enumerate(history):
with st.expander(f"#{idx+1}: {h.get('note', 'No Note')}"):
if st.button(f"Restore #{idx+1}", key=f"rest_{idx}"):
data.update(h) # Simplified restore for brevity
st.session_state.last_mtime = save_json(file_path, data)
st.rerun()
# ==============================================================================
# TAB 2: BATCH PROCESSOR
# ==============================================================================
with tab_batch:
if not is_batch_file:
st.warning("This is not a batch file. Please create a new JSON with 'Is Batch File' checked or open a batch json.")
if st.button("Convert this file to Batch format?"):
# Convert current settings to first batch item
first_item = data.copy()
if "prompt_history" in first_item: del first_item["prompt_history"]
first_item["sequence_number"] = 1
new_data = {"batch_data": [first_item], "prompt_history": data.get("prompt_history", [])}
st.session_state.last_mtime = save_json(file_path, new_data)
st.session_state.data_cache = new_data
st.rerun()
else:
# BATCH EDITOR LOGIC
batch_list = data.get("batch_data", [])
st.info(f"Batch contains {len(batch_list)} sequences.")
# --- RENDER SEQUENCES ---
for i, seq in enumerate(batch_list):
seq_num = seq.get("sequence_number", i+1)
with st.expander(f"🎬 Sequence #{seq_num} : {seq.get('current_prompt', '')[:40]}...", expanded=False):
# Action Bar
b_col1, b_col2, b_col3 = st.columns([1, 1, 4])
if b_col1.button("📥 Copy from Editor", key=f"copy_single_{i}", help="Paste settings from the Single Editor tab"):
# Merge defaults + cached single editor state + keep sequence number
updated_seq = DEFAULTS.copy()
updated_seq.update(st.session_state.single_editor_cache)
updated_seq["sequence_number"] = seq_num
# Remove non-sequence keys
if "prompt_history" in updated_seq: del updated_seq["prompt_history"]
batch_list[i] = updated_seq
save_json(file_path, data)
st.toast(f"Sequence {seq_num} updated from Editor!", icon="📥")
st.rerun()
if b_col2.button("🗑️ Remove", key=f"del_seq_{i}"):
batch_list.pop(i)
save_json(file_path, data)
st.rerun()
# Editable Fields for this Sequence
st.markdown("---")
sb_col1, sb_col2 = st.columns([2, 1])
with sb_col1:
# Prompts
seq["general_prompt"] = st.text_area("General P", value=seq.get("general_prompt", ""), height=60, key=f"b_gp_{i}")
seq["general_negative"] = st.text_area("General N", value=seq.get("general_negative", ""), height=60, key=f"b_gn_{i}")
seq["current_prompt"] = st.text_area("Specific P", value=seq.get("current_prompt", ""), height=100, key=f"b_sp_{i}")
seq["negative"] = st.text_area("Specific N", value=seq.get("negative", ""), height=60, key=f"b_sn_{i}")
with sb_col2:
# Key settings
seq["sequence_number"] = st.number_input("Seq Num", value=int(seq_num), key=f"b_seqn_{i}")
seq["seed"] = st.number_input("Seed", value=int(seq.get("seed", 0)), key=f"b_seed_{i}")
seq["camera"] = st.text_input("Camera", value=seq.get("camera", ""), key=f"b_cam_{i}")
# Paths
if "video file path" in seq or "vace" in selected_file_name:
seq["video file path"] = st.text_input("Video Path", value=seq.get("video file path", ""), key=f"b_vid_{i}")
if "reference image path" in seq or "i2v" in selected_file_name:
seq["reference image path"] = st.text_input("Ref Img", value=seq.get("reference image path", ""), key=f"b_ref_{i}")
st.markdown("---")
archive_note = st.text_input("Archive Note (Optional)", placeholder="e.g. V1 with high motion")
if st.button("📦 Snapshot to History", use_container_width=True):
entry = {
"general_prompt": gen_prompt, "general_negative": gen_negative,
"prompt": new_prompt, "negative": new_negative,
"seed": new_seed,
"note": archive_note if archive_note else f"Snapshot {len(data.get('prompt_history', [])) + 1}",
"loras": loras,
**spec_fields
}
if "prompt_history" not in data: data["prompt_history"] = []
data["prompt_history"].insert(0, entry)
# Update main state too
data.update(entry)
data["current_prompt"] = new_prompt
if st.button(" Add New Sequence", type="primary"):
# Create new blank sequence
new_seq = DEFAULTS.copy()
if "prompt_history" in new_seq: del new_seq["prompt_history"]
# Auto-increment
max_seq = 0
for s in batch_list:
if "sequence_number" in s: max_seq = max(max_seq, int(s["sequence_number"]))
new_seq["sequence_number"] = max_seq + 1
batch_list.append(new_seq)
save_json(file_path, data)
st.rerun()
# Global Batch Save
if st.button("💾 Save Batch Changes"):
data["batch_data"] = batch_list
st.session_state.last_mtime = save_json(file_path, data)
st.session_state.data_cache = data
st.toast("Archived & Saved!", icon="📦")
st.rerun()
st.markdown("---")
st.subheader("Media Preview")
preview_path = None
for k in ["reference path", "video file path", "reference image path", "flf image path"]:
if spec_fields.get(k):
preview_path = spec_fields[k]
break
if preview_path:
full_prev_path = Path(preview_path) if os.path.isabs(preview_path) else st.session_state.current_dir / preview_path
if full_prev_path.exists():
ext = full_prev_path.suffix.lower()
if ext in ['.mp4', '.avi', '.mov']:
st.video(str(full_prev_path))
else:
st.image(str(full_prev_path))
else:
st.warning(f"File not found: {preview_path}")
else:
st.info("No media path set.")
# ---------------- HISTORY SECTION ----------------
st.markdown("---")
history = data.get("prompt_history", [])
h_head_1, h_head_2 = st.columns([1, 2])
h_head_1.subheader(f"History ({len(history)})")
search_term = h_head_2.text_input("🔍 Search History", placeholder="Filter...").lower()
if history:
for idx, h in enumerate(history):
note_text = str(h.get('note', '')).lower()
if search_term and search_term not in note_text: continue
note_title = h.get('note', 'No Note') or "No Note"
expander_label = f"#{idx+1}: {note_title}"
with st.container():
if st.session_state.edit_history_idx == idx:
with st.expander(f"📝 EDITING: {note_title}", expanded=True):
st.info("Editing History Entry")
edit_note = st.text_input("Note", value=h.get('note', ''), key=f"edit_note_{idx}")
ec_seed1, ec_seed2 = st.columns([1, 3])
edit_seed = ec_seed1.number_input("Seed", value=int(h.get('seed', 0)), step=1, key=f"edit_seed_{idx}")
# EDIT ALL PROMPTS
st.caption("General Layer")
edit_gen_p = st.text_area("Gen Prompt", value=h.get('general_prompt', ''), height=60, key=f"egp_{idx}")
edit_gen_n = st.text_area("Gen Negative", value=h.get('general_negative', ''), height=60, key=f"egn_{idx}")
st.caption("Specific Layer")
edit_prompt = st.text_area("Prompt", value=h.get('prompt', ''), height=100, key=f"edit_prompt_{idx}")
edit_negative = st.text_area("Negative", value=h.get('negative', ''), height=60, key=f"edit_neg_{idx}")
ec1, ec2 = st.columns([1, 4])
if ec1.button("💾 Save", key=f"save_edit_{idx}", type="primary"):
h.update({
'note': edit_note, 'seed': edit_seed,
'general_prompt': edit_gen_p, 'general_negative': edit_gen_n,
'prompt': edit_prompt, 'negative': edit_negative
})
st.session_state.last_mtime = save_json(file_path, data)
st.session_state.data_cache = data
st.session_state.edit_history_idx = None
st.toast("History entry updated!", icon="✏️")
st.rerun()
if ec2.button("Cancel", key=f"cancel_edit_{idx}"):
st.session_state.edit_history_idx = None
st.rerun()
else:
with st.expander(expander_label):
col_h1, col_h2 = st.columns([3, 1])
with col_h1:
st.caption(f"📝 Prompts (Seed: {h.get('seed', 0)})")
# Show combined snippet
st.text(f"GEN: {h.get('general_prompt', '')[:50]}...\nSPEC: {h.get('prompt', '')[:50]}...")
st.caption("🧩 LoRAs & Files")
info_dict = {k:v for k,v in h.items() if k not in ['prompt', 'negative', 'general_prompt', 'general_negative', 'note', 'seed'] and v}
if 'loras' in h and isinstance(h['loras'], dict):
info_dict.update({k:v for k,v in h['loras'].items() if v})
if 'loras' in info_dict: del info_dict['loras']
st.json(info_dict, expanded=False)
with col_h2:
if st.button("Restore", key=f"rest_{idx}", use_container_width=True):
if is_conflict:
st.error("Resolve conflict first.")
else:
data["current_prompt"] = h.get("prompt", "")
data["negative"] = h.get("negative", "")
data["general_prompt"] = h.get("general_prompt", "")
data["general_negative"] = h.get("general_negative", "")
data["seed"] = int(h.get("seed", 0))
if "loras" in h and isinstance(h["loras"], dict):
data.update(h["loras"])
for k, v in h.items():
if k not in ["note", "prompt", "loras", "negative", "seed", "general_prompt", "general_negative"]:
data[k] = v
st.session_state.last_mtime = save_json(file_path, data)
st.session_state.data_cache = data
st.toast("Restored settings from history!", icon="")
st.rerun()
if st.button("✏️ Edit", key=f"open_edit_{idx}", use_container_width=True):
st.session_state.edit_history_idx = idx
st.rerun()
if st.button("Delete", key=f"del_{idx}", use_container_width=True):
if is_conflict:
st.error("Resolve conflict first.")
else:
data["prompt_history"].pop(idx)
st.session_state.last_mtime = save_json(file_path, data)
st.session_state.data_cache = data
st.rerun()
st.toast("Batch saved!", icon="🚀")