Compare commits
14 Commits
feature/sq
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b042fe4368 | |||
| 04b9ed0e27 | |||
| 1b8d13f7c4 | |||
| 497e6b06fb | |||
| 993fc86070 | |||
| c9bcc735f4 | |||
| dc8f44f02b | |||
| 2a6b4f5245 | |||
| 60d1162700 | |||
| 204fc4ea85 | |||
| 033b3415c2 | |||
| 2ccc3821d6 | |||
| 615755ba44 | |||
| 4b09491242 |
18
db.py
18
db.py
@@ -98,6 +98,24 @@ class ProjectDB:
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def rename_project(self, old_name: str, new_name: str) -> bool:
|
||||||
|
now = time.time()
|
||||||
|
cur = self.conn.execute(
|
||||||
|
"UPDATE projects SET name = ?, updated_at = ? WHERE name = ?",
|
||||||
|
(new_name, now, old_name),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
def update_project_path(self, name: str, folder_path: str) -> bool:
|
||||||
|
now = time.time()
|
||||||
|
cur = self.conn.execute(
|
||||||
|
"UPDATE projects SET folder_path = ?, updated_at = ? WHERE name = ?",
|
||||||
|
(folder_path, now, name),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
def delete_project(self, name: str) -> bool:
|
def delete_project(self, name: str) -> bool:
|
||||||
cur = self.conn.execute("DELETE FROM projects WHERE name = ?", (name,))
|
cur = self.conn.execute("DELETE FROM projects WHERE name = ?", (name,))
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import html
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -17,7 +18,10 @@ class HistoryTree:
|
|||||||
def _migrate_legacy(self, old_list: list[dict[str, Any]]) -> None:
|
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]
|
for _ in range(10):
|
||||||
|
node_id = str(uuid.uuid4())[:8]
|
||||||
|
if node_id not in self.nodes:
|
||||||
|
break
|
||||||
self.nodes[node_id] = {
|
self.nodes[node_id] = {
|
||||||
"id": node_id, "parent": parent, "timestamp": time.time(),
|
"id": node_id, "parent": parent, "timestamp": time.time(),
|
||||||
"data": item, "note": item.get("note", "Legacy Import")
|
"data": item, "note": item.get("note", "Legacy Import")
|
||||||
@@ -27,7 +31,13 @@ class HistoryTree:
|
|||||||
self.head_id = parent
|
self.head_id = parent
|
||||||
|
|
||||||
def commit(self, data: dict[str, Any], note: str = "Snapshot") -> str:
|
def commit(self, data: dict[str, Any], note: str = "Snapshot") -> str:
|
||||||
new_id = str(uuid.uuid4())[:8]
|
# Generate unique node ID with collision check
|
||||||
|
for _ in range(10):
|
||||||
|
new_id = str(uuid.uuid4())[:8]
|
||||||
|
if new_id not in self.nodes:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise ValueError("Failed to generate unique node ID after 10 attempts")
|
||||||
|
|
||||||
# Cycle detection: walk parent chain from head to verify no cycle
|
# Cycle detection: walk parent chain from head to verify no cycle
|
||||||
if self.head_id:
|
if self.head_id:
|
||||||
@@ -38,7 +48,7 @@ class HistoryTree:
|
|||||||
raise ValueError(f"Cycle detected in history tree at node {current}")
|
raise ValueError(f"Cycle detected in history tree at node {current}")
|
||||||
visited.add(current)
|
visited.add(current)
|
||||||
node = self.nodes.get(current)
|
node = self.nodes.get(current)
|
||||||
current = node["parent"] if node else None
|
current = node.get("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():
|
||||||
@@ -114,8 +124,12 @@ class HistoryTree:
|
|||||||
# Build reverse lookup: node_id -> branch name (walk each branch ancestry)
|
# Build reverse lookup: node_id -> branch name (walk each branch ancestry)
|
||||||
node_to_branch: dict[str, str] = {}
|
node_to_branch: dict[str, str] = {}
|
||||||
for b_name, tip_id in self.branches.items():
|
for b_name, tip_id in self.branches.items():
|
||||||
|
visited = set()
|
||||||
current = tip_id
|
current = tip_id
|
||||||
while current and current in self.nodes:
|
while current and current in self.nodes:
|
||||||
|
if current in visited:
|
||||||
|
break
|
||||||
|
visited.add(current)
|
||||||
if current not in node_to_branch:
|
if current not in node_to_branch:
|
||||||
node_to_branch[current] = b_name
|
node_to_branch[current] = b_name
|
||||||
current = self.nodes[current].get('parent')
|
current = self.nodes[current].get('parent')
|
||||||
@@ -154,13 +168,14 @@ class HistoryTree:
|
|||||||
full_note = n.get('note', 'Step')
|
full_note = n.get('note', 'Step')
|
||||||
|
|
||||||
display_note = (full_note[:max_note_len] + '..') if len(full_note) > max_note_len else full_note
|
display_note = (full_note[:max_note_len] + '..') if len(full_note) > max_note_len else full_note
|
||||||
|
display_note = html.escape(display_note)
|
||||||
|
|
||||||
ts = time.strftime('%b %d %H:%M', time.localtime(n['timestamp']))
|
ts = time.strftime('%b %d %H:%M', time.localtime(n['timestamp']))
|
||||||
|
|
||||||
# Branch label for tip nodes
|
# Branch label for tip nodes
|
||||||
branch_label = ""
|
branch_label = ""
|
||||||
if nid in tip_to_branches:
|
if nid in tip_to_branches:
|
||||||
branch_label = ", ".join(tip_to_branches[nid])
|
branch_label = html.escape(", ".join(tip_to_branches[nid]))
|
||||||
|
|
||||||
# COLORS — per-branch tint, overridden for HEAD and tips
|
# COLORS — per-branch tint, overridden for HEAD and tips
|
||||||
b_name = node_to_branch.get(nid)
|
b_name = node_to_branch.get(nid)
|
||||||
@@ -190,11 +205,18 @@ class HistoryTree:
|
|||||||
+ '</TABLE>>'
|
+ '</TABLE>>'
|
||||||
)
|
)
|
||||||
|
|
||||||
safe_tooltip = full_note.replace('"', "'")
|
safe_tooltip = (full_note
|
||||||
dot.append(f' "{nid}" [label={label}, tooltip="{safe_tooltip}"];')
|
.replace('\\', '\\\\')
|
||||||
|
.replace('"', '\\"')
|
||||||
|
.replace('\n', ' ')
|
||||||
|
.replace('\r', '')
|
||||||
|
.replace(']', ']'))
|
||||||
|
safe_nid = nid.replace('"', '_')
|
||||||
|
dot.append(f' "{safe_nid}" [label={label}, tooltip="{safe_tooltip}"];')
|
||||||
|
|
||||||
if n["parent"] and n["parent"] in self.nodes:
|
if n.get("parent") and n["parent"] in self.nodes:
|
||||||
dot.append(f' "{n["parent"]}" -> "{nid}";')
|
safe_parent = n["parent"].replace('"', '_')
|
||||||
|
dot.append(f' "{safe_parent}" -> "{safe_nid}";')
|
||||||
|
|
||||||
dot.append("}")
|
dot.append("}")
|
||||||
return "\n".join(dot)
|
return "\n".join(dot)
|
||||||
|
|||||||
19
main.py
19
main.py
@@ -1,3 +1,4 @@
|
|||||||
|
import copy
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -156,6 +157,20 @@ def index():
|
|||||||
background: rgba(255,255,255,0.2);
|
background: rgba(255,255,255,0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sub-sequence accent colors (per sub-index, cycling) */
|
||||||
|
.body--dark .subsegment-color-0 > .q-expansion-item__container > .q-item { border-left: 6px solid #06B6D4; padding-left: 10px; }
|
||||||
|
.body--dark .subsegment-color-0 .q-expansion-item__toggle-icon { color: #06B6D4 !important; }
|
||||||
|
.body--dark .subsegment-color-1 > .q-expansion-item__container > .q-item { border-left: 6px solid #A78BFA; padding-left: 10px; }
|
||||||
|
.body--dark .subsegment-color-1 .q-expansion-item__toggle-icon { color: #A78BFA !important; }
|
||||||
|
.body--dark .subsegment-color-2 > .q-expansion-item__container > .q-item { border-left: 6px solid #34D399; padding-left: 10px; }
|
||||||
|
.body--dark .subsegment-color-2 .q-expansion-item__toggle-icon { color: #34D399 !important; }
|
||||||
|
.body--dark .subsegment-color-3 > .q-expansion-item__container > .q-item { border-left: 6px solid #F472B6; padding-left: 10px; }
|
||||||
|
.body--dark .subsegment-color-3 .q-expansion-item__toggle-icon { color: #F472B6 !important; }
|
||||||
|
.body--dark .subsegment-color-4 > .q-expansion-item__container > .q-item { border-left: 6px solid #FBBF24; padding-left: 10px; }
|
||||||
|
.body--dark .subsegment-color-4 .q-expansion-item__toggle-icon { color: #FBBF24 !important; }
|
||||||
|
.body--dark .subsegment-color-5 > .q-expansion-item__container > .q-item { border-left: 6px solid #FB923C; padding-left: 10px; }
|
||||||
|
.body--dark .subsegment-color-5 .q-expansion-item__toggle-icon { color: #FB923C !important; }
|
||||||
|
|
||||||
/* Secondary pane teal accent */
|
/* Secondary pane teal accent */
|
||||||
.pane-secondary .q-field--outlined.q-field--focused .q-field__control:after {
|
.pane-secondary .q-field--outlined.q-field--focused .q-field__control:after {
|
||||||
border-color: #06B6D4 !important;
|
border-color: #06B6D4 !important;
|
||||||
@@ -467,7 +482,7 @@ def render_sidebar(state: AppState, dual_pane: dict):
|
|||||||
if not fn.endswith('.json'):
|
if not fn.endswith('.json'):
|
||||||
fn += '.json'
|
fn += '.json'
|
||||||
path = state.current_dir / fn
|
path = state.current_dir / fn
|
||||||
first_item = DEFAULTS.copy()
|
first_item = copy.deepcopy(DEFAULTS)
|
||||||
first_item[KEY_SEQUENCE_NUMBER] = 1
|
first_item[KEY_SEQUENCE_NUMBER] = 1
|
||||||
save_json(path, {KEY_BATCH_DATA: [first_item]})
|
save_json(path, {KEY_BATCH_DATA: [first_item]})
|
||||||
new_fn_input.set_value('')
|
new_fn_input.set_value('')
|
||||||
@@ -500,7 +515,7 @@ def render_sidebar(state: AppState, dual_pane: dict):
|
|||||||
state.show_comfy_monitor = e.value
|
state.show_comfy_monitor = e.value
|
||||||
state._render_main.refresh()
|
state._render_main.refresh()
|
||||||
|
|
||||||
ui.checkbox('Show Comfy Monitor', value=True, on_change=on_monitor_toggle)
|
ui.checkbox('Show Comfy Monitor', value=state.show_comfy_monitor, on_change=on_monitor_toggle)
|
||||||
|
|
||||||
|
|
||||||
# Register REST API routes for ComfyUI connectivity (uses the shared DB instance)
|
# Register REST API routes for ComfyUI connectivity (uses the shared DB instance)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
@@ -88,7 +89,7 @@ if PromptServer is not None:
|
|||||||
async def list_projects_proxy(request):
|
async def list_projects_proxy(request):
|
||||||
manager_url = request.query.get("url", "http://localhost:8080")
|
manager_url = request.query.get("url", "http://localhost:8080")
|
||||||
url = f"{manager_url.rstrip('/')}/api/projects"
|
url = f"{manager_url.rstrip('/')}/api/projects"
|
||||||
data = _fetch_json(url)
|
data = await asyncio.to_thread(_fetch_json, url)
|
||||||
return web.json_response(data)
|
return web.json_response(data)
|
||||||
|
|
||||||
@PromptServer.instance.routes.get("/json_manager/list_project_files")
|
@PromptServer.instance.routes.get("/json_manager/list_project_files")
|
||||||
@@ -96,7 +97,7 @@ if PromptServer is not None:
|
|||||||
manager_url = request.query.get("url", "http://localhost:8080")
|
manager_url = request.query.get("url", "http://localhost:8080")
|
||||||
project = urllib.parse.quote(request.query.get("project", ""), safe='')
|
project = urllib.parse.quote(request.query.get("project", ""), safe='')
|
||||||
url = f"{manager_url.rstrip('/')}/api/projects/{project}/files"
|
url = f"{manager_url.rstrip('/')}/api/projects/{project}/files"
|
||||||
data = _fetch_json(url)
|
data = await asyncio.to_thread(_fetch_json, url)
|
||||||
return web.json_response(data)
|
return web.json_response(data)
|
||||||
|
|
||||||
@PromptServer.instance.routes.get("/json_manager/list_project_sequences")
|
@PromptServer.instance.routes.get("/json_manager/list_project_sequences")
|
||||||
@@ -105,7 +106,7 @@ if PromptServer is not None:
|
|||||||
project = urllib.parse.quote(request.query.get("project", ""), safe='')
|
project = urllib.parse.quote(request.query.get("project", ""), safe='')
|
||||||
file_name = urllib.parse.quote(request.query.get("file", ""), safe='')
|
file_name = urllib.parse.quote(request.query.get("file", ""), safe='')
|
||||||
url = f"{manager_url.rstrip('/')}/api/projects/{project}/files/{file_name}/sequences"
|
url = f"{manager_url.rstrip('/')}/api/projects/{project}/files/{file_name}/sequences"
|
||||||
data = _fetch_json(url)
|
data = await asyncio.to_thread(_fetch_json, url)
|
||||||
return web.json_response(data)
|
return web.json_response(data)
|
||||||
|
|
||||||
@PromptServer.instance.routes.get("/json_manager/get_project_keys")
|
@PromptServer.instance.routes.get("/json_manager/get_project_keys")
|
||||||
@@ -117,7 +118,7 @@ if PromptServer is not None:
|
|||||||
seq = int(request.query.get("seq", "1"))
|
seq = int(request.query.get("seq", "1"))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
seq = 1
|
seq = 1
|
||||||
data = _fetch_keys(manager_url, project, file_name, seq)
|
data = await asyncio.to_thread(_fetch_keys, manager_url, project, file_name, seq)
|
||||||
if data.get("error") in ("http_error", "network_error", "parse_error"):
|
if data.get("error") in ("http_error", "network_error", "parse_error"):
|
||||||
status = data.get("status", 502)
|
status = data.get("status", 502)
|
||||||
return web.json_response(data, status=status)
|
return web.json_response(data, status=status)
|
||||||
@@ -138,6 +139,7 @@ class ProjectLoaderDynamic:
|
|||||||
"project_name": ("STRING", {"default": "", "multiline": False}),
|
"project_name": ("STRING", {"default": "", "multiline": False}),
|
||||||
"file_name": ("STRING", {"default": "", "multiline": False}),
|
"file_name": ("STRING", {"default": "", "multiline": False}),
|
||||||
"sequence_number": ("INT", {"default": 1, "min": 1, "max": 9999}),
|
"sequence_number": ("INT", {"default": 1, "min": 1, "max": 9999}),
|
||||||
|
"refresh": (["off", "on"],),
|
||||||
},
|
},
|
||||||
"optional": {
|
"optional": {
|
||||||
"output_keys": ("STRING", {"default": ""}),
|
"output_keys": ("STRING", {"default": ""}),
|
||||||
@@ -152,7 +154,7 @@ class ProjectLoaderDynamic:
|
|||||||
OUTPUT_NODE = False
|
OUTPUT_NODE = False
|
||||||
|
|
||||||
def load_dynamic(self, manager_url, project_name, file_name, sequence_number,
|
def load_dynamic(self, manager_url, project_name, file_name, sequence_number,
|
||||||
output_keys="", output_types=""):
|
refresh="off", output_keys="", output_types=""):
|
||||||
# Fetch keys metadata (includes total_sequences count)
|
# Fetch keys metadata (includes total_sequences count)
|
||||||
keys_meta = _fetch_keys(manager_url, project_name, file_name, sequence_number)
|
keys_meta = _fetch_keys(manager_url, project_name, file_name, sequence_number)
|
||||||
if keys_meta.get("error") in ("http_error", "network_error", "parse_error"):
|
if keys_meta.get("error") in ("http_error", "network_error", "parse_error"):
|
||||||
|
|||||||
141
tab_batch_ng.py
141
tab_batch_ng.py
@@ -1,4 +1,6 @@
|
|||||||
import copy
|
import copy
|
||||||
|
import json
|
||||||
|
import math
|
||||||
import random
|
import random
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -13,6 +15,7 @@ from history_tree import HistoryTree
|
|||||||
|
|
||||||
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.bmp', '.gif'}
|
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.bmp', '.gif'}
|
||||||
SUB_SEGMENT_MULTIPLIER = 1000
|
SUB_SEGMENT_MULTIPLIER = 1000
|
||||||
|
SUB_SEGMENT_NUM_COLORS = 6
|
||||||
FRAME_TO_SKIP_DEFAULT = DEFAULTS['frame_to_skip']
|
FRAME_TO_SKIP_DEFAULT = DEFAULTS['frame_to_skip']
|
||||||
|
|
||||||
VACE_MODES = [
|
VACE_MODES = [
|
||||||
@@ -76,6 +79,48 @@ def find_insert_position(batch_list, parent_index, parent_seq_num):
|
|||||||
return pos
|
return pos
|
||||||
|
|
||||||
|
|
||||||
|
# --- Auto change note ---
|
||||||
|
|
||||||
|
def _auto_change_note(htree, batch_list):
|
||||||
|
"""Compare current batch_list against last snapshot and describe changes."""
|
||||||
|
# Get previous batch data from the current head
|
||||||
|
if not htree.head_id or htree.head_id not in htree.nodes:
|
||||||
|
return f'Initial save ({len(batch_list)} sequences)'
|
||||||
|
|
||||||
|
prev_data = htree.nodes[htree.head_id].get('data', {})
|
||||||
|
prev_batch = prev_data.get(KEY_BATCH_DATA, [])
|
||||||
|
|
||||||
|
prev_by_seq = {int(s.get(KEY_SEQUENCE_NUMBER, 0)): s for s in prev_batch}
|
||||||
|
curr_by_seq = {int(s.get(KEY_SEQUENCE_NUMBER, 0)): s for s in batch_list}
|
||||||
|
|
||||||
|
added = sorted(set(curr_by_seq) - set(prev_by_seq))
|
||||||
|
removed = sorted(set(prev_by_seq) - set(curr_by_seq))
|
||||||
|
|
||||||
|
changed_keys = set()
|
||||||
|
for seq_num in sorted(set(curr_by_seq) & set(prev_by_seq)):
|
||||||
|
old, new = prev_by_seq[seq_num], curr_by_seq[seq_num]
|
||||||
|
all_keys = set(old) | set(new)
|
||||||
|
for k in all_keys:
|
||||||
|
if old.get(k) != new.get(k):
|
||||||
|
changed_keys.add(k)
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
if added:
|
||||||
|
parts.append(f'Added seq {", ".join(str(s) for s in added)}')
|
||||||
|
if removed:
|
||||||
|
parts.append(f'Removed seq {", ".join(str(s) for s in removed)}')
|
||||||
|
if changed_keys:
|
||||||
|
# Show up to 4 changed field names
|
||||||
|
keys_list = sorted(changed_keys)
|
||||||
|
if len(keys_list) > 4:
|
||||||
|
keys_str = ', '.join(keys_list[:4]) + f' +{len(keys_list) - 4} more'
|
||||||
|
else:
|
||||||
|
keys_str = ', '.join(keys_list)
|
||||||
|
parts.append(f'Changed: {keys_str}')
|
||||||
|
|
||||||
|
return '; '.join(parts) if parts else 'No changes detected'
|
||||||
|
|
||||||
|
|
||||||
# --- Helper for repetitive dict-bound inputs ---
|
# --- Helper for repetitive dict-bound inputs ---
|
||||||
|
|
||||||
def dict_input(element_fn, label, seq, key, **kwargs):
|
def dict_input(element_fn, label, seq, key, **kwargs):
|
||||||
@@ -99,6 +144,8 @@ def dict_number(label, seq, key, default=0, **kwargs):
|
|||||||
try:
|
try:
|
||||||
# Try float first to handle "1.5" strings, then check if it's a clean int
|
# Try float first to handle "1.5" strings, then check if it's a clean int
|
||||||
fval = float(val)
|
fval = float(val)
|
||||||
|
if not math.isfinite(fval):
|
||||||
|
fval = float(default)
|
||||||
val = int(fval) if fval == int(fval) else fval
|
val = int(fval) if fval == int(fval) else fval
|
||||||
except (ValueError, TypeError, OverflowError):
|
except (ValueError, TypeError, OverflowError):
|
||||||
val = default
|
val = default
|
||||||
@@ -109,10 +156,13 @@ def dict_number(label, seq, key, default=0, **kwargs):
|
|||||||
if v is None:
|
if v is None:
|
||||||
v = d
|
v = d
|
||||||
elif isinstance(v, float):
|
elif isinstance(v, float):
|
||||||
try:
|
if not math.isfinite(v):
|
||||||
v = int(v) if v == int(v) else v
|
|
||||||
except (OverflowError, ValueError):
|
|
||||||
v = d
|
v = d
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
v = int(v) if v == int(v) else v
|
||||||
|
except (OverflowError, ValueError):
|
||||||
|
v = d
|
||||||
seq[k] = v
|
seq[k] = v
|
||||||
|
|
||||||
el.on('blur', lambda _: _sync())
|
el.on('blur', lambda _: _sync())
|
||||||
@@ -223,13 +273,13 @@ def render_batch_processor(state: AppState):
|
|||||||
|
|
||||||
with ui.row().classes('q-mt-sm'):
|
with ui.row().classes('q-mt-sm'):
|
||||||
def add_empty():
|
def add_empty():
|
||||||
_add_sequence(DEFAULTS.copy())
|
_add_sequence(copy.deepcopy(DEFAULTS))
|
||||||
|
|
||||||
def add_from_source():
|
def add_from_source():
|
||||||
item = copy.deepcopy(DEFAULTS)
|
item = copy.deepcopy(DEFAULTS)
|
||||||
src_batch = _src_cache['batch']
|
src_batch = _src_cache['batch']
|
||||||
sel_idx = src_seq_select.value
|
sel_idx = src_seq_select.value
|
||||||
if src_batch and sel_idx is not None:
|
if src_batch and sel_idx is not None and int(sel_idx) < len(src_batch):
|
||||||
item.update(copy.deepcopy(src_batch[int(sel_idx)]))
|
item.update(copy.deepcopy(src_batch[int(sel_idx)]))
|
||||||
elif _src_cache['data']:
|
elif _src_cache['data']:
|
||||||
item.update(copy.deepcopy(_src_cache['data']))
|
item.update(copy.deepcopy(_src_cache['data']))
|
||||||
@@ -242,7 +292,7 @@ def render_batch_processor(state: AppState):
|
|||||||
lora_keys = ['lora 1 high', 'lora 1 low', 'lora 2 high', 'lora 2 low',
|
lora_keys = ['lora 1 high', 'lora 1 low', 'lora 2 high', 'lora 2 low',
|
||||||
'lora 3 high', 'lora 3 low']
|
'lora 3 high', 'lora 3 low']
|
||||||
standard_keys = {
|
standard_keys = {
|
||||||
'general_prompt', 'general_negative', 'current_prompt', 'negative', 'prompt',
|
'name', 'mode', 'general_prompt', 'general_negative', 'current_prompt', 'negative', 'prompt',
|
||||||
'seed', 'cfg', 'camera', 'flf', KEY_SEQUENCE_NUMBER,
|
'seed', 'cfg', 'camera', 'flf', KEY_SEQUENCE_NUMBER,
|
||||||
'frame_to_skip', 'end_frame', 'transition', 'vace_length',
|
'frame_to_skip', 'end_frame', 'transition', 'vace_length',
|
||||||
'input_a_frames', 'input_b_frames', 'reference switch', 'vace schedule',
|
'input_a_frames', 'input_b_frames', 'reference switch', 'vace schedule',
|
||||||
@@ -291,8 +341,12 @@ def render_batch_processor(state: AppState):
|
|||||||
htree = HistoryTree(tree_data)
|
htree = HistoryTree(tree_data)
|
||||||
snapshot_payload = copy.deepcopy(data)
|
snapshot_payload = copy.deepcopy(data)
|
||||||
snapshot_payload.pop(KEY_HISTORY_TREE, None)
|
snapshot_payload.pop(KEY_HISTORY_TREE, None)
|
||||||
note = commit_input.value if commit_input.value else 'Batch Update'
|
note = commit_input.value if commit_input.value else _auto_change_note(htree, batch_list)
|
||||||
htree.commit(snapshot_payload, note=note)
|
try:
|
||||||
|
htree.commit(snapshot_payload, note=note)
|
||||||
|
except ValueError as e:
|
||||||
|
ui.notify(f'Save failed: {e}', type='negative')
|
||||||
|
return
|
||||||
data[KEY_HISTORY_TREE] = htree.to_dict()
|
data[KEY_HISTORY_TREE] = htree.to_dict()
|
||||||
save_json(file_path, data)
|
save_json(file_path, data)
|
||||||
if state.db_enabled and state.current_project and state.db:
|
if state.db_enabled and state.current_project and state.db:
|
||||||
@@ -321,21 +375,40 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
|
|||||||
refresh_list.refresh()
|
refresh_list.refresh()
|
||||||
|
|
||||||
seq_num = seq.get(KEY_SEQUENCE_NUMBER, i + 1)
|
seq_num = seq.get(KEY_SEQUENCE_NUMBER, i + 1)
|
||||||
|
seq_name = seq.get('name', '')
|
||||||
|
|
||||||
if is_subsegment(seq_num):
|
if is_subsegment(seq_num):
|
||||||
label = f'Sub #{parent_of(seq_num)}.{sub_index_of(seq_num)} ({int(seq_num)})'
|
label = f'Sub #{parent_of(seq_num)}.{sub_index_of(seq_num)} ({int(seq_num)})'
|
||||||
else:
|
else:
|
||||||
label = f'Sequence #{seq_num}'
|
label = f'Sequence #{seq_num}'
|
||||||
|
if seq_name:
|
||||||
|
label += f' — {seq_name}'
|
||||||
|
|
||||||
with ui.expansion(label, icon='movie').classes('w-full'):
|
if is_subsegment(seq_num):
|
||||||
|
color_idx = (sub_index_of(seq_num) - 1) % SUB_SEGMENT_NUM_COLORS
|
||||||
|
exp_classes = f'w-full subsegment-color-{color_idx}'
|
||||||
|
else:
|
||||||
|
exp_classes = 'w-full'
|
||||||
|
with ui.expansion(label, icon='movie').classes(exp_classes) as expansion:
|
||||||
# --- Action row ---
|
# --- Action row ---
|
||||||
with ui.row().classes('w-full q-gutter-sm action-row'):
|
with ui.row().classes('w-full q-gutter-sm action-row'):
|
||||||
|
# Rename
|
||||||
|
async def rename(s=seq):
|
||||||
|
result = await ui.run_javascript(
|
||||||
|
f'prompt("Rename sequence:", {json.dumps(s.get("name", ""))})',
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
if result is not None:
|
||||||
|
s['name'] = result
|
||||||
|
commit('Renamed!')
|
||||||
|
|
||||||
|
ui.button('Rename', icon='edit', on_click=rename).props('outline')
|
||||||
# Copy from source
|
# Copy from source
|
||||||
def copy_source(idx=i, sn=seq_num):
|
def copy_source(idx=i, sn=seq_num):
|
||||||
item = copy.deepcopy(DEFAULTS)
|
item = copy.deepcopy(DEFAULTS)
|
||||||
src_batch = src_cache['batch']
|
src_batch = src_cache['batch']
|
||||||
sel_idx = src_seq_select.value
|
sel_idx = src_seq_select.value
|
||||||
if src_batch and sel_idx is not None:
|
if src_batch and sel_idx is not None and int(sel_idx) < len(src_batch):
|
||||||
item.update(copy.deepcopy(src_batch[int(sel_idx)]))
|
item.update(copy.deepcopy(src_batch[int(sel_idx)]))
|
||||||
elif src_cache['data']:
|
elif src_cache['data']:
|
||||||
item.update(copy.deepcopy(src_cache['data']))
|
item.update(copy.deepcopy(src_cache['data']))
|
||||||
@@ -390,8 +463,9 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
|
|||||||
|
|
||||||
# Delete
|
# Delete
|
||||||
def delete(idx=i):
|
def delete(idx=i):
|
||||||
batch_list.pop(idx)
|
if idx < len(batch_list):
|
||||||
commit()
|
batch_list.pop(idx)
|
||||||
|
commit()
|
||||||
|
|
||||||
ui.button(icon='delete', on_click=delete).props('color=negative')
|
ui.button(icon='delete', on_click=delete).props('color=negative')
|
||||||
|
|
||||||
@@ -410,6 +484,9 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
|
|||||||
'w-full q-mt-sm').props('outlined rows=2')
|
'w-full q-mt-sm').props('outlined rows=2')
|
||||||
|
|
||||||
with splitter.after:
|
with splitter.after:
|
||||||
|
# Mode
|
||||||
|
dict_number('Mode', seq, 'mode').props('outlined').classes('w-full')
|
||||||
|
|
||||||
# Sequence number
|
# Sequence number
|
||||||
sn_label = (
|
sn_label = (
|
||||||
f'Seq Number (Sub #{parent_of(seq_num)}.{sub_index_of(seq_num)})'
|
f'Seq Number (Sub #{parent_of(seq_num)}.{sub_index_of(seq_num)})'
|
||||||
@@ -541,7 +618,13 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
|
|||||||
|
|
||||||
def _render_vace_settings(i, seq, batch_list, data, file_path, state, refresh_list):
|
def _render_vace_settings(i, seq, batch_list, data, file_path, state, refresh_list):
|
||||||
# VACE Schedule (needed early for both columns)
|
# VACE Schedule (needed early for both columns)
|
||||||
sched_val = max(0, min(int(seq.get('vace schedule', 1)), len(VACE_MODES) - 1))
|
def _safe_int(val, default=0):
|
||||||
|
try:
|
||||||
|
return int(float(val))
|
||||||
|
except (ValueError, TypeError, OverflowError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
sched_val = max(0, min(_safe_int(seq.get('vace schedule', 1), 1), len(VACE_MODES) - 1))
|
||||||
|
|
||||||
# Mode reference dialog
|
# Mode reference dialog
|
||||||
with ui.dialog() as ref_dlg, ui.card():
|
with ui.dialog() as ref_dlg, ui.card():
|
||||||
@@ -562,18 +645,18 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, state, refresh_li
|
|||||||
fts_input = dict_number('Frame to Skip', seq, 'frame_to_skip').classes(
|
fts_input = dict_number('Frame to Skip', seq, 'frame_to_skip').classes(
|
||||||
'col').props('outlined')
|
'col').props('outlined')
|
||||||
|
|
||||||
_original_fts = int(seq.get('frame_to_skip', FRAME_TO_SKIP_DEFAULT))
|
_original_fts = _safe_int(seq.get('frame_to_skip', FRAME_TO_SKIP_DEFAULT), FRAME_TO_SKIP_DEFAULT)
|
||||||
|
|
||||||
def shift_fts(idx=i, orig=_original_fts):
|
def shift_fts(idx=i, orig=_original_fts):
|
||||||
new_fts = int(fts_input.value) if fts_input.value is not None else orig
|
new_fts = _safe_int(fts_input.value, orig)
|
||||||
delta = new_fts - orig
|
delta = new_fts - orig
|
||||||
if delta == 0:
|
if delta == 0:
|
||||||
ui.notify('No change to shift', type='info')
|
ui.notify('No change to shift', type='info')
|
||||||
return
|
return
|
||||||
shifted = 0
|
shifted = 0
|
||||||
for j in range(idx + 1, len(batch_list)):
|
for j in range(idx + 1, len(batch_list)):
|
||||||
batch_list[j]['frame_to_skip'] = int(
|
batch_list[j]['frame_to_skip'] = _safe_int(
|
||||||
batch_list[j].get('frame_to_skip', FRAME_TO_SKIP_DEFAULT)) + delta
|
batch_list[j].get('frame_to_skip', FRAME_TO_SKIP_DEFAULT), FRAME_TO_SKIP_DEFAULT) + delta
|
||||||
shifted += 1
|
shifted += 1
|
||||||
data[KEY_BATCH_DATA] = batch_list
|
data[KEY_BATCH_DATA] = batch_list
|
||||||
save_json(file_path, data)
|
save_json(file_path, data)
|
||||||
@@ -597,7 +680,7 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, state, refresh_li
|
|||||||
ui.button(icon='help', on_click=ref_dlg.open).props('flat dense round')
|
ui.button(icon='help', on_click=ref_dlg.open).props('flat dense round')
|
||||||
|
|
||||||
def update_mode_label(e):
|
def update_mode_label(e):
|
||||||
idx = int(e.sender.value) if e.sender.value is not None else 0
|
idx = _safe_int(e.sender.value, 0)
|
||||||
idx = max(0, min(idx, len(VACE_MODES) - 1))
|
idx = max(0, min(idx, len(VACE_MODES) - 1))
|
||||||
mode_label.set_text(VACE_MODES[idx])
|
mode_label.set_text(VACE_MODES[idx])
|
||||||
|
|
||||||
@@ -611,10 +694,10 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, state, refresh_li
|
|||||||
'outlined').classes('w-full q-mt-sm')
|
'outlined').classes('w-full q-mt-sm')
|
||||||
|
|
||||||
# VACE Length + output calculation
|
# VACE Length + output calculation
|
||||||
input_a = int(seq.get('input_a_frames', 16))
|
input_a = _safe_int(seq.get('input_a_frames', 16), 16)
|
||||||
input_b = int(seq.get('input_b_frames', 16))
|
input_b = _safe_int(seq.get('input_b_frames', 16), 16)
|
||||||
stored_total = int(seq.get('vace_length', 49))
|
stored_total = _safe_int(seq.get('vace_length', 49), 49)
|
||||||
mode_idx = int(seq.get('vace schedule', 1))
|
mode_idx = _safe_int(seq.get('vace schedule', 1), 1)
|
||||||
|
|
||||||
if mode_idx == 0:
|
if mode_idx == 0:
|
||||||
base_length = max(stored_total - input_a, 1)
|
base_length = max(stored_total - input_a, 1)
|
||||||
@@ -633,10 +716,10 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, state, refresh_li
|
|||||||
|
|
||||||
# Recalculate VACE output when any input changes
|
# Recalculate VACE output when any input changes
|
||||||
def recalc_vace(*_args):
|
def recalc_vace(*_args):
|
||||||
mi = int(vs_input.value) if vs_input.value is not None else 0
|
mi = _safe_int(vs_input.value, 0)
|
||||||
ia = int(ia_input.value) if ia_input.value is not None else 16
|
ia = _safe_int(ia_input.value, 16)
|
||||||
ib = int(ib_input.value) if ib_input.value is not None else 16
|
ib = _safe_int(ib_input.value, 16)
|
||||||
nb = int(vl_input.value) if vl_input.value is not None else 1
|
nb = _safe_int(vl_input.value, 1)
|
||||||
|
|
||||||
if mi == 0:
|
if mi == 0:
|
||||||
raw = nb + ia
|
raw = nb + ia
|
||||||
@@ -721,7 +804,11 @@ def _render_mass_update(batch_list, data, file_path, state: AppState, refresh_li
|
|||||||
htree = HistoryTree(data.get(KEY_HISTORY_TREE, {}))
|
htree = HistoryTree(data.get(KEY_HISTORY_TREE, {}))
|
||||||
snapshot = copy.deepcopy(data)
|
snapshot = copy.deepcopy(data)
|
||||||
snapshot.pop(KEY_HISTORY_TREE, None)
|
snapshot.pop(KEY_HISTORY_TREE, None)
|
||||||
htree.commit(snapshot, f"Mass update: {', '.join(selected_keys)}")
|
try:
|
||||||
|
htree.commit(snapshot, f"Mass update: {', '.join(selected_keys)}")
|
||||||
|
except ValueError as e:
|
||||||
|
ui.notify(f'Mass update failed: {e}', type='negative')
|
||||||
|
return
|
||||||
data[KEY_HISTORY_TREE] = htree.to_dict()
|
data[KEY_HISTORY_TREE] = htree.to_dict()
|
||||||
save_json(file_path, data)
|
save_json(file_path, data)
|
||||||
if state.db_enabled and state.current_project and state.db:
|
if state.db_enabled and state.current_project and state.db:
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ def _render_single_instance(state: AppState, instance_config: dict, index: int,
|
|||||||
|
|
||||||
async def refresh_status():
|
async def refresh_status():
|
||||||
status_container.clear()
|
status_container.clear()
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_running_loop()
|
||||||
res, err = await loop.run_in_executor(
|
res, err = await loop.run_in_executor(
|
||||||
None, lambda: _fetch_blocking(f'{comfy_url}/queue'))
|
None, lambda: _fetch_blocking(f'{comfy_url}/queue'))
|
||||||
with status_container:
|
with status_container:
|
||||||
@@ -237,7 +237,7 @@ def _render_single_instance(state: AppState, instance_config: dict, index: int,
|
|||||||
|
|
||||||
async def check_image():
|
async def check_image():
|
||||||
img_container.clear()
|
img_container.clear()
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_running_loop()
|
||||||
res, err = await loop.run_in_executor(
|
res, err = await loop.run_in_executor(
|
||||||
None, lambda: _fetch_blocking(f'{comfy_url}/history', timeout=2))
|
None, lambda: _fetch_blocking(f'{comfy_url}/history', timeout=2))
|
||||||
with img_container:
|
with img_container:
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
from state import AppState
|
from state import AppState
|
||||||
from db import ProjectDB
|
from db import ProjectDB
|
||||||
from utils import save_config, sync_to_db, KEY_BATCH_DATA
|
from utils import save_config, sync_to_db
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -109,6 +111,49 @@ def render_projects_tab(state: AppState):
|
|||||||
ui.button('Deactivate', icon='cancel',
|
ui.button('Deactivate', icon='cancel',
|
||||||
on_click=deactivate).props('flat dense')
|
on_click=deactivate).props('flat dense')
|
||||||
|
|
||||||
|
async def rename_proj(name=proj['name']):
|
||||||
|
new_name = await ui.run_javascript(
|
||||||
|
f'prompt("Rename project:", {json.dumps(name)})',
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
if new_name and new_name.strip() and new_name.strip() != name:
|
||||||
|
new_name = new_name.strip()
|
||||||
|
try:
|
||||||
|
state.db.rename_project(name, new_name)
|
||||||
|
if state.current_project == name:
|
||||||
|
state.current_project = new_name
|
||||||
|
state.config['current_project'] = new_name
|
||||||
|
save_config(state.current_dir,
|
||||||
|
state.config.get('favorites', []),
|
||||||
|
state.config)
|
||||||
|
ui.notify(f'Renamed to "{new_name}"', type='positive')
|
||||||
|
render_project_list.refresh()
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
ui.notify(f'A project named "{new_name}" already exists',
|
||||||
|
type='warning')
|
||||||
|
except Exception as e:
|
||||||
|
ui.notify(f'Error: {e}', type='negative')
|
||||||
|
|
||||||
|
ui.button('Rename', icon='edit',
|
||||||
|
on_click=rename_proj).props('flat dense')
|
||||||
|
|
||||||
|
async def change_path(name=proj['name'], path=proj['folder_path']):
|
||||||
|
new_path = await ui.run_javascript(
|
||||||
|
f'prompt("New path for project:", {json.dumps(path)})',
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
if new_path and new_path.strip() and new_path.strip() != path:
|
||||||
|
new_path = new_path.strip()
|
||||||
|
if not Path(new_path).is_dir():
|
||||||
|
ui.notify(f'Warning: "{new_path}" does not exist',
|
||||||
|
type='warning')
|
||||||
|
state.db.update_project_path(name, new_path)
|
||||||
|
ui.notify(f'Path updated to "{new_path}"', type='positive')
|
||||||
|
render_project_list.refresh()
|
||||||
|
|
||||||
|
ui.button('Path', icon='folder',
|
||||||
|
on_click=change_path).props('flat dense')
|
||||||
|
|
||||||
def import_folder(pid=proj['id'], pname=proj['name']):
|
def import_folder(pid=proj['id'], pname=proj['name']):
|
||||||
_import_folder(state, pid, pname, render_project_list)
|
_import_folder(state, pid, pname, render_project_list)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import copy
|
import copy
|
||||||
|
import json
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
@@ -9,15 +10,37 @@ from utils import save_json, sync_to_db, KEY_BATCH_DATA, KEY_HISTORY_TREE
|
|||||||
|
|
||||||
|
|
||||||
def _delete_nodes(htree, data, file_path, node_ids):
|
def _delete_nodes(htree, data, file_path, node_ids):
|
||||||
"""Delete nodes with backup, branch cleanup, and head fallback."""
|
"""Delete nodes with backup, branch cleanup, re-parenting, and head fallback."""
|
||||||
if 'history_tree_backup' not in data:
|
if 'history_tree_backup' not in data:
|
||||||
data['history_tree_backup'] = []
|
data['history_tree_backup'] = []
|
||||||
data['history_tree_backup'].append(copy.deepcopy(htree.to_dict()))
|
data['history_tree_backup'].append(copy.deepcopy(htree.to_dict()))
|
||||||
|
data['history_tree_backup'] = data['history_tree_backup'][-10:]
|
||||||
|
# Save deleted node parents before removal (needed for branch re-pointing)
|
||||||
|
deleted_parents = {}
|
||||||
|
for nid in node_ids:
|
||||||
|
deleted_node = htree.nodes.get(nid)
|
||||||
|
if deleted_node:
|
||||||
|
deleted_parents[nid] = deleted_node.get('parent')
|
||||||
|
# Re-parent children of deleted nodes — walk up to find a surviving ancestor
|
||||||
|
for nid in node_ids:
|
||||||
|
surviving_parent = deleted_parents.get(nid)
|
||||||
|
while surviving_parent in node_ids:
|
||||||
|
surviving_parent = deleted_parents.get(surviving_parent)
|
||||||
|
for child in htree.nodes.values():
|
||||||
|
if child.get('parent') == nid:
|
||||||
|
child['parent'] = surviving_parent
|
||||||
for nid in node_ids:
|
for nid in node_ids:
|
||||||
htree.nodes.pop(nid, None)
|
htree.nodes.pop(nid, None)
|
||||||
|
# Re-point branches whose tip was deleted to a surviving ancestor
|
||||||
for b, tip in list(htree.branches.items()):
|
for b, tip in list(htree.branches.items()):
|
||||||
if tip in node_ids:
|
if tip in node_ids:
|
||||||
del htree.branches[b]
|
new_tip = deleted_parents.get(tip)
|
||||||
|
while new_tip in node_ids:
|
||||||
|
new_tip = deleted_parents.get(new_tip)
|
||||||
|
if new_tip and new_tip in htree.nodes:
|
||||||
|
htree.branches[b] = new_tip
|
||||||
|
else:
|
||||||
|
del htree.branches[b]
|
||||||
if htree.head_id in node_ids:
|
if htree.head_id in node_ids:
|
||||||
if htree.nodes:
|
if htree.nodes:
|
||||||
htree.head_id = sorted(htree.nodes.values(),
|
htree.head_id = sorted(htree.nodes.values(),
|
||||||
@@ -152,8 +175,12 @@ def _render_batch_delete(htree, data, file_path, state, refresh_fn):
|
|||||||
def _walk_branch_nodes(htree, tip_id):
|
def _walk_branch_nodes(htree, tip_id):
|
||||||
"""Walk parent pointers from tip, returning nodes newest-first."""
|
"""Walk parent pointers from tip, returning nodes newest-first."""
|
||||||
nodes = []
|
nodes = []
|
||||||
|
visited = set()
|
||||||
current = tip_id
|
current = tip_id
|
||||||
while current and current in htree.nodes:
|
while current and current in htree.nodes:
|
||||||
|
if current in visited:
|
||||||
|
break
|
||||||
|
visited.add(current)
|
||||||
nodes.append(htree.nodes[current])
|
nodes.append(htree.nodes[current])
|
||||||
current = htree.nodes[current].get('parent')
|
current = htree.nodes[current].get('parent')
|
||||||
return nodes
|
return nodes
|
||||||
@@ -172,10 +199,14 @@ def _find_active_branch(htree):
|
|||||||
def _find_branch_for_node(htree, node_id):
|
def _find_branch_for_node(htree, node_id):
|
||||||
"""Return the branch name whose ancestry contains node_id, or None."""
|
"""Return the branch name whose ancestry contains node_id, or None."""
|
||||||
for b_name, tip_id in htree.branches.items():
|
for b_name, tip_id in htree.branches.items():
|
||||||
|
visited = set()
|
||||||
current = tip_id
|
current = tip_id
|
||||||
while current and current in htree.nodes:
|
while current and current in htree.nodes:
|
||||||
|
if current in visited:
|
||||||
|
break
|
||||||
if current == node_id:
|
if current == node_id:
|
||||||
return b_name
|
return b_name
|
||||||
|
visited.add(current)
|
||||||
current = htree.nodes[current].get('parent')
|
current = htree.nodes[current].get('parent')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -310,6 +341,10 @@ def _render_node_manager(all_nodes, htree, data, file_path, restore_fn, refresh_
|
|||||||
_delete_nodes(htree, data, file_path, {sel_id})
|
_delete_nodes(htree, data, file_path, {sel_id})
|
||||||
if state and state.db_enabled and state.current_project and state.db:
|
if state and state.db_enabled and state.current_project and state.db:
|
||||||
sync_to_db(state.db, state.current_project, file_path, data)
|
sync_to_db(state.db, state.current_project, file_path, data)
|
||||||
|
# Reset selection if branch was removed
|
||||||
|
if selected['branch'] not in htree.branches:
|
||||||
|
selected['branch'] = next(iter(htree.branches), None)
|
||||||
|
selected['node_id'] = htree.head_id
|
||||||
ui.notify('Node Deleted', type='positive')
|
ui.notify('Node Deleted', type='positive')
|
||||||
refresh_fn()
|
refresh_fn()
|
||||||
|
|
||||||
@@ -433,7 +468,7 @@ def _render_graphviz(dot_source: str, selected_node_id: str | None = None):
|
|||||||
src = graphviz.Source(dot_source)
|
src = graphviz.Source(dot_source)
|
||||||
svg = src.pipe(format='svg').decode('utf-8')
|
svg = src.pipe(format='svg').decode('utf-8')
|
||||||
|
|
||||||
sel_escaped = selected_node_id.replace("'", "\\'") if selected_node_id else ''
|
sel_escaped = json.dumps(selected_node_id or '')[1:-1] # strip quotes, get JS-safe content
|
||||||
|
|
||||||
# CSS inline (allowed), JS via run_javascript (script tags blocked)
|
# CSS inline (allowed), JS via run_javascript (script tags blocked)
|
||||||
css = '''<style>
|
css = '''<style>
|
||||||
@@ -490,11 +525,18 @@ def _render_graphviz(dot_source: str, selected_node_id: str | None = None):
|
|||||||
|
|
||||||
|
|
||||||
def _restore_node(data, node, htree, file_path, state: AppState):
|
def _restore_node(data, node, htree, file_path, state: AppState):
|
||||||
"""Restore a history node as the current version."""
|
"""Restore a history node as the current version (full replace, not merge)."""
|
||||||
node_data = copy.deepcopy(node['data'])
|
node_data = copy.deepcopy(node.get('data', {}))
|
||||||
if KEY_BATCH_DATA not in node_data and KEY_BATCH_DATA in data:
|
# Preserve the history tree before clearing
|
||||||
del data[KEY_BATCH_DATA]
|
preserved_tree = data.get(KEY_HISTORY_TREE)
|
||||||
|
preserved_backup = data.get('history_tree_backup')
|
||||||
|
data.clear()
|
||||||
data.update(node_data)
|
data.update(node_data)
|
||||||
|
# Re-attach history tree (not part of snapshot data)
|
||||||
|
if preserved_tree is not None:
|
||||||
|
data[KEY_HISTORY_TREE] = preserved_tree
|
||||||
|
if preserved_backup is not None:
|
||||||
|
data['history_tree_backup'] = preserved_backup
|
||||||
htree.head_id = node['id']
|
htree.head_id = node['id']
|
||||||
data[KEY_HISTORY_TREE] = htree.to_dict()
|
data[KEY_HISTORY_TREE] = htree.to_dict()
|
||||||
save_json(file_path, data)
|
save_json(file_path, data)
|
||||||
@@ -511,7 +553,7 @@ def _render_data_preview(nid, htree):
|
|||||||
ui.label('No node selected.').classes('text-caption')
|
ui.label('No node selected.').classes('text-caption')
|
||||||
return
|
return
|
||||||
|
|
||||||
node_data = htree.nodes[nid]['data']
|
node_data = htree.nodes[nid].get('data', {})
|
||||||
batch_list = node_data.get(KEY_BATCH_DATA, [])
|
batch_list = node_data.get(KEY_BATCH_DATA, [])
|
||||||
|
|
||||||
if batch_list and isinstance(batch_list, list) and len(batch_list) > 0:
|
if batch_list and isinstance(batch_list, list) and len(batch_list) > 0:
|
||||||
|
|||||||
34
utils.py
34
utils.py
@@ -1,3 +1,4 @@
|
|||||||
|
import copy
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -30,6 +31,7 @@ DEFAULTS = {
|
|||||||
"cfg": 1.5,
|
"cfg": 1.5,
|
||||||
|
|
||||||
# --- Settings ---
|
# --- Settings ---
|
||||||
|
"mode": 0,
|
||||||
"camera": "static",
|
"camera": "static",
|
||||||
"flf": 0.0,
|
"flf": 0.0,
|
||||||
|
|
||||||
@@ -112,14 +114,17 @@ def save_config(current_dir, favorites, extra_data=None):
|
|||||||
existing = load_config()
|
existing = load_config()
|
||||||
data.update(existing)
|
data.update(existing)
|
||||||
|
|
||||||
data["last_dir"] = str(current_dir)
|
|
||||||
data["favorites"] = favorites
|
|
||||||
|
|
||||||
if extra_data:
|
if extra_data:
|
||||||
data.update(extra_data)
|
data.update(extra_data)
|
||||||
|
|
||||||
with open(CONFIG_FILE, 'w') as f:
|
# Force-set explicit params last so extra_data can't override them
|
||||||
|
data["last_dir"] = str(current_dir)
|
||||||
|
data["favorites"] = favorites
|
||||||
|
|
||||||
|
tmp = CONFIG_FILE.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, CONFIG_FILE)
|
||||||
|
|
||||||
def load_snippets():
|
def load_snippets():
|
||||||
if SNIPPETS_FILE.exists():
|
if SNIPPETS_FILE.exists():
|
||||||
@@ -131,8 +136,10 @@ def load_snippets():
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
def save_snippets(snippets):
|
def save_snippets(snippets):
|
||||||
with open(SNIPPETS_FILE, 'w') as f:
|
tmp = SNIPPETS_FILE.with_suffix('.json.tmp')
|
||||||
|
with open(tmp, 'w') as f:
|
||||||
json.dump(snippets, f, indent=4)
|
json.dump(snippets, f, indent=4)
|
||||||
|
os.replace(tmp, SNIPPETS_FILE)
|
||||||
|
|
||||||
def load_json(path: str | Path) -> tuple[dict[str, Any], float]:
|
def load_json(path: str | Path) -> tuple[dict[str, Any], float]:
|
||||||
path = Path(path)
|
path = Path(path)
|
||||||
@@ -181,7 +188,7 @@ def sync_to_db(db, project_name: str, file_path: Path, data: dict) -> None:
|
|||||||
top_level = {k: v for k, v in data.items()
|
top_level = {k: v for k, v in data.items()
|
||||||
if k not in (KEY_BATCH_DATA, KEY_HISTORY_TREE)}
|
if k not in (KEY_BATCH_DATA, KEY_HISTORY_TREE)}
|
||||||
if not df:
|
if not df:
|
||||||
now = __import__('time').time()
|
now = time.time()
|
||||||
cur = db.conn.execute(
|
cur = db.conn.execute(
|
||||||
"INSERT INTO data_files (project_id, name, data_type, top_level, created_at, updated_at) "
|
"INSERT INTO data_files (project_id, name, data_type, top_level, created_at, updated_at) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
@@ -191,7 +198,7 @@ def sync_to_db(db, project_name: str, file_path: Path, data: dict) -> None:
|
|||||||
else:
|
else:
|
||||||
df_id = df["id"]
|
df_id = df["id"]
|
||||||
# Update top_level metadata
|
# Update top_level metadata
|
||||||
now = __import__('time').time()
|
now = time.time()
|
||||||
db.conn.execute(
|
db.conn.execute(
|
||||||
"UPDATE data_files SET top_level = ?, updated_at = ? WHERE id = ?",
|
"UPDATE data_files SET top_level = ?, updated_at = ? WHERE id = ?",
|
||||||
(json.dumps(top_level), now, df_id),
|
(json.dumps(top_level), now, df_id),
|
||||||
@@ -205,17 +212,18 @@ def sync_to_db(db, project_name: str, file_path: Path, data: dict) -> None:
|
|||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
seq_num = int(item.get(KEY_SEQUENCE_NUMBER, 0))
|
seq_num = int(item.get(KEY_SEQUENCE_NUMBER, 0))
|
||||||
now = __import__('time').time()
|
now = time.time()
|
||||||
db.conn.execute(
|
db.conn.execute(
|
||||||
"INSERT INTO sequences (data_file_id, sequence_number, data, updated_at) "
|
"INSERT INTO sequences (data_file_id, sequence_number, data, updated_at) "
|
||||||
"VALUES (?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?) "
|
||||||
|
"ON CONFLICT(data_file_id, sequence_number) DO UPDATE SET data=excluded.data, updated_at=excluded.updated_at",
|
||||||
(df_id, seq_num, json.dumps(item), now),
|
(df_id, seq_num, json.dumps(item), now),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sync history tree
|
# Sync history tree
|
||||||
history_tree = data.get(KEY_HISTORY_TREE)
|
history_tree = data.get(KEY_HISTORY_TREE)
|
||||||
if history_tree and isinstance(history_tree, dict):
|
if history_tree and isinstance(history_tree, dict):
|
||||||
now = __import__('time').time()
|
now = time.time()
|
||||||
db.conn.execute(
|
db.conn.execute(
|
||||||
"INSERT INTO history_trees (data_file_id, tree_data, updated_at) "
|
"INSERT INTO history_trees (data_file_id, tree_data, updated_at) "
|
||||||
"VALUES (?, ?, ?) "
|
"VALUES (?, ?, ?) "
|
||||||
@@ -236,10 +244,10 @@ def sync_to_db(db, project_name: str, file_path: Path, data: dict) -> None:
|
|||||||
|
|
||||||
def generate_templates(current_dir: Path) -> None:
|
def generate_templates(current_dir: Path) -> None:
|
||||||
"""Creates batch template files if folder is empty."""
|
"""Creates batch template files if folder is empty."""
|
||||||
first = DEFAULTS.copy()
|
first = copy.deepcopy(DEFAULTS)
|
||||||
first[KEY_SEQUENCE_NUMBER] = 1
|
first[KEY_SEQUENCE_NUMBER] = 1
|
||||||
save_json(current_dir / "batch_prompt_i2v.json", {KEY_BATCH_DATA: [first]})
|
save_json(current_dir / "batch_prompt_i2v.json", {KEY_BATCH_DATA: [first]})
|
||||||
|
|
||||||
first2 = DEFAULTS.copy()
|
first2 = copy.deepcopy(DEFAULTS)
|
||||||
first2[KEY_SEQUENCE_NUMBER] = 1
|
first2[KEY_SEQUENCE_NUMBER] = 1
|
||||||
save_json(current_dir / "batch_prompt_vace_extend.json", {KEY_BATCH_DATA: [first2]})
|
save_json(current_dir / "batch_prompt_vace_extend.json", {KEY_BATCH_DATA: [first2]})
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ app.registerExtension({
|
|||||||
|
|
||||||
// Auto-refresh with 500ms debounce on widget changes
|
// Auto-refresh with 500ms debounce on widget changes
|
||||||
this._refreshTimer = null;
|
this._refreshTimer = null;
|
||||||
const autoRefreshWidgets = ["project_name", "file_name", "sequence_number"];
|
const autoRefreshWidgets = ["project_name", "file_name", "sequence_number", "refresh"];
|
||||||
for (const widgetName of autoRefreshWidgets) {
|
for (const widgetName of autoRefreshWidgets) {
|
||||||
const w = this.widgets?.find(w => w.name === widgetName);
|
const w = this.widgets?.find(w => w.name === widgetName);
|
||||||
if (w) {
|
if (w) {
|
||||||
@@ -117,11 +117,11 @@ app.registerExtension({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store keys and types in hidden widgets for persistence (comma-separated)
|
// Store keys and types in hidden widgets for persistence (JSON)
|
||||||
const okWidget = this.widgets?.find(w => w.name === "output_keys");
|
const okWidget = this.widgets?.find(w => w.name === "output_keys");
|
||||||
if (okWidget) okWidget.value = keys.join(",");
|
if (okWidget) okWidget.value = JSON.stringify(keys);
|
||||||
const otWidget = this.widgets?.find(w => w.name === "output_types");
|
const otWidget = this.widgets?.find(w => w.name === "output_types");
|
||||||
if (otWidget) otWidget.value = types.join(",");
|
if (otWidget) otWidget.value = JSON.stringify(types);
|
||||||
|
|
||||||
// Slot 0 is always total_sequences (INT) — ensure it exists
|
// Slot 0 is always total_sequences (INT) — ensure it exists
|
||||||
if (this.outputs.length === 0 || this.outputs[0].name !== "total_sequences") {
|
if (this.outputs.length === 0 || this.outputs[0].name !== "total_sequences") {
|
||||||
@@ -198,12 +198,18 @@ app.registerExtension({
|
|||||||
const okWidget = this.widgets?.find(w => w.name === "output_keys");
|
const okWidget = this.widgets?.find(w => w.name === "output_keys");
|
||||||
const otWidget = this.widgets?.find(w => w.name === "output_types");
|
const otWidget = this.widgets?.find(w => w.name === "output_types");
|
||||||
|
|
||||||
const keys = okWidget?.value
|
let keys = [];
|
||||||
? okWidget.value.split(",").filter(k => k.trim())
|
let types = [];
|
||||||
: [];
|
if (okWidget?.value) {
|
||||||
const types = otWidget?.value
|
try { keys = JSON.parse(okWidget.value); } catch (_) {
|
||||||
? otWidget.value.split(",")
|
keys = okWidget.value.split(",").filter(k => k.trim());
|
||||||
: [];
|
}
|
||||||
|
}
|
||||||
|
if (otWidget?.value) {
|
||||||
|
try { types = JSON.parse(otWidget.value); } catch (_) {
|
||||||
|
types = otWidget.value.split(",");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure slot 0 is total_sequences (INT)
|
// Ensure slot 0 is total_sequences (INT)
|
||||||
if (this.outputs.length === 0 || this.outputs[0].name !== "total_sequences") {
|
if (this.outputs.length === 0 || this.outputs[0].name !== "total_sequences") {
|
||||||
@@ -245,8 +251,8 @@ app.registerExtension({
|
|||||||
} else if (this.outputs.length > 1) {
|
} else if (this.outputs.length > 1) {
|
||||||
// Widget values empty but serialized dynamic outputs exist — sync widgets
|
// Widget values empty but serialized dynamic outputs exist — sync widgets
|
||||||
const dynamicOutputs = this.outputs.slice(1);
|
const dynamicOutputs = this.outputs.slice(1);
|
||||||
if (okWidget) okWidget.value = dynamicOutputs.map(o => o.name).join(",");
|
if (okWidget) okWidget.value = JSON.stringify(dynamicOutputs.map(o => o.name));
|
||||||
if (otWidget) otWidget.value = dynamicOutputs.map(o => o.type).join(",");
|
if (otWidget) otWidget.value = JSON.stringify(dynamicOutputs.map(o => o.type));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setSize(this.computeSize());
|
this.setSize(this.computeSize());
|
||||||
|
|||||||
Reference in New Issue
Block a user