diff --git a/stream_editor.py b/stream_editor.py index a08506b..07530b6 100644 --- a/stream_editor.py +++ b/stream_editor.py @@ -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("", 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("", 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="🚀")