Compare commits
68 Commits
sql
...
bdcc05f388
| Author | SHA1 | Date | |
|---|---|---|---|
| bdcc05f388 | |||
| 31da900502 | |||
| f8f71b002d | |||
| bc75e7f341 | |||
| 6a3b72c035 | |||
| 387d4d874c | |||
| 7261f2c689 | |||
| 2263c3f598 | |||
| 7252fa3855 | |||
| a747f86daa | |||
| f5e242950d | |||
| dfab5e12ab | |||
| a08f2676f5 | |||
| 3255fe76dc | |||
| 0d44944192 | |||
| 8cc244e8be | |||
| e841e9b76b | |||
| a4717dfab6 | |||
| 3718975d99 | |||
| 40ffdcf671 | |||
| 81ecb91835 | |||
| e196ad27f5 | |||
| bd628b062e | |||
| 1abae0de22 | |||
| 64472c7850 | |||
| 907e7efd68 | |||
| 0cfe9c9d4b | |||
| 563dba5a0c | |||
| b7164ae167 | |||
| adff3d0124 | |||
| f0ffeef731 | |||
| 2473a3d20c | |||
| 9aad04bb02 | |||
| 45da9ee431 | |||
| 8c2b0f7809 | |||
| 58345dc7c0 | |||
| 941eb836b0 | |||
| c757038535 | |||
| 8a86915347 | |||
| bde8bc5805 | |||
| e4360f9124 | |||
| a88226778e | |||
| 94dbbc694f | |||
| 2653b5a0ee | |||
| 56db4080de | |||
| 87ed2f1dfb | |||
| e6ef69b126 | |||
| 676160be8c | |||
| 8d0e16ac63 | |||
| a1bda9a979 | |||
| b02bf124fb | |||
| 326ae25ab2 | |||
| 268de89f6d | |||
| 80b77b0218 | |||
| b19e7b937c | |||
| 316ef0e620 | |||
| 18550005dd | |||
| 65e19fb7ff | |||
| b25814f756 | |||
| 2b4221e444 | |||
| a5c5410b04 | |||
| 213aa254fb | |||
| f51a0d6fe0 | |||
| d054ff2725 | |||
| 7b4b0ff7ee | |||
| d3deb58469 | |||
| a6b88467a8 | |||
| f7d7e74cb9 |
416
README.md
416
README.md
@@ -1,121 +1,335 @@
|
|||||||
# 🎛️ AI Settings Manager for ComfyUI
|
<p align="center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="480" height="100" viewBox="0 0 480 100">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#1a1a2e;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#16213e;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="accent" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" style="stop-color:#e94560" />
|
||||||
|
<stop offset="100%" style="stop-color:#0f3460" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="480" height="100" rx="16" fill="url(#bg)" />
|
||||||
|
<rect x="20" y="72" width="440" height="3" rx="1.5" fill="url(#accent)" opacity="0.6" />
|
||||||
|
<text x="240" y="36" text-anchor="middle" fill="#e94560" font-family="monospace" font-size="13" font-weight="bold">{ JSON }</text>
|
||||||
|
<text x="240" y="60" text-anchor="middle" fill="#eee" font-family="sans-serif" font-size="22" font-weight="bold">ComfyUI JSON Manager</text>
|
||||||
|
<text x="240" y="90" text-anchor="middle" fill="#888" font-family="sans-serif" font-size="11">Visual dashboard & dynamic nodes for AI video workflows</text>
|
||||||
|
</svg>
|
||||||
|
</p>
|
||||||
|
|
||||||
A 100% vibecoded, visual dashboard for managing, versioning, and batch-processing JSON configuration files used in AI video generation workflows (I2V, VACE).
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/License-Apache_2.0-blue.svg" alt="License" />
|
||||||
|
<img src="https://img.shields.io/badge/Python-3.10%2B-green" alt="Python" />
|
||||||
|
<img src="https://img.shields.io/badge/Built%20with-Streamlit-red" alt="Streamlit" />
|
||||||
|
<img src="https://img.shields.io/badge/ComfyUI-Custom%20Nodes-purple" alt="ComfyUI" />
|
||||||
|
</p>
|
||||||
|
|
||||||
This tool consists of two parts:
|
A visual dashboard for managing, versioning, and batch-processing JSON configuration files used in AI video generation workflows (I2V, VACE). Two parts:
|
||||||
1. **Streamlit Web Interface:** A Dockerized editor to manage prompts, LoRAs, settings, and **branching history**.
|
|
||||||
2. **ComfyUI Custom Nodes:** A set of nodes to read these JSON files (including custom keys) directly into your workflows.
|
|
||||||
|
|
||||||
  
