Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ba8ce45846 | |||
| 1ec3abf17a | |||
| 686d4687c3 | |||
| a37dd82ae3 | |||
| 3b11a4e974 | |||
| 5eb82f8ff6 | |||
| bf598ebf80 | |||
| 6e232da193 | |||
| ff5802ab63 | |||
| 413e1c09e9 | |||
| 672b28e27f | |||
| 3dc91319a2 | |||
| bd36b4b725 | |||
| 77eb3473ab |
@@ -81,7 +81,7 @@ class ProjectDB:
|
||||
self._migrate_all_lora_data()
|
||||
|
||||
def _migrate_all_lora_data(self) -> None:
|
||||
"""One-time bulk migration: merge separate lora strength keys in all stored sequences."""
|
||||
"""Bulk migration: split combined lora 'name:strength' into separate keys."""
|
||||
rows = self.conn.execute("SELECT id, data FROM sequences").fetchall()
|
||||
updated = 0
|
||||
self.conn.execute("BEGIN")
|
||||
@@ -244,7 +244,7 @@ class ProjectDB:
|
||||
|
||||
@staticmethod
|
||||
def _migrate_lora_keys(data: dict) -> dict:
|
||||
"""Merge lora name + strength into single 'name:strength' key, remove separate strength keys."""
|
||||
"""Split combined lora 'name:strength' into separate name and strength keys."""
|
||||
for idx in range(1, 4):
|
||||
for tier in ('high', 'low'):
|
||||
name_key = f'lora {idx} {tier}'
|
||||
@@ -252,16 +252,31 @@ class ProjectDB:
|
||||
raw = str(data.get(name_key, ''))
|
||||
if raw.startswith('<lora:'):
|
||||
inner = raw.replace('<lora:', '').replace('>', '')
|
||||
data[name_key] = inner
|
||||
if ':' not in inner:
|
||||
strength = data.pop(str_key, 1.0)
|
||||
data[name_key] = f'{inner}:{float(strength)}' if inner else ''
|
||||
if ':' in inner:
|
||||
parts = inner.rsplit(':', 1)
|
||||
data[name_key] = parts[0]
|
||||
try:
|
||||
data[str_key] = float(parts[1])
|
||||
except ValueError:
|
||||
data[str_key] = 1.0
|
||||
else:
|
||||
data.pop(str_key, None)
|
||||
elif str_key in data:
|
||||
strength = float(data.pop(str_key))
|
||||
if raw and ':' not in raw:
|
||||
data[name_key] = f'{raw}:{strength}'
|
||||
data[name_key] = inner
|
||||
if str_key not in data:
|
||||
data[str_key] = 1.0
|
||||
elif ':' in raw and raw:
|
||||
parts = raw.rsplit(':', 1)
|
||||
try:
|
||||
strength = float(parts[1])
|
||||
data[name_key] = parts[0]
|
||||
data[str_key] = strength
|
||||
except ValueError:
|
||||
if str_key not in data:
|
||||
data[str_key] = 1.0
|
||||
elif raw:
|
||||
# Name exists without colon, ensure strength key exists
|
||||
if str_key not in data:
|
||||
data[str_key] = 1.0
|
||||
# If name is empty, don't add a strength key
|
||||
return data
|
||||
|
||||
def get_sequence(self, data_file_id: int, sequence_number: int) -> dict | None:
|
||||
@@ -324,27 +339,34 @@ class ProjectDB:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def save_history_tree(self, data_file_id: int, tree_data: dict) -> None:
|
||||
"""Save history tree, extracting node snapshots into separate table."""
|
||||
"""Save history tree, extracting snapshot data into separate table.
|
||||
|
||||
Supports both new format (snapshots dict) and old format (nodes dict).
|
||||
"""
|
||||
now = time.time()
|
||||
nodes = tree_data.get("nodes", {})
|
||||
if "snapshots" in tree_data:
|
||||
entries = tree_data.get("snapshots", {})
|
||||
entry_key = "snapshots"
|
||||
else:
|
||||
entries = tree_data.get("nodes", {})
|
||||
entry_key = "nodes"
|
||||
slim_tree = dict(tree_data)
|
||||
slim_nodes = {}
|
||||
for nid, node in nodes.items():
|
||||
slim_nodes[nid] = {k: v for k, v in node.items() if k != "data"}
|
||||
slim_tree["nodes"] = slim_nodes
|
||||
slim_entries = {}
|
||||
for eid, entry in entries.items():
|
||||
slim_entries[eid] = {k: v for k, v in entry.items() if k != "data"}
|
||||
slim_tree[entry_key] = slim_entries
|
||||
|
||||
self.conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
# Extract snapshot data from nodes into history_snapshots table
|
||||
for nid, node in nodes.items():
|
||||
snap = node.get("data")
|
||||
for eid, entry in entries.items():
|
||||
snap = entry.get("data")
|
||||
if snap:
|
||||
self.conn.execute(
|
||||
"INSERT INTO history_snapshots (data_file_id, node_id, snapshot_data, updated_at) "
|
||||
"VALUES (?, ?, ?, ?) "
|
||||
"ON CONFLICT(data_file_id, node_id) DO UPDATE SET "
|
||||
"snapshot_data=excluded.snapshot_data, updated_at=excluded.updated_at",
|
||||
(data_file_id, nid, json.dumps(snap), now),
|
||||
(data_file_id, eid, json.dumps(snap), now),
|
||||
)
|
||||
self.conn.execute(
|
||||
"INSERT INTO history_trees (data_file_id, tree_data, updated_at) "
|
||||
@@ -447,24 +469,30 @@ class ProjectDB:
|
||||
)
|
||||
|
||||
# Import history tree (extract snapshots into separate table)
|
||||
# Supports both new format (snapshots dict) and old format (nodes dict)
|
||||
history_tree = data.get(KEY_HISTORY_TREE)
|
||||
if history_tree and isinstance(history_tree, dict):
|
||||
now = time.time()
|
||||
nodes = history_tree.get("nodes", {})
|
||||
if "snapshots" in history_tree:
|
||||
entries = history_tree.get("snapshots", {})
|
||||
entry_key = "snapshots"
|
||||
else:
|
||||
entries = history_tree.get("nodes", {})
|
||||
entry_key = "nodes"
|
||||
slim_tree = dict(history_tree)
|
||||
slim_nodes = {}
|
||||
for nid, node in nodes.items():
|
||||
snap = node.get("data")
|
||||
slim_entries = {}
|
||||
for eid, entry in entries.items():
|
||||
snap = entry.get("data")
|
||||
if snap:
|
||||
self.conn.execute(
|
||||
"INSERT INTO history_snapshots (data_file_id, node_id, snapshot_data, updated_at) "
|
||||
"VALUES (?, ?, ?, ?) "
|
||||
"ON CONFLICT(data_file_id, node_id) DO UPDATE SET "
|
||||
"snapshot_data=excluded.snapshot_data, updated_at=excluded.updated_at",
|
||||
(df_id, nid, json.dumps(snap), now),
|
||||
(df_id, eid, json.dumps(snap), now),
|
||||
)
|
||||
slim_nodes[nid] = {k: v for k, v in node.items() if k != "data"}
|
||||
slim_tree["nodes"] = slim_nodes
|
||||
slim_entries[eid] = {k: v for k, v in entry.items() if k != "data"}
|
||||
slim_tree[entry_key] = slim_entries
|
||||
self.conn.execute(
|
||||
"INSERT INTO history_trees (data_file_id, tree_data, updated_at) "
|
||||
"VALUES (?, ?, ?) "
|
||||
@@ -524,9 +552,9 @@ class ProjectDB:
|
||||
# Load history tree (metadata only, no snapshot data)
|
||||
tree = self.get_history_tree(df["id"])
|
||||
if tree:
|
||||
# Strip any residual snapshot data from nodes
|
||||
for node in tree.get("nodes", {}).values():
|
||||
node.pop("data", None)
|
||||
# Strip any residual snapshot data (supports both formats)
|
||||
for entry in tree.get("snapshots", tree.get("nodes", {})).values():
|
||||
entry.pop("data", None)
|
||||
data["history_tree"] = tree
|
||||
t3 = time.time()
|
||||
|
||||
|
||||
@@ -295,12 +295,12 @@ def index():
|
||||
sync_to_db, pane_state.db, pane_state.current_project, fp, data)
|
||||
tree = data.get('history_tree')
|
||||
if tree and isinstance(tree, dict):
|
||||
for node in tree.get('nodes', {}).values():
|
||||
node.pop('data', None)
|
||||
for entry in tree.get('snapshots', tree.get('nodes', {})).values():
|
||||
entry.pop('data', None)
|
||||
for backup in data.get('history_tree_backup', []):
|
||||
if isinstance(backup, dict):
|
||||
for node in backup.get('nodes', {}).values():
|
||||
node.pop('data', None)
|
||||
for entry in backup.get('snapshots', backup.get('nodes', {})).values():
|
||||
entry.pop('data', None)
|
||||
pane_state.data_cache = data
|
||||
pane_state.last_mtime = fp.stat().st_mtime if fp.exists() else 0
|
||||
pane_state.loaded_file = str(fp)
|
||||
@@ -339,13 +339,13 @@ def index():
|
||||
sync_to_db, state.db, state.current_project, fp, data)
|
||||
tree = data.get('history_tree')
|
||||
if tree and isinstance(tree, dict):
|
||||
for node in tree.get('nodes', {}).values():
|
||||
node.pop('data', None)
|
||||
for entry in tree.get('snapshots', tree.get('nodes', {})).values():
|
||||
entry.pop('data', None)
|
||||
# Strip snapshot data from history_tree_backup to prevent RAM/disk bloat
|
||||
for backup in data.get('history_tree_backup', []):
|
||||
if isinstance(backup, dict):
|
||||
for node in backup.get('nodes', {}).values():
|
||||
node.pop('data', None)
|
||||
for entry in backup.get('snapshots', backup.get('nodes', {})).values():
|
||||
entry.pop('data', None)
|
||||
state.data_cache = data
|
||||
state.last_mtime = fp.stat().st_mtime if fp.exists() else 0
|
||||
state.loaded_file = str(fp)
|
||||
|
||||
+15
-5
@@ -208,7 +208,7 @@ class ProjectLoaderDynamic:
|
||||
|
||||
|
||||
class ProjectSource:
|
||||
"""Config-only node — holds project connection settings, no outputs."""
|
||||
"""Config node — holds project connection settings, outputs sequence_number."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
@@ -221,14 +221,14 @@ class ProjectSource:
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
RETURN_NAMES = ()
|
||||
RETURN_TYPES = ("INT",)
|
||||
RETURN_NAMES = ("sequence_number",)
|
||||
FUNCTION = "hold_config"
|
||||
CATEGORY = "utils/json/project"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def hold_config(self, manager_url, project_name, file_name, sequence_number, label):
|
||||
return ()
|
||||
return (sequence_number,)
|
||||
|
||||
|
||||
class ProjectKey:
|
||||
@@ -264,10 +264,20 @@ class ProjectKey:
|
||||
file_name="", sequence_number=1):
|
||||
# source_label is used by JS to identify which ProjectSource to sync
|
||||
# config from. The actual config arrives via the optional widgets below.
|
||||
sequence_number = int(sequence_number)
|
||||
logger.info("ProjectKey.fetch_key: source=%s key=%s url=%s project=%s file=%s seq=%s",
|
||||
source_label, key_name, manager_url, project_name, file_name, sequence_number)
|
||||
data = _fetch_data(manager_url, project_name, file_name, sequence_number)
|
||||
if data.get("error") in ("http_error", "network_error", "parse_error"):
|
||||
msg = data.get("message", "Unknown error")
|
||||
raise RuntimeError(f"Failed to fetch data: {msg}")
|
||||
logger.warning("ProjectKey.fetch_key failed: %s", msg)
|
||||
# Return empty/default instead of crashing the workflow
|
||||
if key_type == "INT":
|
||||
return (0,)
|
||||
elif key_type == "FLOAT":
|
||||
return (0.0,)
|
||||
else:
|
||||
return ("",)
|
||||
|
||||
val = data.get(key_name, "")
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
KEY_PROMPT_HISTORY = "prompt_history"
|
||||
|
||||
|
||||
class SnapshotTimeline:
|
||||
"""Flat chronological snapshot list — replaces the old HistoryTree DAG."""
|
||||
|
||||
def __init__(self, raw_data: dict[str, Any]) -> None:
|
||||
# Detect and migrate old HistoryTree format
|
||||
if "nodes" in raw_data and "branches" in raw_data:
|
||||
self._migrate_from_tree(raw_data)
|
||||
elif KEY_PROMPT_HISTORY in raw_data and isinstance(raw_data[KEY_PROMPT_HISTORY], list):
|
||||
self._migrate_legacy(raw_data[KEY_PROMPT_HISTORY])
|
||||
else:
|
||||
self.snapshots: dict[str, dict[str, Any]] = raw_data.get("snapshots", {})
|
||||
self.current_id: str | None = raw_data.get("current_id", None)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Migration
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _migrate_from_tree(self, raw_data: dict[str, Any]) -> None:
|
||||
"""Flatten old HistoryTree nodes into snapshot list, discarding DAG info."""
|
||||
self.snapshots = {}
|
||||
nodes = raw_data.get("nodes", {})
|
||||
for nid, node in nodes.items():
|
||||
self.snapshots[nid] = {
|
||||
"id": nid,
|
||||
"timestamp": node.get("timestamp", time.time()),
|
||||
"note": node.get("note", "Migrated"),
|
||||
"pinned": False,
|
||||
"auto": False,
|
||||
"seq_count": self._count_seqs(node.get("data")),
|
||||
}
|
||||
# Preserve snapshot data if present
|
||||
if "data" in node and node["data"]:
|
||||
self.snapshots[nid]["data"] = node["data"]
|
||||
self.current_id = raw_data.get("head_id")
|
||||
|
||||
def _migrate_legacy(self, old_list: list[dict[str, Any]]) -> None:
|
||||
"""Convert ancient prompt_history list into snapshots."""
|
||||
self.snapshots = {}
|
||||
self.current_id = None
|
||||
for item in reversed(old_list):
|
||||
sid = self._make_id()
|
||||
self.snapshots[sid] = {
|
||||
"id": sid,
|
||||
"timestamp": time.time(),
|
||||
"note": item.get("note", "Legacy Import"),
|
||||
"pinned": False,
|
||||
"auto": False,
|
||||
"seq_count": self._count_seqs(item),
|
||||
"data": item,
|
||||
}
|
||||
self.current_id = sid
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Core operations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def record(self, data: dict[str, Any], note: str = "Snapshot",
|
||||
auto: bool = False) -> str:
|
||||
"""Create a new snapshot and return its ID."""
|
||||
sid = self._make_id()
|
||||
self.snapshots[sid] = {
|
||||
"id": sid,
|
||||
"timestamp": time.time(),
|
||||
"note": note,
|
||||
"pinned": False,
|
||||
"auto": auto,
|
||||
"seq_count": self._count_seqs(data),
|
||||
"data": data,
|
||||
}
|
||||
self.current_id = sid
|
||||
return sid
|
||||
|
||||
def get_snapshot_data(self, snapshot_id: str) -> dict[str, Any] | None:
|
||||
"""Return the inline snapshot data if present."""
|
||||
snap = self.snapshots.get(snapshot_id)
|
||||
if snap:
|
||||
return snap.get("data")
|
||||
return None
|
||||
|
||||
def toggle_pin(self, snapshot_id: str) -> bool:
|
||||
"""Toggle pinned state, return new value."""
|
||||
snap = self.snapshots.get(snapshot_id)
|
||||
if snap:
|
||||
snap["pinned"] = not snap.get("pinned", False)
|
||||
return snap["pinned"]
|
||||
return False
|
||||
|
||||
def delete(self, snapshot_id: str) -> None:
|
||||
"""Remove a snapshot."""
|
||||
self.snapshots.pop(snapshot_id, None)
|
||||
if self.current_id == snapshot_id:
|
||||
# Fall back to most recent remaining
|
||||
if self.snapshots:
|
||||
self.current_id = max(
|
||||
self.snapshots.values(), key=lambda s: s["timestamp"]
|
||||
)["id"]
|
||||
else:
|
||||
self.current_id = None
|
||||
|
||||
def strip_snapshots(self) -> None:
|
||||
"""Remove inline data from all snapshots (for slim JSON storage)."""
|
||||
for snap in self.snapshots.values():
|
||||
snap.pop("data", None)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Serialization
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"snapshots": self.snapshots,
|
||||
"current_id": self.current_id,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _make_id(self) -> str:
|
||||
for _ in range(10):
|
||||
sid = str(uuid.uuid4())[:8]
|
||||
if sid not in self.snapshots:
|
||||
return sid
|
||||
raise ValueError("Failed to generate unique snapshot ID after 10 attempts")
|
||||
|
||||
@staticmethod
|
||||
def _count_seqs(data: dict | None) -> int:
|
||||
if not data:
|
||||
return 0
|
||||
from utils import KEY_BATCH_DATA
|
||||
batch = data.get(KEY_BATCH_DATA, [])
|
||||
return len(batch) if isinstance(batch, list) else 0
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Diff function
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def diff_snapshots(old_batch: list[dict], new_batch: list[dict]) -> list[dict]:
|
||||
"""Compare two batch lists by sequence_number, return per-sequence diffs.
|
||||
|
||||
Returns a list of dicts:
|
||||
{
|
||||
"seq_num": int,
|
||||
"status": "unchanged" | "changed" | "added" | "removed",
|
||||
"changes": [{"field": str, "old": Any, "new": Any}],
|
||||
}
|
||||
"""
|
||||
from utils import KEY_SEQUENCE_NUMBER
|
||||
|
||||
old_by_seq = {int(s.get(KEY_SEQUENCE_NUMBER, 0)): s for s in old_batch}
|
||||
new_by_seq = {int(s.get(KEY_SEQUENCE_NUMBER, 0)): s for s in new_batch}
|
||||
|
||||
all_seqs = sorted(set(old_by_seq) | set(new_by_seq))
|
||||
result = []
|
||||
|
||||
for seq_num in all_seqs:
|
||||
old_item = old_by_seq.get(seq_num)
|
||||
new_item = new_by_seq.get(seq_num)
|
||||
|
||||
if old_item and not new_item:
|
||||
result.append({"seq_num": seq_num, "status": "removed", "changes": []})
|
||||
elif new_item and not old_item:
|
||||
result.append({"seq_num": seq_num, "status": "added", "changes": []})
|
||||
else:
|
||||
# Both exist — field-by-field comparison
|
||||
all_keys = sorted(set(old_item) | set(new_item))
|
||||
changes = []
|
||||
for k in all_keys:
|
||||
old_val = old_item.get(k)
|
||||
new_val = new_item.get(k)
|
||||
if old_val != new_val:
|
||||
changes.append({"field": k, "old": old_val, "new": new_val})
|
||||
status = "changed" if changes else "unchanged"
|
||||
result.append({"seq_num": seq_num, "status": status, "changes": changes})
|
||||
|
||||
return result
|
||||
@@ -13,7 +13,7 @@ class AppState:
|
||||
snippets: dict = field(default_factory=dict)
|
||||
file_path: Path | None = None
|
||||
restored_indicator: str | None = None
|
||||
timeline_selected_nodes: set = field(default_factory=set)
|
||||
timeline_selected_id: str | None = None
|
||||
live_toggles: dict = field(default_factory=dict)
|
||||
show_comfy_monitor: bool = True
|
||||
|
||||
|
||||
+56
-49
@@ -16,9 +16,11 @@ from utils import (
|
||||
DEFAULTS, save_json, load_json, sync_to_db,
|
||||
KEY_BATCH_DATA, KEY_HISTORY_TREE, KEY_PROMPT_HISTORY, KEY_SEQUENCE_NUMBER,
|
||||
)
|
||||
from history_tree import HistoryTree
|
||||
from snapshot_timeline import SnapshotTimeline
|
||||
|
||||
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.bmp', '.gif'}
|
||||
_AUTO_SNAP_DEBOUNCE = 30 # seconds between auto-snapshots
|
||||
_last_auto_snap: dict[str, float] = {} # file_path -> timestamp
|
||||
SUB_SEGMENT_MULTIPLIER = 1000
|
||||
SUB_SEGMENT_NUM_COLORS = 6
|
||||
FRAME_TO_SKIP_DEFAULT = DEFAULTS['frame_to_skip']
|
||||
@@ -86,18 +88,18 @@ def find_insert_position(batch_list, parent_index, parent_seq_num):
|
||||
|
||||
# --- Auto change note ---
|
||||
|
||||
def _auto_change_note(htree, batch_list, state=None, file_path=None):
|
||||
def _auto_change_note(timeline, batch_list, state=None, file_path=None):
|
||||
"""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:
|
||||
# Get previous batch data from the current snapshot
|
||||
if not timeline.current_id or timeline.current_id not in timeline.snapshots:
|
||||
return f'Initial save ({len(batch_list)} sequences)'
|
||||
|
||||
# Load previous snapshot from DB (nodes no longer hold data in memory)
|
||||
prev_data = htree.nodes[htree.head_id].get('data')
|
||||
# Load previous snapshot from inline data or DB
|
||||
prev_data = timeline.get_snapshot_data(timeline.current_id)
|
||||
if not prev_data and state and state.db_enabled and state.db and state.current_project and file_path:
|
||||
df = state.db.get_data_file_by_names(state.current_project, file_path.stem)
|
||||
if df:
|
||||
prev_data = state.db.get_node_snapshot(df['id'], htree.head_id)
|
||||
prev_data = state.db.get_node_snapshot(df['id'], timeline.current_id)
|
||||
prev_batch = (prev_data or {}).get(KEY_BATCH_DATA, [])
|
||||
|
||||
prev_by_seq = {int(s.get(KEY_SEQUENCE_NUMBER, 0)): s for s in prev_batch}
|
||||
@@ -306,9 +308,9 @@ def render_batch_processor(state: AppState):
|
||||
ui.button('From Source', icon='file_download', on_click=add_from_source)
|
||||
|
||||
# --- Standard / LoRA / VACE key sets ---
|
||||
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 high strength', 'lora 1 low', 'lora 1 low strength',
|
||||
'lora 2 high', 'lora 2 high strength', 'lora 2 low', 'lora 2 low strength',
|
||||
'lora 3 high', 'lora 3 high strength', 'lora 3 low', 'lora 3 low strength']
|
||||
standard_keys = {
|
||||
'name', 'mode', 'general_prompt', 'general_negative', 'current_prompt', 'negative', 'prompt',
|
||||
'seed', 'cfg', 'camera', 'flf', KEY_SEQUENCE_NUMBER,
|
||||
@@ -363,38 +365,34 @@ def render_batch_processor(state: AppState):
|
||||
logger.info("save_and_snap START")
|
||||
data[KEY_BATCH_DATA] = batch_list
|
||||
tree_data = data.get(KEY_HISTORY_TREE, {})
|
||||
htree = HistoryTree(tree_data)
|
||||
note = commit_input.value if commit_input.value else _auto_change_note(htree, batch_list, state=state, file_path=file_path)
|
||||
timeline = SnapshotTimeline(tree_data)
|
||||
note = commit_input.value if commit_input.value else _auto_change_note(timeline, batch_list, state=state, file_path=file_path)
|
||||
# Single serialization: json roundtrip gives us an isolated snapshot
|
||||
# without the expensive deepcopy
|
||||
t1 = time.perf_counter()
|
||||
snapshot_json = json.dumps({k: v for k, v in data.items()
|
||||
if k != KEY_HISTORY_TREE})
|
||||
snapshot_payload = json.loads(snapshot_json)
|
||||
logger.info("save_and_snap snapshot %.3fs", time.perf_counter() - t1)
|
||||
try:
|
||||
htree.commit(snapshot_payload, note=note)
|
||||
timeline.record(snapshot_payload, note=note)
|
||||
except ValueError as e:
|
||||
ui.notify(f'Save failed: {e}', type='negative')
|
||||
return
|
||||
if state.db_enabled and state.current_project and state.db:
|
||||
# DB path: sync full tree (with snapshots) to DB, then
|
||||
# write slim tree (no snapshots) to JSON and memory
|
||||
full_tree = htree.to_dict()
|
||||
full_tree = timeline.to_dict()
|
||||
data[KEY_HISTORY_TREE] = full_tree
|
||||
t1 = time.perf_counter()
|
||||
db_snapshot = json.loads(json.dumps(data))
|
||||
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, db_snapshot)
|
||||
logger.info("save_and_snap sync_to_db %.3fs", time.perf_counter() - t1)
|
||||
htree.strip_snapshots()
|
||||
data[KEY_HISTORY_TREE] = htree.to_dict()
|
||||
timeline.strip_snapshots()
|
||||
data[KEY_HISTORY_TREE] = timeline.to_dict()
|
||||
t1 = time.perf_counter()
|
||||
slim_snapshot = json.loads(json.dumps(data))
|
||||
await asyncio.to_thread(save_json, file_path, slim_snapshot)
|
||||
logger.info("save_and_snap save_json %.3fs", time.perf_counter() - t1)
|
||||
else:
|
||||
# No DB: write full tree (with snapshots) to JSON
|
||||
data[KEY_HISTORY_TREE] = htree.to_dict()
|
||||
data[KEY_HISTORY_TREE] = timeline.to_dict()
|
||||
t1 = time.perf_counter()
|
||||
save_snapshot = json.loads(json.dumps(data))
|
||||
await asyncio.to_thread(save_json, file_path, save_snapshot)
|
||||
@@ -416,9 +414,30 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
|
||||
refresh_list):
|
||||
async def commit(message=None):
|
||||
data[KEY_BATCH_DATA] = batch_list
|
||||
# Auto-snapshot with debounce
|
||||
fp_key = str(file_path)
|
||||
now = time.time()
|
||||
did_snap = False
|
||||
if now - _last_auto_snap.get(fp_key, 0) >= _AUTO_SNAP_DEBOUNCE:
|
||||
timeline = SnapshotTimeline(data.get(KEY_HISTORY_TREE, {}))
|
||||
snap_json = json.dumps({k: v for k, v in data.items()
|
||||
if k != KEY_HISTORY_TREE})
|
||||
snap_payload = json.loads(snap_json)
|
||||
try:
|
||||
timeline.record(snap_payload, note=message or "Auto-save", auto=True)
|
||||
if state.db_enabled and state.current_project and state.db:
|
||||
data[KEY_HISTORY_TREE] = timeline.to_dict()
|
||||
db_snap = json.loads(json.dumps(data))
|
||||
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, db_snap)
|
||||
timeline.strip_snapshots()
|
||||
did_snap = True
|
||||
data[KEY_HISTORY_TREE] = timeline.to_dict()
|
||||
_last_auto_snap[fp_key] = now
|
||||
except ValueError:
|
||||
pass # Non-critical: skip auto-snapshot on ID collision
|
||||
snapshot = json.loads(json.dumps(data))
|
||||
await asyncio.to_thread(save_json, file_path, snapshot)
|
||||
if state.db_enabled and state.current_project and state.db:
|
||||
if state.db_enabled and state.current_project and state.db and not did_snap:
|
||||
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, snapshot)
|
||||
if message:
|
||||
ui.notify(message, type='positive')
|
||||
@@ -592,23 +611,12 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
|
||||
for tier, tier_label in [('high', 'High'), ('low', 'Low')]:
|
||||
lora_key = f'lora {lora_idx} {tier}'
|
||||
|
||||
# Parse combined name:strength value
|
||||
raw = str(seq.get(lora_key, ''))
|
||||
# Handle legacy <lora:> wrapper
|
||||
if raw.startswith('<lora:'):
|
||||
raw = raw.replace('<lora:', '').replace('>', '')
|
||||
# Also remove stale separate strength key if present
|
||||
seq.pop(f'lora {lora_idx} {tier} strength', None)
|
||||
|
||||
if ':' in raw and raw:
|
||||
parts = raw.rsplit(':', 1)
|
||||
lora_name = parts[0]
|
||||
try:
|
||||
lora_strength = float(parts[1])
|
||||
except ValueError:
|
||||
lora_strength = 1.0
|
||||
else:
|
||||
lora_name = raw
|
||||
lora_name = str(seq.get(lora_key, ''))
|
||||
strength_key = f'lora {lora_idx} {tier} strength'
|
||||
lora_strength = seq.get(strength_key, 1.0)
|
||||
try:
|
||||
lora_strength = float(lora_strength)
|
||||
except (ValueError, TypeError):
|
||||
lora_strength = 1.0
|
||||
|
||||
with ui.row().classes('w-full items-center q-gutter-sm'):
|
||||
@@ -625,10 +633,9 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
|
||||
format='%.1f',
|
||||
).props('outlined dense').style('max-width: 80px')
|
||||
|
||||
def _lora_sync(k=lora_key, n_inp=name_input, s_inp=strength_input):
|
||||
name = n_inp.value or ''
|
||||
strength = s_inp.value if s_inp.value is not None else 1.0
|
||||
seq[k] = f'{name}:{strength}' if name else ''
|
||||
def _lora_sync(k=lora_key, sk=strength_key, n_inp=name_input, s_inp=strength_input):
|
||||
seq[k] = n_inp.value or ''
|
||||
seq[sk] = float(s_inp.value) if s_inp.value is not None else 1.0
|
||||
|
||||
name_input.on('blur', lambda _, s=_lora_sync: s())
|
||||
name_input.on('update:model-value', lambda _, s=_lora_sync: s())
|
||||
@@ -857,26 +864,26 @@ def _render_mass_update(batch_list, data, file_path, state: AppState, refresh_li
|
||||
batch_list[idx][key] = copy.deepcopy(source_seq.get(key))
|
||||
|
||||
data[KEY_BATCH_DATA] = batch_list
|
||||
htree = HistoryTree(data.get(KEY_HISTORY_TREE, {}))
|
||||
timeline = SnapshotTimeline(data.get(KEY_HISTORY_TREE, {}))
|
||||
snapshot_json = json.dumps({k: v for k, v in data.items()
|
||||
if k != KEY_HISTORY_TREE})
|
||||
snapshot = json.loads(snapshot_json)
|
||||
try:
|
||||
htree.commit(snapshot, f"Mass update: {', '.join(selected_keys)}")
|
||||
timeline.record(snapshot, f"Mass update: {', '.join(selected_keys)}")
|
||||
except ValueError as e:
|
||||
ui.notify(f'Mass update failed: {e}', type='negative')
|
||||
return
|
||||
if state.db_enabled and state.current_project and state.db:
|
||||
full_tree = htree.to_dict()
|
||||
full_tree = timeline.to_dict()
|
||||
data[KEY_HISTORY_TREE] = full_tree
|
||||
db_snapshot = json.loads(json.dumps(data))
|
||||
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, db_snapshot)
|
||||
htree.strip_snapshots()
|
||||
data[KEY_HISTORY_TREE] = htree.to_dict()
|
||||
timeline.strip_snapshots()
|
||||
data[KEY_HISTORY_TREE] = timeline.to_dict()
|
||||
slim_snapshot = json.loads(json.dumps(data))
|
||||
await asyncio.to_thread(save_json, file_path, slim_snapshot)
|
||||
else:
|
||||
data[KEY_HISTORY_TREE] = htree.to_dict()
|
||||
data[KEY_HISTORY_TREE] = timeline.to_dict()
|
||||
save_snapshot = json.loads(json.dumps(data))
|
||||
await asyncio.to_thread(save_json, file_path, save_snapshot)
|
||||
ui.notify(f'Updated {len(targets)} sequences', type='positive')
|
||||
|
||||
+517
-591
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -208,10 +208,10 @@ class TestHistoryTrees:
|
||||
def test_upsert_updates(self, db):
|
||||
pid = db.create_project("p1", "/p1")
|
||||
df_id = db.create_data_file(pid, "batch", "generic")
|
||||
db.save_history_tree(df_id, {"v": 1})
|
||||
db.save_history_tree(df_id, {"v": 2})
|
||||
db.save_history_tree(df_id, {"snapshots": {}, "v": 1})
|
||||
db.save_history_tree(df_id, {"snapshots": {}, "v": 2})
|
||||
result = db.get_history_tree(df_id)
|
||||
assert result == {"v": 2}
|
||||
assert result == {"snapshots": {}, "v": 2}
|
||||
|
||||
def test_get_nonexistent(self, db):
|
||||
pid = db.create_project("p1", "/p1")
|
||||
|
||||
@@ -213,22 +213,22 @@ class TestProjectSource:
|
||||
assert "sequence_number" in inputs["required"]
|
||||
assert "label" in inputs["required"]
|
||||
|
||||
def test_no_outputs(self):
|
||||
def test_outputs_sequence_number(self):
|
||||
from project_loader import ProjectSource
|
||||
assert ProjectSource.RETURN_TYPES == ()
|
||||
assert ProjectSource.RETURN_NAMES == ()
|
||||
assert ProjectSource.RETURN_TYPES == ("INT",)
|
||||
assert ProjectSource.RETURN_NAMES == ("sequence_number",)
|
||||
|
||||
def test_hold_config_returns_empty(self):
|
||||
def test_hold_config_returns_sequence_number(self):
|
||||
from project_loader import ProjectSource
|
||||
node = ProjectSource()
|
||||
result = node.hold_config(
|
||||
manager_url="http://localhost:8080",
|
||||
project_name="proj1",
|
||||
file_name="batch_i2v",
|
||||
sequence_number=1,
|
||||
sequence_number=42,
|
||||
label="my_source"
|
||||
)
|
||||
assert result == ()
|
||||
assert result == (42,)
|
||||
|
||||
def test_category(self):
|
||||
from project_loader import ProjectSource
|
||||
@@ -311,21 +311,33 @@ class TestProjectKey:
|
||||
)
|
||||
assert result == ("",)
|
||||
|
||||
def test_fetch_key_network_error(self):
|
||||
def test_fetch_key_network_error_returns_default(self):
|
||||
from project_loader import ProjectKey
|
||||
node = ProjectKey()
|
||||
error_resp = {"error": "network_error", "message": "Connection refused"}
|
||||
with patch("project_loader._fetch_data", return_value=error_resp):
|
||||
with pytest.raises(RuntimeError, match="Failed to fetch"):
|
||||
node.fetch_key(
|
||||
source_label="my_source",
|
||||
key_name="prompt",
|
||||
key_type="STRING",
|
||||
manager_url="http://localhost:8080",
|
||||
project_name="proj1",
|
||||
file_name="batch_i2v",
|
||||
sequence_number=1,
|
||||
)
|
||||
result = node.fetch_key(
|
||||
source_label="my_source",
|
||||
key_name="prompt",
|
||||
key_type="STRING",
|
||||
manager_url="http://localhost:8080",
|
||||
project_name="proj1",
|
||||
file_name="batch_i2v",
|
||||
sequence_number=1,
|
||||
)
|
||||
assert result == ("",)
|
||||
|
||||
def test_fetch_key_error_returns_int_default(self):
|
||||
from project_loader import ProjectKey
|
||||
node = ProjectKey()
|
||||
error_resp = {"error": "http_error", "status": 404, "message": "Not found"}
|
||||
with patch("project_loader._fetch_data", return_value=error_resp):
|
||||
result = node.fetch_key(
|
||||
source_label="s", key_name="seed", key_type="INT",
|
||||
manager_url="http://localhost:8080", project_name="p",
|
||||
file_name="f", sequence_number=1,
|
||||
)
|
||||
assert result == (0,)
|
||||
|
||||
def test_category(self):
|
||||
from project_loader import ProjectKey
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import pytest
|
||||
from snapshot_timeline import SnapshotTimeline, diff_snapshots
|
||||
|
||||
|
||||
def test_record_creates_snapshot():
|
||||
tl = SnapshotTimeline({})
|
||||
sid = tl.record({"batch_data": [{"seed": 42}]}, note="first")
|
||||
assert sid in tl.snapshots
|
||||
assert tl.current_id == sid
|
||||
assert tl.snapshots[sid]["note"] == "first"
|
||||
assert tl.snapshots[sid]["auto"] is False
|
||||
assert tl.snapshots[sid]["seq_count"] == 1
|
||||
|
||||
|
||||
def test_record_auto_flag():
|
||||
tl = SnapshotTimeline({})
|
||||
sid = tl.record({"batch_data": []}, note="auto save", auto=True)
|
||||
assert tl.snapshots[sid]["auto"] is True
|
||||
|
||||
|
||||
def test_multiple_records():
|
||||
tl = SnapshotTimeline({})
|
||||
id1 = tl.record({"batch_data": [{"a": 1}]}, note="one")
|
||||
id2 = tl.record({"batch_data": [{"b": 2}]}, note="two")
|
||||
assert len(tl.snapshots) == 2
|
||||
assert tl.current_id == id2
|
||||
|
||||
|
||||
def test_to_dict_roundtrip():
|
||||
tl = SnapshotTimeline({})
|
||||
tl.record({"batch_data": [{"x": 1}]}, note="test")
|
||||
d = tl.to_dict()
|
||||
tl2 = SnapshotTimeline(d)
|
||||
assert tl2.current_id == tl.current_id
|
||||
assert set(tl2.snapshots.keys()) == set(tl.snapshots.keys())
|
||||
|
||||
|
||||
def test_migrate_from_history_tree():
|
||||
"""Old HistoryTree format should be flattened into snapshots."""
|
||||
old_data = {
|
||||
"nodes": {
|
||||
"aaa": {"id": "aaa", "parent": None, "timestamp": 1000, "note": "First", "data": {"batch_data": [{"seed": 1}]}},
|
||||
"bbb": {"id": "bbb", "parent": "aaa", "timestamp": 2000, "note": "Second", "data": {"batch_data": [{"seed": 2}]}},
|
||||
},
|
||||
"branches": {"main": "bbb"},
|
||||
"head_id": "bbb",
|
||||
}
|
||||
tl = SnapshotTimeline(old_data)
|
||||
assert len(tl.snapshots) == 2
|
||||
assert tl.current_id == "bbb"
|
||||
assert tl.snapshots["aaa"]["note"] == "First"
|
||||
assert tl.snapshots["bbb"]["note"] == "Second"
|
||||
# Data should be preserved
|
||||
assert tl.snapshots["aaa"]["data"]["batch_data"] == [{"seed": 1}]
|
||||
|
||||
|
||||
def test_migrate_from_history_tree_no_data():
|
||||
"""Slim tree nodes (no inline data) should still migrate."""
|
||||
old_data = {
|
||||
"nodes": {
|
||||
"aaa": {"id": "aaa", "parent": None, "timestamp": 1000, "note": "First"},
|
||||
},
|
||||
"branches": {"main": "aaa"},
|
||||
"head_id": "aaa",
|
||||
}
|
||||
tl = SnapshotTimeline(old_data)
|
||||
assert len(tl.snapshots) == 1
|
||||
assert tl.snapshots["aaa"]["seq_count"] == 0
|
||||
|
||||
|
||||
def test_migrate_legacy_prompt_history():
|
||||
legacy = {
|
||||
"prompt_history": [
|
||||
{"note": "A", "seed": 1},
|
||||
{"note": "B", "seed": 2},
|
||||
]
|
||||
}
|
||||
tl = SnapshotTimeline(legacy)
|
||||
assert len(tl.snapshots) == 2
|
||||
assert tl.current_id is not None
|
||||
|
||||
|
||||
def test_toggle_pin():
|
||||
tl = SnapshotTimeline({})
|
||||
sid = tl.record({"batch_data": []}, note="test")
|
||||
assert tl.snapshots[sid]["pinned"] is False
|
||||
result = tl.toggle_pin(sid)
|
||||
assert result is True
|
||||
assert tl.snapshots[sid]["pinned"] is True
|
||||
result = tl.toggle_pin(sid)
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_delete_snapshot():
|
||||
tl = SnapshotTimeline({})
|
||||
id1 = tl.record({"batch_data": []}, note="one")
|
||||
id2 = tl.record({"batch_data": []}, note="two")
|
||||
tl.delete(id2)
|
||||
assert id2 not in tl.snapshots
|
||||
assert tl.current_id == id1
|
||||
|
||||
|
||||
def test_delete_all_snapshots():
|
||||
tl = SnapshotTimeline({})
|
||||
sid = tl.record({"batch_data": []}, note="only")
|
||||
tl.delete(sid)
|
||||
assert len(tl.snapshots) == 0
|
||||
assert tl.current_id is None
|
||||
|
||||
|
||||
def test_strip_snapshots():
|
||||
tl = SnapshotTimeline({})
|
||||
tl.record({"batch_data": [{"a": 1}]}, note="test")
|
||||
tl.strip_snapshots()
|
||||
for snap in tl.snapshots.values():
|
||||
assert "data" not in snap
|
||||
|
||||
|
||||
def test_get_snapshot_data():
|
||||
tl = SnapshotTimeline({})
|
||||
sid = tl.record({"batch_data": [{"x": 1}]}, note="test")
|
||||
data = tl.get_snapshot_data(sid)
|
||||
assert data == {"batch_data": [{"x": 1}]}
|
||||
assert tl.get_snapshot_data("nonexistent") is None
|
||||
|
||||
|
||||
# --- diff_snapshots tests ---
|
||||
|
||||
def test_diff_unchanged():
|
||||
batch = [{"sequence_number": 1, "seed": 42}]
|
||||
result = diff_snapshots(batch, batch)
|
||||
assert len(result) == 1
|
||||
assert result[0]["status"] == "unchanged"
|
||||
assert result[0]["changes"] == []
|
||||
|
||||
|
||||
def test_diff_changed():
|
||||
old = [{"sequence_number": 1, "seed": 42, "cfg": 1.5}]
|
||||
new = [{"sequence_number": 1, "seed": 99, "cfg": 1.5}]
|
||||
result = diff_snapshots(old, new)
|
||||
assert result[0]["status"] == "changed"
|
||||
assert len(result[0]["changes"]) == 1
|
||||
assert result[0]["changes"][0]["field"] == "seed"
|
||||
assert result[0]["changes"][0]["old"] == 42
|
||||
assert result[0]["changes"][0]["new"] == 99
|
||||
|
||||
|
||||
def test_diff_added_and_removed():
|
||||
old = [{"sequence_number": 1, "seed": 1}]
|
||||
new = [{"sequence_number": 2, "seed": 2}]
|
||||
result = diff_snapshots(old, new)
|
||||
assert len(result) == 2
|
||||
statuses = {r["seq_num"]: r["status"] for r in result}
|
||||
assert statuses[1] == "removed"
|
||||
assert statuses[2] == "added"
|
||||
|
||||
|
||||
def test_diff_empty():
|
||||
assert diff_snapshots([], []) == []
|
||||
@@ -49,13 +49,19 @@ DEFAULTS = {
|
||||
"reference path": "",
|
||||
"flf image path": "",
|
||||
|
||||
# --- LoRAs (format: "name:strength" or empty) ---
|
||||
# --- LoRAs (name as STRING, strength as FLOAT) ---
|
||||
"lora 1 high": "",
|
||||
"lora 1 high strength": 1.0,
|
||||
"lora 1 low": "",
|
||||
"lora 1 low strength": 1.0,
|
||||
"lora 2 high": "",
|
||||
"lora 2 high strength": 1.0,
|
||||
"lora 2 low": "",
|
||||
"lora 2 low strength": 1.0,
|
||||
"lora 3 high": "",
|
||||
"lora 3 low": ""
|
||||
"lora 3 high strength": 1.0,
|
||||
"lora 3 low": "",
|
||||
"lora 3 low strength": 1.0
|
||||
}
|
||||
|
||||
CONFIG_FILE = Path(".editor_config.json")
|
||||
@@ -145,12 +151,12 @@ def save_snippets(snippets):
|
||||
os.replace(tmp, SNIPPETS_FILE)
|
||||
|
||||
def _migrate_lora_keys(data: dict) -> None:
|
||||
"""Merge lora name + strength into single 'name:strength' key, remove separate strength keys.
|
||||
"""Split combined lora 'name:strength' into separate name and strength keys.
|
||||
|
||||
Handles three legacy formats:
|
||||
1. <lora:Name:0.5> → Name:0.5
|
||||
2. Separate name_key + str_key → name:strength (then delete str_key)
|
||||
3. Already merged name:strength → no change
|
||||
Handles legacy formats:
|
||||
1. <lora:Name:0.5> → name_key='Name', str_key=0.5
|
||||
2. 'Name:0.5' (merged) → name_key='Name', str_key=0.5
|
||||
3. Already split (name_key + str_key exist) → no change
|
||||
"""
|
||||
for item in data.get(KEY_BATCH_DATA, []):
|
||||
if not isinstance(item, dict):
|
||||
@@ -162,24 +168,35 @@ def _migrate_lora_keys(data: dict) -> None:
|
||||
raw = str(item.get(name_key, ''))
|
||||
|
||||
if raw.startswith('<lora:'):
|
||||
# Legacy <lora:Name:0.5> format → Name:0.5
|
||||
# Legacy <lora:Name:0.5> format
|
||||
inner = raw.replace('<lora:', '').replace('>', '')
|
||||
item[name_key] = inner # already name:strength or just name
|
||||
if ':' not in inner:
|
||||
# No strength in the wrapper, check separate key
|
||||
strength = item.pop(str_key, 1.0)
|
||||
item[name_key] = f'{inner}:{float(strength)}' if inner else ''
|
||||
if ':' in inner:
|
||||
parts = inner.rsplit(':', 1)
|
||||
item[name_key] = parts[0]
|
||||
try:
|
||||
item[str_key] = float(parts[1])
|
||||
except ValueError:
|
||||
item[str_key] = 1.0
|
||||
else:
|
||||
item.pop(str_key, None)
|
||||
elif str_key in item:
|
||||
# Separate strength key exists → merge into name:strength
|
||||
strength = float(item.pop(str_key))
|
||||
if raw:
|
||||
# Avoid double-merging if already has name:strength format
|
||||
if ':' not in raw:
|
||||
item[name_key] = f'{raw}:{strength}'
|
||||
# else: already merged, just remove the stale strength key
|
||||
# No change needed if already in name:strength format or empty
|
||||
item[name_key] = inner
|
||||
if str_key not in item:
|
||||
item[str_key] = 1.0
|
||||
elif ':' in raw and raw:
|
||||
# Combined 'name:strength' format → split
|
||||
parts = raw.rsplit(':', 1)
|
||||
try:
|
||||
strength = float(parts[1])
|
||||
item[name_key] = parts[0]
|
||||
item[str_key] = strength
|
||||
except ValueError:
|
||||
# Not a valid strength, leave as-is
|
||||
if str_key not in item:
|
||||
item[str_key] = 1.0
|
||||
elif raw:
|
||||
# Name exists without colon, ensure strength key exists
|
||||
if str_key not in item:
|
||||
item[str_key] = 1.0
|
||||
# If name is empty, don't add a strength key
|
||||
|
||||
|
||||
def load_json(path: str | Path) -> tuple[dict[str, Any], float]:
|
||||
@@ -288,38 +305,47 @@ def sync_to_db(db, project_name: str, file_path: Path, data: dict) -> None:
|
||||
else:
|
||||
db.conn.execute("DELETE FROM sequences WHERE data_file_id = ?", (df_id,))
|
||||
|
||||
# Sync history tree (extract node snapshots into separate table)
|
||||
# Sync history tree (extract snapshot data into separate table)
|
||||
# Supports both new format (snapshots dict) and old format (nodes dict)
|
||||
history_tree = data.get(KEY_HISTORY_TREE)
|
||||
if history_tree and isinstance(history_tree, dict):
|
||||
nodes = history_tree.get("nodes", {})
|
||||
# Detect format: new has "snapshots", old has "nodes"
|
||||
if "snapshots" in history_tree:
|
||||
entries = history_tree.get("snapshots", {})
|
||||
else:
|
||||
entries = history_tree.get("nodes", {})
|
||||
slim_tree = dict(history_tree)
|
||||
slim_nodes = {}
|
||||
for nid, node in nodes.items():
|
||||
snap = node.get("data")
|
||||
slim_entries = {}
|
||||
for eid, entry in entries.items():
|
||||
snap = entry.get("data")
|
||||
if snap:
|
||||
db.conn.execute(
|
||||
"INSERT INTO history_snapshots (data_file_id, node_id, snapshot_data, updated_at) "
|
||||
"VALUES (?, ?, ?, ?) "
|
||||
"ON CONFLICT(data_file_id, node_id) DO UPDATE SET "
|
||||
"snapshot_data=excluded.snapshot_data, updated_at=excluded.updated_at",
|
||||
(df_id, nid, json.dumps(snap), now),
|
||||
(df_id, eid, json.dumps(snap), now),
|
||||
)
|
||||
slim_nodes[nid] = {k: v for k, v in node.items() if k != "data"}
|
||||
slim_tree["nodes"] = slim_nodes
|
||||
slim_entries[eid] = {k: v for k, v in entry.items() if k != "data"}
|
||||
# Write back slim version using the correct key
|
||||
if "snapshots" in history_tree:
|
||||
slim_tree["snapshots"] = slim_entries
|
||||
else:
|
||||
slim_tree["nodes"] = slim_entries
|
||||
db.conn.execute(
|
||||
"INSERT INTO history_trees (data_file_id, tree_data, updated_at) "
|
||||
"VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(data_file_id) DO UPDATE SET tree_data=excluded.tree_data, updated_at=excluded.updated_at",
|
||||
(df_id, json.dumps(slim_tree), now),
|
||||
)
|
||||
# Clean up orphaned snapshots for nodes no longer in tree
|
||||
current_node_ids = set(nodes.keys())
|
||||
if current_node_ids:
|
||||
placeholders = ",".join("?" for _ in current_node_ids)
|
||||
# Clean up orphaned snapshots
|
||||
current_ids = set(entries.keys())
|
||||
if current_ids:
|
||||
placeholders = ",".join("?" for _ in current_ids)
|
||||
db.conn.execute(
|
||||
f"DELETE FROM history_snapshots WHERE data_file_id = ? "
|
||||
f"AND node_id NOT IN ({placeholders})",
|
||||
(df_id, *current_node_ids),
|
||||
(df_id, *current_ids),
|
||||
)
|
||||
else:
|
||||
db.conn.execute(
|
||||
|
||||
+90
-50
@@ -19,6 +19,39 @@ app.registerExtension({
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name !== "ProjectKey") return;
|
||||
|
||||
// Helper: properly hide a widget (works for all types including INT)
|
||||
function hideWidget(widget) {
|
||||
if (widget.origType === undefined) widget.origType = widget.type;
|
||||
widget.type = "hidden";
|
||||
widget.hidden = true;
|
||||
widget.computeSize = () => [0, -4];
|
||||
}
|
||||
|
||||
// Helper: replace a STRING widget with a proper combo widget
|
||||
function replaceWithCombo(node, name, values, callback) {
|
||||
const idx = node.widgets?.findIndex(w => w.name === name);
|
||||
if (idx === -1 || idx === undefined) return null;
|
||||
const oldWidget = node.widgets[idx];
|
||||
const savedValue = oldWidget.value || "";
|
||||
// Ensure values list is never empty (combo shows undefined otherwise)
|
||||
const comboValues = values.length > 0 ? values : [""];
|
||||
// Always preserve saved value — it may not be in the list yet (load-order race)
|
||||
if (savedValue && !comboValues.includes(savedValue)) {
|
||||
comboValues.unshift(savedValue);
|
||||
}
|
||||
const defaultValue = savedValue || comboValues[0];
|
||||
// Remove old STRING widget
|
||||
node.widgets.splice(idx, 1);
|
||||
// Insert a real combo widget at the same position
|
||||
const combo = node.addWidget("combo", name, defaultValue, callback, { values: comboValues });
|
||||
// Move it from the end to the original position
|
||||
if (node.widgets.length > 1) {
|
||||
node.widgets.splice(node.widgets.length - 1, 1);
|
||||
node.widgets.splice(idx, 0, combo);
|
||||
}
|
||||
return combo;
|
||||
}
|
||||
|
||||
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
origOnNodeCreated?.apply(this, arguments);
|
||||
@@ -27,37 +60,24 @@ app.registerExtension({
|
||||
// Hide the connection-config widgets (synced from source by JS)
|
||||
for (const name of ["manager_url", "project_name", "file_name", "sequence_number", "key_type"]) {
|
||||
const w = this.widgets?.find(w => w.name === name);
|
||||
if (w) { w.type = "hidden"; w.computeSize = () => [0, -4]; }
|
||||
if (w) hideWidget(w);
|
||||
}
|
||||
|
||||
// Convert source_label to a dynamic combo
|
||||
const srcWidget = this.widgets?.find(w => w.name === "source_label");
|
||||
if (srcWidget) {
|
||||
srcWidget.type = "combo";
|
||||
srcWidget.options = { values: [] };
|
||||
srcWidget.value = srcWidget.value || "";
|
||||
const node = this;
|
||||
const origCb = srcWidget.callback;
|
||||
srcWidget.callback = function (...args) {
|
||||
origCb?.apply(this, args);
|
||||
node._syncFromSource();
|
||||
node._refreshKeys();
|
||||
};
|
||||
}
|
||||
// Replace source_label STRING with a proper combo widget
|
||||
const node = this;
|
||||
const sourceLabels = this._getSourceLabels?.() || [];
|
||||
const srcCombo = replaceWithCombo(this, "source_label", sourceLabels, function (value) {
|
||||
node._syncFromSource();
|
||||
node._refreshKeys();
|
||||
});
|
||||
// Set first available source or "none" placeholder
|
||||
if (srcCombo) srcCombo.value = sourceLabels[0] || "";
|
||||
|
||||
// Convert key_name to a dynamic combo
|
||||
const keyWidget = this.widgets?.find(w => w.name === "key_name");
|
||||
if (keyWidget) {
|
||||
keyWidget.type = "combo";
|
||||
keyWidget.options = { values: [] };
|
||||
keyWidget.value = keyWidget.value || "";
|
||||
const node = this;
|
||||
const origCb = keyWidget.callback;
|
||||
keyWidget.callback = function (...args) {
|
||||
origCb?.apply(this, args);
|
||||
node._applyKeySelection();
|
||||
};
|
||||
}
|
||||
// Replace key_name STRING with a proper combo widget
|
||||
const keyCombo = replaceWithCombo(this, "key_name", [], function (value) {
|
||||
node._applyKeySelection();
|
||||
});
|
||||
if (keyCombo) keyCombo.value = "";
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (!this._configured) {
|
||||
@@ -105,11 +125,17 @@ app.registerExtension({
|
||||
nodeType.prototype._syncFromSource = function () {
|
||||
const srcWidget = this.widgets?.find(w => w.name === "source_label");
|
||||
const source = this._findSource(srcWidget?.value);
|
||||
if (!source) return;
|
||||
if (!source) {
|
||||
console.log(`[ProjectKey] _syncFromSource id=${this.id}: no source found for label="${srcWidget?.value}"`);
|
||||
return;
|
||||
}
|
||||
for (const name of ["manager_url", "project_name", "file_name", "sequence_number"]) {
|
||||
const dst = this.widgets?.find(w => w.name === name);
|
||||
const src = source.widgets?.find(w => w.name === name);
|
||||
if (dst && src) dst.value = src.value;
|
||||
if (dst && src) {
|
||||
dst.value = src.value;
|
||||
console.log(`[ProjectKey] _syncFromSource id=${this.id}: ${name}="${src.value}"`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -120,7 +146,11 @@ app.registerExtension({
|
||||
const fileW = this.widgets?.find(w => w.name === "file_name");
|
||||
const seqW = this.widgets?.find(w => w.name === "sequence_number");
|
||||
|
||||
if (!urlW?.value || !projW?.value || !fileW?.value) return;
|
||||
console.log(`[ProjectKey] _refreshKeys id=${this.id}: url="${urlW?.value}" project="${projW?.value}" file="${fileW?.value}" seq=${seqW?.value}`);
|
||||
if (!urlW?.value || !projW?.value || !fileW?.value) {
|
||||
console.log(`[ProjectKey] _refreshKeys: skipped (missing config)`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await api.fetchApi(
|
||||
@@ -135,14 +165,11 @@ app.registerExtension({
|
||||
this._availableKeys = data.keys;
|
||||
this._availableTypes = data.types;
|
||||
|
||||
// Update key_name combo values
|
||||
// Update key_name combo options only — never change the selection
|
||||
const keyWidget = this.widgets?.find(w => w.name === "key_name");
|
||||
if (keyWidget) {
|
||||
keyWidget.options.values = data.keys;
|
||||
// Keep current selection if still valid
|
||||
if (!data.keys.includes(keyWidget.value)) {
|
||||
keyWidget.value = data.keys[0] || "";
|
||||
}
|
||||
// Selection is sticky: user must change it manually
|
||||
this._applyKeySelection();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -174,7 +201,7 @@ app.registerExtension({
|
||||
app.graph?.setDirtyCanvas(true, true);
|
||||
};
|
||||
|
||||
// --- Populate source dropdown on click (lazy refresh) ---
|
||||
// --- Sync config on click (lazy, no key refresh to avoid race) ---
|
||||
const origOnMouseDown = nodeType.prototype.onMouseDown;
|
||||
nodeType.prototype.onMouseDown = function (e, localPos, graphCanvas) {
|
||||
origOnMouseDown?.apply(this, arguments);
|
||||
@@ -182,6 +209,8 @@ app.registerExtension({
|
||||
if (srcWidget) {
|
||||
srcWidget.options.values = this._getSourceLabels();
|
||||
}
|
||||
// Sync config values from source (synchronous, safe)
|
||||
this._syncFromSource();
|
||||
};
|
||||
|
||||
// --- Restore state on workflow load ---
|
||||
@@ -193,31 +222,42 @@ app.registerExtension({
|
||||
// Hide config widgets
|
||||
for (const name of ["manager_url", "project_name", "file_name", "sequence_number", "key_type"]) {
|
||||
const w = this.widgets?.find(w => w.name === name);
|
||||
if (w) { w.type = "hidden"; w.computeSize = () => [0, -4]; }
|
||||
if (w) hideWidget(w);
|
||||
}
|
||||
|
||||
// Restore combo types
|
||||
// Ensure source_label is a proper combo (may still be STRING from serialization)
|
||||
const srcWidget = this.widgets?.find(w => w.name === "source_label");
|
||||
if (srcWidget) {
|
||||
srcWidget.type = "combo";
|
||||
srcWidget.options = { values: this._getSourceLabels() };
|
||||
if (srcWidget && srcWidget.type !== "combo") {
|
||||
const node = this;
|
||||
replaceWithCombo(this, "source_label", this._getSourceLabels(), function (value) {
|
||||
node._syncFromSource();
|
||||
node._refreshKeys();
|
||||
});
|
||||
} else if (srcWidget) {
|
||||
srcWidget.options.values = this._getSourceLabels();
|
||||
}
|
||||
|
||||
// Ensure key_name is a proper combo
|
||||
const keyWidget = this.widgets?.find(w => w.name === "key_name");
|
||||
if (keyWidget) {
|
||||
keyWidget.type = "combo";
|
||||
keyWidget.options = { values: [] };
|
||||
if (keyWidget && keyWidget.type !== "combo") {
|
||||
const node = this;
|
||||
replaceWithCombo(this, "key_name", [], function (value) {
|
||||
node._applyKeySelection();
|
||||
});
|
||||
}
|
||||
|
||||
// Re-find widgets after possible replacement
|
||||
const finalKeyWidget = this.widgets?.find(w => w.name === "key_name");
|
||||
|
||||
// Update title from saved key
|
||||
if (keyWidget?.value) {
|
||||
this.title = `Key: ${keyWidget.value}`;
|
||||
if (finalKeyWidget?.value) {
|
||||
this.title = `Key: ${finalKeyWidget.value}`;
|
||||
}
|
||||
|
||||
// Restore output slot name from saved key_name
|
||||
if (keyWidget?.value && this.outputs.length > 0) {
|
||||
this.outputs[0].name = keyWidget.value;
|
||||
this.outputs[0].label = keyWidget.value;
|
||||
if (finalKeyWidget?.value && this.outputs.length > 0) {
|
||||
this.outputs[0].name = finalKeyWidget.value;
|
||||
this.outputs[0].label = finalKeyWidget.value;
|
||||
const ktWidget = this.widgets?.find(w => w.name === "key_type");
|
||||
if (ktWidget?.value) this.outputs[0].type = ktWidget.value;
|
||||
}
|
||||
|
||||
+118
-1
@@ -1,4 +1,5 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "json.manager.project.source",
|
||||
@@ -6,14 +7,114 @@ app.registerExtension({
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name !== "ProjectSource") return;
|
||||
|
||||
// Helper: replace a STRING widget with a proper combo widget
|
||||
function replaceWithCombo(node, name, values, callback) {
|
||||
const idx = node.widgets?.findIndex(w => w.name === name);
|
||||
if (idx === -1 || idx === undefined) return null;
|
||||
const oldWidget = node.widgets[idx];
|
||||
const savedValue = oldWidget.value || "";
|
||||
const comboValues = values.length > 0 ? values : [""];
|
||||
// Always preserve saved value (may not be in list yet)
|
||||
if (savedValue && !comboValues.includes(savedValue)) {
|
||||
comboValues.unshift(savedValue);
|
||||
}
|
||||
const defaultValue = savedValue || comboValues[0];
|
||||
node.widgets.splice(idx, 1);
|
||||
const combo = node.addWidget("combo", name, defaultValue, callback, { values: comboValues });
|
||||
if (node.widgets.length > 1) {
|
||||
node.widgets.splice(node.widgets.length - 1, 1);
|
||||
node.widgets.splice(idx, 0, combo);
|
||||
}
|
||||
return combo;
|
||||
}
|
||||
|
||||
// Fetch file list from API and update file_name combo
|
||||
async function refreshFiles(node) {
|
||||
const urlW = node.widgets?.find(w => w.name === "manager_url");
|
||||
const projW = node.widgets?.find(w => w.name === "project_name");
|
||||
if (!urlW?.value || !projW?.value) return;
|
||||
|
||||
try {
|
||||
const resp = await api.fetchApi(
|
||||
`/json_manager/list_project_files?url=${encodeURIComponent(urlW.value)}&project=${encodeURIComponent(projW.value)}`
|
||||
);
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const fileList = (data.files || []).map(f => f.name || f);
|
||||
console.log(`[ProjectSource] refreshFiles: got ${fileList.length} files:`, fileList);
|
||||
|
||||
const fileW = node.widgets?.find(w => w.name === "file_name");
|
||||
if (fileW) {
|
||||
const currentValue = fileW.value;
|
||||
fileW.options.values = fileList.length > 0 ? fileList : [""];
|
||||
// Keep current selection if still valid
|
||||
if (currentValue && fileList.includes(currentValue)) {
|
||||
fileW.value = currentValue;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[ProjectSource] Failed to refresh files:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify all ProjectKey nodes referencing this source to re-sync
|
||||
function notifyRelays(sourceNode) {
|
||||
if (!sourceNode.graph?._nodes) return;
|
||||
const labelW = sourceNode.widgets?.find(w => w.name === "label");
|
||||
if (!labelW?.value) return;
|
||||
console.log(`[ProjectSource] notifyRelays: label="${labelW.value}", scanning ${sourceNode.graph._nodes.length} nodes`);
|
||||
let matched = 0;
|
||||
for (const node of sourceNode.graph._nodes) {
|
||||
if (node.type === "ProjectKey" && node._syncFromSource && node._refreshKeys) {
|
||||
const srcW = node.widgets?.find(w => w.name === "source_label");
|
||||
console.log(`[ProjectSource] ProjectKey id=${node.id} source_label="${srcW?.value}"`);
|
||||
if (srcW?.value === labelW.value) {
|
||||
matched++;
|
||||
node._syncFromSource();
|
||||
node._refreshKeys();
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`[ProjectSource] notifyRelays: matched ${matched} relays`);
|
||||
}
|
||||
|
||||
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
origOnNodeCreated?.apply(this, arguments);
|
||||
|
||||
const node = this;
|
||||
|
||||
// Replace file_name STRING with a combo
|
||||
replaceWithCombo(this, "file_name", [], function (value) {
|
||||
notifyRelays(node);
|
||||
});
|
||||
|
||||
// Hook manager_url and project_name to refresh file list + notify relays
|
||||
for (const name of ["manager_url", "project_name"]) {
|
||||
const w = this.widgets?.find(w => w.name === name);
|
||||
if (w) {
|
||||
const origCb = w.callback;
|
||||
w.callback = function (...args) {
|
||||
origCb?.apply(this, args);
|
||||
refreshFiles(node);
|
||||
notifyRelays(node);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Hook sequence_number to notify relays
|
||||
const seqW = this.widgets?.find(w => w.name === "sequence_number");
|
||||
if (seqW) {
|
||||
const origCb = seqW.callback;
|
||||
seqW.callback = function (...args) {
|
||||
origCb?.apply(this, args);
|
||||
notifyRelays(node);
|
||||
};
|
||||
}
|
||||
|
||||
// Update title when label changes
|
||||
const labelWidget = this.widgets?.find(w => w.name === "label");
|
||||
if (labelWidget) {
|
||||
const node = this;
|
||||
const origCallback = labelWidget.callback;
|
||||
labelWidget.callback = function (...args) {
|
||||
origCallback?.apply(this, args);
|
||||
@@ -32,10 +133,26 @@ app.registerExtension({
|
||||
const origOnConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function (info) {
|
||||
origOnConfigure?.apply(this, arguments);
|
||||
|
||||
// Ensure file_name is a combo (may be STRING from serialization)
|
||||
const fileW = this.widgets?.find(w => w.name === "file_name");
|
||||
if (fileW && fileW.type !== "combo") {
|
||||
const node = this;
|
||||
replaceWithCombo(this, "file_name", [], function (value) {
|
||||
notifyRelays(node);
|
||||
});
|
||||
}
|
||||
|
||||
const labelWidget = this.widgets?.find(w => w.name === "label");
|
||||
if (labelWidget?.value) {
|
||||
this.title = `Source: ${labelWidget.value}`;
|
||||
}
|
||||
|
||||
// Deferred: refresh file list once graph is ready
|
||||
const node = this;
|
||||
queueMicrotask(() => {
|
||||
refreshFiles(node);
|
||||
});
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user