|
1. **Streamlit Web Interface** — Dockerized editor for prompts, LoRAs, settings, and branching history
|
||||||
---
|
2. **ComfyUI Custom Nodes** — Read JSON files directly into workflows, including a dynamic node that auto-discovers keys
|
||||||
|
|
||||||
## ✨ Features
|
|
||||||
|
|
||||||
### 📝 Single File Editor
|
|
||||||
* **Visual Interface:** Edit Prompts, Negative Prompts, Seeds, LoRAs, and advanced settings (Camera, FLF, VACE params) without touching raw JSON.
|
|
||||||
* **🔧 Custom Parameters:** Add arbitrary key-value pairs (e.g., `controlnet_strength`, `my_custom_value`) that persist and can be read by ComfyUI.
|
|
||||||
* **Conflict Protection:** Prevents accidental overwrites if the file is modified by another tab or process.
|
|
||||||
* **Snippet Library:** Save reusable prompt fragments (e.g., "Cinematic Lighting", "Anime Style") and append them with one click.
|
|
||||||
|
|
||||||
### 🚀 Batch Processor
|
|
||||||
* **Sequence Management:** Create unlimited sequences within a single JSON file.
|
|
||||||
* **Smart Import:** Copy settings from **any other file** or **history entry** into your current batch sequence.
|
|
||||||
* **Custom Keys per Shot:** Define unique parameters for specific shots in a batch (e.g., Shot 1 has `fog: 0.5`, Shot 2 has `fog: 0.0`).
|
|
||||||
* **Promote to Single:** One-click convert a specific batch sequence back into a standalone Single File.
|
|
||||||
|
|
||||||
### 🕒 Visual Timeline (New!)
|
|
||||||
* **Git-Style Branching:** A dedicated tab visualizes your edit history as a **horizontal node graph**.
|
|
||||||
* **Non-Destructive:** If you jump back to an old version and make changes, the system automatically **forks a new branch** so you never lose history.
|
|
||||||
* **Visual Diff:** Inspect any past version and see a "Delta View" highlighting exactly what changed (e.g., `Seed: 100 -> 555`) compared to your current state.
|
|
||||||
* **Interactive Mode (WIP):** A zoomed-out, interactive canvas to explore complex history trees.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛠️ Installation
|
## Features
|
||||||
|
|
||||||
### 1. Unraid / Docker Setup (The Manager)
|
<table>
|
||||||
This tool is designed to run as a lightweight container on Unraid.
|
<tr>
|
||||||
|
<td width="50%">
|
||||||
|
|
||||||
1. **Prepare a Folder:** Create a folder on your server (e.g., `/mnt/user/appdata/ai-manager/`) and place the following files inside:
|
<h3>
|
||||||
* `app.py`
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><rect width="20" height="20" rx="4" fill="#e94560"/><text x="10" y="14" text-anchor="middle" fill="#fff" font-size="11">B</text></svg>
|
||||||
* `utils.py`
|
Batch Processor
|
||||||
* `history_tree.py` (New logic engine)
|
</h3>
|
||||||
* `tab_single.py`
|
|
||||||
* `tab_batch.py`
|
|
||||||
* `tab_timeline.py`
|
|
||||||
* `tab_timeline_wip.py`
|
|
||||||
2. **Add Container in Unraid:**
|
|
||||||
* **Repository:** `python:3.12-slim`
|
|
||||||
* **Network:** `Bridge`
|
|
||||||
* **WebUI:** `http://[IP]:[PORT:8501]`
|
|
||||||
3. **Path Mappings:**
|
|
||||||
* **App Location:** Container `/app` ↔ Host `/mnt/user/appdata/ai-manager/`
|
|
||||||
* **Project Data:** Container `/mnt/user/` ↔ Host `/mnt/user/` (Your media/JSON location)
|
|
||||||
4. **Post Arguments (Crucial):**
|
|
||||||
Enable "Advanced View" and paste this command to install the required graph engines:
|
|
||||||
```bash
|
|
||||||
/bin/sh -c "apt-get update && apt-get install -y graphviz && pip install streamlit opencv-python-headless graphviz streamlit-agraph && cd /app && streamlit run app.py --server.headless true --server.port 8501"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. ComfyUI Setup (The Nodes)
|
- Unlimited sequences within a single JSON file
|
||||||
1. Navigate to your ComfyUI installation: `ComfyUI/custom_nodes/`
|
- Import settings from any file or history entry
|
||||||
2. Create a folder named `ComfyUI-JSON-Loader`.
|
- Per-shot custom keys (e.g. Shot 1: `fog: 0.5`, Shot 2: `fog: 0.0`)
|
||||||
3. Place the `json_loader.py` file inside.
|
- Clone, reorder, and manage sequences visually
|
||||||
4. Restart ComfyUI.
|
- Conflict protection against external file modifications
|
||||||
|
- Snippet library for reusable prompt fragments
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td width="50%">
|
||||||
|
|
||||||
|
<h3>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><rect width="20" height="20" rx="4" fill="#533483"/><text x="10" y="14" text-anchor="middle" fill="#fff" font-size="11">T</text></svg>
|
||||||
|
Visual Timeline
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
- Git-style branching with horizontal node graph
|
||||||
|
- Non-destructive: forking on old-version edits preserves all history
|
||||||
|
- Visual diff highlighting changes between any two versions
|
||||||
|
- Restore any past state with one click
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td width="50%">
|
||||||
|
|
||||||
|
<h3>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><rect width="20" height="20" rx="4" fill="#2b9348"/><text x="10" y="14" text-anchor="middle" fill="#fff" font-size="11">D</text></svg>
|
||||||
|
Dynamic Node (New)
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
- Auto-discovers all JSON keys and exposes them as outputs
|
||||||
|
- No code changes needed when JSON structure evolves
|
||||||
|
- Preserves connections when keys are added on refresh
|
||||||
|
- Native type handling: `int`, `float`, `string`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🖥️ Usage Guide
|
## Installation
|
||||||
|
|
||||||
|
### 1. Unraid / Docker (Streamlit Manager)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Repository: python:3.12-slim
|
||||||
|
# Network: Bridge
|
||||||
|
# WebUI: http://[IP]:[PORT:8501]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Path Mappings:**
|
||||||
|
| Container | Host | Purpose |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| `/app` | `/mnt/user/appdata/ai-manager/` | App files |
|
||||||
|
| `/mnt/user/` | `/mnt/user/` | Project data / JSON location |
|
||||||
|
|
||||||
|
**Post Arguments:**
|
||||||
|
```bash
|
||||||
|
/bin/sh -c "apt-get update && apt-get install -y graphviz && \
|
||||||
|
pip install streamlit opencv-python-headless graphviz streamlit-agraph && \
|
||||||
|
cd /app && streamlit run app.py --server.headless true --server.port 8501"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. ComfyUI (Custom Nodes)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ComfyUI/custom_nodes/
|
||||||
|
git clone <this-repo> ComfyUI-JSON-Manager
|
||||||
|
# Restart ComfyUI
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ComfyUI Nodes
|
||||||
|
|
||||||
|
### Node Overview
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Diagram: shows JSON file flowing into different node types
|
||||||
|
-->
|
||||||
|
<p align="center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="280" viewBox="0 0 720 280">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="nodeBg" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#2d2d3d" />
|
||||||
|
<stop offset="100%" style="stop-color:#1e1e2e" />
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="shadow">
|
||||||
|
<feDropShadow dx="1" dy="2" stdDeviation="3" flood-opacity="0.3"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- JSON File -->
|
||||||
|
<rect x="10" y="100" width="120" height="60" rx="8" fill="#0f3460" filter="url(#shadow)" />
|
||||||
|
<text x="70" y="125" text-anchor="middle" fill="#aaa" font-family="monospace" font-size="10">batch_prompt</text>
|
||||||
|
<text x="70" y="142" text-anchor="middle" fill="#fff" font-family="monospace" font-size="13" font-weight="bold">.json</text>
|
||||||
|
|
||||||
|
<!-- Arrow -->
|
||||||
|
<line x1="130" y1="130" x2="170" y2="130" stroke="#555" stroke-width="2" marker-end="url(#arrowhead)"/>
|
||||||
|
<defs><marker id="arrowhead" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#555"/></marker></defs>
|
||||||
|
|
||||||
|
<!-- Dynamic Node -->
|
||||||
|
<rect x="180" y="20" width="200" height="70" rx="10" fill="url(#nodeBg)" stroke="#2b9348" stroke-width="2" filter="url(#shadow)" />
|
||||||
|
<text x="280" y="44" text-anchor="middle" fill="#2b9348" font-family="sans-serif" font-size="12" font-weight="bold">JSON Loader (Dynamic)</text>
|
||||||
|
<text x="280" y="62" text-anchor="middle" fill="#888" font-family="monospace" font-size="10">auto-discovers keys</text>
|
||||||
|
<text x="280" y="78" text-anchor="middle" fill="#666" font-family="monospace" font-size="9">click Refresh to populate</text>
|
||||||
|
|
||||||
|
<!-- Batch I2V Node -->
|
||||||
|
<rect x="180" y="105" width="200" height="50" rx="10" fill="url(#nodeBg)" stroke="#e94560" stroke-width="2" filter="url(#shadow)" />
|
||||||
|
<text x="280" y="127" text-anchor="middle" fill="#e94560" font-family="sans-serif" font-size="12" font-weight="bold">JSON Batch Loader (I2V)</text>
|
||||||
|
<text x="280" y="144" text-anchor="middle" fill="#888" font-family="monospace" font-size="10">prompts, flf, seed, paths</text>
|
||||||
|
|
||||||
|
<!-- Batch VACE Node -->
|
||||||
|
<rect x="180" y="170" width="200" height="50" rx="10" fill="url(#nodeBg)" stroke="#533483" stroke-width="2" filter="url(#shadow)" />
|
||||||
|
<text x="280" y="192" text-anchor="middle" fill="#533483" font-family="sans-serif" font-size="12" font-weight="bold">JSON Batch Loader (VACE)</text>
|
||||||
|
<text x="280" y="209" text-anchor="middle" fill="#888" font-family="monospace" font-size="10">+ vace frames, schedule</text>
|
||||||
|
|
||||||
|
<!-- Custom Nodes -->
|
||||||
|
<rect x="180" y="235" width="200" height="40" rx="10" fill="url(#nodeBg)" stroke="#0f3460" stroke-width="2" filter="url(#shadow)" />
|
||||||
|
<text x="280" y="260" text-anchor="middle" fill="#0f3460" font-family="sans-serif" font-size="12" font-weight="bold">JSON Loader (Custom 1/3/6)</text>
|
||||||
|
|
||||||
|
<!-- Output labels -->
|
||||||
|
<line x1="380" y1="55" x2="420" y2="55" stroke="#2b9348" stroke-width="1.5"/>
|
||||||
|
<text x="430" y="47" fill="#aaa" font-family="monospace" font-size="9">general_prompt</text>
|
||||||
|
<text x="430" y="59" fill="#aaa" font-family="monospace" font-size="9">seed (int)</text>
|
||||||
|
<text x="430" y="71" fill="#aaa" font-family="monospace" font-size="9">my_custom_key ...</text>
|
||||||
|
|
||||||
|
<line x1="380" y1="130" x2="420" y2="130" stroke="#e94560" stroke-width="1.5"/>
|
||||||
|
<text x="430" y="127" fill="#aaa" font-family="monospace" font-size="9">general_prompt, camera,</text>
|
||||||
|
<text x="430" y="139" fill="#aaa" font-family="monospace" font-size="9">flf, seed, paths ...</text>
|
||||||
|
|
||||||
|
<line x1="380" y1="195" x2="420" y2="195" stroke="#533483" stroke-width="1.5"/>
|
||||||
|
<text x="430" y="192" fill="#aaa" font-family="monospace" font-size="9">+ frame_to_skip, vace_schedule,</text>
|
||||||
|
<text x="430" y="204" fill="#aaa" font-family="monospace" font-size="9">input_a_frames ...</text>
|
||||||
|
|
||||||
|
<line x1="380" y1="255" x2="420" y2="255" stroke="#0f3460" stroke-width="1.5"/>
|
||||||
|
<text x="430" y="259" fill="#aaa" font-family="monospace" font-size="9">manual key lookup (1-6 slots)</text>
|
||||||
|
</svg>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
### Dynamic Node
|
||||||
|
|
||||||
|
The **JSON Loader (Dynamic)** node reads your JSON file and automatically creates output slots for every key it finds. No code changes needed when your JSON structure evolves.
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
1. Enter a `json_path` and `sequence_number`
|
||||||
|
2. Click **Refresh Outputs**
|
||||||
|
3. Outputs appear named after JSON keys, with native types preserved
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="500" height="240" viewBox="0 0 500 240">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="dynBg" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#353545" />
|
||||||
|
<stop offset="100%" style="stop-color:#252535" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Node body -->
|
||||||
|
<rect x="20" y="10" width="240" height="220" rx="10" fill="url(#dynBg)" stroke="#2b9348" stroke-width="2" />
|
||||||
|
<rect x="20" y="10" width="240" height="28" rx="10" fill="#2b9348" />
|
||||||
|
<rect x="20" y="28" width="240" height="10" fill="#2b9348" />
|
||||||
|
<text x="140" y="31" text-anchor="middle" fill="#fff" font-family="sans-serif" font-size="13" font-weight="bold">JSON Loader (Dynamic)</text>
|
||||||
|
|
||||||
|
<!-- Inputs -->
|
||||||
|
<text x="35" y="60" fill="#ccc" font-family="monospace" font-size="10">json_path: /data/prompt.json</text>
|
||||||
|
<text x="35" y="78" fill="#ccc" font-family="monospace" font-size="10">sequence_number: 1</text>
|
||||||
|
|
||||||
|
<!-- Refresh button -->
|
||||||
|
<rect x="45" y="88" width="190" height="24" rx="5" fill="#2b9348" opacity="0.3" stroke="#2b9348" stroke-width="1"/>
|
||||||
|
<text x="140" y="104" text-anchor="middle" fill="#2b9348" font-family="sans-serif" font-size="11" font-weight="bold">Refresh Outputs</text>
|
||||||
|
|
||||||
|
<!-- Output slots -->
|
||||||
|
<circle cx="260" cy="130" r="5" fill="#6bcb77"/>
|
||||||
|
<text x="245" y="134" text-anchor="end" fill="#ccc" font-family="monospace" font-size="10">general_prompt</text>
|
||||||
|
|
||||||
|
<circle cx="260" cy="150" r="5" fill="#6bcb77"/>
|
||||||
|
<text x="245" y="154" text-anchor="end" fill="#ccc" font-family="monospace" font-size="10">negative</text>
|
||||||
|
|
||||||
|
<circle cx="260" cy="170" r="5" fill="#4d96ff"/>
|
||||||
|
<text x="245" y="174" text-anchor="end" fill="#ccc" font-family="monospace" font-size="10">seed</text>
|
||||||
|
|
||||||
|
<circle cx="260" cy="190" r="5" fill="#ff6b6b"/>
|
||||||
|
<text x="245" y="194" text-anchor="end" fill="#ccc" font-family="monospace" font-size="10">flf</text>
|
||||||
|
|
||||||
|
<circle cx="260" cy="210" r="5" fill="#6bcb77"/>
|
||||||
|
<text x="245" y="214" text-anchor="end" fill="#ccc" font-family="monospace" font-size="10">camera</text>
|
||||||
|
|
||||||
|
<!-- Connection lines to downstream -->
|
||||||
|
<line x1="265" y1="130" x2="340" y2="130" stroke="#6bcb77" stroke-width="1.5"/>
|
||||||
|
<line x1="265" y1="170" x2="340" y2="165" stroke="#4d96ff" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Downstream node -->
|
||||||
|
<rect x="340" y="115" width="140" height="65" rx="8" fill="url(#dynBg)" stroke="#555" stroke-width="1.5" />
|
||||||
|
<text x="410" y="137" text-anchor="middle" fill="#aaa" font-family="sans-serif" font-size="11">KSampler</text>
|
||||||
|
<circle cx="340" cy="130" r="4" fill="#6bcb77"/>
|
||||||
|
<text x="350" y="150" fill="#777" font-family="monospace" font-size="9">positive</text>
|
||||||
|
<circle cx="340" cy="165" r="4" fill="#4d96ff"/>
|
||||||
|
<text x="350" y="170" fill="#777" font-family="monospace" font-size="9">seed</text>
|
||||||
|
|
||||||
|
<!-- Legend -->
|
||||||
|
<circle cx="30" y="248" r="4" fill="#6bcb77"/>
|
||||||
|
<text x="40" y="252" fill="#888" font-family="monospace" font-size="9">STRING</text>
|
||||||
|
<circle cx="100" y="248" r="4" fill="#4d96ff"/>
|
||||||
|
<text x="110" y="252" fill="#888" font-family="monospace" font-size="9">INT</text>
|
||||||
|
<circle cx="155" y="248" r="4" fill="#ff6b6b"/>
|
||||||
|
<text x="165" y="252" fill="#888" font-family="monospace" font-size="9">FLOAT</text>
|
||||||
|
</svg>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
**Type handling:** Values keep their native Python type — `int` stays `int`, `float` stays `float`, booleans become `"true"`/`"false"` strings, everything else becomes `string`. The `*` (any) output type allows connecting to any input.
|
||||||
|
|
||||||
|
**Refreshing is safe:** Clicking Refresh after adding new keys to your JSON preserves all existing connections. Only removed keys get disconnected.
|
||||||
|
|
||||||
|
### Standard & Batch Nodes
|
||||||
|
|
||||||
|
| Node | Outputs | Use Case |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| **JSON Loader (Standard/I2V)** | prompts, flf, seed, paths | Single-file I2V workflows |
|
||||||
|
| **JSON Loader (VACE Full)** | above + VACE integers | Single-file VACE workflows |
|
||||||
|
| **JSON Loader (LoRAs Only)** | 6 LoRA strings | Single-file LoRA loading |
|
||||||
|
| **JSON Batch Loader (I2V)** | prompts, flf, seed, paths | Batch I2V with sequence_number |
|
||||||
|
| **JSON Batch Loader (VACE)** | above + VACE integers | Batch VACE with sequence_number |
|
||||||
|
| **JSON Batch Loader (LoRAs)** | 6 LoRA strings | Batch LoRA loading |
|
||||||
|
| **JSON Loader (Custom 1/3/6)** | 1, 3, or 6 string values | Manual key lookup by name |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Web Interface Usage
|
||||||
|
|
||||||
### The Web Interface
|
|
||||||
Navigate to your container's IP (e.g., `http://192.168.1.100:8501`).
|
Navigate to your container's IP (e.g., `http://192.168.1.100:8501`).
|
||||||
|
|
||||||
* **Custom Parameters:** Scroll to the bottom of the editor (Single or Batch) to find the "🔧 Custom Parameters" section. Type a Key (e.g., `strength`) and Value (e.g., `0.8`) and click "Add".
|
**Path navigation** supports case-insensitive matching — typing `/media/P5/myFolder` will resolve to `/media/p5/MyFolder` automatically.
|
||||||
* **Timeline:** Switch to the **Timeline Tab** to see your version history.
|
|
||||||
* **Restore:** Select a node from the list or click on the graph (WIP tab) to view details. Click "Restore" to revert settings to that point.
|
|
||||||
* **Branching:** If you restore an old node and click "Save/Snap", a new branch is created automatically.
|
|
||||||
|
|
||||||
### ComfyUI Workflow
|
- **Custom Parameters:** Scroll to "Custom Parameters" in any editor tab. Type a key and value, click Add.
|
||||||
Search for "JSON" in ComfyUI to find the new nodes.
|
- **Timeline:** Switch to the Timeline tab to see version history as a graph. Restore any version, and new edits fork a branch automatically.
|
||||||
|
- **Snippets:** Save reusable prompt fragments and append them with one click.
|
||||||
<img width="1251" height="921" alt="image" src="https://github.com/user-attachments/assets/06d567f8-15ee-4011-9b86-d0b43ce1ba74" />
|
|
||||||
|
|
||||||
#### Standard Nodes
|
|
||||||
| Node Name | Description |
|
|
||||||
| :--- | :--- |
|
|
||||||
| **JSON Loader (Standard/I2V)** | Outputs prompts, FLF, Seed, and paths for I2V. |
|
|
||||||
| **JSON Loader (VACE Full)** | Outputs everything above plus VACE integers (frames to skip, schedule, etc.). |
|
|
||||||
| **JSON Loader (LoRAs Only)** | Outputs the 6 LoRA strings. |
|
|
||||||
|
|
||||||
#### Universal Custom Nodes (New!)
|
|
||||||
These nodes read *any* key you added in the "Custom Parameters" section. They work for both Single files (ignores sequence input) and Batch files (reads specific sequence).
|
|
||||||
|
|
||||||
| Node Name | Description |
|
|
||||||
| :--- | :--- |
|
|
||||||
| **JSON Loader (Custom 1)** | Reads 1 custom key. Input the key name (e.g., "strength"), outputs the value string. |
|
|
||||||
| **JSON Loader (Custom 3)** | Reads 3 custom keys. |
|
|
||||||
| **JSON Loader (Custom 6)** | Reads 6 custom keys. |
|
|
||||||
|
|
||||||
#### Batch Nodes
|
|
||||||
These nodes require an integer input (Primitive or Batch Indexer) for `sequence_number`.
|
|
||||||
|
|
||||||
| Node Name | Description |
|
|
||||||
| :--- | :--- |
|
|
||||||
| **JSON Batch Loader (I2V)** | Loads specific sequence data for I2V. |
|
|
||||||
| **JSON Batch Loader (VACE)** | Loads specific sequence data for VACE. |
|
|
||||||
| **JSON Batch Loader (LoRAs)** | Loads specific LoRAs for that sequence. |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📂 File Structure
|
## JSON Format
|
||||||
|
|
||||||
```text
|
```jsonc
|
||||||
/ai-manager
|
{
|
||||||
├── app.py # Main entry point & Tab controller
|
"batch_data": [
|
||||||
├── utils.py # I/O logic, Config, and Defaults
|
{
|
||||||
├── history_tree.py # Graph logic, Branching engine, Graphviz generator
|
"sequence_number": 1,
|
||||||
├── tab_single.py # Single Editor UI
|
"general_prompt": "A cinematic scene...",
|
||||||
├── tab_batch.py # Batch Processor UI
|
"negative": "blurry, low quality",
|
||||||
├── tab_timeline.py # Stable Timeline UI (Compact Graphviz + Diff Inspector)
|
"seed": 42,
|
||||||
├── tab_timeline_wip.py # Interactive Timeline UI (Streamlit Agraph)
|
"flf": 0.5,
|
||||||
└── json_loader.py # ComfyUI Custom Node script
|
"camera": "pan_left",
|
||||||
|
"video file path": "/data/input.mp4",
|
||||||
|
"reference image path": "/data/ref.png",
|
||||||
|
"my_custom_key": "any value"
|
||||||
|
// ... any additional keys are auto-discovered by the Dynamic node
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
ComfyUI-JSON-Manager/
|
||||||
|
├── __init__.py # ComfyUI entry point, exports nodes + WEB_DIRECTORY
|
||||||
|
├── json_loader.py # All ComfyUI node classes + /json_manager/get_keys API
|
||||||
|
├── web/
|
||||||
|
│ └── json_dynamic.js # Frontend extension for Dynamic node (refresh, show/hide)
|
||||||
|
├── app.py # Streamlit main entry point & navigator
|
||||||
|
├── utils.py # I/O, config, defaults, case-insensitive path resolver
|
||||||
|
├── history_tree.py # Git-style branching engine
|
||||||
|
├── tab_batch.py # Batch processor UI
|
||||||
|
├── tab_timeline.py # Visual timeline UI
|
||||||
|
├── tab_comfy.py # ComfyUI server monitor
|
||||||
|
├── tab_raw.py # Raw JSON editor
|
||||||
|
└── tests/
|
||||||
|
├── test_json_loader.py
|
||||||
|
├── test_utils.py
|
||||||
|
└── test_history_tree.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[Apache 2.0](LICENSE)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
from .json_loader import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
from .json_loader import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||||
|
|
||||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
|
WEB_DIRECTORY = "./web"
|
||||||
|
|
||||||
|
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY']
|
||||||
|
|||||||
154
app.py
154
app.py
@@ -1,17 +1,17 @@
|
|||||||
import streamlit as st
|
import streamlit as st
|
||||||
import random
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# --- Import Custom Modules ---
|
# --- Import Custom Modules ---
|
||||||
from utils import (
|
from utils import (
|
||||||
load_config, save_config, load_snippets, save_snippets,
|
load_config, save_config, load_snippets, save_snippets,
|
||||||
load_json, save_json, generate_templates, DEFAULTS
|
load_json, save_json, generate_templates, DEFAULTS,
|
||||||
|
KEY_BATCH_DATA, KEY_SEQUENCE_NUMBER,
|
||||||
|
resolve_path_case_insensitive,
|
||||||
)
|
)
|
||||||
from tab_single import render_single_editor
|
|
||||||
from tab_batch import render_batch_processor
|
from tab_batch import render_batch_processor
|
||||||
from tab_timeline import render_timeline_tab
|
from tab_timeline import render_timeline_tab
|
||||||
from tab_timeline_wip import render_timeline_wip
|
|
||||||
from tab_comfy import render_comfy_monitor
|
from tab_comfy import render_comfy_monitor
|
||||||
|
from tab_raw import render_raw_editor
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 1. PAGE CONFIGURATION
|
# 1. PAGE CONFIGURATION
|
||||||
@@ -21,31 +21,21 @@ st.set_page_config(layout="wide", page_title="AI Settings Manager")
|
|||||||
# ==========================================
|
# ==========================================
|
||||||
# 2. SESSION STATE INITIALIZATION
|
# 2. SESSION STATE INITIALIZATION
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
_SESSION_DEFAULTS = {
|
||||||
|
"snippets": load_snippets,
|
||||||
|
"loaded_file": lambda: None,
|
||||||
|
"last_mtime": lambda: 0,
|
||||||
|
"ui_reset_token": lambda: 0,
|
||||||
|
"active_tab_name": lambda: "🚀 Batch Processor",
|
||||||
|
}
|
||||||
|
|
||||||
if 'config' not in st.session_state:
|
if 'config' not in st.session_state:
|
||||||
st.session_state.config = load_config()
|
st.session_state.config = load_config()
|
||||||
st.session_state.current_dir = Path(st.session_state.config.get("last_dir", Path.cwd()))
|
st.session_state.current_dir = Path(st.session_state.config.get("last_dir", Path.cwd()))
|
||||||
|
|
||||||
if 'snippets' not in st.session_state:
|
for key, factory in _SESSION_DEFAULTS.items():
|
||||||
st.session_state.snippets = load_snippets()
|
if key not in st.session_state:
|
||||||
|
st.session_state[key] = factory()
|
||||||
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 'single_editor_cache' not in st.session_state:
|
|
||||||
st.session_state.single_editor_cache = DEFAULTS.copy()
|
|
||||||
|
|
||||||
if 'ui_reset_token' not in st.session_state:
|
|
||||||
st.session_state.ui_reset_token = 0
|
|
||||||
|
|
||||||
# Track the active tab state for programmatic switching
|
|
||||||
if 'active_tab_name' not in st.session_state:
|
|
||||||
st.session_state.active_tab_name = "📝 Single Editor"
|
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 3. SIDEBAR (NAVIGATOR & TOOLS)
|
# 3. SIDEBAR (NAVIGATOR & TOOLS)
|
||||||
@@ -54,30 +44,55 @@ with st.sidebar:
|
|||||||
st.header("📂 Navigator")
|
st.header("📂 Navigator")
|
||||||
|
|
||||||
# --- Path Navigator ---
|
# --- Path Navigator ---
|
||||||
new_path = st.text_input("Current Path", value=str(st.session_state.current_dir))
|
# Sync widget to current_dir on first load or after external change
|
||||||
if new_path != str(st.session_state.current_dir):
|
if "nav_path_input" not in st.session_state or st.session_state.get("_sync_nav_path"):
|
||||||
p = Path(new_path)
|
st.session_state.nav_path_input = str(st.session_state.current_dir)
|
||||||
if p.exists() and p.is_dir():
|
st.session_state._sync_nav_path = False
|
||||||
|
|
||||||
|
def _on_path_change():
|
||||||
|
new_path = st.session_state.nav_path_input
|
||||||
|
p = resolve_path_case_insensitive(new_path)
|
||||||
|
if p is not None and p.is_dir():
|
||||||
st.session_state.current_dir = p
|
st.session_state.current_dir = p
|
||||||
st.session_state.config['last_dir'] = str(p)
|
st.session_state.config['last_dir'] = str(p)
|
||||||
save_config(st.session_state.current_dir, st.session_state.config['favorites'])
|
save_config(st.session_state.current_dir, st.session_state.config['favorites'])
|
||||||
st.rerun()
|
st.session_state.loaded_file = None
|
||||||
|
# Always resync widget to canonical path form
|
||||||
|
st.session_state._sync_nav_path = True
|
||||||
|
|
||||||
|
st.text_input("Current Path", key="nav_path_input", on_change=_on_path_change)
|
||||||
|
|
||||||
# --- Favorites System ---
|
# --- Favorites System ---
|
||||||
if st.button("📌 Pin Current Folder"):
|
if st.button("📌 Pin Folder", use_container_width=True):
|
||||||
if str(st.session_state.current_dir) not in st.session_state.config['favorites']:
|
if str(st.session_state.current_dir) not in st.session_state.config['favorites']:
|
||||||
st.session_state.config['favorites'].append(str(st.session_state.current_dir))
|
st.session_state.config['favorites'].append(str(st.session_state.current_dir))
|
||||||
save_config(st.session_state.current_dir, st.session_state.config['favorites'])
|
save_config(st.session_state.current_dir, st.session_state.config['favorites'])
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
fav_selection = st.radio(
|
favorites = st.session_state.config['favorites']
|
||||||
|
if favorites:
|
||||||
|
def _on_fav_jump():
|
||||||
|
sel = st.session_state._fav_radio
|
||||||
|
if sel != "Select..." and sel != str(st.session_state.current_dir):
|
||||||
|
st.session_state.current_dir = Path(sel)
|
||||||
|
st.session_state._sync_nav_path = True
|
||||||
|
|
||||||
|
st.radio(
|
||||||
"Jump to:",
|
"Jump to:",
|
||||||
["Select..."] + st.session_state.config['favorites'],
|
["Select..."] + favorites,
|
||||||
index=0,
|
index=0,
|
||||||
label_visibility="collapsed"
|
key="_fav_radio",
|
||||||
|
label_visibility="collapsed",
|
||||||
|
on_change=_on_fav_jump,
|
||||||
)
|
)
|
||||||
if fav_selection != "Select..." and fav_selection != str(st.session_state.current_dir):
|
|
||||||
st.session_state.current_dir = Path(fav_selection)
|
# Unpin buttons for each favorite
|
||||||
|
for fav in favorites:
|
||||||
|
fc1, fc2 = st.columns([4, 1])
|
||||||
|
fc1.caption(fav)
|
||||||
|
if fc2.button("❌", key=f"unpin_{fav}"):
|
||||||
|
st.session_state.config['favorites'].remove(fav)
|
||||||
|
save_config(st.session_state.current_dir, st.session_state.config['favorites'])
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
st.markdown("---")
|
st.markdown("---")
|
||||||
@@ -99,7 +114,6 @@ with st.sidebar:
|
|||||||
for name, content in st.session_state.snippets.items():
|
for name, content in st.session_state.snippets.items():
|
||||||
col_s1, col_s2 = st.columns([4, 1])
|
col_s1, col_s2 = st.columns([4, 1])
|
||||||
if col_s1.button(f"➕ {name}", use_container_width=True):
|
if col_s1.button(f"➕ {name}", use_container_width=True):
|
||||||
st.session_state.append_prompt = content
|
|
||||||
st.rerun()
|
st.rerun()
|
||||||
if col_s2.button("🗑️", key=f"del_snip_{name}"):
|
if col_s2.button("🗑️", key=f"del_snip_{name}"):
|
||||||
del st.session_state.snippets[name]
|
del st.session_state.snippets[name]
|
||||||
@@ -119,26 +133,34 @@ with st.sidebar:
|
|||||||
|
|
||||||
with st.expander("Create New JSON"):
|
with st.expander("Create New JSON"):
|
||||||
new_filename = st.text_input("Filename", placeholder="my_prompt_vace")
|
new_filename = st.text_input("Filename", placeholder="my_prompt_vace")
|
||||||
is_batch = st.checkbox("Is Batch File?")
|
|
||||||
if st.button("Create"):
|
if st.button("Create"):
|
||||||
if not new_filename.endswith(".json"): new_filename += ".json"
|
if not new_filename.endswith(".json"): new_filename += ".json"
|
||||||
path = st.session_state.current_dir / new_filename
|
path = st.session_state.current_dir / new_filename
|
||||||
if is_batch:
|
first_item = DEFAULTS.copy()
|
||||||
data = {"batch_data": []}
|
first_item[KEY_SEQUENCE_NUMBER] = 1
|
||||||
else:
|
data = {KEY_BATCH_DATA: [first_item]}
|
||||||
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)
|
save_json(path, data)
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
# --- File Selector ---
|
# --- File Selector ---
|
||||||
|
selected_file_name = None
|
||||||
|
if json_files:
|
||||||
|
file_names = [f.name for f in json_files]
|
||||||
if 'file_selector' not in st.session_state:
|
if 'file_selector' not in st.session_state:
|
||||||
st.session_state.file_selector = json_files[0].name if json_files else None
|
st.session_state.file_selector = file_names[0]
|
||||||
if st.session_state.file_selector not in [f.name for f in json_files] and json_files:
|
if st.session_state.file_selector not in file_names:
|
||||||
st.session_state.file_selector = json_files[0].name
|
st.session_state.file_selector = file_names[0]
|
||||||
|
|
||||||
selected_file_name = st.radio("Select File", [f.name for f in json_files], key="file_selector")
|
selected_file_name = st.radio("Select File", file_names, key="file_selector")
|
||||||
|
else:
|
||||||
|
st.info("No JSON files in this folder.")
|
||||||
|
if 'file_selector' in st.session_state:
|
||||||
|
del st.session_state.file_selector
|
||||||
|
st.session_state.loaded_file = None
|
||||||
|
|
||||||
|
# --- GLOBAL MONITOR TOGGLE (NEW) ---
|
||||||
|
st.markdown("---")
|
||||||
|
show_monitor = st.checkbox("Show Comfy Monitor", value=True)
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 4. MAIN APP LOGIC
|
# 4. MAIN APP LOGIC
|
||||||
@@ -154,61 +176,49 @@ if selected_file_name:
|
|||||||
st.session_state.loaded_file = str(file_path)
|
st.session_state.loaded_file = str(file_path)
|
||||||
|
|
||||||
# Clear transient states
|
# Clear transient states
|
||||||
if 'append_prompt' in st.session_state: del st.session_state.append_prompt
|
|
||||||
if 'rand_seed' in st.session_state: del st.session_state.rand_seed
|
|
||||||
if 'restored_indicator' in st.session_state: del st.session_state.restored_indicator
|
if 'restored_indicator' in st.session_state: del st.session_state.restored_indicator
|
||||||
st.session_state.edit_history_idx = None
|
|
||||||
|
|
||||||
# --- AUTO-SWITCH TAB LOGIC ---
|
# --- AUTO-SWITCH TAB LOGIC ---
|
||||||
# If the file has 'batch_data' or is a list, force Batch tab.
|
|
||||||
# Otherwise, force Single tab.
|
|
||||||
is_batch = "batch_data" in data or isinstance(data, list)
|
|
||||||
if is_batch:
|
|
||||||
st.session_state.active_tab_name = "🚀 Batch Processor"
|
st.session_state.active_tab_name = "🚀 Batch Processor"
|
||||||
else:
|
|
||||||
st.session_state.active_tab_name = "📝 Single Editor"
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
data = st.session_state.data_cache
|
data = st.session_state.data_cache
|
||||||
|
|
||||||
st.title(f"Editing: {selected_file_name}")
|
st.title(f"Editing: {selected_file_name}")
|
||||||
|
|
||||||
# --- CONTROLLED NAVIGATION (REPLACES ST.TABS) ---
|
# --- CONTROLLED NAVIGATION ---
|
||||||
# Using radio buttons allows us to change 'active_tab_name' programmatically above.
|
# Removed "🔌 Comfy Monitor" from this list
|
||||||
tabs_list = [
|
tabs_list = [
|
||||||
"📝 Single Editor",
|
|
||||||
"🚀 Batch Processor",
|
"🚀 Batch Processor",
|
||||||
"🕒 Timeline",
|
"🕒 Timeline",
|
||||||
"🧪 Interactive Timeline",
|
"💻 Raw Editor"
|
||||||
"🔌 Comfy Monitor"
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Ensure active tab is valid (safety check)
|
|
||||||
if st.session_state.active_tab_name not in tabs_list:
|
if st.session_state.active_tab_name not in tabs_list:
|
||||||
st.session_state.active_tab_name = tabs_list[0]
|
st.session_state.active_tab_name = tabs_list[0]
|
||||||
|
|
||||||
current_tab = st.radio(
|
current_tab = st.radio(
|
||||||
"Navigation",
|
"Navigation",
|
||||||
tabs_list,
|
tabs_list,
|
||||||
key="active_tab_name", # Binds to session state
|
key="active_tab_name",
|
||||||
horizontal=True,
|
horizontal=True,
|
||||||
label_visibility="collapsed"
|
label_visibility="collapsed"
|
||||||
)
|
)
|
||||||
|
|
||||||
st.markdown("---")
|
st.markdown("---")
|
||||||
|
|
||||||
# --- RENDER SELECTED TAB ---
|
# --- RENDER EDITOR TABS ---
|
||||||
if current_tab == "📝 Single Editor":
|
if current_tab == "🚀 Batch Processor":
|
||||||
render_single_editor(data, file_path)
|
|
||||||
|
|
||||||
elif current_tab == "🚀 Batch Processor":
|
|
||||||
render_batch_processor(data, file_path, json_files, st.session_state.current_dir, selected_file_name)
|
render_batch_processor(data, file_path, json_files, st.session_state.current_dir, selected_file_name)
|
||||||
|
|
||||||
elif current_tab == "🕒 Timeline":
|
elif current_tab == "🕒 Timeline":
|
||||||
render_timeline_tab(data, file_path)
|
render_timeline_tab(data, file_path)
|
||||||
|
|
||||||
elif current_tab == "🧪 Interactive Timeline":
|
elif current_tab == "💻 Raw Editor":
|
||||||
render_timeline_wip(data, file_path)
|
render_raw_editor(data, file_path)
|
||||||
|
|
||||||
elif current_tab == "🔌 Comfy Monitor":
|
# --- GLOBAL PERSISTENT MONITOR ---
|
||||||
|
if show_monitor:
|
||||||
|
st.markdown("---")
|
||||||
|
with st.expander("🔌 ComfyUI Monitor", expanded=True):
|
||||||
render_comfy_monitor()
|
render_comfy_monitor()
|
||||||
70
database.py
70
database.py
@@ -1,70 +0,0 @@
|
|||||||
import sqlite3
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
|
|
||||||
DB_FILE = "comfy_settings.db"
|
|
||||||
|
|
||||||
def init_db():
|
|
||||||
"""Initialize the database table if it doesn't exist."""
|
|
||||||
conn = sqlite3.connect(DB_FILE)
|
|
||||||
c = conn.cursor()
|
|
||||||
# We store the unique name of the file and the entire JSON blob
|
|
||||||
c.execute('''CREATE TABLE IF NOT EXISTS settings (
|
|
||||||
filename TEXT PRIMARY KEY,
|
|
||||||
data TEXT,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)''')
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def save_setting(filename, data):
|
|
||||||
"""Save settings to DB and then export to JSON file."""
|
|
||||||
# 1. Save to Database
|
|
||||||
conn = sqlite3.connect(DB_FILE)
|
|
||||||
c = conn.cursor()
|
|
||||||
json_str = json.dumps(data)
|
|
||||||
c.execute("INSERT OR REPLACE INTO settings (filename, data) VALUES (?, ?)",
|
|
||||||
(filename, json_str))
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
# 2. Produce JSON File (The artifact for ComfyUI)
|
|
||||||
# Ensure the directory exists if filename has a path
|
|
||||||
if os.path.dirname(filename):
|
|
||||||
os.makedirs(os.path.dirname(filename), exist_ok=True)
|
|
||||||
|
|
||||||
with open(filename, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(data, f, indent=4)
|
|
||||||
|
|
||||||
def load_setting(filename):
|
|
||||||
"""Load settings from DB. Fallback to file if not in DB."""
|
|
||||||
conn = sqlite3.connect(DB_FILE)
|
|
||||||
c = conn.cursor()
|
|
||||||
c.execute("SELECT data FROM settings WHERE filename=?", (filename,))
|
|
||||||
row = c.fetchone()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if row:
|
|
||||||
return json.loads(row[0])
|
|
||||||
else:
|
|
||||||
# Fallback: If not in DB, try reading the file and import it
|
|
||||||
if os.path.exists(filename):
|
|
||||||
try:
|
|
||||||
with open(filename, 'r', encoding='utf-8') as f:
|
|
||||||
data = json.load(f)
|
|
||||||
# Auto-import to DB
|
|
||||||
save_setting(filename, data)
|
|
||||||
return data
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error loading file {filename}: {e}")
|
|
||||||
return {}
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def get_all_filenames():
|
|
||||||
"""Retrieve all filenames stored in the DB."""
|
|
||||||
conn = sqlite3.connect(DB_FILE)
|
|
||||||
c = conn.cursor()
|
|
||||||
c.execute("SELECT filename FROM settings")
|
|
||||||
rows = c.fetchall()
|
|
||||||
conn.close()
|
|
||||||
return [row[0] for row in rows]
|
|
||||||
111
history_tree.py
111
history_tree.py
@@ -1,16 +1,20 @@
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
KEY_PROMPT_HISTORY = "prompt_history"
|
||||||
|
|
||||||
|
|
||||||
class HistoryTree:
|
class HistoryTree:
|
||||||
def __init__(self, raw_data):
|
def __init__(self, raw_data: dict[str, Any]) -> None:
|
||||||
self.nodes = raw_data.get("nodes", {})
|
self.nodes: dict[str, dict[str, Any]] = raw_data.get("nodes", {})
|
||||||
self.branches = raw_data.get("branches", {"main": None})
|
self.branches: dict[str, str | None] = raw_data.get("branches", {"main": None})
|
||||||
self.head_id = raw_data.get("head_id", None)
|
self.head_id: str | None = raw_data.get("head_id", None)
|
||||||
|
|
||||||
if "prompt_history" in raw_data and isinstance(raw_data["prompt_history"], list) and not self.nodes:
|
if KEY_PROMPT_HISTORY in raw_data and isinstance(raw_data[KEY_PROMPT_HISTORY], list) and not self.nodes:
|
||||||
self._migrate_legacy(raw_data["prompt_history"])
|
self._migrate_legacy(raw_data[KEY_PROMPT_HISTORY])
|
||||||
|
|
||||||
def _migrate_legacy(self, old_list):
|
def _migrate_legacy(self, old_list: list[dict[str, Any]]) -> None:
|
||||||
parent = None
|
parent = None
|
||||||
for item in reversed(old_list):
|
for item in reversed(old_list):
|
||||||
node_id = str(uuid.uuid4())[:8]
|
node_id = str(uuid.uuid4())[:8]
|
||||||
@@ -22,9 +26,20 @@ class HistoryTree:
|
|||||||
self.branches["main"] = parent
|
self.branches["main"] = parent
|
||||||
self.head_id = parent
|
self.head_id = parent
|
||||||
|
|
||||||
def commit(self, data, note="Snapshot"):
|
def commit(self, data: dict[str, Any], note: str = "Snapshot") -> str:
|
||||||
new_id = str(uuid.uuid4())[:8]
|
new_id = str(uuid.uuid4())[:8]
|
||||||
|
|
||||||
|
# Cycle detection: walk parent chain from head to verify no cycle
|
||||||
|
if self.head_id:
|
||||||
|
visited = set()
|
||||||
|
current = self.head_id
|
||||||
|
while current:
|
||||||
|
if current in visited:
|
||||||
|
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
|
||||||
|
|
||||||
active_branch = None
|
active_branch = None
|
||||||
for b_name, tip_id in self.branches.items():
|
for b_name, tip_id in self.branches.items():
|
||||||
if tip_id == self.head_id:
|
if tip_id == self.head_id:
|
||||||
@@ -45,43 +60,83 @@ class HistoryTree:
|
|||||||
self.head_id = new_id
|
self.head_id = new_id
|
||||||
return new_id
|
return new_id
|
||||||
|
|
||||||
def checkout(self, node_id):
|
def checkout(self, node_id: str) -> dict[str, Any] | None:
|
||||||
if node_id in self.nodes:
|
if node_id in self.nodes:
|
||||||
self.head_id = node_id
|
self.head_id = node_id
|
||||||
return self.nodes[node_id]["data"]
|
return self.nodes[node_id]["data"]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self) -> dict[str, Any]:
|
||||||
return {"nodes": self.nodes, "branches": self.branches, "head_id": self.head_id}
|
return {"nodes": self.nodes, "branches": self.branches, "head_id": self.head_id}
|
||||||
|
|
||||||
# --- UPDATED GRAPH GENERATOR ---
|
# --- UPDATED GRAPH GENERATOR ---
|
||||||
def generate_graph(self, direction="LR"):
|
def generate_graph(self, direction: str = "LR") -> str:
|
||||||
"""
|
"""
|
||||||
Generates Graphviz source.
|
Generates Graphviz source.
|
||||||
direction: "LR" (Horizontal) or "TB" (Vertical)
|
direction: "LR" (Horizontal) or "TB" (Vertical)
|
||||||
"""
|
"""
|
||||||
|
node_count = len(self.nodes)
|
||||||
|
is_vertical = direction == "TB"
|
||||||
|
|
||||||
|
# Vertical mode uses much tighter spacing
|
||||||
|
if is_vertical:
|
||||||
|
if node_count <= 5:
|
||||||
|
nodesep, ranksep = 0.3, 0.2
|
||||||
|
elif node_count <= 15:
|
||||||
|
nodesep, ranksep = 0.2, 0.15
|
||||||
|
else:
|
||||||
|
nodesep, ranksep = 0.1, 0.1
|
||||||
|
else:
|
||||||
|
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 = [
|
dot = [
|
||||||
'digraph History {',
|
'digraph History {',
|
||||||
f' rankdir={direction};', # Dynamic Direction
|
f' rankdir={direction};',
|
||||||
' bgcolor="white";',
|
' bgcolor="white";',
|
||||||
' splines=ortho;',
|
' splines=ortho;',
|
||||||
|
f' nodesep={nodesep};',
|
||||||
# TIGHT SPACING
|
f' ranksep={ranksep};',
|
||||||
' nodesep=0.2;',
|
|
||||||
' ranksep=0.3;',
|
|
||||||
|
|
||||||
# GLOBAL STYLES
|
|
||||||
' node [shape=plain, fontname="Arial"];',
|
' node [shape=plain, fontname="Arial"];',
|
||||||
' edge [color="#888888", arrowsize=0.6, penwidth=1.0];'
|
' edge [color="#888888", arrowsize=0.6, penwidth=1.0];'
|
||||||
]
|
]
|
||||||
|
|
||||||
sorted_nodes = sorted(self.nodes.values(), key=lambda x: x["timestamp"])
|
sorted_nodes = sorted(self.nodes.values(), key=lambda x: x["timestamp"])
|
||||||
|
|
||||||
|
# Font sizes and padding - smaller for vertical
|
||||||
|
if is_vertical:
|
||||||
|
note_font_size = 8
|
||||||
|
meta_font_size = 7
|
||||||
|
cell_padding = 2
|
||||||
|
max_note_len = 18
|
||||||
|
else:
|
||||||
|
note_font_size = 10
|
||||||
|
meta_font_size = 8
|
||||||
|
cell_padding = 4
|
||||||
|
max_note_len = 25
|
||||||
|
|
||||||
for n in sorted_nodes:
|
for n in sorted_nodes:
|
||||||
nid = n["id"]
|
nid = n["id"]
|
||||||
full_note = n.get('note', 'Step')
|
full_note = n.get('note', 'Step')
|
||||||
|
|
||||||
display_note = (full_note[:15] + '..') if len(full_note) > 15 else full_note
|
display_note = (full_note[:max_note_len] + '..') if len(full_note) > max_note_len 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
|
# COLORS
|
||||||
bg_color = "#f9f9f9"
|
bg_color = "#f9f9f9"
|
||||||
@@ -89,19 +144,25 @@ class HistoryTree:
|
|||||||
border_width = "1"
|
border_width = "1"
|
||||||
|
|
||||||
if nid == self.head_id:
|
if nid == self.head_id:
|
||||||
bg_color = "#fff6cd" # Yellow for Current
|
bg_color = "#fff6cd"
|
||||||
border_color = "#eebb00"
|
border_color = "#eebb00"
|
||||||
border_width = "2"
|
border_width = "2"
|
||||||
elif nid in self.branches.values():
|
elif nid in self.branches.values():
|
||||||
bg_color = "#e6ffe6" # Green for Tips
|
bg_color = "#e6ffe6"
|
||||||
border_color = "#66aa66"
|
border_color = "#66aa66"
|
||||||
|
|
||||||
# HTML LABEL
|
# HTML LABEL
|
||||||
|
rows = [
|
||||||
|
f'<TR><TD><B><FONT POINT-SIZE="{note_font_size}">{display_note}</FONT></B></TD></TR>',
|
||||||
|
f'<TR><TD><FONT POINT-SIZE="{meta_font_size}" COLOR="#555555">{ts} • {nid[:4]}</FONT></TD></TR>',
|
||||||
|
]
|
||||||
|
if branch_label:
|
||||||
|
rows.append(f'<TR><TD><FONT POINT-SIZE="{meta_font_size}" COLOR="#4488cc"><I>{branch_label}</I></FONT></TD></TR>')
|
||||||
|
|
||||||
label = (
|
label = (
|
||||||
f'<<TABLE BORDER="{border_width}" CELLBORDER="0" CELLSPACING="0" CELLPADDING="4" BGCOLOR="{bg_color}" COLOR="{border_color}">'
|
f'<<TABLE BORDER="{border_width}" CELLBORDER="0" CELLSPACING="0" CELLPADDING="{cell_padding}" BGCOLOR="{bg_color}" COLOR="{border_color}">'
|
||||||
f'<TR><TD><B><FONT POINT-SIZE="10">{display_note}</FONT></B></TD></TR>'
|
+ "".join(rows)
|
||||||
f'<TR><TD><FONT POINT-SIZE="8" COLOR="#555555">{nid[:4]}</FONT></TD></TR>'
|
+ '</TABLE>>'
|
||||||
f'</TABLE>>'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
safe_tooltip = full_note.replace('"', "'")
|
safe_tooltip = full_note.replace('"', "'")
|
||||||
|
|||||||
204
json_loader.py
204
json_loader.py
@@ -1,17 +1,145 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
KEY_BATCH_DATA = "batch_data"
|
||||||
|
MAX_DYNAMIC_OUTPUTS = 32
|
||||||
|
|
||||||
|
|
||||||
|
class AnyType(str):
|
||||||
|
"""Universal connector type that matches any ComfyUI type."""
|
||||||
|
def __ne__(self, __value: object) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
any_type = AnyType("*")
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
from server import PromptServer
|
||||||
|
from aiohttp import web
|
||||||
|
except ImportError:
|
||||||
|
PromptServer = None
|
||||||
|
|
||||||
|
|
||||||
|
def to_float(val: Any) -> float:
|
||||||
|
try:
|
||||||
|
return float(val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def to_int(val: Any) -> int:
|
||||||
|
try:
|
||||||
|
return int(float(val))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_batch_item(data: dict[str, Any], sequence_number: int) -> dict[str, Any]:
|
||||||
|
"""Resolve batch item by sequence_number field, falling back to array index."""
|
||||||
|
if KEY_BATCH_DATA in data and isinstance(data[KEY_BATCH_DATA], list) and len(data[KEY_BATCH_DATA]) > 0:
|
||||||
|
# Search by sequence_number field first
|
||||||
|
for item in data[KEY_BATCH_DATA]:
|
||||||
|
if int(item.get("sequence_number", 0)) == sequence_number:
|
||||||
|
return item
|
||||||
|
# Fallback to array index
|
||||||
|
idx = max(0, min(sequence_number - 1, len(data[KEY_BATCH_DATA]) - 1))
|
||||||
|
logger.warning(f"No item with sequence_number={sequence_number}, falling back to index {idx}")
|
||||||
|
return data[KEY_BATCH_DATA][idx]
|
||||||
|
return data
|
||||||
|
|
||||||
# --- Shared Helper ---
|
# --- Shared Helper ---
|
||||||
def read_json_data(json_path):
|
def read_json_data(json_path: str) -> dict[str, Any]:
|
||||||
if not os.path.exists(json_path):
|
if not os.path.exists(json_path):
|
||||||
print(f"[JSON Loader] Warning: File not found at {json_path}")
|
logger.warning(f"File not found at {json_path}")
|
||||||
return {}
|
return {}
|
||||||
try:
|
try:
|
||||||
with open(json_path, 'r') as f:
|
with open(json_path, 'r') as f:
|
||||||
return json.load(f)
|
data = json.load(f)
|
||||||
except Exception as e:
|
except (json.JSONDecodeError, IOError) as e:
|
||||||
print(f"[JSON Loader] Error: {e}")
|
logger.warning(f"Error reading {json_path}: {e}")
|
||||||
return {}
|
return {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
logger.warning(f"Expected dict from {json_path}, got {type(data).__name__}")
|
||||||
|
return {}
|
||||||
|
return data
|
||||||
|
|
||||||
|
# --- API Route ---
|
||||||
|
if PromptServer is not None:
|
||||||
|
@PromptServer.instance.routes.get("/json_manager/get_keys")
|
||||||
|
async def get_keys_route(request):
|
||||||
|
json_path = request.query.get("path", "")
|
||||||
|
try:
|
||||||
|
seq = int(request.query.get("sequence_number", "1"))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
seq = 1
|
||||||
|
data = read_json_data(json_path)
|
||||||
|
target = get_batch_item(data, seq)
|
||||||
|
keys = []
|
||||||
|
types = []
|
||||||
|
if isinstance(target, dict):
|
||||||
|
for k, v in target.items():
|
||||||
|
keys.append(k)
|
||||||
|
if isinstance(v, bool):
|
||||||
|
types.append("STRING")
|
||||||
|
elif isinstance(v, int):
|
||||||
|
types.append("INT")
|
||||||
|
elif isinstance(v, float):
|
||||||
|
types.append("FLOAT")
|
||||||
|
else:
|
||||||
|
types.append("STRING")
|
||||||
|
return web.json_response({"keys": keys, "types": types})
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 0. DYNAMIC NODE
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
class JSONLoaderDynamic:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(s):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"json_path": ("STRING", {"default": "", "multiline": False}),
|
||||||
|
"sequence_number": ("INT", {"default": 1, "min": 1, "max": 9999}),
|
||||||
|
},
|
||||||
|
"optional": {
|
||||||
|
"output_keys": ("STRING", {"default": ""}),
|
||||||
|
"output_types": ("STRING", {"default": ""}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = tuple(any_type for _ in range(MAX_DYNAMIC_OUTPUTS))
|
||||||
|
RETURN_NAMES = tuple(f"output_{i}" for i in range(MAX_DYNAMIC_OUTPUTS))
|
||||||
|
FUNCTION = "load_dynamic"
|
||||||
|
CATEGORY = "utils/json"
|
||||||
|
OUTPUT_NODE = False
|
||||||
|
|
||||||
|
def load_dynamic(self, json_path, sequence_number, output_keys="", output_types=""):
|
||||||
|
data = read_json_data(json_path)
|
||||||
|
target = get_batch_item(data, sequence_number)
|
||||||
|
|
||||||
|
keys = [k.strip() for k in output_keys.split(",") if k.strip()] if output_keys else []
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for key in keys:
|
||||||
|
val = target.get(key, "")
|
||||||
|
if isinstance(val, bool):
|
||||||
|
results.append(str(val).lower())
|
||||||
|
elif isinstance(val, int):
|
||||||
|
results.append(val)
|
||||||
|
elif isinstance(val, float):
|
||||||
|
results.append(val)
|
||||||
|
else:
|
||||||
|
results.append(str(val))
|
||||||
|
|
||||||
|
# Pad to MAX_DYNAMIC_OUTPUTS
|
||||||
|
while len(results) < MAX_DYNAMIC_OUTPUTS:
|
||||||
|
results.append("")
|
||||||
|
|
||||||
|
return tuple(results)
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 1. STANDARD NODES (Single File)
|
# 1. STANDARD NODES (Single File)
|
||||||
@@ -47,13 +175,6 @@ class JSONLoaderStandard:
|
|||||||
|
|
||||||
def load_standard(self, json_path):
|
def load_standard(self, json_path):
|
||||||
data = read_json_data(json_path)
|
data = read_json_data(json_path)
|
||||||
def to_float(val):
|
|
||||||
try: return float(val)
|
|
||||||
except: return 0.0
|
|
||||||
def to_int(val):
|
|
||||||
try: return int(float(val))
|
|
||||||
except: return 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
str(data.get("general_prompt", "")), str(data.get("general_negative", "")),
|
str(data.get("general_prompt", "")), str(data.get("general_negative", "")),
|
||||||
str(data.get("current_prompt", "")), str(data.get("negative", "")),
|
str(data.get("current_prompt", "")), str(data.get("negative", "")),
|
||||||
@@ -74,20 +195,13 @@ class JSONLoaderVACE:
|
|||||||
|
|
||||||
def load_vace(self, json_path):
|
def load_vace(self, json_path):
|
||||||
data = read_json_data(json_path)
|
data = read_json_data(json_path)
|
||||||
def to_float(val):
|
|
||||||
try: return float(val)
|
|
||||||
except: return 0.0
|
|
||||||
def to_int(val):
|
|
||||||
try: return int(float(val))
|
|
||||||
except: return 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
str(data.get("general_prompt", "")), str(data.get("general_negative", "")),
|
str(data.get("general_prompt", "")), str(data.get("general_negative", "")),
|
||||||
str(data.get("current_prompt", "")), str(data.get("negative", "")),
|
str(data.get("current_prompt", "")), str(data.get("negative", "")),
|
||||||
str(data.get("camera", "")), to_float(data.get("flf", 0.0)),
|
str(data.get("camera", "")), to_float(data.get("flf", 0.0)),
|
||||||
to_int(data.get("seed", 0)),
|
to_int(data.get("seed", 0)),
|
||||||
to_int(data.get("frame_to_skip", 81)), to_int(data.get("input_a_frames", 0)),
|
to_int(data.get("frame_to_skip", 81)), to_int(data.get("input_a_frames", 16)),
|
||||||
to_int(data.get("input_b_frames", 0)), str(data.get("reference path", "")),
|
to_int(data.get("input_b_frames", 16)), str(data.get("reference path", "")),
|
||||||
to_int(data.get("reference switch", 1)), to_int(data.get("vace schedule", 1)),
|
to_int(data.get("reference switch", 1)), to_int(data.get("vace schedule", 1)),
|
||||||
str(data.get("video file path", "")), str(data.get("reference image path", ""))
|
str(data.get("video file path", "")), str(data.get("reference image path", ""))
|
||||||
)
|
)
|
||||||
@@ -107,10 +221,7 @@ class JSONLoaderBatchLoRA:
|
|||||||
|
|
||||||
def load_batch_loras(self, json_path, sequence_number):
|
def load_batch_loras(self, json_path, sequence_number):
|
||||||
data = read_json_data(json_path)
|
data = read_json_data(json_path)
|
||||||
target_data = data
|
target_data = get_batch_item(data, sequence_number)
|
||||||
if "batch_data" in data and isinstance(data["batch_data"], list) and len(data["batch_data"]) > 0:
|
|
||||||
idx = (sequence_number - 1) % len(data["batch_data"])
|
|
||||||
target_data = data["batch_data"][idx]
|
|
||||||
return (
|
return (
|
||||||
str(target_data.get("lora 1 high", "")), str(target_data.get("lora 1 low", "")),
|
str(target_data.get("lora 1 high", "")), str(target_data.get("lora 1 low", "")),
|
||||||
str(target_data.get("lora 2 high", "")), str(target_data.get("lora 2 low", "")),
|
str(target_data.get("lora 2 high", "")), str(target_data.get("lora 2 low", "")),
|
||||||
@@ -128,16 +239,8 @@ class JSONLoaderBatchI2V:
|
|||||||
|
|
||||||
def load_batch_i2v(self, json_path, sequence_number):
|
def load_batch_i2v(self, json_path, sequence_number):
|
||||||
data = read_json_data(json_path)
|
data = read_json_data(json_path)
|
||||||
target_data = data
|
target_data = get_batch_item(data, sequence_number)
|
||||||
if "batch_data" in data and isinstance(data["batch_data"], list) and len(data["batch_data"]) > 0:
|
|
||||||
idx = (sequence_number - 1) % len(data["batch_data"])
|
|
||||||
target_data = data["batch_data"][idx]
|
|
||||||
def to_float(val):
|
|
||||||
try: return float(val)
|
|
||||||
except: return 0.0
|
|
||||||
def to_int(val):
|
|
||||||
try: return int(float(val))
|
|
||||||
except: return 0
|
|
||||||
return (
|
return (
|
||||||
str(target_data.get("general_prompt", "")), str(target_data.get("general_negative", "")),
|
str(target_data.get("general_prompt", "")), str(target_data.get("general_negative", "")),
|
||||||
str(target_data.get("current_prompt", "")), str(target_data.get("negative", "")),
|
str(target_data.get("current_prompt", "")), str(target_data.get("negative", "")),
|
||||||
@@ -157,22 +260,14 @@ class JSONLoaderBatchVACE:
|
|||||||
|
|
||||||
def load_batch_vace(self, json_path, sequence_number):
|
def load_batch_vace(self, json_path, sequence_number):
|
||||||
data = read_json_data(json_path)
|
data = read_json_data(json_path)
|
||||||
target_data = data
|
target_data = get_batch_item(data, sequence_number)
|
||||||
if "batch_data" in data and isinstance(data["batch_data"], list) and len(data["batch_data"]) > 0:
|
|
||||||
idx = (sequence_number - 1) % len(data["batch_data"])
|
|
||||||
target_data = data["batch_data"][idx]
|
|
||||||
def to_float(val):
|
|
||||||
try: return float(val)
|
|
||||||
except: return 0.0
|
|
||||||
def to_int(val):
|
|
||||||
try: return int(float(val))
|
|
||||||
except: return 0
|
|
||||||
return (
|
return (
|
||||||
str(target_data.get("general_prompt", "")), str(target_data.get("general_negative", "")),
|
str(target_data.get("general_prompt", "")), str(target_data.get("general_negative", "")),
|
||||||
str(target_data.get("current_prompt", "")), str(target_data.get("negative", "")),
|
str(target_data.get("current_prompt", "")), str(target_data.get("negative", "")),
|
||||||
str(target_data.get("camera", "")), to_float(target_data.get("flf", 0.0)),
|
str(target_data.get("camera", "")), to_float(target_data.get("flf", 0.0)),
|
||||||
to_int(target_data.get("seed", 0)), to_int(target_data.get("frame_to_skip", 81)),
|
to_int(target_data.get("seed", 0)), to_int(target_data.get("frame_to_skip", 81)),
|
||||||
to_int(target_data.get("input_a_frames", 0)), to_int(target_data.get("input_b_frames", 0)),
|
to_int(target_data.get("input_a_frames", 16)), to_int(target_data.get("input_b_frames", 16)),
|
||||||
str(target_data.get("reference path", "")), to_int(target_data.get("reference switch", 1)),
|
str(target_data.get("reference path", "")), to_int(target_data.get("reference switch", 1)),
|
||||||
to_int(target_data.get("vace schedule", 1)), str(target_data.get("video file path", "")),
|
to_int(target_data.get("vace schedule", 1)), str(target_data.get("video file path", "")),
|
||||||
str(target_data.get("reference image path", ""))
|
str(target_data.get("reference image path", ""))
|
||||||
@@ -199,10 +294,7 @@ class JSONLoaderCustom1:
|
|||||||
|
|
||||||
def load_custom(self, json_path, sequence_number, key_1=""):
|
def load_custom(self, json_path, sequence_number, key_1=""):
|
||||||
data = read_json_data(json_path)
|
data = read_json_data(json_path)
|
||||||
target_data = data
|
target_data = get_batch_item(data, sequence_number)
|
||||||
if "batch_data" in data and isinstance(data["batch_data"], list) and len(data["batch_data"]) > 0:
|
|
||||||
idx = (sequence_number - 1) % len(data["batch_data"])
|
|
||||||
target_data = data["batch_data"][idx]
|
|
||||||
return (str(target_data.get(key_1, "")),)
|
return (str(target_data.get(key_1, "")),)
|
||||||
|
|
||||||
class JSONLoaderCustom3:
|
class JSONLoaderCustom3:
|
||||||
@@ -226,10 +318,7 @@ class JSONLoaderCustom3:
|
|||||||
|
|
||||||
def load_custom(self, json_path, sequence_number, key_1="", key_2="", key_3=""):
|
def load_custom(self, json_path, sequence_number, key_1="", key_2="", key_3=""):
|
||||||
data = read_json_data(json_path)
|
data = read_json_data(json_path)
|
||||||
target_data = data
|
target_data = get_batch_item(data, sequence_number)
|
||||||
if "batch_data" in data and isinstance(data["batch_data"], list) and len(data["batch_data"]) > 0:
|
|
||||||
idx = (sequence_number - 1) % len(data["batch_data"])
|
|
||||||
target_data = data["batch_data"][idx]
|
|
||||||
return (
|
return (
|
||||||
str(target_data.get(key_1, "")),
|
str(target_data.get(key_1, "")),
|
||||||
str(target_data.get(key_2, "")),
|
str(target_data.get(key_2, "")),
|
||||||
@@ -260,10 +349,7 @@ class JSONLoaderCustom6:
|
|||||||
|
|
||||||
def load_custom(self, json_path, sequence_number, key_1="", key_2="", key_3="", key_4="", key_5="", key_6=""):
|
def load_custom(self, json_path, sequence_number, key_1="", key_2="", key_3="", key_4="", key_5="", key_6=""):
|
||||||
data = read_json_data(json_path)
|
data = read_json_data(json_path)
|
||||||
target_data = data
|
target_data = get_batch_item(data, sequence_number)
|
||||||
if "batch_data" in data and isinstance(data["batch_data"], list) and len(data["batch_data"]) > 0:
|
|
||||||
idx = (sequence_number - 1) % len(data["batch_data"])
|
|
||||||
target_data = data["batch_data"][idx]
|
|
||||||
return (
|
return (
|
||||||
str(target_data.get(key_1, "")), str(target_data.get(key_2, "")),
|
str(target_data.get(key_1, "")), str(target_data.get(key_2, "")),
|
||||||
str(target_data.get(key_3, "")), str(target_data.get(key_4, "")),
|
str(target_data.get(key_3, "")), str(target_data.get(key_4, "")),
|
||||||
@@ -272,6 +358,7 @@ class JSONLoaderCustom6:
|
|||||||
|
|
||||||
# --- Mappings ---
|
# --- Mappings ---
|
||||||
NODE_CLASS_MAPPINGS = {
|
NODE_CLASS_MAPPINGS = {
|
||||||
|
"JSONLoaderDynamic": JSONLoaderDynamic,
|
||||||
"JSONLoaderLoRA": JSONLoaderLoRA,
|
"JSONLoaderLoRA": JSONLoaderLoRA,
|
||||||
"JSONLoaderStandard": JSONLoaderStandard,
|
"JSONLoaderStandard": JSONLoaderStandard,
|
||||||
"JSONLoaderVACE": JSONLoaderVACE,
|
"JSONLoaderVACE": JSONLoaderVACE,
|
||||||
@@ -284,6 +371,7 @@ NODE_CLASS_MAPPINGS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
|
"JSONLoaderDynamic": "JSON Loader (Dynamic)",
|
||||||
"JSONLoaderLoRA": "JSON Loader (LoRAs Only)",
|
"JSONLoaderLoRA": "JSON Loader (LoRAs Only)",
|
||||||
"JSONLoaderStandard": "JSON Loader (Standard/I2V)",
|
"JSONLoaderStandard": "JSON Loader (Standard/I2V)",
|
||||||
"JSONLoaderVACE": "JSON Loader (VACE Full)",
|
"JSONLoaderVACE": "JSON Loader (VACE Full)",
|
||||||
|
|||||||
474
tab_batch.py
474
tab_batch.py
@@ -1,8 +1,126 @@
|
|||||||
import streamlit as st
|
import streamlit as st
|
||||||
import random
|
import random
|
||||||
from utils import DEFAULTS, save_json, load_json
|
import copy
|
||||||
|
from pathlib import Path
|
||||||
|
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
|
||||||
|
|
||||||
|
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"}
|
||||||
|
|
||||||
|
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):
|
||||||
|
"""Render the mass update UI section."""
|
||||||
|
with st.expander("🔄 Mass Update", expanded=False):
|
||||||
|
if len(batch_list) < 2:
|
||||||
|
st.info("Need at least 2 sequences for mass update.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Source sequence selector
|
||||||
|
source_idx = st.selectbox(
|
||||||
|
"Copy from sequence:",
|
||||||
|
range(len(batch_list)),
|
||||||
|
format_func=lambda i: format_seq_label(batch_list[i].get('sequence_number', i+1)),
|
||||||
|
key=f"{key_prefix}_mass_src"
|
||||||
|
)
|
||||||
|
source_seq = batch_list[source_idx]
|
||||||
|
|
||||||
|
# Field multi-select (exclude sequence_number)
|
||||||
|
available_keys = [k for k in source_seq.keys() if k != "sequence_number"]
|
||||||
|
selected_keys = st.multiselect("Fields to copy:", available_keys, key=f"{key_prefix}_mass_fields")
|
||||||
|
|
||||||
|
if not selected_keys:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Target sequence checkboxes
|
||||||
|
st.write("Apply to:")
|
||||||
|
select_all = st.checkbox("Select All", key=f"{key_prefix}_mass_all")
|
||||||
|
|
||||||
|
target_indices = []
|
||||||
|
target_cols = st.columns(min(4, len(batch_list) - 1)) if len(batch_list) > 1 else [st]
|
||||||
|
col_idx = 0
|
||||||
|
for i, seq in enumerate(batch_list):
|
||||||
|
if i == source_idx:
|
||||||
|
continue
|
||||||
|
seq_num = seq.get("sequence_number", i + 1)
|
||||||
|
with target_cols[col_idx % len(target_cols)]:
|
||||||
|
checked = select_all or st.checkbox(format_seq_label(seq_num), key=f"{key_prefix}_mass_t{i}")
|
||||||
|
if checked:
|
||||||
|
target_indices.append(i)
|
||||||
|
col_idx += 1
|
||||||
|
|
||||||
|
# Preview
|
||||||
|
if target_indices and selected_keys:
|
||||||
|
with st.expander("Preview changes", expanded=True):
|
||||||
|
for key in selected_keys:
|
||||||
|
val = source_seq.get(key, "")
|
||||||
|
display_val = str(val)[:100] + "..." if len(str(val)) > 100 else str(val)
|
||||||
|
st.caption(f"**{key}**: {display_val}")
|
||||||
|
|
||||||
|
# Apply button
|
||||||
|
if st.button("Apply Changes", type="primary", key=f"{key_prefix}_mass_apply"):
|
||||||
|
for i in target_indices:
|
||||||
|
for key in selected_keys:
|
||||||
|
batch_list[i][key] = copy.deepcopy(source_seq.get(key))
|
||||||
|
|
||||||
|
# Save with history snapshot
|
||||||
|
data[KEY_BATCH_DATA] = batch_list
|
||||||
|
htree = HistoryTree(data.get(KEY_HISTORY_TREE, {}))
|
||||||
|
snapshot_payload = copy.deepcopy(data)
|
||||||
|
if KEY_HISTORY_TREE in snapshot_payload:
|
||||||
|
del snapshot_payload[KEY_HISTORY_TREE]
|
||||||
|
htree.commit(snapshot_payload, f"Mass update: {', '.join(selected_keys)}")
|
||||||
|
data[KEY_HISTORY_TREE] = htree.to_dict()
|
||||||
|
save_json(file_path, data)
|
||||||
|
st.session_state.data_cache = data
|
||||||
|
st.session_state.ui_reset_token += 1
|
||||||
|
st.toast(f"Updated {len(target_indices)} sequences", icon="✅")
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
|
||||||
def create_batch_callback(original_filename, current_data, current_dir):
|
def create_batch_callback(original_filename, current_data, current_dir):
|
||||||
new_name = f"batch_{original_filename}"
|
new_name = f"batch_{original_filename}"
|
||||||
new_path = current_dir / new_name
|
new_path = current_dir / new_name
|
||||||
@@ -12,15 +130,15 @@ def create_batch_callback(original_filename, current_data, current_dir):
|
|||||||
return
|
return
|
||||||
|
|
||||||
first_item = current_data.copy()
|
first_item = current_data.copy()
|
||||||
if "prompt_history" in first_item: del first_item["prompt_history"]
|
if KEY_PROMPT_HISTORY in first_item: del first_item[KEY_PROMPT_HISTORY]
|
||||||
if "history_tree" in first_item: del first_item["history_tree"]
|
if KEY_HISTORY_TREE in first_item: del first_item[KEY_HISTORY_TREE]
|
||||||
|
|
||||||
first_item["sequence_number"] = 1
|
first_item[KEY_SEQUENCE_NUMBER] = 1
|
||||||
|
|
||||||
new_data = {
|
new_data = {
|
||||||
"batch_data": [first_item],
|
KEY_BATCH_DATA: [first_item],
|
||||||
"history_tree": {},
|
KEY_HISTORY_TREE: {},
|
||||||
"prompt_history": []
|
KEY_PROMPT_HISTORY: []
|
||||||
}
|
}
|
||||||
|
|
||||||
save_json(new_path, new_data)
|
save_json(new_path, new_data)
|
||||||
@@ -29,7 +147,7 @@ def create_batch_callback(original_filename, current_data, current_dir):
|
|||||||
|
|
||||||
|
|
||||||
def render_batch_processor(data, file_path, json_files, current_dir, selected_file_name):
|
def render_batch_processor(data, file_path, json_files, current_dir, selected_file_name):
|
||||||
is_batch_file = "batch_data" in data or isinstance(data, list)
|
is_batch_file = KEY_BATCH_DATA in data or isinstance(data, list)
|
||||||
|
|
||||||
if not is_batch_file:
|
if not is_batch_file:
|
||||||
st.warning("This is a Single file. To use Batch mode, create a copy.")
|
st.warning("This is a Single file. To use Batch mode, create a copy.")
|
||||||
@@ -39,7 +157,7 @@ def render_batch_processor(data, file_path, json_files, current_dir, selected_fi
|
|||||||
if 'restored_indicator' in st.session_state and st.session_state.restored_indicator:
|
if 'restored_indicator' in st.session_state and st.session_state.restored_indicator:
|
||||||
st.info(f"📍 Editing Restored Version: **{st.session_state.restored_indicator}**")
|
st.info(f"📍 Editing Restored Version: **{st.session_state.restored_indicator}**")
|
||||||
|
|
||||||
batch_list = data.get("batch_data", [])
|
batch_list = data.get(KEY_BATCH_DATA, [])
|
||||||
|
|
||||||
# --- ADD NEW SEQUENCE AREA ---
|
# --- ADD NEW SEQUENCE AREA ---
|
||||||
st.subheader("Add New Sequence")
|
st.subheader("Add New Sequence")
|
||||||
@@ -52,23 +170,34 @@ def render_batch_processor(data, file_path, json_files, current_dir, selected_fi
|
|||||||
src_data, _ = load_json(current_dir / src_name)
|
src_data, _ = load_json(current_dir / src_name)
|
||||||
|
|
||||||
with ac2:
|
with ac2:
|
||||||
src_hist = src_data.get("prompt_history", [])
|
src_batch = src_data.get(KEY_BATCH_DATA, [])
|
||||||
h_opts = [f"#{i+1}: {h.get('note', 'No Note')} ({h.get('prompt', '')[:15]}...)" for i, h in enumerate(src_hist)] if src_hist else []
|
if src_batch:
|
||||||
sel_hist = st.selectbox("History Entry (Legacy):", h_opts, key="batch_src_hist")
|
seq_opts = list(range(len(src_batch)))
|
||||||
|
sel_seq_idx = st.selectbox(
|
||||||
|
"Source Sequence:",
|
||||||
|
seq_opts,
|
||||||
|
format_func=lambda i: format_seq_label(src_batch[i].get(KEY_SEQUENCE_NUMBER, i + 1)),
|
||||||
|
key="batch_src_seq"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
st.caption("Single file (no sequences)")
|
||||||
|
sel_seq_idx = None
|
||||||
|
|
||||||
bc1, bc2, bc3 = st.columns(3)
|
bc1, bc2 = st.columns(2)
|
||||||
|
|
||||||
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 "sequence_number" in s: max_seq = max(max_seq, int(s["sequence_number"]))
|
sn = int(s.get(KEY_SEQUENCE_NUMBER, 0))
|
||||||
new_item["sequence_number"] = max_seq + 1
|
if not is_subsegment(sn):
|
||||||
|
max_seq = max(max_seq, sn)
|
||||||
|
new_item[KEY_SEQUENCE_NUMBER] = max_seq + 1
|
||||||
|
|
||||||
for k in ["prompt_history", "history_tree", "note", "loras"]:
|
for k in [KEY_PROMPT_HISTORY, KEY_HISTORY_TREE, "note", "loras"]:
|
||||||
if k in new_item: del new_item[k]
|
if k in new_item: del new_item[k]
|
||||||
|
|
||||||
batch_list.append(new_item)
|
batch_list.append(new_item)
|
||||||
data["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
|
||||||
st.rerun()
|
st.rerun()
|
||||||
@@ -76,102 +205,158 @@ def render_batch_processor(data, file_path, json_files, current_dir, selected_fi
|
|||||||
if bc1.button("➕ Add Empty", use_container_width=True):
|
if bc1.button("➕ Add Empty", use_container_width=True):
|
||||||
add_sequence(DEFAULTS.copy())
|
add_sequence(DEFAULTS.copy())
|
||||||
|
|
||||||
if bc2.button("➕ From File", use_container_width=True, help=f"Copy {src_name}"):
|
if bc2.button("➕ From Source", use_container_width=True, help=f"Import from {src_name}"):
|
||||||
item = DEFAULTS.copy()
|
item = DEFAULTS.copy()
|
||||||
flat = src_data["batch_data"][0] if "batch_data" in src_data and src_data["batch_data"] else src_data
|
if src_batch and sel_seq_idx is not None:
|
||||||
item.update(flat)
|
item.update(src_batch[sel_seq_idx])
|
||||||
add_sequence(item)
|
else:
|
||||||
|
item.update(src_data)
|
||||||
if bc3.button("➕ From History", use_container_width=True, disabled=not src_hist):
|
|
||||||
if sel_hist:
|
|
||||||
idx = int(sel_hist.split(":")[0].replace("#", "")) - 1
|
|
||||||
item = DEFAULTS.copy()
|
|
||||||
h_item = src_hist[idx]
|
|
||||||
item.update(h_item)
|
|
||||||
if "loras" in h_item and isinstance(h_item["loras"], dict):
|
|
||||||
item.update(h_item["loras"])
|
|
||||||
add_sequence(item)
|
add_sequence(item)
|
||||||
|
|
||||||
# --- 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 ---
|
||||||
|
ui_reset_token = st.session_state.get("ui_reset_token", 0)
|
||||||
|
_render_mass_update(batch_list, data, file_path, f"{selected_file_name}_v{ui_reset_token}")
|
||||||
|
|
||||||
|
# Updated LoRA keys to match new logic
|
||||||
lora_keys = ["lora 1 high", "lora 1 low", "lora 2 high", "lora 2 low", "lora 3 high", "lora 3 low"]
|
lora_keys = ["lora 1 high", "lora 1 low", "lora 2 high", "lora 2 low", "lora 3 high", "lora 3 low"]
|
||||||
standard_keys = {
|
standard_keys = {
|
||||||
"general_prompt", "general_negative", "current_prompt", "negative", "prompt", "seed",
|
"general_prompt", "general_negative", "current_prompt", "negative", "prompt", "seed", "cfg",
|
||||||
"camera", "flf", "sequence_number"
|
"camera", "flf", KEY_SEQUENCE_NUMBER
|
||||||
}
|
}
|
||||||
standard_keys.update(lora_keys)
|
standard_keys.update(lora_keys)
|
||||||
standard_keys.update([
|
standard_keys.update([
|
||||||
"frame_to_skip", "input_a_frames", "input_b_frames", "reference switch", "vace schedule",
|
"frame_to_skip", "end_frame", "transition", "vace_length",
|
||||||
|
"input_a_frames", "input_b_frames", "reference switch", "vace schedule",
|
||||||
"reference path", "video file path", "reference image path", "flf image path"
|
"reference path", "video file path", "reference image path", "flf image path"
|
||||||
])
|
])
|
||||||
|
|
||||||
|
VACE_MODES = [
|
||||||
|
"End Extend", "Pre Extend", "Middle Extend", "Edge Extend",
|
||||||
|
"Join Extend", "Bidirectional Extend", "Frame Interpolation",
|
||||||
|
"Replace/Inpaint", "Video Inpaint", "Keyframe",
|
||||||
|
]
|
||||||
|
VACE_FORMULAS = [
|
||||||
|
"base + A", # 0 End Extend
|
||||||
|
"base + B", # 1 Pre Extend
|
||||||
|
"base + A + B", # 2 Middle Extend
|
||||||
|
"base + A + B", # 3 Edge Extend
|
||||||
|
"base + A + B", # 4 Join Extend
|
||||||
|
"base + A + B", # 5 Bidirectional
|
||||||
|
"(B-1) * step", # 6 Frame Interpolation
|
||||||
|
"snap(source)", # 7 Replace/Inpaint
|
||||||
|
"snap(source)", # 8 Video Inpaint
|
||||||
|
"base + A + B", # 9 Keyframe
|
||||||
|
]
|
||||||
|
|
||||||
for i, seq in enumerate(batch_list):
|
for i, seq in enumerate(batch_list):
|
||||||
seq_num = seq.get("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):
|
||||||
# --- NEW: ACTION ROW WITH CLONING ---
|
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 ---
|
||||||
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])
|
||||||
|
|
||||||
# 1. Copy Source
|
# 1. Copy Source
|
||||||
with act_c1:
|
with act_c1:
|
||||||
if st.button(f"📥 Copy {src_name}", key=f"{prefix}_copy", use_container_width=True):
|
if st.button(f"📥 Copy {src_name}", key=f"{prefix}_copy", use_container_width=True):
|
||||||
item = DEFAULTS.copy()
|
item = DEFAULTS.copy()
|
||||||
flat = src_data["batch_data"][0] if "batch_data" in src_data and src_data["batch_data"] else src_data
|
if src_batch and sel_seq_idx is not None:
|
||||||
item.update(flat)
|
item.update(src_batch[sel_seq_idx])
|
||||||
item["sequence_number"] = seq_num
|
else:
|
||||||
for k in ["prompt_history", "history_tree"]:
|
item.update(src_data)
|
||||||
|
item[KEY_SEQUENCE_NUMBER] = seq_num
|
||||||
|
for k in [KEY_PROMPT_HISTORY, KEY_HISTORY_TREE]:
|
||||||
if k in item: del item[k]
|
if k in item: del item[k]
|
||||||
batch_list[i] = item
|
batch_list[i] = item
|
||||||
data["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
|
||||||
st.toast("Copied!", icon="📥")
|
st.toast("Copied!", icon="📥")
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
# 2. Cloning Tools (Next / End)
|
# 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)
|
||||||
|
|
||||||
# Clone Next
|
|
||||||
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 = seq.copy()
|
new_seq = copy.deepcopy(seq)
|
||||||
# Calculate new max sequence number
|
|
||||||
max_sn = 0
|
max_sn = 0
|
||||||
for s in batch_list: max_sn = max(max_sn, int(s.get("sequence_number", 0)))
|
for s in batch_list:
|
||||||
new_seq["sequence_number"] = max_sn + 1
|
sn = int(s.get(KEY_SEQUENCE_NUMBER, 0))
|
||||||
|
if not is_subsegment(sn):
|
||||||
batch_list.insert(i + 1, new_seq)
|
max_sn = max(max_sn, sn)
|
||||||
data["batch_data"] = batch_list
|
new_seq[KEY_SEQUENCE_NUMBER] = max_sn + 1
|
||||||
|
if not is_subsegment(seq_num):
|
||||||
|
insert_pos = find_insert_position(batch_list, i, int(seq_num))
|
||||||
|
else:
|
||||||
|
insert_pos = i + 1
|
||||||
|
batch_list.insert(insert_pos, new_seq)
|
||||||
|
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
|
||||||
st.toast("Cloned to Next!", icon="👯")
|
st.toast("Cloned to Next!", icon="👯")
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
# Clone End
|
|
||||||
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 = seq.copy()
|
new_seq = copy.deepcopy(seq)
|
||||||
max_sn = 0
|
max_sn = 0
|
||||||
for s in batch_list: max_sn = max(max_sn, int(s.get("sequence_number", 0)))
|
for s in batch_list:
|
||||||
new_seq["sequence_number"] = max_sn + 1
|
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
|
||||||
batch_list.append(new_seq)
|
batch_list.append(new_seq)
|
||||||
data["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
|
||||||
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):
|
||||||
single_data = seq.copy()
|
single_data = seq.copy()
|
||||||
single_data["prompt_history"] = data.get("prompt_history", [])
|
single_data[KEY_PROMPT_HISTORY] = data.get(KEY_PROMPT_HISTORY, [])
|
||||||
single_data["history_tree"] = data.get("history_tree", {})
|
single_data[KEY_HISTORY_TREE] = data.get(KEY_HISTORY_TREE, {})
|
||||||
if "sequence_number" in single_data: del single_data["sequence_number"]
|
if KEY_SEQUENCE_NUMBER in single_data: del single_data[KEY_SEQUENCE_NUMBER]
|
||||||
save_json(file_path, single_data)
|
save_json(file_path, single_data)
|
||||||
|
st.session_state.data_cache = single_data
|
||||||
|
st.session_state.ui_reset_token += 1
|
||||||
st.toast("Converted to Single!", icon="✅")
|
st.toast("Converted to Single!", icon="✅")
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
@@ -179,8 +364,9 @@ def render_batch_processor(data, file_path, json_files, current_dir, selected_fi
|
|||||||
with act_c4:
|
with act_c4:
|
||||||
if st.button("🗑️", key=f"{prefix}_del", use_container_width=True):
|
if st.button("🗑️", key=f"{prefix}_del", use_container_width=True):
|
||||||
batch_list.pop(i)
|
batch_list.pop(i)
|
||||||
data["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.rerun()
|
st.rerun()
|
||||||
|
|
||||||
st.markdown("---")
|
st.markdown("---")
|
||||||
@@ -188,11 +374,12 @@ def render_batch_processor(data, file_path, json_files, current_dir, selected_fi
|
|||||||
with c1:
|
with c1:
|
||||||
seq["general_prompt"] = st.text_area("General Prompt", value=seq.get("general_prompt", ""), height=60, key=f"{prefix}_gp")
|
seq["general_prompt"] = st.text_area("General Prompt", value=seq.get("general_prompt", ""), height=60, key=f"{prefix}_gp")
|
||||||
seq["general_negative"] = st.text_area("General Negative", value=seq.get("general_negative", ""), height=60, key=f"{prefix}_gn")
|
seq["general_negative"] = st.text_area("General Negative", value=seq.get("general_negative", ""), height=60, key=f"{prefix}_gn")
|
||||||
seq["current_prompt"] = st.text_area("Specific Prompt", value=seq.get("current_prompt", ""), height=100, key=f"{prefix}_sp")
|
seq["current_prompt"] = st.text_area("Specific Prompt", value=seq.get("current_prompt", ""), height=300, key=f"{prefix}_sp")
|
||||||
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["sequence_number"] = st.number_input("Seq Num", 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"
|
||||||
@@ -207,36 +394,139 @@ def render_batch_processor(data, file_path, json_files, current_dir, selected_fi
|
|||||||
val = st.number_input("Seed", value=current_seed, key=seed_key)
|
val = st.number_input("Seed", value=current_seed, key=seed_key)
|
||||||
seq["seed"] = val
|
seq["seed"] = val
|
||||||
|
|
||||||
|
seq["cfg"] = st.number_input("CFG", value=float(seq.get("cfg", DEFAULTS["cfg"])), step=0.5, format="%.1f", key=f"{prefix}_cfg")
|
||||||
seq["camera"] = st.text_input("Camera", value=seq.get("camera", ""), key=f"{prefix}_cam")
|
seq["camera"] = st.text_input("Camera", value=seq.get("camera", ""), key=f"{prefix}_cam")
|
||||||
seq["flf"] = st.text_input("FLF", value=str(seq.get("flf", DEFAULTS["flf"])), key=f"{prefix}_flf")
|
seq["flf"] = st.text_input("FLF", value=str(seq.get("flf", DEFAULTS["flf"])), key=f"{prefix}_flf")
|
||||||
|
|
||||||
if "video file path" in seq or "vace" in selected_file_name:
|
seq["end_frame"] = st.number_input("End Frame", value=int(seq.get("end_frame", 0)), key=f"{prefix}_ef")
|
||||||
seq["video file path"] = st.text_input("Video Path", value=seq.get("video file path", ""), key=f"{prefix}_vid")
|
seq["video file path"] = st.text_input("Video File Path", value=seq.get("video file path", ""), key=f"{prefix}_vid")
|
||||||
|
for img_label, img_key, img_suffix in [
|
||||||
|
("Reference Image Path", "reference image path", "rip"),
|
||||||
|
("Reference Path", "reference path", "rp"),
|
||||||
|
("FLF Image Path", "flf image path", "flfi"),
|
||||||
|
]:
|
||||||
|
img_col, prev_col = st.columns([5, 1])
|
||||||
|
seq[img_key] = img_col.text_input(img_label, value=seq.get(img_key, ""), key=f"{prefix}_{img_suffix}")
|
||||||
|
img_path = Path(seq[img_key]) if seq[img_key] else None
|
||||||
|
if img_path and img_path.exists() and img_path.suffix.lower() in IMAGE_EXTENSIONS:
|
||||||
|
with prev_col.popover("👁"):
|
||||||
|
st.image(str(img_path), use_container_width=True)
|
||||||
with st.expander("VACE Settings"):
|
with st.expander("VACE Settings"):
|
||||||
seq["frame_to_skip"] = st.number_input("Skip", value=int(seq.get("frame_to_skip", 81)), key=f"{prefix}_fts")
|
fts_col, fts_btn = st.columns([3, 1])
|
||||||
seq["input_a_frames"] = st.number_input("In A", value=int(seq.get("input_a_frames", 0)), key=f"{prefix}_ia")
|
saved_fts_key = f"{prefix}_fts_saved"
|
||||||
seq["input_b_frames"] = st.number_input("In B", value=int(seq.get("input_b_frames", 0)), key=f"{prefix}_ib")
|
if saved_fts_key not in st.session_state:
|
||||||
seq["reference switch"] = st.number_input("Switch", value=int(seq.get("reference switch", 1)), key=f"{prefix}_rsw")
|
st.session_state[saved_fts_key] = int(seq.get("frame_to_skip", 81))
|
||||||
seq["vace schedule"] = st.number_input("Sched", value=int(seq.get("vace schedule", 1)), key=f"{prefix}_vsc")
|
old_fts = st.session_state[saved_fts_key]
|
||||||
seq["reference path"] = st.text_input("Ref Path", value=seq.get("reference path", ""), key=f"{prefix}_rp")
|
seq["frame_to_skip"] = fts_col.number_input("Frame to Skip", value=old_fts, key=f"{prefix}_fts")
|
||||||
seq["reference image path"] = st.text_input("Ref Img", value=seq.get("reference image path", ""), key=f"{prefix}_rip")
|
delta = int(seq["frame_to_skip"]) - old_fts
|
||||||
|
delta_label = f"Shift ↓ ({delta:+d})" if delta != 0 else "Shift ↓ (0)"
|
||||||
|
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 delta != 0:
|
||||||
|
shifted = 0
|
||||||
|
for j in range(i + 1, len(batch_list)):
|
||||||
|
batch_list[j]["frame_to_skip"] = int(batch_list[j].get("frame_to_skip", 81)) + delta
|
||||||
|
shifted += 1
|
||||||
|
data[KEY_BATCH_DATA] = batch_list
|
||||||
|
save_json(file_path, data)
|
||||||
|
st.session_state.ui_reset_token += 1
|
||||||
|
st.toast(f"Shifted {shifted} sequences by {delta:+d}", icon="⏬")
|
||||||
|
st.rerun()
|
||||||
|
else:
|
||||||
|
st.toast("No change to shift", icon="ℹ️")
|
||||||
|
seq["transition"] = st.text_input("Transition", value=str(seq.get("transition", "1-2")), key=f"{prefix}_trans")
|
||||||
|
|
||||||
if "i2v" in selected_file_name and "vace" not in selected_file_name:
|
vs_col, vs_label = st.columns([3, 1])
|
||||||
seq["reference image path"] = st.text_input("Ref Img", value=seq.get("reference image path", ""), key=f"{prefix}_ri2")
|
sched_val = int(seq.get("vace schedule", 1))
|
||||||
seq["flf image path"] = st.text_input("FLF Img", value=seq.get("flf image path", ""), key=f"{prefix}_flfi")
|
seq["vace schedule"] = vs_col.number_input("VACE Schedule", value=sched_val, min_value=0, max_value=len(VACE_MODES) - 1, key=f"{prefix}_vsc")
|
||||||
|
mode_idx = int(seq["vace schedule"])
|
||||||
|
vs_label.write("")
|
||||||
|
vs_label.write("")
|
||||||
|
vs_label.caption(VACE_MODES[mode_idx])
|
||||||
|
|
||||||
# --- LoRA Settings (Reverted to plain text) ---
|
with st.popover("📋 Mode Reference"):
|
||||||
|
st.markdown(
|
||||||
|
"| # | Mode | Formula |\n"
|
||||||
|
"|:--|:-----|:--------|\n"
|
||||||
|
+ "\n".join(
|
||||||
|
f"| **{j}** | {VACE_MODES[j]} | `{VACE_FORMULAS[j]}` |"
|
||||||
|
for j in range(len(VACE_MODES))
|
||||||
|
)
|
||||||
|
+ "\n\n*All totals snapped to 4n+1 (1,5,9,…,49,…,81,…)*"
|
||||||
|
)
|
||||||
|
|
||||||
|
seq["input_a_frames"] = st.number_input("Input A Frames", value=int(seq.get("input_a_frames", 16)), key=f"{prefix}_ia")
|
||||||
|
seq["input_b_frames"] = st.number_input("Input B Frames", value=int(seq.get("input_b_frames", 16)), key=f"{prefix}_ib")
|
||||||
|
input_a = int(seq.get("input_a_frames", 16))
|
||||||
|
input_b = int(seq.get("input_b_frames", 16))
|
||||||
|
stored_total = int(seq.get("vace_length", 49))
|
||||||
|
# Reverse using same mode formula that was used to store
|
||||||
|
if mode_idx == 0:
|
||||||
|
base_length = max(stored_total - input_a, 1)
|
||||||
|
elif mode_idx == 1:
|
||||||
|
base_length = max(stored_total - input_b, 1)
|
||||||
|
else:
|
||||||
|
base_length = max(stored_total - input_a - input_b, 1)
|
||||||
|
vl_col, vl_out = st.columns([3, 1])
|
||||||
|
new_base = vl_col.number_input("VACE Length", value=base_length, min_value=1, key=f"{prefix}_vl")
|
||||||
|
if mode_idx == 0: # End Extend: base + A
|
||||||
|
raw_total = new_base + input_a
|
||||||
|
elif mode_idx == 1: # Pre Extend: base + B
|
||||||
|
raw_total = new_base + input_b
|
||||||
|
else: # Most modes: base + A + B
|
||||||
|
raw_total = new_base + input_a + input_b
|
||||||
|
# Snap to 4n+1 (1,5,9,13,...,81,...) to match VACE sampler
|
||||||
|
seq["vace_length"] = ((raw_total + 2) // 4) * 4 + 1
|
||||||
|
vl_out.metric("Output", seq["vace_length"])
|
||||||
|
seq["reference switch"] = st.number_input("Reference Switch", value=int(seq.get("reference switch", 1)), key=f"{prefix}_rsw")
|
||||||
|
|
||||||
|
# --- UPDATED: LoRA Settings with Tag Wrapping ---
|
||||||
with st.expander("💊 LoRA Settings"):
|
with st.expander("💊 LoRA Settings"):
|
||||||
lc1, lc2, lc3 = st.columns(3)
|
lc1, lc2, lc3 = st.columns(3)
|
||||||
with lc1:
|
|
||||||
seq["lora 1 high"] = st.text_input("LoRA 1 Name", value=seq.get("lora 1 high", ""), key=f"{prefix}_l1h")
|
# Helper to render the tag wrapper UI
|
||||||
seq["lora 1 low"] = st.text_input("LoRA 1 Strength", value=str(seq.get("lora 1 low", "")), key=f"{prefix}_l1l")
|
def render_lora_col(col_obj, lora_idx):
|
||||||
with lc2:
|
with col_obj:
|
||||||
seq["lora 2 high"] = st.text_input("LoRA 2 Name", value=seq.get("lora 2 high", ""), key=f"{prefix}_l2h")
|
st.caption(f"**LoRA {lora_idx}**")
|
||||||
seq["lora 2 low"] = st.text_input("LoRA 2 Strength", value=str(seq.get("lora 2 low", "")), key=f"{prefix}_l2l")
|
|
||||||
with lc3:
|
# --- HIGH ---
|
||||||
seq["lora 3 high"] = st.text_input("LoRA 3 Name", value=seq.get("lora 3 high", ""), key=f"{prefix}_l3h")
|
k_high = f"lora {lora_idx} high"
|
||||||
seq["lora 3 low"] = st.text_input("LoRA 3 Strength", value=str(seq.get("lora 3 low", "")), key=f"{prefix}_l3l")
|
raw_h = str(seq.get(k_high, ""))
|
||||||
|
# Strip tags for display
|
||||||
|
disp_h = raw_h.replace("<lora:", "").replace(">", "")
|
||||||
|
|
||||||
|
st.write("High:")
|
||||||
|
rh1, rh2, rh3 = st.columns([0.25, 1, 0.1])
|
||||||
|
rh1.markdown("<div style='text-align: right; padding-top: 8px;'><code><lora:</code></div>", unsafe_allow_html=True)
|
||||||
|
val_h = rh2.text_input(f"L{lora_idx}H", value=disp_h, key=f"{prefix}_l{lora_idx}h", label_visibility="collapsed")
|
||||||
|
rh3.markdown("<div style='padding-top: 8px;'><code>></code></div>", unsafe_allow_html=True)
|
||||||
|
|
||||||
|
if val_h:
|
||||||
|
seq[k_high] = f"<lora:{val_h}>"
|
||||||
|
else:
|
||||||
|
seq[k_high] = ""
|
||||||
|
|
||||||
|
# --- LOW ---
|
||||||
|
k_low = f"lora {lora_idx} low"
|
||||||
|
raw_l = str(seq.get(k_low, ""))
|
||||||
|
# Strip tags for display
|
||||||
|
disp_l = raw_l.replace("<lora:", "").replace(">", "")
|
||||||
|
|
||||||
|
st.write("Low:")
|
||||||
|
rl1, rl2, rl3 = st.columns([0.25, 1, 0.1])
|
||||||
|
rl1.markdown("<div style='text-align: right; padding-top: 8px;'><code><lora:</code></div>", unsafe_allow_html=True)
|
||||||
|
val_l = rl2.text_input(f"L{lora_idx}L", value=disp_l, key=f"{prefix}_l{lora_idx}l", label_visibility="collapsed")
|
||||||
|
rl3.markdown("<div style='padding-top: 8px;'><code>></code></div>", unsafe_allow_html=True)
|
||||||
|
|
||||||
|
if val_l:
|
||||||
|
seq[k_low] = f"<lora:{val_l}>"
|
||||||
|
else:
|
||||||
|
seq[k_low] = ""
|
||||||
|
|
||||||
|
render_lora_col(lc1, 1)
|
||||||
|
render_lora_col(lc2, 2)
|
||||||
|
render_lora_col(lc3, 3)
|
||||||
|
|
||||||
# --- CUSTOM PARAMETERS ---
|
# --- CUSTOM PARAMETERS ---
|
||||||
st.markdown("---")
|
st.markdown("---")
|
||||||
@@ -284,17 +574,17 @@ def render_batch_processor(data, file_path, json_files, current_dir, selected_fi
|
|||||||
|
|
||||||
with col_save:
|
with col_save:
|
||||||
if st.button("💾 Save & Snap", use_container_width=True):
|
if st.button("💾 Save & Snap", use_container_width=True):
|
||||||
data["batch_data"] = batch_list
|
data[KEY_BATCH_DATA] = batch_list
|
||||||
|
|
||||||
tree_data = data.get("history_tree", {})
|
tree_data = data.get(KEY_HISTORY_TREE, {})
|
||||||
htree = HistoryTree(tree_data)
|
htree = HistoryTree(tree_data)
|
||||||
|
|
||||||
snapshot_payload = data.copy()
|
snapshot_payload = copy.deepcopy(data)
|
||||||
if "history_tree" in snapshot_payload: del snapshot_payload["history_tree"]
|
if KEY_HISTORY_TREE in snapshot_payload: del snapshot_payload[KEY_HISTORY_TREE]
|
||||||
|
|
||||||
htree.commit(snapshot_payload, note=commit_msg if commit_msg else "Batch Update")
|
htree.commit(snapshot_payload, note=commit_msg if commit_msg else "Batch Update")
|
||||||
|
|
||||||
data["history_tree"] = htree.to_dict()
|
data[KEY_HISTORY_TREE] = htree.to_dict()
|
||||||
save_json(file_path, data)
|
save_json(file_path, data)
|
||||||
|
|
||||||
if 'restored_indicator' in st.session_state:
|
if 'restored_indicator' in st.session_state:
|
||||||
|
|||||||
112
tab_comfy.py
112
tab_comfy.py
@@ -2,14 +2,32 @@ import streamlit as st
|
|||||||
import requests
|
import requests
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
import urllib.parse
|
||||||
|
import html
|
||||||
|
import time # <--- NEW IMPORT
|
||||||
from utils import save_config
|
from utils import save_config
|
||||||
|
|
||||||
def render_single_instance(instance_config, index, all_instances):
|
def render_single_instance(instance_config, index, all_instances, timeout_minutes):
|
||||||
url = instance_config.get("url", "http://127.0.0.1:8188")
|
url = instance_config.get("url", "http://127.0.0.1:8188")
|
||||||
name = instance_config.get("name", f"Server {index+1}")
|
name = instance_config.get("name", f"Server {index+1}")
|
||||||
|
|
||||||
COMFY_URL = url.rstrip("/")
|
COMFY_URL = url.rstrip("/")
|
||||||
|
|
||||||
|
# --- TIMEOUT LOGIC ---
|
||||||
|
# Generate unique keys for session state
|
||||||
|
toggle_key = f"live_toggle_{index}"
|
||||||
|
start_time_key = f"live_start_{index}"
|
||||||
|
|
||||||
|
# Check if we need to auto-close
|
||||||
|
if st.session_state.get(toggle_key, False) and timeout_minutes > 0:
|
||||||
|
start_time = st.session_state.get(start_time_key, 0)
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
if elapsed > (timeout_minutes * 60):
|
||||||
|
st.session_state[toggle_key] = False
|
||||||
|
# We don't need st.rerun() here because the fragment loop will pick up the state change on the next pass
|
||||||
|
# but an explicit rerun makes it snappy.
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
c_head, c_set = st.columns([3, 1])
|
c_head, c_set = st.columns([3, 1])
|
||||||
c_head.markdown(f"### 🔌 {name}")
|
c_head.markdown(f"### 🔌 {name}")
|
||||||
|
|
||||||
@@ -29,7 +47,7 @@ def render_single_instance(instance_config, index, all_instances):
|
|||||||
save_config(
|
save_config(
|
||||||
st.session_state.current_dir,
|
st.session_state.current_dir,
|
||||||
st.session_state.config['favorites'],
|
st.session_state.config['favorites'],
|
||||||
{"comfy_instances": all_instances}
|
st.session_state.config
|
||||||
)
|
)
|
||||||
st.toast("Server config saved!", icon="💾")
|
st.toast("Server config saved!", icon="💾")
|
||||||
st.rerun()
|
st.rerun()
|
||||||
@@ -41,7 +59,7 @@ def render_single_instance(instance_config, index, all_instances):
|
|||||||
save_config(
|
save_config(
|
||||||
st.session_state.current_dir,
|
st.session_state.current_dir,
|
||||||
st.session_state.config['favorites'],
|
st.session_state.config['favorites'],
|
||||||
{"comfy_instances": all_instances}
|
st.session_state.config
|
||||||
)
|
)
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
@@ -64,18 +82,32 @@ def render_single_instance(instance_config, index, all_instances):
|
|||||||
col1.metric("Status", "🔴 Offline")
|
col1.metric("Status", "🔴 Offline")
|
||||||
col2.metric("Pending", "-")
|
col2.metric("Pending", "-")
|
||||||
col3.metric("Running", "-")
|
col3.metric("Running", "-")
|
||||||
st.error(f"Could not connect to {COMFY_URL}")
|
st.error(f"Could not connect to API at {COMFY_URL}")
|
||||||
return
|
|
||||||
|
|
||||||
# --- 2. LIVE VIEW (WITH TOGGLE) ---
|
# --- 2. LIVE VIEW (VIA REMOTE BROWSER) ---
|
||||||
st.write("")
|
st.write("")
|
||||||
c_label, c_ctrl = st.columns([1, 2])
|
c_label, c_ctrl = st.columns([1, 2])
|
||||||
c_label.subheader("📺 Live View")
|
c_label.subheader("📺 Live View")
|
||||||
|
|
||||||
# LIVE PREVIEW TOGGLE
|
# Capture the toggle interaction to set start time
|
||||||
enable_preview = c_ctrl.checkbox("Enable Live Preview", value=True, key=f"live_toggle_{index}")
|
def on_toggle_change():
|
||||||
|
if st.session_state[toggle_key]:
|
||||||
|
st.session_state[start_time_key] = time.time()
|
||||||
|
|
||||||
|
enable_preview = c_ctrl.checkbox(
|
||||||
|
"Enable Live Preview",
|
||||||
|
value=False,
|
||||||
|
key=toggle_key,
|
||||||
|
on_change=on_toggle_change
|
||||||
|
)
|
||||||
|
|
||||||
if enable_preview:
|
if enable_preview:
|
||||||
|
# Display Countdown if timeout is active
|
||||||
|
if timeout_minutes > 0:
|
||||||
|
elapsed = time.time() - st.session_state.get(start_time_key, time.time())
|
||||||
|
remaining = (timeout_minutes * 60) - elapsed
|
||||||
|
st.caption(f"⏱️ Auto-off in: **{int(remaining)}s**")
|
||||||
|
|
||||||
# Height Slider
|
# Height Slider
|
||||||
iframe_h = st.slider(
|
iframe_h = st.slider(
|
||||||
"Height (px)",
|
"Height (px)",
|
||||||
@@ -83,16 +115,27 @@ def render_single_instance(instance_config, index, all_instances):
|
|||||||
key=f"h_slider_{index}"
|
key=f"h_slider_{index}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Get Configured Viewer URL
|
||||||
|
viewer_base = st.session_state.config.get("viewer_url", "")
|
||||||
|
final_src = viewer_base.strip()
|
||||||
|
|
||||||
|
# Validate URL scheme before embedding
|
||||||
|
parsed = urllib.parse.urlparse(final_src)
|
||||||
|
if final_src and parsed.scheme in ("http", "https"):
|
||||||
|
safe_src = html.escape(final_src, quote=True)
|
||||||
|
st.info(f"Viewing via Remote Browser: `{final_src}`")
|
||||||
st.markdown(
|
st.markdown(
|
||||||
f"""
|
f"""
|
||||||
<iframe src="{COMFY_URL}" width="100%" height="{iframe_h}px"
|
<iframe src="{safe_src}" width="100%" height="{iframe_h}px"
|
||||||
style="border: 1px solid #444; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.3);">
|
style="border: 2px solid #666; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.3);">
|
||||||
</iframe>
|
</iframe>
|
||||||
""",
|
""",
|
||||||
unsafe_allow_html=True
|
unsafe_allow_html=True
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
st.info("Live Preview is disabled. Enable it above to see the interface.")
|
st.warning("No valid viewer URL configured. Set one in Monitor Settings below.")
|
||||||
|
else:
|
||||||
|
st.info("Live Preview is disabled.")
|
||||||
|
|
||||||
st.markdown("---")
|
st.markdown("---")
|
||||||
|
|
||||||
@@ -130,7 +173,42 @@ def render_single_instance(instance_config, index, all_instances):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
st.error(f"Error fetching image: {e}")
|
st.error(f"Error fetching image: {e}")
|
||||||
|
|
||||||
def render_comfy_monitor():
|
# Check for fragment support (Streamlit 1.37+)
|
||||||
|
if hasattr(st, "fragment"):
|
||||||
|
# This decorator ensures this function re-runs every 10 seconds automatically
|
||||||
|
# allowing it to catch the timeout even if you are away from the keyboard.
|
||||||
|
@st.fragment(run_every=300)
|
||||||
|
def _monitor_fragment():
|
||||||
|
_render_content()
|
||||||
|
else:
|
||||||
|
# Fallback for older Streamlit versions (Won't auto-refresh while idle)
|
||||||
|
def _monitor_fragment():
|
||||||
|
_render_content()
|
||||||
|
|
||||||
|
def _render_content():
|
||||||
|
# --- GLOBAL SETTINGS FOR MONITOR ---
|
||||||
|
with st.expander("🔧 Monitor Settings", expanded=False):
|
||||||
|
c_set1, c_set2 = st.columns(2)
|
||||||
|
|
||||||
|
current_viewer = st.session_state.config.get("viewer_url", "")
|
||||||
|
new_viewer = c_set1.text_input("Remote Browser URL", value=current_viewer, help="e.g., http://localhost:5800")
|
||||||
|
|
||||||
|
# New Timeout Slider
|
||||||
|
current_timeout = st.session_state.config.get("monitor_timeout", 0)
|
||||||
|
new_timeout = c_set2.slider("Live Preview Timeout (Minutes)", 0, 60, value=current_timeout, help="0 = Always On. Sets how long the preview stays open before auto-closing.")
|
||||||
|
|
||||||
|
if st.button("💾 Save Monitor Settings"):
|
||||||
|
st.session_state.config["viewer_url"] = new_viewer
|
||||||
|
st.session_state.config["monitor_timeout"] = new_timeout
|
||||||
|
save_config(
|
||||||
|
st.session_state.current_dir,
|
||||||
|
st.session_state.config['favorites'],
|
||||||
|
st.session_state.config
|
||||||
|
)
|
||||||
|
st.success("Settings saved!")
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
# --- INSTANCE MANAGEMENT ---
|
||||||
if "comfy_instances" not in st.session_state.config:
|
if "comfy_instances" not in st.session_state.config:
|
||||||
st.session_state.config["comfy_instances"] = [
|
st.session_state.config["comfy_instances"] = [
|
||||||
{"name": "Main Server", "url": "http://192.168.1.100:8188"}
|
{"name": "Main Server", "url": "http://192.168.1.100:8188"}
|
||||||
@@ -140,9 +218,11 @@ def render_comfy_monitor():
|
|||||||
tab_names = [i["name"] for i in instances] + ["➕ Add Server"]
|
tab_names = [i["name"] for i in instances] + ["➕ Add Server"]
|
||||||
tabs = st.tabs(tab_names)
|
tabs = st.tabs(tab_names)
|
||||||
|
|
||||||
|
timeout_val = st.session_state.config.get("monitor_timeout", 0)
|
||||||
|
|
||||||
for i, tab in enumerate(tabs[:-1]):
|
for i, tab in enumerate(tabs[:-1]):
|
||||||
with tab:
|
with tab:
|
||||||
render_single_instance(instances[i], i, instances)
|
render_single_instance(instances[i], i, instances, timeout_val)
|
||||||
|
|
||||||
with tabs[-1]:
|
with tabs[-1]:
|
||||||
st.header("Add New ComfyUI Instance")
|
st.header("Add New ComfyUI Instance")
|
||||||
@@ -157,9 +237,13 @@ def render_comfy_monitor():
|
|||||||
save_config(
|
save_config(
|
||||||
st.session_state.current_dir,
|
st.session_state.current_dir,
|
||||||
st.session_state.config['favorites'],
|
st.session_state.config['favorites'],
|
||||||
{"comfy_instances": instances}
|
st.session_state.config
|
||||||
)
|
)
|
||||||
st.success("Server Added!")
|
st.success("Server Added!")
|
||||||
st.rerun()
|
st.rerun()
|
||||||
else:
|
else:
|
||||||
st.error("Please fill in both Name and URL.")
|
st.error("Please fill in both Name and URL.")
|
||||||
|
|
||||||
|
def render_comfy_monitor():
|
||||||
|
# We call the wrapper which decides if it's a fragment or not
|
||||||
|
_monitor_fragment()
|
||||||
78
tab_raw.py
Normal file
78
tab_raw.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import streamlit as st
|
||||||
|
import json
|
||||||
|
import copy
|
||||||
|
from utils import save_json, get_file_mtime, KEY_HISTORY_TREE, KEY_PROMPT_HISTORY
|
||||||
|
|
||||||
|
def render_raw_editor(data, file_path):
|
||||||
|
st.subheader(f"💻 Raw Editor: {file_path.name}")
|
||||||
|
|
||||||
|
# Toggle to hide massive history objects
|
||||||
|
# This is crucial because history trees can get huge and make the text area laggy.
|
||||||
|
col_ctrl, col_info = st.columns([1, 2])
|
||||||
|
with col_ctrl:
|
||||||
|
hide_history = st.checkbox(
|
||||||
|
"Hide History (Safe Mode)",
|
||||||
|
value=True,
|
||||||
|
help="Hides 'history_tree' and 'prompt_history' to keep the editor fast and prevent accidental deletion of version control."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prepare display data
|
||||||
|
if hide_history:
|
||||||
|
display_data = copy.deepcopy(data)
|
||||||
|
# Safely remove heavy keys for the view only
|
||||||
|
if KEY_HISTORY_TREE in display_data: del display_data[KEY_HISTORY_TREE]
|
||||||
|
if KEY_PROMPT_HISTORY in display_data: del display_data[KEY_PROMPT_HISTORY]
|
||||||
|
else:
|
||||||
|
display_data = data
|
||||||
|
|
||||||
|
# Convert to string
|
||||||
|
# ensure_ascii=False ensures emojis and special chars render correctly
|
||||||
|
try:
|
||||||
|
json_str = json.dumps(display_data, indent=4, ensure_ascii=False)
|
||||||
|
except Exception as e:
|
||||||
|
st.error(f"Error serializing JSON: {e}")
|
||||||
|
json_str = "{}"
|
||||||
|
|
||||||
|
# The Text Editor
|
||||||
|
# We use ui_reset_token in the key to force the text area to reload content on save
|
||||||
|
new_json_str = st.text_area(
|
||||||
|
"JSON Content",
|
||||||
|
value=json_str,
|
||||||
|
height=650,
|
||||||
|
key=f"raw_edit_{file_path.name}_{st.session_state.ui_reset_token}"
|
||||||
|
)
|
||||||
|
|
||||||
|
st.markdown("---")
|
||||||
|
|
||||||
|
if st.button("💾 Save Raw Changes", type="primary", use_container_width=True):
|
||||||
|
try:
|
||||||
|
# 1. Parse the text back to JSON
|
||||||
|
input_data = json.loads(new_json_str)
|
||||||
|
|
||||||
|
# 2. If we were in Safe Mode, we must merge the hidden history back in
|
||||||
|
if hide_history:
|
||||||
|
if KEY_HISTORY_TREE in data:
|
||||||
|
input_data[KEY_HISTORY_TREE] = data[KEY_HISTORY_TREE]
|
||||||
|
if KEY_PROMPT_HISTORY in data:
|
||||||
|
input_data[KEY_PROMPT_HISTORY] = data[KEY_PROMPT_HISTORY]
|
||||||
|
|
||||||
|
# 3. Save to Disk
|
||||||
|
save_json(file_path, input_data)
|
||||||
|
|
||||||
|
# 4. Update Session State
|
||||||
|
# We clear and update the existing dictionary object so other tabs see the changes
|
||||||
|
data.clear()
|
||||||
|
data.update(input_data)
|
||||||
|
|
||||||
|
# 5. Update Metadata to prevent conflict warnings
|
||||||
|
st.session_state.last_mtime = get_file_mtime(file_path)
|
||||||
|
st.session_state.ui_reset_token += 1
|
||||||
|
|
||||||
|
st.toast("Raw JSON Saved Successfully!", icon="✅")
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
st.error(f"❌ Invalid JSON Syntax: {e}")
|
||||||
|
st.error("Please fix the formatting errors above before saving.")
|
||||||
|
except Exception as e:
|
||||||
|
st.error(f"❌ Unexpected Error: {e}")
|
||||||
250
tab_single.py
250
tab_single.py
@@ -1,250 +0,0 @@
|
|||||||
import streamlit as st
|
|
||||||
import random
|
|
||||||
from utils import DEFAULTS, save_json, get_file_mtime
|
|
||||||
|
|
||||||
def render_single_editor(data, file_path):
|
|
||||||
is_batch_file = "batch_data" in data or isinstance(data, list)
|
|
||||||
|
|
||||||
if is_batch_file:
|
|
||||||
st.info("This is a batch file. Switch to the 'Batch Processor' tab.")
|
|
||||||
return
|
|
||||||
|
|
||||||
col1, col2 = st.columns([2, 1])
|
|
||||||
|
|
||||||
# Unique prefix for this file's widgets + Version Token (Fixes Restore bug)
|
|
||||||
fk = f"{file_path.name}_v{st.session_state.ui_reset_token}"
|
|
||||||
|
|
||||||
# --- FORM ---
|
|
||||||
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, key=f"{fk}_gp")
|
|
||||||
gen_negative = st.text_area("General Negative", value=data.get("general_negative", DEFAULTS["general_negative"]), height=100, key=f"{fk}_gn")
|
|
||||||
|
|
||||||
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, key=f"{fk}_sp")
|
|
||||||
new_negative = st.text_area("Specific Negative", value=data.get("negative", ""), height=100, key=f"{fk}_sn")
|
|
||||||
|
|
||||||
# Seed
|
|
||||||
col_seed_val, col_seed_btn = st.columns([4, 1])
|
|
||||||
seed_key = f"{fk}_seed"
|
|
||||||
|
|
||||||
with col_seed_btn:
|
|
||||||
st.write("")
|
|
||||||
st.write("")
|
|
||||||
if st.button("🎲 Randomize", key=f"{fk}_rand"):
|
|
||||||
st.session_state[seed_key] = 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", key=seed_key)
|
|
||||||
data["seed"] = new_seed
|
|
||||||
|
|
||||||
# LoRAs
|
|
||||||
st.subheader("LoRAs")
|
|
||||||
l_col1, l_col2 = st.columns(2)
|
|
||||||
loras = {}
|
|
||||||
lora_keys = ["lora 1 high", "lora 1 low", "lora 2 high", "lora 2 low", "lora 3 high", "lora 3 low"]
|
|
||||||
for i, k in enumerate(lora_keys):
|
|
||||||
with (l_col1 if i % 2 == 0 else l_col2):
|
|
||||||
loras[k] = st.text_input(k.title(), value=data.get(k, ""), key=f"{fk}_{k}")
|
|
||||||
|
|
||||||
# Settings
|
|
||||||
st.subheader("Settings")
|
|
||||||
spec_fields = {}
|
|
||||||
spec_fields["camera"] = st.text_input("Camera", value=str(data.get("camera", DEFAULTS["camera"])), key=f"{fk}_cam")
|
|
||||||
spec_fields["flf"] = st.text_input("FLF", value=str(data.get("flf", DEFAULTS["flf"])), key=f"{fk}_flf")
|
|
||||||
|
|
||||||
# Explicitly track standard setting keys to exclude them from custom list
|
|
||||||
standard_keys = {
|
|
||||||
"general_prompt", "general_negative", "current_prompt", "negative", "prompt", "seed",
|
|
||||||
"camera", "flf", "batch_data", "prompt_history", "sequence_number", "ui_reset_token",
|
|
||||||
"model_name", "vae_name", "steps", "cfg", "denoise", "sampler_name", "scheduler"
|
|
||||||
}
|
|
||||||
standard_keys.update(lora_keys)
|
|
||||||
|
|
||||||
if "vace" in file_path.name:
|
|
||||||
vace_keys = ["frame_to_skip", "input_a_frames", "input_b_frames", "reference switch", "vace schedule", "reference path", "video file path", "reference image path"]
|
|
||||||
standard_keys.update(vace_keys)
|
|
||||||
|
|
||||||
spec_fields["frame_to_skip"] = st.number_input("Frame to Skip", value=int(data.get("frame_to_skip", 81)), key=f"{fk}_fts")
|
|
||||||
spec_fields["input_a_frames"] = st.number_input("Input A Frames", value=int(data.get("input_a_frames", 0)), key=f"{fk}_ia")
|
|
||||||
spec_fields["input_b_frames"] = st.number_input("Input B Frames", value=int(data.get("input_b_frames", 0)), key=f"{fk}_ib")
|
|
||||||
spec_fields["reference switch"] = st.number_input("Reference Switch", value=int(data.get("reference switch", 1)), key=f"{fk}_rsw")
|
|
||||||
spec_fields["vace schedule"] = st.number_input("VACE Schedule", value=int(data.get("vace schedule", 1)), key=f"{fk}_vsc")
|
|
||||||
for f in ["reference path", "video file path", "reference image path"]:
|
|
||||||
spec_fields[f] = st.text_input(f.title(), value=str(data.get(f, "")), key=f"{fk}_{f}")
|
|
||||||
elif "i2v" in file_path.name:
|
|
||||||
i2v_keys = ["reference image path", "flf image path", "video file path"]
|
|
||||||
standard_keys.update(i2v_keys)
|
|
||||||
|
|
||||||
for f in i2v_keys:
|
|
||||||
spec_fields[f] = st.text_input(f.title(), value=str(data.get(f, "")), key=f"{fk}_{f}")
|
|
||||||
|
|
||||||
# --- CUSTOM PARAMETERS LOGIC ---
|
|
||||||
st.markdown("---")
|
|
||||||
st.subheader("🔧 Custom Parameters")
|
|
||||||
|
|
||||||
# Filter keys: Only those NOT in the standard set
|
|
||||||
custom_keys = [k for k in data.keys() if k not in standard_keys]
|
|
||||||
|
|
||||||
keys_to_remove = []
|
|
||||||
|
|
||||||
if custom_keys:
|
|
||||||
for k in custom_keys:
|
|
||||||
c1, c2, c3 = st.columns([1, 2, 0.5])
|
|
||||||
c1.text_input("Key", value=k, disabled=True, key=f"{fk}_ck_lbl_{k}", label_visibility="collapsed")
|
|
||||||
val = c2.text_input("Value", value=str(data[k]), key=f"{fk}_cv_{k}", label_visibility="collapsed")
|
|
||||||
data[k] = val
|
|
||||||
|
|
||||||
if c3.button("🗑️", key=f"{fk}_cdel_{k}"):
|
|
||||||
keys_to_remove.append(k)
|
|
||||||
else:
|
|
||||||
st.caption("No custom keys added.")
|
|
||||||
|
|
||||||
# Add New Key Interface
|
|
||||||
with st.expander("➕ Add New Parameter"):
|
|
||||||
nk_col, nv_col = st.columns(2)
|
|
||||||
new_k = nk_col.text_input("Key Name", key=f"{fk}_new_k")
|
|
||||||
new_v = nv_col.text_input("Value", key=f"{fk}_new_v")
|
|
||||||
|
|
||||||
if st.button("Add Parameter", key=f"{fk}_add_cust"):
|
|
||||||
if new_k and new_k not in data:
|
|
||||||
data[new_k] = new_v
|
|
||||||
st.rerun()
|
|
||||||
elif new_k in data:
|
|
||||||
st.error(f"Key '{new_k}' already exists!")
|
|
||||||
|
|
||||||
# Apply Removals
|
|
||||||
if keys_to_remove:
|
|
||||||
for k in keys_to_remove:
|
|
||||||
del data[k]
|
|
||||||
st.rerun()
|
|
||||||
|
|
||||||
# --- ACTIONS & HISTORY ---
|
|
||||||
with col2:
|
|
||||||
current_state = {
|
|
||||||
"general_prompt": gen_prompt, "general_negative": gen_negative,
|
|
||||||
"current_prompt": new_prompt, "negative": new_negative,
|
|
||||||
"seed": new_seed, **loras, **spec_fields
|
|
||||||
}
|
|
||||||
|
|
||||||
# MERGE CUSTOM KEYS
|
|
||||||
for k in custom_keys:
|
|
||||||
if k not in keys_to_remove:
|
|
||||||
current_state[k] = data[k]
|
|
||||||
|
|
||||||
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 changed!")
|
|
||||||
if st.button("Force Save"):
|
|
||||||
data.update(current_state)
|
|
||||||
save_json(file_path, data) # No return val in new utils
|
|
||||||
st.session_state.last_mtime = get_file_mtime(file_path) # Manual Update
|
|
||||||
st.session_state.data_cache = data
|
|
||||||
st.toast("Saved!", icon="⚠️")
|
|
||||||
st.rerun()
|
|
||||||
if st.button("Reload File"):
|
|
||||||
st.session_state.loaded_file = None
|
|
||||||
st.rerun()
|
|
||||||
else:
|
|
||||||
if st.button("💾 Update File", use_container_width=True):
|
|
||||||
data.update(current_state)
|
|
||||||
save_json(file_path, data)
|
|
||||||
st.session_state.last_mtime = get_file_mtime(file_path)
|
|
||||||
st.session_state.data_cache = data
|
|
||||||
st.toast("Updated!", icon="✅")
|
|
||||||
|
|
||||||
st.markdown("---")
|
|
||||||
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 "Snapshot", **current_state}
|
|
||||||
if "prompt_history" not in data: data["prompt_history"] = []
|
|
||||||
data["prompt_history"].insert(0, entry)
|
|
||||||
data.update(entry)
|
|
||||||
save_json(file_path, data)
|
|
||||||
st.session_state.last_mtime = get_file_mtime(file_path)
|
|
||||||
st.session_state.data_cache = data
|
|
||||||
st.toast("Archived!", icon="📦")
|
|
||||||
st.rerun()
|
|
||||||
|
|
||||||
# --- FULL HISTORY PANEL ---
|
|
||||||
st.markdown("---")
|
|
||||||
st.subheader("History")
|
|
||||||
history = data.get("prompt_history", [])
|
|
||||||
|
|
||||||
if not history:
|
|
||||||
st.caption("No history yet.")
|
|
||||||
|
|
||||||
for idx, h in enumerate(history):
|
|
||||||
note = h.get('note', 'No Note')
|
|
||||||
|
|
||||||
with st.container():
|
|
||||||
if st.session_state.edit_history_idx == idx:
|
|
||||||
with st.expander(f"📝 Editing: {note}", expanded=True):
|
|
||||||
edit_note = st.text_input("Note", value=note, key=f"h_en_{idx}")
|
|
||||||
edit_seed = st.number_input("Seed", value=int(h.get('seed', 0)), key=f"h_es_{idx}")
|
|
||||||
edit_gp = st.text_area("General P", value=h.get('general_prompt', ''), height=60, key=f"h_egp_{idx}")
|
|
||||||
edit_gn = st.text_area("General N", value=h.get('general_negative', ''), height=60, key=f"h_egn_{idx}")
|
|
||||||
edit_sp = st.text_area("Specific P", value=h.get('prompt', ''), height=100, key=f"h_esp_{idx}")
|
|
||||||
edit_sn = st.text_area("Specific N", value=h.get('negative', ''), height=60, key=f"h_esn_{idx}")
|
|
||||||
|
|
||||||
hc1, hc2 = st.columns([1, 4])
|
|
||||||
if hc1.button("💾 Save", key=f"h_save_{idx}"):
|
|
||||||
h.update({
|
|
||||||
'note': edit_note, 'seed': edit_seed,
|
|
||||||
'general_prompt': edit_gp, 'general_negative': edit_gn,
|
|
||||||
'prompt': edit_sp, 'negative': edit_sn
|
|
||||||
})
|
|
||||||
save_json(file_path, data)
|
|
||||||
st.session_state.last_mtime = get_file_mtime(file_path)
|
|
||||||
st.session_state.data_cache = data
|
|
||||||
st.session_state.edit_history_idx = None
|
|
||||||
st.rerun()
|
|
||||||
if hc2.button("Cancel", key=f"h_can_{idx}"):
|
|
||||||
st.session_state.edit_history_idx = None
|
|
||||||
st.rerun()
|
|
||||||
|
|
||||||
else:
|
|
||||||
with st.expander(f"#{idx+1}: {note}"):
|
|
||||||
st.caption(f"Seed: {h.get('seed', 0)}")
|
|
||||||
st.text(f"SPEC: {h.get('prompt', '')[:40]}...")
|
|
||||||
|
|
||||||
view_data = {k:v for k,v in h.items() if k not in ['prompt', 'negative', 'general_prompt', 'general_negative', 'note']}
|
|
||||||
st.json(view_data, expanded=False)
|
|
||||||
|
|
||||||
bh1, bh2, bh3 = st.columns([2, 1, 1])
|
|
||||||
|
|
||||||
if bh1.button("Restore", key=f"h_rest_{idx}", use_container_width=True):
|
|
||||||
data.update(h)
|
|
||||||
if 'prompt' in h: data['current_prompt'] = h['prompt']
|
|
||||||
save_json(file_path, data)
|
|
||||||
st.session_state.last_mtime = get_file_mtime(file_path)
|
|
||||||
st.session_state.data_cache = data
|
|
||||||
|
|
||||||
# Refresh UI
|
|
||||||
st.session_state.ui_reset_token += 1
|
|
||||||
|
|
||||||
st.toast("Restored!", icon="⏪")
|
|
||||||
st.rerun()
|
|
||||||
|
|
||||||
if bh2.button("✏️", key=f"h_edit_{idx}"):
|
|
||||||
st.session_state.edit_history_idx = idx
|
|
||||||
st.rerun()
|
|
||||||
|
|
||||||
if bh3.button("🗑️", key=f"h_del_{idx}"):
|
|
||||||
history.pop(idx)
|
|
||||||
save_json(file_path, data)
|
|
||||||
st.session_state.last_mtime = get_file_mtime(file_path)
|
|
||||||
st.session_state.data_cache = data
|
|
||||||
st.rerun()
|
|
||||||
323
tab_timeline.py
323
tab_timeline.py
@@ -1,23 +1,33 @@
|
|||||||
import streamlit as st
|
import streamlit as st
|
||||||
import json
|
import copy
|
||||||
import graphviz
|
|
||||||
import time
|
import time
|
||||||
from history_tree import HistoryTree
|
from history_tree import HistoryTree
|
||||||
from utils import save_json
|
from utils import save_json, KEY_BATCH_DATA, KEY_HISTORY_TREE
|
||||||
|
|
||||||
|
try:
|
||||||
|
from streamlit_agraph import agraph, Node, Edge, Config
|
||||||
|
AGRAPH_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
AGRAPH_AVAILABLE = False
|
||||||
|
|
||||||
|
|
||||||
def render_timeline_tab(data, file_path):
|
def render_timeline_tab(data, file_path):
|
||||||
tree_data = data.get("history_tree", {})
|
tree_data = data.get(KEY_HISTORY_TREE, {})
|
||||||
if not tree_data:
|
if not tree_data:
|
||||||
st.info("No history timeline exists. Make some changes in the Editor first!")
|
st.info("No history timeline exists. Make some changes in the Editor first!")
|
||||||
return
|
return
|
||||||
|
|
||||||
htree = HistoryTree(tree_data)
|
htree = HistoryTree(tree_data)
|
||||||
|
|
||||||
|
# --- Initialize selection state ---
|
||||||
|
if "timeline_selected_nodes" not in st.session_state:
|
||||||
|
st.session_state.timeline_selected_nodes = set()
|
||||||
|
|
||||||
if 'restored_indicator' in st.session_state and st.session_state.restored_indicator:
|
if 'restored_indicator' in st.session_state and st.session_state.restored_indicator:
|
||||||
st.info(f"📍 Editing Restored Version: **{st.session_state.restored_indicator}**")
|
st.info(f"📍 Editing Restored Version: **{st.session_state.restored_indicator}**")
|
||||||
|
|
||||||
# --- VIEW SWITCHER ---
|
# --- VIEW SWITCHER + SELECTION MODE ---
|
||||||
c_title, c_view = st.columns([2, 1])
|
c_title, c_view, c_toggle = st.columns([2, 1, 0.6])
|
||||||
c_title.subheader("🕰️ Version History")
|
c_title.subheader("🕰️ Version History")
|
||||||
|
|
||||||
view_mode = c_view.radio(
|
view_mode = c_view.radio(
|
||||||
@@ -27,59 +37,151 @@ def render_timeline_tab(data, file_path):
|
|||||||
label_visibility="collapsed"
|
label_visibility="collapsed"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
selection_mode = c_toggle.toggle("Select to Delete", key="timeline_selection_mode")
|
||||||
|
if not selection_mode:
|
||||||
|
st.session_state.timeline_selected_nodes = set()
|
||||||
|
|
||||||
|
# --- Build sorted node list (shared by all views) ---
|
||||||
|
all_nodes = list(htree.nodes.values())
|
||||||
|
all_nodes.sort(key=lambda x: x["timestamp"], reverse=True)
|
||||||
|
|
||||||
|
# --- MULTISELECT PICKER (shown when selection mode is on) ---
|
||||||
|
if selection_mode:
|
||||||
|
def _fmt_node_option(nid):
|
||||||
|
n = htree.nodes[nid]
|
||||||
|
ts = time.strftime('%b %d %H:%M', time.localtime(n['timestamp']))
|
||||||
|
note = n.get('note', 'Step')
|
||||||
|
head = " (HEAD)" if nid == htree.head_id else ""
|
||||||
|
return f"{note} • {ts} ({nid[:6]}){head}"
|
||||||
|
|
||||||
|
all_ids = [n["id"] for n in all_nodes]
|
||||||
|
current_selection = [nid for nid in all_ids if nid in st.session_state.timeline_selected_nodes]
|
||||||
|
picked = st.multiselect(
|
||||||
|
"Select nodes to delete:",
|
||||||
|
options=all_ids,
|
||||||
|
default=current_selection,
|
||||||
|
format_func=_fmt_node_option,
|
||||||
|
)
|
||||||
|
st.session_state.timeline_selected_nodes = set(picked)
|
||||||
|
|
||||||
|
c_all, c_none, _ = st.columns([1, 1, 4])
|
||||||
|
if c_all.button("Select All", use_container_width=True):
|
||||||
|
st.session_state.timeline_selected_nodes = set(all_ids)
|
||||||
|
st.rerun()
|
||||||
|
if c_none.button("Deselect All", use_container_width=True):
|
||||||
|
st.session_state.timeline_selected_nodes = set()
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
# --- RENDER GRAPH VIEWS ---
|
# --- RENDER GRAPH VIEWS ---
|
||||||
if view_mode in ["🌳 Horizontal", "🌲 Vertical"]:
|
if view_mode in ["🌳 Horizontal", "🌲 Vertical"]:
|
||||||
direction = "LR" if view_mode == "🌳 Horizontal" else "TB"
|
direction = "LR" if view_mode == "🌳 Horizontal" else "TB"
|
||||||
|
|
||||||
|
if AGRAPH_AVAILABLE:
|
||||||
|
# Interactive graph with streamlit-agraph
|
||||||
|
selected_set = st.session_state.timeline_selected_nodes if selection_mode else set()
|
||||||
|
clicked_node = _render_interactive_graph(htree, direction, selected_set)
|
||||||
|
if clicked_node and clicked_node in htree.nodes:
|
||||||
|
if selection_mode:
|
||||||
|
# Toggle node in selection set
|
||||||
|
if clicked_node in st.session_state.timeline_selected_nodes:
|
||||||
|
st.session_state.timeline_selected_nodes.discard(clicked_node)
|
||||||
|
else:
|
||||||
|
st.session_state.timeline_selected_nodes.add(clicked_node)
|
||||||
|
st.rerun()
|
||||||
|
else:
|
||||||
|
node = htree.nodes[clicked_node]
|
||||||
|
if clicked_node != htree.head_id:
|
||||||
|
_restore_node(data, node, htree, file_path)
|
||||||
|
else:
|
||||||
|
# Fallback to static graphviz
|
||||||
try:
|
try:
|
||||||
graph_dot = htree.generate_graph(direction=direction)
|
graph_dot = htree.generate_graph(direction=direction)
|
||||||
|
if direction == "LR":
|
||||||
|
st.graphviz_chart(graph_dot, use_container_width=True)
|
||||||
|
else:
|
||||||
|
_, col_center, _ = st.columns([1, 2, 1])
|
||||||
|
with col_center:
|
||||||
st.graphviz_chart(graph_dot, use_container_width=True)
|
st.graphviz_chart(graph_dot, use_container_width=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
st.error(f"Graph Error: {e}")
|
st.error(f"Graph Error: {e}")
|
||||||
|
st.caption("💡 Install `streamlit-agraph` for interactive click-to-restore")
|
||||||
|
|
||||||
# --- RENDER LINEAR LOG VIEW ---
|
# --- RENDER LINEAR LOG VIEW ---
|
||||||
elif view_mode == "📜 Linear Log":
|
elif view_mode == "📜 Linear Log":
|
||||||
st.caption("A simple chronological list of all snapshots.")
|
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:
|
for n in all_nodes:
|
||||||
is_head = (n["id"] == htree.head_id)
|
is_head = (n["id"] == htree.head_id)
|
||||||
with st.container():
|
with st.container():
|
||||||
|
if selection_mode:
|
||||||
|
c0, c1, c2, c3 = st.columns([0.3, 0.5, 4, 1])
|
||||||
|
with c0:
|
||||||
|
is_selected = n["id"] in st.session_state.timeline_selected_nodes
|
||||||
|
if st.checkbox("", value=is_selected, key=f"log_sel_{n['id']}", label_visibility="collapsed"):
|
||||||
|
st.session_state.timeline_selected_nodes.add(n["id"])
|
||||||
|
else:
|
||||||
|
st.session_state.timeline_selected_nodes.discard(n["id"])
|
||||||
|
else:
|
||||||
c1, c2, c3 = st.columns([0.5, 4, 1])
|
c1, c2, c3 = st.columns([0.5, 4, 1])
|
||||||
with c1:
|
with c1:
|
||||||
st.markdown("### 📍" if is_head else "### ⚫")
|
st.markdown("### 📍" if is_head else "### ⚫")
|
||||||
with c2:
|
with c2:
|
||||||
note_txt = n.get('note', 'Step')
|
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:
|
if is_head:
|
||||||
st.markdown(f"**{note_txt}** (Current)")
|
st.markdown(f"**{note_txt}** (Current)")
|
||||||
else:
|
else:
|
||||||
st.write(f"**{note_txt}**")
|
st.write(f"**{note_txt}**")
|
||||||
st.caption(f"ID: {n['id'][:6]} • Time: {ts}")
|
st.caption(f"ID: {n['id'][:6]} • {ts}")
|
||||||
with c3:
|
with c3:
|
||||||
if not is_head:
|
if not is_head and not selection_mode:
|
||||||
if st.button("⏪", key=f"log_rst_{n['id']}", help="Restore this version"):
|
if st.button("⏪", key=f"log_rst_{n['id']}", help="Restore this version"):
|
||||||
data.update(n["data"])
|
_restore_node(data, n, htree, file_path)
|
||||||
htree.head_id = n['id']
|
|
||||||
data["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()
|
|
||||||
st.divider()
|
st.divider()
|
||||||
|
|
||||||
|
# --- BATCH DELETE UI ---
|
||||||
|
if selection_mode and st.session_state.timeline_selected_nodes:
|
||||||
|
# Prune any selected IDs that no longer exist in the tree
|
||||||
|
valid_selected = st.session_state.timeline_selected_nodes & set(htree.nodes.keys())
|
||||||
|
st.session_state.timeline_selected_nodes = valid_selected
|
||||||
|
count = len(valid_selected)
|
||||||
|
if count > 0:
|
||||||
|
st.warning(f"**{count}** node{'s' if count != 1 else ''} selected for deletion.")
|
||||||
|
if st.button(f"🗑️ Delete {count} Node{'s' if count != 1 else ''}", type="primary"):
|
||||||
|
# Backup
|
||||||
|
if "history_tree_backup" not in data:
|
||||||
|
data["history_tree_backup"] = []
|
||||||
|
data["history_tree_backup"].append(copy.deepcopy(htree.to_dict()))
|
||||||
|
# Delete all selected nodes
|
||||||
|
for nid in valid_selected:
|
||||||
|
if nid in htree.nodes:
|
||||||
|
del htree.nodes[nid]
|
||||||
|
# Clean up branch tips
|
||||||
|
for b, tip in list(htree.branches.items()):
|
||||||
|
if tip in valid_selected:
|
||||||
|
del htree.branches[b]
|
||||||
|
# Reassign HEAD if deleted
|
||||||
|
if htree.head_id in valid_selected:
|
||||||
|
if htree.nodes:
|
||||||
|
fallback = sorted(htree.nodes.values(), key=lambda x: x["timestamp"])[-1]
|
||||||
|
htree.head_id = fallback["id"]
|
||||||
|
else:
|
||||||
|
htree.head_id = None
|
||||||
|
# Save and reset
|
||||||
|
data[KEY_HISTORY_TREE] = htree.to_dict()
|
||||||
|
save_json(file_path, data)
|
||||||
|
st.session_state.timeline_selected_nodes = set()
|
||||||
|
st.toast(f"Deleted {count} node{'s' if count != 1 else ''}!", icon="🗑️")
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
st.markdown("---")
|
st.markdown("---")
|
||||||
|
|
||||||
# --- ACTIONS & SELECTION ---
|
# --- NODE SELECTOR ---
|
||||||
col_sel, col_act = st.columns([3, 1])
|
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):
|
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:
|
with col_sel:
|
||||||
current_idx = 0
|
current_idx = 0
|
||||||
@@ -95,29 +197,23 @@ def render_timeline_tab(data, file_path):
|
|||||||
index=current_idx
|
index=current_idx
|
||||||
)
|
)
|
||||||
|
|
||||||
if selected_node:
|
if not selected_node:
|
||||||
|
return
|
||||||
|
|
||||||
node_data = selected_node["data"]
|
node_data = selected_node["data"]
|
||||||
|
|
||||||
# --- ACTIONS ---
|
# --- RESTORE ---
|
||||||
with col_act:
|
with col_act:
|
||||||
st.write(""); st.write("")
|
st.write(""); st.write("")
|
||||||
if st.button("⏪ Restore Version", type="primary", use_container_width=True):
|
if st.button("⏪ Restore Version", type="primary", use_container_width=True):
|
||||||
data.update(node_data)
|
_restore_node(data, selected_node, htree, file_path)
|
||||||
htree.head_id = selected_node['id']
|
|
||||||
data["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.rerun()
|
|
||||||
|
|
||||||
# --- RENAME ---
|
# --- RENAME ---
|
||||||
rn_col1, rn_col2 = st.columns([3, 1])
|
rn_col1, rn_col2 = st.columns([3, 1])
|
||||||
new_label = rn_col1.text_input("Rename Label", value=selected_node.get("note", ""))
|
new_label = rn_col1.text_input("Rename Label", value=selected_node.get("note", ""))
|
||||||
if rn_col2.button("Update Label"):
|
if rn_col2.button("Update Label"):
|
||||||
selected_node["note"] = new_label
|
selected_node["note"] = new_label
|
||||||
data["history_tree"] = htree.to_dict()
|
data[KEY_HISTORY_TREE] = htree.to_dict()
|
||||||
save_json(file_path, data)
|
save_json(file_path, data)
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
@@ -127,6 +223,9 @@ def render_timeline_tab(data, file_path):
|
|||||||
st.warning("Deleting a node cannot be undone.")
|
st.warning("Deleting a node cannot be undone.")
|
||||||
if st.button("🗑️ Delete This Node", type="primary"):
|
if st.button("🗑️ Delete This Node", type="primary"):
|
||||||
if selected_node['id'] in htree.nodes:
|
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']]
|
del htree.nodes[selected_node['id']]
|
||||||
for b, tip in list(htree.branches.items()):
|
for b, tip in list(htree.branches.items()):
|
||||||
if tip == selected_node['id']:
|
if tip == selected_node['id']:
|
||||||
@@ -137,7 +236,155 @@ def render_timeline_tab(data, file_path):
|
|||||||
htree.head_id = fallback["id"]
|
htree.head_id = fallback["id"]
|
||||||
else:
|
else:
|
||||||
htree.head_id = None
|
htree.head_id = None
|
||||||
data["history_tree"] = htree.to_dict()
|
data[KEY_HISTORY_TREE] = htree.to_dict()
|
||||||
save_json(file_path, data)
|
save_json(file_path, data)
|
||||||
st.toast("Node Deleted", icon="🗑️")
|
st.toast("Node Deleted", icon="🗑️")
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
|
# --- DATA PREVIEW ---
|
||||||
|
st.markdown("---")
|
||||||
|
with st.expander("🔍 Data Preview", expanded=False):
|
||||||
|
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)):
|
||||||
|
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 _render_interactive_graph(htree, direction, selected_nodes=None):
|
||||||
|
"""Render an interactive graph using streamlit-agraph. Returns clicked node id."""
|
||||||
|
if selected_nodes is None:
|
||||||
|
selected_nodes = set()
|
||||||
|
|
||||||
|
# Build reverse lookup: branch tip -> branch name(s)
|
||||||
|
tip_to_branches = {}
|
||||||
|
for b_name, tip_id in htree.branches.items():
|
||||||
|
if tip_id:
|
||||||
|
tip_to_branches.setdefault(tip_id, []).append(b_name)
|
||||||
|
|
||||||
|
sorted_nodes_list = sorted(htree.nodes.values(), key=lambda x: x["timestamp"])
|
||||||
|
|
||||||
|
nodes = []
|
||||||
|
edges = []
|
||||||
|
|
||||||
|
for n in sorted_nodes_list:
|
||||||
|
nid = n["id"]
|
||||||
|
full_note = n.get('note', 'Step')
|
||||||
|
display_note = (full_note[:20] + '..') if len(full_note) > 20 else full_note
|
||||||
|
ts = time.strftime('%b %d %H:%M', time.localtime(n['timestamp']))
|
||||||
|
|
||||||
|
# Branch label
|
||||||
|
branch_label = ""
|
||||||
|
if nid in tip_to_branches:
|
||||||
|
branch_label = f"\n[{', '.join(tip_to_branches[nid])}]"
|
||||||
|
|
||||||
|
label = f"{display_note}\n{ts}{branch_label}"
|
||||||
|
|
||||||
|
# Colors - selected nodes override to red
|
||||||
|
if nid in selected_nodes:
|
||||||
|
color = "#ff5555" # Selected for deletion - red
|
||||||
|
elif nid == htree.head_id:
|
||||||
|
color = "#ffdd44" # Current head - bright yellow
|
||||||
|
elif nid in htree.branches.values():
|
||||||
|
color = "#66dd66" # Branch tip - bright green
|
||||||
|
else:
|
||||||
|
color = "#aaccff" # Normal - light blue
|
||||||
|
|
||||||
|
nodes.append(Node(
|
||||||
|
id=nid,
|
||||||
|
label=label,
|
||||||
|
size=20,
|
||||||
|
color=color,
|
||||||
|
font={"size": 10, "color": "#ffffff"}
|
||||||
|
))
|
||||||
|
|
||||||
|
if n["parent"] and n["parent"] in htree.nodes:
|
||||||
|
edges.append(Edge(source=n["parent"], target=nid, color="#888888"))
|
||||||
|
|
||||||
|
# Config based on direction
|
||||||
|
is_horizontal = direction == "LR"
|
||||||
|
config = Config(
|
||||||
|
width="100%",
|
||||||
|
height=400 if is_horizontal else 600,
|
||||||
|
directed=True,
|
||||||
|
hierarchical=True,
|
||||||
|
physics=False,
|
||||||
|
nodeHighlightBehavior=True,
|
||||||
|
highlightColor="#ffcc00",
|
||||||
|
collapsible=False,
|
||||||
|
layout={
|
||||||
|
"hierarchical": {
|
||||||
|
"enabled": True,
|
||||||
|
"direction": "LR" if is_horizontal else "UD",
|
||||||
|
"sortMethod": "directed",
|
||||||
|
"levelSeparation": 150 if is_horizontal else 80,
|
||||||
|
"nodeSpacing": 100 if is_horizontal else 60,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return agraph(nodes=nodes, edges=edges, config=config)
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
|||||||
@@ -1,175 +0,0 @@
|
|||||||
import streamlit as st
|
|
||||||
import json
|
|
||||||
from history_tree import HistoryTree
|
|
||||||
from utils import save_json
|
|
||||||
from streamlit_agraph import agraph, Node, Edge, Config
|
|
||||||
|
|
||||||
def render_timeline_wip(data, file_path):
|
|
||||||
tree_data = data.get("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"
|
|
||||||
))
|
|
||||||
|
|
||||||
config = Config(
|
|
||||||
width="100%",
|
|
||||||
height="400px",
|
|
||||||
directed=True,
|
|
||||||
physics=False,
|
|
||||||
hierarchical=True,
|
|
||||||
layout={
|
|
||||||
"hierarchical": {
|
|
||||||
"enabled": True,
|
|
||||||
"levelSeparation": 150,
|
|
||||||
"nodeSpacing": 100,
|
|
||||||
"treeSpacing": 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}"):
|
|
||||||
data.update(node_data)
|
|
||||||
htree.head_id = target_node_id
|
|
||||||
|
|
||||||
data["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("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)
|
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
5
tests/conftest.py
Normal file
5
tests/conftest.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add project root to sys.path so tests can import project modules
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
1
tests/pytest.ini
Normal file
1
tests/pytest.ini
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[pytest]
|
||||||
67
tests/test_history_tree.py
Normal file
67
tests/test_history_tree.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import pytest
|
||||||
|
from history_tree import HistoryTree
|
||||||
|
|
||||||
|
|
||||||
|
def test_commit_creates_node_with_correct_parent():
|
||||||
|
tree = HistoryTree({})
|
||||||
|
id1 = tree.commit({"a": 1}, note="first")
|
||||||
|
id2 = tree.commit({"b": 2}, note="second")
|
||||||
|
|
||||||
|
assert tree.nodes[id1]["parent"] is None
|
||||||
|
assert tree.nodes[id2]["parent"] == id1
|
||||||
|
|
||||||
|
|
||||||
|
def test_checkout_returns_correct_data():
|
||||||
|
tree = HistoryTree({})
|
||||||
|
id1 = tree.commit({"val": 42}, note="snap")
|
||||||
|
result = tree.checkout(id1)
|
||||||
|
assert result == {"val": 42}
|
||||||
|
|
||||||
|
|
||||||
|
def test_checkout_nonexistent_returns_none():
|
||||||
|
tree = HistoryTree({})
|
||||||
|
assert tree.checkout("nonexistent") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_cycle_detection_raises():
|
||||||
|
tree = HistoryTree({})
|
||||||
|
id1 = tree.commit({"a": 1})
|
||||||
|
# Manually introduce a cycle
|
||||||
|
tree.nodes[id1]["parent"] = id1
|
||||||
|
with pytest.raises(ValueError, match="Cycle detected"):
|
||||||
|
tree.commit({"b": 2})
|
||||||
|
|
||||||
|
|
||||||
|
def test_branch_creation_on_detached_head():
|
||||||
|
tree = HistoryTree({})
|
||||||
|
id1 = tree.commit({"a": 1})
|
||||||
|
id2 = tree.commit({"b": 2})
|
||||||
|
# Detach head by checking out a non-tip node
|
||||||
|
tree.checkout(id1)
|
||||||
|
# head_id is now id1, which is no longer a branch tip (main points to id2)
|
||||||
|
id3 = tree.commit({"c": 3})
|
||||||
|
# A new branch should have been created
|
||||||
|
assert len(tree.branches) == 2
|
||||||
|
assert tree.nodes[id3]["parent"] == id1
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_migration():
|
||||||
|
legacy = {
|
||||||
|
"prompt_history": [
|
||||||
|
{"note": "Entry A", "seed": 1},
|
||||||
|
{"note": "Entry B", "seed": 2},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
tree = HistoryTree(legacy)
|
||||||
|
assert len(tree.nodes) == 2
|
||||||
|
assert tree.head_id is not None
|
||||||
|
assert tree.branches["main"] == tree.head_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_dict_roundtrip():
|
||||||
|
tree = HistoryTree({})
|
||||||
|
tree.commit({"x": 1}, note="test")
|
||||||
|
d = tree.to_dict()
|
||||||
|
tree2 = HistoryTree(d)
|
||||||
|
assert tree2.head_id == tree.head_id
|
||||||
|
assert tree2.nodes == tree.nodes
|
||||||
165
tests/test_json_loader.py
Normal file
165
tests/test_json_loader.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from json_loader import (
|
||||||
|
to_float, to_int, get_batch_item, read_json_data,
|
||||||
|
JSONLoaderDynamic, MAX_DYNAMIC_OUTPUTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestToFloat:
|
||||||
|
def test_valid(self):
|
||||||
|
assert to_float("3.14") == 3.14
|
||||||
|
assert to_float(5) == 5.0
|
||||||
|
|
||||||
|
def test_invalid(self):
|
||||||
|
assert to_float("abc") == 0.0
|
||||||
|
|
||||||
|
def test_none(self):
|
||||||
|
assert to_float(None) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestToInt:
|
||||||
|
def test_valid(self):
|
||||||
|
assert to_int("7") == 7
|
||||||
|
assert to_int(3.9) == 3
|
||||||
|
|
||||||
|
def test_invalid(self):
|
||||||
|
assert to_int("xyz") == 0
|
||||||
|
|
||||||
|
def test_none(self):
|
||||||
|
assert to_int(None) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetBatchItem:
|
||||||
|
def test_lookup_by_sequence_number_field(self):
|
||||||
|
data = {"batch_data": [
|
||||||
|
{"sequence_number": 1, "a": "first"},
|
||||||
|
{"sequence_number": 5, "a": "fifth"},
|
||||||
|
{"sequence_number": 3, "a": "third"},
|
||||||
|
]}
|
||||||
|
assert get_batch_item(data, 5) == {"sequence_number": 5, "a": "fifth"}
|
||||||
|
assert get_batch_item(data, 3) == {"sequence_number": 3, "a": "third"}
|
||||||
|
|
||||||
|
def test_fallback_to_index(self):
|
||||||
|
data = {"batch_data": [{"a": 1}, {"a": 2}, {"a": 3}]}
|
||||||
|
assert get_batch_item(data, 2) == {"a": 2}
|
||||||
|
|
||||||
|
def test_clamp_high(self):
|
||||||
|
data = {"batch_data": [{"a": 1}, {"a": 2}]}
|
||||||
|
assert get_batch_item(data, 99) == {"a": 2}
|
||||||
|
|
||||||
|
def test_clamp_low(self):
|
||||||
|
data = {"batch_data": [{"a": 1}, {"a": 2}]}
|
||||||
|
assert get_batch_item(data, 0) == {"a": 1}
|
||||||
|
|
||||||
|
def test_no_batch_data(self):
|
||||||
|
data = {"key": "val"}
|
||||||
|
assert get_batch_item(data, 1) == data
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadJsonData:
|
||||||
|
def test_missing_file(self, tmp_path):
|
||||||
|
assert read_json_data(str(tmp_path / "nope.json")) == {}
|
||||||
|
|
||||||
|
def test_invalid_json(self, tmp_path):
|
||||||
|
p = tmp_path / "bad.json"
|
||||||
|
p.write_text("{broken")
|
||||||
|
assert read_json_data(str(p)) == {}
|
||||||
|
|
||||||
|
def test_non_dict_json(self, tmp_path):
|
||||||
|
p = tmp_path / "list.json"
|
||||||
|
p.write_text(json.dumps([1, 2, 3]))
|
||||||
|
assert read_json_data(str(p)) == {}
|
||||||
|
|
||||||
|
def test_valid(self, tmp_path):
|
||||||
|
p = tmp_path / "ok.json"
|
||||||
|
p.write_text(json.dumps({"key": "val"}))
|
||||||
|
assert read_json_data(str(p)) == {"key": "val"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestJSONLoaderDynamic:
|
||||||
|
def _make_json(self, tmp_path, data):
|
||||||
|
p = tmp_path / "test.json"
|
||||||
|
p.write_text(json.dumps(data))
|
||||||
|
return str(p)
|
||||||
|
|
||||||
|
def test_known_keys(self, tmp_path):
|
||||||
|
path = self._make_json(tmp_path, {"name": "alice", "age": 30, "score": 9.5})
|
||||||
|
loader = JSONLoaderDynamic()
|
||||||
|
result = loader.load_dynamic(path, 1, output_keys="name,age,score")
|
||||||
|
assert result[0] == "alice"
|
||||||
|
assert result[1] == 30
|
||||||
|
assert result[2] == 9.5
|
||||||
|
|
||||||
|
def test_empty_output_keys(self, tmp_path):
|
||||||
|
path = self._make_json(tmp_path, {"name": "alice"})
|
||||||
|
loader = JSONLoaderDynamic()
|
||||||
|
result = loader.load_dynamic(path, 1, output_keys="")
|
||||||
|
assert len(result) == MAX_DYNAMIC_OUTPUTS
|
||||||
|
assert all(v == "" for v in result)
|
||||||
|
|
||||||
|
def test_pads_to_max(self, tmp_path):
|
||||||
|
path = self._make_json(tmp_path, {"a": "1", "b": "2"})
|
||||||
|
loader = JSONLoaderDynamic()
|
||||||
|
result = loader.load_dynamic(path, 1, output_keys="a,b")
|
||||||
|
assert len(result) == MAX_DYNAMIC_OUTPUTS
|
||||||
|
assert result[0] == "1"
|
||||||
|
assert result[1] == "2"
|
||||||
|
assert all(v == "" for v in result[2:])
|
||||||
|
|
||||||
|
def test_type_preservation_int(self, tmp_path):
|
||||||
|
path = self._make_json(tmp_path, {"count": 42})
|
||||||
|
loader = JSONLoaderDynamic()
|
||||||
|
result = loader.load_dynamic(path, 1, output_keys="count")
|
||||||
|
assert result[0] == 42
|
||||||
|
assert isinstance(result[0], int)
|
||||||
|
|
||||||
|
def test_type_preservation_float(self, tmp_path):
|
||||||
|
path = self._make_json(tmp_path, {"rate": 3.14})
|
||||||
|
loader = JSONLoaderDynamic()
|
||||||
|
result = loader.load_dynamic(path, 1, output_keys="rate")
|
||||||
|
assert result[0] == 3.14
|
||||||
|
assert isinstance(result[0], float)
|
||||||
|
|
||||||
|
def test_type_preservation_str(self, tmp_path):
|
||||||
|
path = self._make_json(tmp_path, {"label": "hello"})
|
||||||
|
loader = JSONLoaderDynamic()
|
||||||
|
result = loader.load_dynamic(path, 1, output_keys="label")
|
||||||
|
assert result[0] == "hello"
|
||||||
|
assert isinstance(result[0], str)
|
||||||
|
|
||||||
|
def test_bool_becomes_string(self, tmp_path):
|
||||||
|
path = self._make_json(tmp_path, {"flag": True, "off": False})
|
||||||
|
loader = JSONLoaderDynamic()
|
||||||
|
result = loader.load_dynamic(path, 1, output_keys="flag,off")
|
||||||
|
assert result[0] == "true"
|
||||||
|
assert result[1] == "false"
|
||||||
|
assert isinstance(result[0], str)
|
||||||
|
|
||||||
|
def test_missing_key_returns_empty_string(self, tmp_path):
|
||||||
|
path = self._make_json(tmp_path, {"a": "1"})
|
||||||
|
loader = JSONLoaderDynamic()
|
||||||
|
result = loader.load_dynamic(path, 1, output_keys="a,nonexistent")
|
||||||
|
assert result[0] == "1"
|
||||||
|
assert result[1] == ""
|
||||||
|
|
||||||
|
def test_missing_file_returns_all_empty(self, tmp_path):
|
||||||
|
loader = JSONLoaderDynamic()
|
||||||
|
result = loader.load_dynamic(str(tmp_path / "nope.json"), 1, output_keys="a,b")
|
||||||
|
assert len(result) == MAX_DYNAMIC_OUTPUTS
|
||||||
|
assert result[0] == ""
|
||||||
|
assert result[1] == ""
|
||||||
|
|
||||||
|
def test_batch_data(self, tmp_path):
|
||||||
|
path = self._make_json(tmp_path, {
|
||||||
|
"batch_data": [
|
||||||
|
{"sequence_number": 1, "x": "first"},
|
||||||
|
{"sequence_number": 2, "x": "second"},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
loader = JSONLoaderDynamic()
|
||||||
|
result = loader.load_dynamic(path, 2, output_keys="x")
|
||||||
|
assert result[0] == "second"
|
||||||
101
tests/test_utils.py
Normal file
101
tests/test_utils.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Mock streamlit before importing utils
|
||||||
|
import sys
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
sys.modules.setdefault("streamlit", MagicMock())
|
||||||
|
|
||||||
|
from utils import load_json, save_json, get_file_mtime, ALLOWED_BASE_DIR, DEFAULTS, resolve_path_case_insensitive
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_json_valid(tmp_path):
|
||||||
|
p = tmp_path / "test.json"
|
||||||
|
data = {"key": "value"}
|
||||||
|
p.write_text(json.dumps(data))
|
||||||
|
result, mtime = load_json(p)
|
||||||
|
assert result == data
|
||||||
|
assert mtime > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_json_missing(tmp_path):
|
||||||
|
p = tmp_path / "nope.json"
|
||||||
|
result, mtime = load_json(p)
|
||||||
|
assert result == DEFAULTS.copy()
|
||||||
|
assert mtime == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_json_invalid(tmp_path):
|
||||||
|
p = tmp_path / "bad.json"
|
||||||
|
p.write_text("{not valid json")
|
||||||
|
result, mtime = load_json(p)
|
||||||
|
assert result == DEFAULTS.copy()
|
||||||
|
assert mtime == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_json_atomic(tmp_path):
|
||||||
|
p = tmp_path / "out.json"
|
||||||
|
data = {"hello": "world"}
|
||||||
|
save_json(p, data)
|
||||||
|
assert p.exists()
|
||||||
|
assert not p.with_suffix(".json.tmp").exists()
|
||||||
|
assert json.loads(p.read_text()) == data
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_json_overwrites(tmp_path):
|
||||||
|
p = tmp_path / "out.json"
|
||||||
|
save_json(p, {"a": 1})
|
||||||
|
save_json(p, {"b": 2})
|
||||||
|
assert json.loads(p.read_text()) == {"b": 2}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_file_mtime_existing(tmp_path):
|
||||||
|
p = tmp_path / "f.txt"
|
||||||
|
p.write_text("x")
|
||||||
|
assert get_file_mtime(p) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_file_mtime_missing(tmp_path):
|
||||||
|
assert get_file_mtime(tmp_path / "missing.txt") == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_allowed_base_dir_is_set():
|
||||||
|
assert ALLOWED_BASE_DIR is not None
|
||||||
|
assert isinstance(ALLOWED_BASE_DIR, Path)
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolvePathCaseInsensitive:
|
||||||
|
def test_exact_match(self, tmp_path):
|
||||||
|
d = tmp_path / "MyFolder"
|
||||||
|
d.mkdir()
|
||||||
|
result = resolve_path_case_insensitive(str(d))
|
||||||
|
assert result == d.resolve()
|
||||||
|
|
||||||
|
def test_wrong_case_single_component(self, tmp_path):
|
||||||
|
d = tmp_path / "MyFolder"
|
||||||
|
d.mkdir()
|
||||||
|
wrong = tmp_path / "myfolder"
|
||||||
|
result = resolve_path_case_insensitive(str(wrong))
|
||||||
|
assert result == d.resolve()
|
||||||
|
|
||||||
|
def test_wrong_case_nested(self, tmp_path):
|
||||||
|
d = tmp_path / "Parent" / "Child"
|
||||||
|
d.mkdir(parents=True)
|
||||||
|
wrong = tmp_path / "parent" / "CHILD"
|
||||||
|
result = resolve_path_case_insensitive(str(wrong))
|
||||||
|
assert result == d.resolve()
|
||||||
|
|
||||||
|
def test_no_match_returns_none(self, tmp_path):
|
||||||
|
result = resolve_path_case_insensitive(str(tmp_path / "nonexistent"))
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_file_path(self, tmp_path):
|
||||||
|
f = tmp_path / "Data.json"
|
||||||
|
f.write_text("{}")
|
||||||
|
wrong = tmp_path / "data.JSON"
|
||||||
|
result = resolve_path_case_insensitive(str(wrong))
|
||||||
|
assert result == f.resolve()
|
||||||
112
utils.py
112
utils.py
@@ -1,33 +1,48 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
|
|
||||||
|
# --- Magic String Keys ---
|
||||||
|
KEY_BATCH_DATA = "batch_data"
|
||||||
|
KEY_HISTORY_TREE = "history_tree"
|
||||||
|
KEY_PROMPT_HISTORY = "prompt_history"
|
||||||
|
KEY_SEQUENCE_NUMBER = "sequence_number"
|
||||||
|
|
||||||
|
# Configure logging for the application
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Default structure for new files
|
# Default structure for new files
|
||||||
DEFAULTS = {
|
DEFAULTS = {
|
||||||
# --- Standard Keys for your Restored Single Tab ---
|
# --- Prompts ---
|
||||||
"general_prompt": "", # Global positive
|
"general_prompt": "",
|
||||||
"general_negative": "", # Global negative
|
"general_negative": "Vivid tones, overexposed, static, blurry details, subtitles, style, artwork, painting, picture, still image, overall gray, worst quality, low quality, JPEG compression artifacts, ugly, deformed, extra fingers, poorly drawn hands, poorly drawn face, distorted, disfigured, malformed limbs, fused fingers, unmoving frame, cluttered background, three legs",
|
||||||
"current_prompt": "", # Specific positive
|
"current_prompt": "",
|
||||||
"negative": "", # Specific negative
|
"negative": "",
|
||||||
"seed": -1,
|
"seed": -1,
|
||||||
|
"cfg": 1.5,
|
||||||
|
|
||||||
# --- Settings ---
|
# --- Settings ---
|
||||||
"camera": "static",
|
"camera": "static",
|
||||||
"flf": 0.0,
|
"flf": 0.0,
|
||||||
"steps": 20,
|
|
||||||
"cfg": 7.0,
|
|
||||||
"sampler_name": "euler",
|
|
||||||
"scheduler": "normal",
|
|
||||||
"denoise": 1.0,
|
|
||||||
"model_name": "v1-5-pruned-emaonly.ckpt",
|
|
||||||
"vae_name": "vae-ft-mse-840000-ema-pruned.ckpt",
|
|
||||||
|
|
||||||
# --- I2V / VACE Specifics ---
|
# --- I2V / VACE Specifics ---
|
||||||
"frame_to_skip": 81,
|
"frame_to_skip": 81,
|
||||||
|
"end_frame": 0,
|
||||||
|
"transition": "1-2",
|
||||||
|
"vace_length": 49,
|
||||||
"vace schedule": 1,
|
"vace schedule": 1,
|
||||||
"input_a_frames": 0,
|
"input_a_frames": 16,
|
||||||
"input_b_frames": 0,
|
"input_b_frames": 16,
|
||||||
"reference switch": 1,
|
"reference switch": 1,
|
||||||
"video file path": "",
|
"video file path": "",
|
||||||
"reference image path": "",
|
"reference image path": "",
|
||||||
@@ -43,14 +58,51 @@ DEFAULTS = {
|
|||||||
CONFIG_FILE = Path(".editor_config.json")
|
CONFIG_FILE = Path(".editor_config.json")
|
||||||
SNIPPETS_FILE = Path(".editor_snippets.json")
|
SNIPPETS_FILE = Path(".editor_snippets.json")
|
||||||
|
|
||||||
|
# No restriction on directory navigation
|
||||||
|
ALLOWED_BASE_DIR = Path("/").resolve()
|
||||||
|
|
||||||
|
def resolve_path_case_insensitive(path: str | Path) -> Path | None:
|
||||||
|
"""Resolve a path with case-insensitive component matching on Linux.
|
||||||
|
|
||||||
|
Walks each component of the path and matches against actual directory
|
||||||
|
entries when an exact match fails. Returns the corrected Path, or None
|
||||||
|
if no match is found.
|
||||||
|
"""
|
||||||
|
p = Path(path)
|
||||||
|
if p.exists():
|
||||||
|
return p.resolve()
|
||||||
|
|
||||||
|
# Start from the root / anchor
|
||||||
|
parts = p.resolve().parts # resolve to get absolute parts
|
||||||
|
built = Path(parts[0]) # root "/"
|
||||||
|
for component in parts[1:]:
|
||||||
|
candidate = built / component
|
||||||
|
if candidate.exists():
|
||||||
|
built = candidate
|
||||||
|
continue
|
||||||
|
# Case-insensitive scan of the parent directory
|
||||||
|
try:
|
||||||
|
lower = component.lower()
|
||||||
|
match = next(
|
||||||
|
(entry for entry in built.iterdir() if entry.name.lower() == lower),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
except PermissionError:
|
||||||
|
return None
|
||||||
|
if match is None:
|
||||||
|
return None
|
||||||
|
built = match
|
||||||
|
return built.resolve()
|
||||||
|
|
||||||
|
|
||||||
def load_config():
|
def load_config():
|
||||||
"""Loads the main editor configuration (Favorites, Last Dir, Servers)."""
|
"""Loads the main editor configuration (Favorites, Last Dir, Servers)."""
|
||||||
if CONFIG_FILE.exists():
|
if CONFIG_FILE.exists():
|
||||||
try:
|
try:
|
||||||
with open(CONFIG_FILE, 'r') as f:
|
with open(CONFIG_FILE, 'r') as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
except:
|
except (json.JSONDecodeError, IOError) as e:
|
||||||
pass
|
logger.warning(f"Failed to load config: {e}")
|
||||||
return {"favorites": [], "last_dir": str(Path.cwd()), "comfy_instances": []}
|
return {"favorites": [], "last_dir": str(Path.cwd()), "comfy_instances": []}
|
||||||
|
|
||||||
def save_config(current_dir, favorites, extra_data=None):
|
def save_config(current_dir, favorites, extra_data=None):
|
||||||
@@ -76,15 +128,15 @@ def load_snippets():
|
|||||||
try:
|
try:
|
||||||
with open(SNIPPETS_FILE, 'r') as f:
|
with open(SNIPPETS_FILE, 'r') as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
except:
|
except (json.JSONDecodeError, IOError) as e:
|
||||||
pass
|
logger.warning(f"Failed to load snippets: {e}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def save_snippets(snippets):
|
def save_snippets(snippets):
|
||||||
with open(SNIPPETS_FILE, 'w') as f:
|
with open(SNIPPETS_FILE, 'w') as f:
|
||||||
json.dump(snippets, f, indent=4)
|
json.dump(snippets, f, indent=4)
|
||||||
|
|
||||||
def load_json(path):
|
def load_json(path: str | Path) -> tuple[dict[str, Any], float]:
|
||||||
path = Path(path)
|
path = Path(path)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return DEFAULTS.copy(), 0
|
return DEFAULTS.copy(), 0
|
||||||
@@ -96,20 +148,26 @@ def load_json(path):
|
|||||||
st.error(f"Error loading JSON: {e}")
|
st.error(f"Error loading JSON: {e}")
|
||||||
return DEFAULTS.copy(), 0
|
return DEFAULTS.copy(), 0
|
||||||
|
|
||||||
def save_json(path, data):
|
def save_json(path: str | Path, data: dict[str, Any]) -> None:
|
||||||
with open(path, 'w') as f:
|
path = Path(path)
|
||||||
|
tmp = path.with_suffix('.json.tmp')
|
||||||
|
with open(tmp, 'w') as f:
|
||||||
json.dump(data, f, indent=4)
|
json.dump(data, f, indent=4)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
def get_file_mtime(path):
|
def get_file_mtime(path: str | Path) -> float:
|
||||||
"""Returns the modification time of a file, or 0 if it doesn't exist."""
|
"""Returns the modification time of a file, or 0 if it doesn't exist."""
|
||||||
path = Path(path)
|
path = Path(path)
|
||||||
if path.exists():
|
if path.exists():
|
||||||
return path.stat().st_mtime
|
return path.stat().st_mtime
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def generate_templates(current_dir):
|
def generate_templates(current_dir: Path) -> None:
|
||||||
"""Creates dummy template files if folder is empty."""
|
"""Creates batch template files if folder is empty."""
|
||||||
save_json(current_dir / "template_i2v.json", DEFAULTS)
|
first = DEFAULTS.copy()
|
||||||
|
first[KEY_SEQUENCE_NUMBER] = 1
|
||||||
|
save_json(current_dir / "batch_prompt_i2v.json", {KEY_BATCH_DATA: [first]})
|
||||||
|
|
||||||
batch_data = {"batch_data": [DEFAULTS.copy(), DEFAULTS.copy()]}
|
first2 = DEFAULTS.copy()
|
||||||
save_json(current_dir / "template_batch.json", batch_data)
|
first2[KEY_SEQUENCE_NUMBER] = 1
|
||||||
|
save_json(current_dir / "batch_prompt_vace_extend.json", {KEY_BATCH_DATA: [first2]})
|
||||||
|
|||||||
140
web/json_dynamic.js
Normal file
140
web/json_dynamic.js
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { app } from "../../scripts/app.js";
|
||||||
|
import { api } from "../../scripts/api.js";
|
||||||
|
|
||||||
|
app.registerExtension({
|
||||||
|
name: "json.manager.dynamic",
|
||||||
|
|
||||||
|
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||||
|
if (nodeData.name !== "JSONLoaderDynamic") return;
|
||||||
|
|
||||||
|
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
|
||||||
|
nodeType.prototype.onNodeCreated = function () {
|
||||||
|
origOnNodeCreated?.apply(this, arguments);
|
||||||
|
|
||||||
|
// Hide internal widgets (managed by JS)
|
||||||
|
for (const name of ["output_keys", "output_types"]) {
|
||||||
|
const w = this.widgets?.find(w => w.name === name);
|
||||||
|
if (w) { w.type = "hidden"; w.computeSize = () => [0, -4]; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all 32 default outputs from Python RETURN_TYPES
|
||||||
|
while (this.outputs.length > 0) {
|
||||||
|
this.removeOutput(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Refresh button
|
||||||
|
this.addWidget("button", "Refresh Outputs", null, () => {
|
||||||
|
this.refreshDynamicOutputs();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setSize(this.computeSize());
|
||||||
|
};
|
||||||
|
|
||||||
|
nodeType.prototype.refreshDynamicOutputs = async function () {
|
||||||
|
const pathWidget = this.widgets?.find(w => w.name === "json_path");
|
||||||
|
const seqWidget = this.widgets?.find(w => w.name === "sequence_number");
|
||||||
|
if (!pathWidget?.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await api.fetchApi(
|
||||||
|
`/json_manager/get_keys?path=${encodeURIComponent(pathWidget.value)}&sequence_number=${seqWidget?.value || 1}`
|
||||||
|
);
|
||||||
|
const { keys, types } = await resp.json();
|
||||||
|
|
||||||
|
// Store keys and types in hidden widgets for persistence
|
||||||
|
const okWidget = this.widgets?.find(w => w.name === "output_keys");
|
||||||
|
if (okWidget) okWidget.value = keys.join(",");
|
||||||
|
const otWidget = this.widgets?.find(w => w.name === "output_types");
|
||||||
|
if (otWidget) otWidget.value = types.join(",");
|
||||||
|
|
||||||
|
// Build a map of current output names to slot indices
|
||||||
|
const oldSlots = {};
|
||||||
|
for (let i = 0; i < this.outputs.length; i++) {
|
||||||
|
oldSlots[this.outputs[i].name] = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build new outputs, reusing existing slots to preserve links
|
||||||
|
const newOutputs = [];
|
||||||
|
for (let k = 0; k < keys.length; k++) {
|
||||||
|
const key = keys[k];
|
||||||
|
const type = types[k] || "*";
|
||||||
|
if (key in oldSlots) {
|
||||||
|
// Reuse existing slot object (keeps links intact)
|
||||||
|
const slot = this.outputs[oldSlots[key]];
|
||||||
|
slot.type = type;
|
||||||
|
newOutputs.push(slot);
|
||||||
|
delete oldSlots[key];
|
||||||
|
} else {
|
||||||
|
// New key — create a fresh slot
|
||||||
|
newOutputs.push({ name: key, type: type, links: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect links on slots that are being removed
|
||||||
|
for (const name in oldSlots) {
|
||||||
|
const idx = oldSlots[name];
|
||||||
|
if (this.outputs[idx]?.links?.length) {
|
||||||
|
for (const linkId of [...this.outputs[idx].links]) {
|
||||||
|
this.graph?.removeLink(linkId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reassign the outputs array and fix link slot indices
|
||||||
|
this.outputs = newOutputs;
|
||||||
|
// Update link origin_slot to match new positions
|
||||||
|
if (this.graph) {
|
||||||
|
for (let i = 0; i < this.outputs.length; i++) {
|
||||||
|
const links = this.outputs[i].links;
|
||||||
|
if (!links) continue;
|
||||||
|
for (const linkId of links) {
|
||||||
|
const link = this.graph.links[linkId];
|
||||||
|
if (link) link.origin_slot = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setSize(this.computeSize());
|
||||||
|
app.graph.setDirtyCanvas(true, true);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[JSONLoaderDynamic] Refresh failed:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Restore state on workflow load
|
||||||
|
const origOnConfigure = nodeType.prototype.onConfigure;
|
||||||
|
nodeType.prototype.onConfigure = function (info) {
|
||||||
|
origOnConfigure?.apply(this, arguments);
|
||||||
|
|
||||||
|
// Hide internal widgets
|
||||||
|
for (const name of ["output_keys", "output_types"]) {
|
||||||
|
const w = this.widgets?.find(w => w.name === name);
|
||||||
|
if (w) { w.type = "hidden"; w.computeSize = () => [0, -4]; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const okWidget = this.widgets?.find(w => w.name === "output_keys");
|
||||||
|
const otWidget = this.widgets?.find(w => w.name === "output_types");
|
||||||
|
|
||||||
|
const keys = okWidget?.value
|
||||||
|
? okWidget.value.split(",").filter(k => k.trim())
|
||||||
|
: [];
|
||||||
|
const types = otWidget?.value
|
||||||
|
? otWidget.value.split(",")
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// On load, LiteGraph already restored serialized outputs with links.
|
||||||
|
// Rename and set types to match stored state (preserves links).
|
||||||
|
for (let i = 0; i < this.outputs.length && i < keys.length; i++) {
|
||||||
|
this.outputs[i].name = keys[i].trim();
|
||||||
|
if (types[i]) this.outputs[i].type = types[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any extra outputs beyond the key count
|
||||||
|
while (this.outputs.length > keys.length) {
|
||||||
|
this.removeOutput(this.outputs.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setSize(this.computeSize());
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user