90 Commits

Author SHA1 Message Date
Ethanfel ba8ce45846 fix: make snapshot list fill available viewport height
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 18:23:56 +01:00
Ethanfel 1ec3abf17a feat: replace Git-DAG timeline with flat snapshot browser
Replace HistoryTree (DAG with branches, Graphviz rendering) with a flat
chronological SnapshotTimeline. New UI features: split-view layout,
snapshot compare/diff, cherry-pick restore of individual sequences or
fields, auto-snapshots with debounce, and pin/filter support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 18:14:22 +01:00
Ethanfel 686d4687c3 fix: cast sequence_number to int in fetch_key
Hidden widget sync passes sequence_number as string, causing format
code errors downstream. Cast to int before use.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 11:42:19 +01:00
Ethanfel a37dd82ae3 fix: parse file list API response format correctly
API returns {"files": [{"name": "...", "data_type": "..."}]}, not a
plain array. Extract file names from the nested structure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 11:26:39 +01:00
Ethanfel 3b11a4e974 feat: file_name combo on ProjectSource, sequence_number output
- file_name is now a combo dropdown populated from the API when
  manager_url and project_name are set
- ProjectSource outputs sequence_number (INT) for downstream use
- Refreshes file list when project_name or manager_url changes
- Updated tests for new output and error-default behavior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 11:20:41 +01:00
Ethanfel 5eb82f8ff6 fix: preserve saved combo value across load-order race
When ProjectKey configures before ProjectSource, _getSourceLabels
returns empty. replaceWithCombo now always keeps the saved value in
the options list so it survives the race condition.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 22:24:17 +01:00
Ethanfel bf598ebf80 fix: make key_name selection sticky, never auto-reset
The key_name combo now only updates its dropdown options from the API
but never changes the user's selected value. Only the output value
refreshes on execution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 21:01:03 +01:00
Ethanfel 6e232da193 fix: remove _refreshKeys from onMouseDown to prevent key reset
The async _refreshKeys call on every mouse click caused a race condition
where clicking the key_name dropdown would trigger a re-fetch that
overwrote the user's selection. Keys are now only refreshed on source
label change and workflow load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 21:00:19 +01:00
Ethanfel ff5802ab63 fix: don't crash on 404, add sync debug logging
ProjectKey.fetch_key now returns empty defaults instead of raising
RuntimeError on API errors. Added logging to _syncFromSource and
fetch_key to trace the seq=4001 vs seq=4 mismatch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 20:58:42 +01:00
Ethanfel 413e1c09e9 fix: ProjectKey pulls fresh data on click, add debug logging
ProjectKey onMouseDown now triggers _syncFromSource + _refreshKeys so
clicking a relay always picks up source config changes. Added console
logs to notifyRelays and _refreshKeys for diagnosing sync issues.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 20:46:50 +01:00
Ethanfel 672b28e27f feat: split lora name and strength into separate keys
Reverses the previous merge migration. Lora data is now stored as
separate keys: 'lora 1 high' (STRING name) and 'lora 1 high strength'
(FLOAT). This allows ProjectKey relay nodes to output name and strength
as properly typed separate values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 16:18:33 +01:00
Ethanfel 3dc91319a2 fix: notify relay nodes when source config changes
When ProjectSource widgets (url, project, file, sequence_number)
change, all ProjectKey nodes referencing that source now re-sync
and refresh their key dropdown immediately.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 12:43:05 +01:00
Ethanfel bd36b4b725 fix: prevent undefined in combo widgets with empty values
Combo widgets show "undefined" when values list is empty. Now ensures
at least one entry (empty string placeholder) and picks a valid default.
Also populates source labels immediately on node creation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 15:34:01 +01:00
Ethanfel 77eb3473ab fix: properly hide INT widgets and create real combo dropdowns
- INT widgets (sequence_number) now properly hidden using origType +
  hidden flag pattern from fast_saver.js
- source_label and key_name are now replaced with real combo widgets
  via addWidget("combo") instead of just setting type="combo" on
  STRING widgets, which didn't produce working dropdowns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 15:27:52 +01:00
Ethanfel 2cf8cc1f0a fix: add IS_CHANGED to ProjectKey and document source_label usage
ProjectKey fetches live API data, so it must re-execute on every queue.
Added comment explaining why source_label exists but is unused in Python.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:42:15 +01:00
Ethanfel 545b864c08 feat: add ProjectKey JS extension with source/key dropdowns
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:39:48 +01:00
Ethanfel ad6cd76b08 feat: add ProjectSource JS extension with label title sync
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:36:29 +01:00
Ethanfel bd7d314ae8 feat: add ProjectKey single-output relay node
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:34:22 +01:00
Ethanfel 628b256981 feat: add ProjectSource config-only node
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:32:47 +01:00
Ethanfel fb007920ee Fix history_tree_backup bloat: strip snapshot data from backups
The history_tree_backup (created by _delete_nodes) was storing full
snapshot data in every backed-up node — 185MB+ per backup entry.
This caused the JSON file to re-bloat to 300MB on every save.

Now:
- _delete_nodes backs up tree metadata only (no snapshot data)
- Load paths strip snapshot data from existing backup entries
- Prevents both disk and RAM bloat from backup accumulation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 10:23:23 +01:00
Ethanfel d3955c489b Write slim history tree to JSON when DB is enabled
The JSON file was storing full snapshot data in every history node,
causing files to grow to multi-GB. Now when DB is enabled:
- sync_to_db receives full tree (extracts snapshots to DB)
- save_json receives slim tree (no snapshot data)
- JSON file stays small, DB is authoritative for snapshots

When DB is disabled, JSON still stores full snapshots (only store).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 10:00:51 +01:00
Ethanfel e575a78893 Fix missing import, add transaction safety, clean orphaned snapshots
- Add load_json to tab_timeline_ng imports (NameError on disk fallback)
- Wrap save_history_tree in BEGIN/COMMIT transaction (was autocommitting
  each statement, risking partial writes on failure)
- Clean up orphaned history_snapshots in sync_to_db when nodes are
  removed from the tree

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 09:56:10 +01:00
Ethanfel a1a85ecc4d Fix bugs in snapshot stripping: auto-note, mass update, DB-off, fallback
- _auto_change_note now loads previous snapshot from DB instead of
  reading stripped in-memory node (was producing wrong commit messages)
- _render_mass_update now strips snapshots after save (was leaking RAM)
- Only strip snapshots when DB is enabled (preview/restore still works
  without DB via in-memory or disk fallback)
- _render_data_preview adds disk fallback matching _restore_node

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 09:52:46 +01:00
Ethanfel eac4e4f08b Fix RAM leak: strip history snapshots from memory, load on demand
History tree nodes stored full data snapshots in memory (5-50MB each),
accumulating with every save. Now:

- New `history_snapshots` DB table stores node data separately
- `save_history_tree` and `sync_to_db` extract snapshots before saving
- In-memory tree nodes only hold metadata (id, parent, note, timestamp)
- Restore and preview load snapshots from DB on demand
- `save_and_snap` uses json roundtrip instead of deepcopy (1 copy not 2)
- `_src_cache` moved to AppState, cleared on file switch
- `strip_snapshots()` method on HistoryTree for explicit cleanup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 09:48:47 +01:00
Ethanfel 79e1426036 Fix dict mutation race: snapshot data before background save/sync
json.dump in a background thread would crash with "dictionary changed
size during iteration" when the UI mutated data concurrently. Now all
save_json and sync_to_db calls receive a json.loads(json.dumps(data))
snapshot, isolating the serialization from live UI mutations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 09:36:46 +01:00
Ethanfel ba330dd208 Fix sub-segment ordering: group subs after their parent in DB load
ORDER BY sequence_number put all subs (>=1000) at the end. Now groups
by parent (seq/1000), main first, then subs in order.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 00:25:19 +01:00
Ethanfel 9c560ccfd0 Add timing breakdown to load_full_data for performance diagnosis
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 00:21:59 +01:00
Ethanfel 480131e327 Add load_full_data method to reconstruct data from DB
Replaces slow JSON file parsing with fast DB queries for file loading.
Returns the same dict structure as load_json (top-level + batch_data +
history_tree).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 00:19:44 +01:00
Ethanfel fac5013359 Load data from DB instead of parsing huge JSON files
- load_file and on_select try db.load_full_data first (~0.01s),
  fall back to load_json only when DB has no data
- Fix unawaited coroutine warning for auto-load (asyncio.ensure_future)
- Fix radio on_change to properly await async load_file
- Reuse current data cache when source file matches current file,
  eliminating a redundant 1.3s JSON parse during render

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 00:17:20 +01:00
Ethanfel 45ce264675 Fix DB init crash: wrap bulk migration in explicit transaction
The COMMIT without BEGIN failed in autocommit mode, crashing ProjectDB
init and leaving _shared_db as None ("Database not initialized").

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 00:03:13 +01:00
Ethanfel 0f134a1a20 Add one-time bulk migration to merge lora keys in all stored sequences
Runs at DB startup, only updates rows that have stale separate strength
keys. No-op once all data is migrated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 23:58:56 +01:00
Ethanfel a9197efacd Merge lora name+strength into single key to stay under 32 output limit
Combines separate lora name and strength keys into "name:strength" format,
removing 6 strength keys to free output slots for mode. Migration handles
legacy <lora:> wrapper, separate strength keys, and already-merged formats.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 23:58:32 +01:00
Ethanfel ecb5cdc13f Add comprehensive timing logs across all critical paths
Logs with perf_counter timing on: file load/save, DB sync, all
render functions, save & snap (with deepcopy/save/sync breakdown),
graphviz cache hit/miss, node restore, and API endpoints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 23:53:30 +01:00
Ethanfel 1386043f69 Remove blanket DEFAULTS backfill that exceeded MAX_DYNAMIC_OUTPUTS
Adding all 33 DEFAULTS keys to sequences caused the ComfyUI loader
to exceed its 32 output slot limit, triggering tuple index out of
range errors. Only lora strength migration remains on load/sync.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 23:47:16 +01:00
Ethanfel c4700c620d Backfill default keys during sync_to_db before writing sequences
Ensures mode and all other DEFAULTS keys are present in DB
sequences without bloating API read responses with unexpected keys.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 23:42:34 +01:00
Ethanfel 589c84fd95 Fix remaining blocking I/O calls and N+1 project query
- tab_batch_ng.py: async create_batch with to_thread save/sync
- tab_raw_ng.py: async do_save with to_thread, replace deepcopy
  with dict comprehension for display data
- main.py: async create_new with to_thread save
- tab_projects_ng.py: replace per-project count_data_files with
  single list_projects_with_file_counts JOIN query
- db.py: add list_projects_with_file_counts method

Zero blocking I/O calls remain in UI callbacks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 23:40:32 +01:00
Ethanfel 37e9e1001e Remove blanket DEFAULTS backfill from DB get_sequence
Adding all DEFAULTS keys to API responses shifted output slot
indices, breaking existing ComfyUI workflow links (tuple index
out of range). Only lora migration remains on the DB read path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 23:35:40 +01:00
Ethanfel 526af7097d Backfill default keys in DB get_sequence for API responses
Ensures the project loader API always returns all expected keys
like mode, cfg, etc. even if they were missing from stored data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 23:28:51 +01:00
Ethanfel c880c16865 Backfill missing default keys into sequences on JSON load
Ensures all sequences have required keys like mode, cfg, etc.
from DEFAULTS via setdefault so they persist on next save.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 23:24:34 +01:00
Ethanfel 82e4ba526c Ensure lora strength is always a float in API and JSON
Force float coercion in both migration paths (load_json and DB read)
and override type detection in get_sequence_keys so lora strength
keys always report as FLOAT regardless of JSON deserialization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 23:02:04 +01:00
Ethanfel 08338746e2 Deactivate graph poll timer on client disconnect
Prevents 'parent slot deleted' RuntimeError when the timeline
tab is destroyed while the timer is still firing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 22:59:24 +01:00
Ethanfel 15047016b9 Migrate legacy LoRA format at JSON load time
Run _migrate_lora_keys on every load_json call so all sequences
get split name/strength keys immediately, not just when the LoRA
expansion is rendered. Next save writes the clean format to disk.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 22:55:38 +01:00
Ethanfel 29aa87ee00 Migrate legacy LoRA format on DB read so API returns split keys
Add _migrate_lora_keys to get_sequence() so the /api endpoints
always return separate lora name and strength keys, even for
data stored in the old <lora:name:strength> format.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 22:52:13 +01:00
Ethanfel be9c95ffbd Split LoRA keys into separate name and strength fields
Store lora name and strength as independent JSON keys instead of the
combined <lora:name:strength> format. Legacy format is auto-migrated
on load. Timeline preview updated to show all 6 LoRA slots.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 22:21:48 +01:00
Ethanfel 074e36f883 Fix blocking I/O on event loop, cache graphviz, optimize DB sync
Move all save_json/load_json/sync_to_db/DB calls off the event loop
with asyncio.to_thread to prevent UI freezes. Cache graphviz SVG by
DOT source hash (bounded LRU of 20). Replace DELETE-all/re-INSERT in
sync_to_db with UPSERT + targeted DELETE. Add DB indexes, COUNT query,
and reduce graph poll interval to 0.5s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 22:17:25 +01:00
Ethanfel b36200faaa Detect stale project references and offer clear/recreate options
When current_project in config references a project not found in the
database, show a warning with buttons to either clear the reference
or recreate the project instead of displaying a misleading label.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 23:36:28 +01:00
Ethanfel 5aac1677f7 Fix page reset on save: async I/O and avoid needless deepcopy
Root cause: save_json + sync_to_db blocked the event loop while
serializing the growing history tree (all snapshots) as JSON,
causing NiceGUI websocket timeout and client reconnect.

- Skip history tree in snapshot deepcopy (copied only to discard)
- Move save_json/sync_to_db to asyncio.to_thread in all callbacks
- Make save_and_snap, commit, sort_by_number, shift_fts,
  _add_sequence, apply_mass_update async

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 23:17:53 +01:00
Ethanfel f3ad3e01bc Fix page reset on save: disable reload, handle orphaned timers
- Set reload=False to prevent watchfiles from triggering full page
  reloads when JSON/DB files are written during save
- Timeline graph poll timer (200ms) now deactivates itself when its
  parent slot is deleted during UI refresh
- Comfy monitor poll timer catches RuntimeError from deleted parent
  slots instead of crashing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 22:50:11 +01:00
Ethanfel efd0a31426 Restrict reload watcher to .py files to prevent page reload on save
reload=True was watching all file changes, causing full page reloads
when save_json/save_config wrote .json and .json.tmp files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 22:30:57 +01:00
Ethanfel b042fe4368 Fix 25+ bugs across rounds 4-8 of comprehensive code review
history_tree.py:
- Cycle protection in generate_graph() parent walk
- KeyError → .get() for malformed node data in commit() and generate_graph()
- UUID collision check with for/else raise in commit() and _migrate_legacy()
- RuntimeError → ValueError for consistent exception handling

tab_timeline_ng.py:
- Re-parent children walks to surviving ancestor for batch deletes
- Branch tip deletion re-points to parent instead of removing branch
- Cycle protection in _walk_branch_nodes and _find_branch_for_node
- Full data.clear() restore instead of merge in _restore_node
- Safe .get('data', {}) in restore and preview
- Reset stale branch selection after node deletion
- json.dumps for safe JS string escaping in graphviz renderer

tab_batch_ng.py:
- NaN/inf rejection in dict_number with math.isfinite()
- _safe_int used in recalc_vace, update_mode_label, frame_to_skip
- Uncaught ValueError from htree.commit() caught with user notification

tab_comfy_ng.py:
- asyncio.get_event_loop() → get_running_loop()

utils.py:
- Atomic writes for save_config and save_snippets
- save_config extra_data can't override explicit last_dir/favorites

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:23:14 +01:00
Ethanfel 04b9ed0e27 Fix 4 bugs: SQL conflict handling, HTML escaping, backup cap, safe int cast
- sync_to_db: use ON CONFLICT for duplicate sequence numbers
- history_tree: html.escape() for Graphviz DOT labels
- tab_timeline_ng: cap history_tree_backup to 10 entries
- tab_batch_ng: add _safe_int() helper for VACE settings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:03:13 +01:00
Ethanfel 1b8d13f7c4 Fix 7 bugs: bounds checks, deepcopy, time import, JS keys, unused import
- Add bounds check on src_batch index in add_from_source and copy_source
- Guard delete callback against stale index after rapid clicks
- Replace __import__('time').time() with time.time() in sync_to_db
- Use deepcopy(DEFAULTS) consistently in utils.py and main.py
- Use JSON.stringify in JS onConfigure fallback path for key storage
- Read state.show_comfy_monitor for checkbox initial value
- Remove unused KEY_BATCH_DATA import from tab_projects_ng

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 20:41:00 +01:00
Ethanfel 497e6b06fb Fix 7 bugs: async proxies, mode default, JS key serialization, validation
- Use asyncio.to_thread for proxy endpoints to avoid blocking event loop
- Add mode to DEFAULTS so it doesn't silently insert 0
- Use JSON serialization for keys in project_dynamic.js (with comma fallback)
- Validate path exists in change_path, friendly error on duplicate rename
- Remove unused exp param from rename closure
- Use deepcopy for DEFAULTS consistently

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 20:29:24 +01:00
Ethanfel 993fc86070 Auto-detect change notes when saving without a manual note
Compares current batch data against the last snapshot to generate
descriptive notes like "Added seq 3; Changed: prompt, seed".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 20:24:07 +01:00
Ethanfel c9bcc735f4 Change refresh input from INT to combo dropdown (off/on)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:29:30 +01:00
Ethanfel dc8f44f02b Add refresh input to ProjectLoaderDynamic to force re-fetch
Changing the refresh value triggers the node to re-fetch keys from
the API, picking up any new or modified fields in the data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:27:51 +01:00
Ethanfel 2a6b4f5245 Add mode integer field above sequence number in batch cards
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 01:07:09 +01:00
Ethanfel 60d1162700 Fix async callbacks: make rename/change_path directly async
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 00:42:00 +01:00
Ethanfel 204fc4ea85 Add rename for sequences and projects, per-sub color cycling, project path editing
- Sequences: add rename button with name shown in expansion header
- Subsequences: cycle through 6 distinct border colors by sub-index
- Projects: add rename and change path buttons with DB methods

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 00:40:35 +01:00
Ethanfel 033b3415c2 Merge branch 'feature/sqlite-project-db' 2026-03-01 00:32:07 +01:00
Ethanfel 2ccc3821d6 Add visual distinction for subsequence cards with teal border
The subsegment-card CSS class was not being applied to subsequence
expansion items. Add the class conditionally and include the teal
accent CSS rules with a 6px left border.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 00:31:00 +01:00
Ethanfel 187b85b054 Clean up: remove unnecessary info.outputs logic, set label on reused slots
The actual fix was setting slot.label alongside slot.name. Reverted
onConfigure to read from widget values (which work correctly) and
ensured label is set on both new and reused output slots.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 00:12:30 +01:00
Ethanfel a0d8cb8bbf Fix: set output label alongside name for LiteGraph rendering
LiteGraph renders slot.label over slot.name — we were updating name
but the display uses label.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 00:10:50 +01:00
Ethanfel d55b3198e8 Fix output names not surviving page refresh in ProjectLoaderDynamic
Read output names from info.outputs (serialized node data) instead of
hidden widget values, which ComfyUI may not persist across reloads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:43:34 +01:00
Ethanfel bf2fca53e0 Remove JSONLoaderDynamic, handled by ComfyUI-JSON-Dynamic extension
The separate ComfyUI-JSON-Dynamic extension provides the same node.
Removes json_loader.py, web/json_dynamic.js, and their tests. Only
ProjectLoaderDynamic remains in this extension.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:18:39 +01:00
Ethanfel 5b71d1b276 Fix output name persistence: use comma-separated like reference impl
JSON.stringify format for hidden widget values didn't survive ComfyUI's
serialization round-trip. Switch to comma-separated strings matching
the proven ComfyUI-JSON-Dynamic implementation. Remove properties-based
approach in favor of the simpler, working pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:11:04 +01:00
Ethanfel 027ef8e78a Fix ProjectLoaderDynamic output names lost on page reload
Hidden widget values for output_keys/output_types were not reliably
restored by ComfyUI on workflow reload. Store keys/types in
node.properties (always persisted by LiteGraph) as primary storage,
with hidden widgets as fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:04:14 +01:00
Ethanfel 86693f608a Remove 9 redundant JSON loader nodes, keep only JSONLoaderDynamic
JSONLoaderDynamic auto-discovers keys at runtime, making the hardcoded
Standard, Batch, and Custom nodes unnecessary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:36:33 +01:00
Ethanfel 615755ba44 Revert "Remove 9 redundant JSON loader nodes, keep only JSONLoaderDynamic"
This reverts commit 4b09491242.
2026-02-28 22:36:25 +01:00
Ethanfel 4b09491242 Remove 9 redundant JSON loader nodes, keep only JSONLoaderDynamic
JSONLoaderDynamic auto-discovers keys at runtime, making the hardcoded
Standard, Batch, and Custom nodes unnecessary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:33:51 +01:00
Ethanfel 4b5fff5c6e Improve ProjectLoaderDynamic UX: single node, error feedback, auto-refresh
Remove 3 redundant hardcoded nodes (Standard/VACE/LoRA), keeping only the
Dynamic node. Add total_sequences INT output (slot 0) for loop counting.
Add structured error handling: _fetch_json returns typed error dicts,
load_dynamic raises RuntimeError with descriptive messages, JS shows
red border/title on errors. Add 500ms debounced auto-refresh on widget
changes. Add 404s for missing project/file in API endpoints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:16:08 +01:00
Ethanfel d07a308865 Harden ROLLBACK against I/O errors in transactions
If the original error (e.g., disk full) also prevents ROLLBACK from
executing, catch and suppress the ROLLBACK failure so the original
exception propagates cleanly and the connection isn't left in a
permanently broken state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:44:12 +01:00
Ethanfel c4d107206f Fix 4 bugs from third code review
- Fix delete_proj not persisting cleared current_project to config:
  page reload after deleting active project restored deleted name,
  silently breaking all DB sync
- Fix sync_to_db crash on non-dict batch_data items: add isinstance
  guard matching import_json_file
- Fix output_types ignored in load_dynamic: parse declared types and
  use to_int()/to_float() to coerce values, so downstream ComfyUI
  nodes receive correct types even when API returns strings
- Fix backward-compat comma-split for types not trimming whitespace:
  legacy workflows with "STRING, INT" got types " INT" breaking
  ComfyUI connection type-matching

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:38:37 +01:00
Ethanfel b499eb4dfd Fix 8 bugs from second code review
HIGH:
- Fix JS TypeError on empty API response: validate keys/types are arrays
  before using them; add HTTP status check (resp.ok)
- Fix BEGIN IMMEDIATE conflict: set isolation_level=None (autocommit) on
  SQLite connection so explicit transactions work without implicit ones

MEDIUM:
- Fix import_json_file non-atomic: wrap entire operation in BEGIN/COMMIT
  with ROLLBACK on error — no more partial imports
- Fix crash on non-dict batch_data items: skip non-dict elements
- Fix comma-in-key corruption: store keys/types as JSON arrays in hidden
  widgets instead of comma-delimited strings (backward-compat fallback)
- Fix blocking I/O in API routes: change async def to def so FastAPI
  auto-threads the synchronous SQLite calls

LOW:
- Fix missing ?. on app.graph.setDirtyCanvas in refreshDynamicOutputs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:32:35 +01:00
Ethanfel ba8f104bc1 Fix 6 bugs found during code review
- Fix NameError: pass state to _render_vace_settings (tab_batch_ng.py)
- Fix non-atomic sync_to_db: use BEGIN IMMEDIATE transaction with rollback
- Fix create_secondary() missing db/current_project/db_enabled fields
- Fix URL encoding: percent-encode project/file names in API URLs
- Fix import_json_file crash on re-import: upsert instead of insert
- Fix dual DB instances: share single ProjectDB between UI and API routes
- Also fixes top_level metadata never being updated on existing data_files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:25:31 +01:00
Ethanfel 6b7e9ea682 Port dynamic node improvements from ComfyUI-JSON-Dynamic
- Deferred output cleanup (_configured flag + queueMicrotask) to prevent
  breaking links when other nodes (e.g. Kijai Set/Get) resolve outputs
  during graph loading
- file_not_found error handling in refresh to keep existing outputs intact
- Fallback widget sync in onConfigure when widget values are empty but
  serialized outputs exist

Applied to both json_dynamic.js and project_dynamic.js.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:15:56 +01:00
Ethanfel c15bec98ce Add SQLite project database + ComfyUI connector nodes
- db.py: ProjectDB class with SQLite schema (projects, data_files,
  sequences, history_trees), WAL mode, CRUD, import, and query helpers
- api_routes.py: REST API endpoints on NiceGUI/FastAPI for ComfyUI
  to query project data over the network
- project_loader.py: ComfyUI nodes (ProjectLoaderDynamic, Standard,
  VACE, LoRA) that fetch data from NiceGUI REST API via HTTP
- web/project_dynamic.js: Frontend JS for dynamic project loader node
- tab_projects_ng.py: Projects management tab in NiceGUI UI
- state.py: Added db, current_project, db_enabled fields
- main.py: DB init, API route registration, projects tab
- utils.py: sync_to_db() dual-write helper
- tab_batch_ng.py, tab_raw_ng.py, tab_timeline_ng.py: dual-write
  sync calls after save_json when project DB is enabled
- __init__.py: Merged project node class mappings
- tests/test_db.py: 30 tests for database layer
- tests/test_project_loader.py: 17 tests for ComfyUI connector nodes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:12:05 +01:00
Ethanfel 0d8e84ea36 Add .gitignore with worktrees, pycache, pytest_cache
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:59:39 +01:00
Ethanfel e2f30b0332 Color graph nodes by branch for visual distinction
Each branch gets a unique subtle tint (grey, blue, purple, coral,
teal, sand) so sub-branches are visually distinguishable. HEAD
(yellow) and branch tip (green) colors still take priority.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:22:45 +01:00
Ethanfel 24f9b7d955 Fix graph interactivity: use querySelector instead of NiceGUI DOM ID
The c{id} DOM ID pattern was wrong. Use document.querySelector with
the timeline-graph class instead, with retries until g.node elements
are present in the DOM.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 00:27:00 +01:00
Ethanfel d56f6d8170 Fix graphviz crash: use polyline splines instead of ortho
splines=ortho triggers a trapezoid-table overflow assertion in
graphviz's dot layout engine on complex graphs. polyline gives
similar angled edges without the crash.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 00:21:43 +01:00
Ethanfel f2980a9f94 Fix graph: NiceGUI blocks script tags in ui.html()
Move JS back to ui.run_javascript() with retry-based DOM lookup
using NiceGUI's element ID (c{id}). CSS stays inline via style tag.
Retries up to 10 times at 50ms intervals to handle Vue async render.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 00:20:32 +01:00
Ethanfel 4e3ff63f6a Fix graph interactivity: embed JS/CSS inline in HTML
Previous approach used ui.run_javascript with getElement() which
failed due to Vue rendering timing. Now embeds the script and style
directly inside the HTML content so there are no DOM lookup or
timing issues — the script runs inline when parsed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 00:19:26 +01:00
Ethanfel 6e01cab5cd Fix graph interactivity: use NiceGUI element ref and requestAnimationFrame
The click handlers weren't attaching because getElementById couldn't
find the container — Python's id() generated IDs that didn't survive
NiceGUI's DOM rendering. Now uses getElement() with the NiceGUI
element ID and defers JS via requestAnimationFrame to ensure the
DOM is ready.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 00:15:56 +01:00
Ethanfel 16ed81f0db Fix tiny graph: keep SVG natural size, scroll on overflow
Stop replacing the SVG's width/height attributes — this was shrinking
the graph to fit the container. Instead keep graphviz's native pt
dimensions and let the scroll container handle overflow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 00:14:25 +01:00
Ethanfel d98cee8015 Fix timeline graph height: remove invalid SVG height="auto"
Drop the fixed height attribute entirely instead of setting it to
"auto", which SVGs don't support. The viewBox attribute handles
proportional scaling when only width="100%" is set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 00:11:03 +01:00
Ethanfel 2ebf3a4fcd Interactive timeline graph: click nodes to select in node manager
Add click-to-select functionality to the graphviz SVG timeline graph.
Clicking a node highlights it with an amber border, auto-switches the
branch selector, and updates the node manager panel. The SVG is now
responsive (100% width, scroll container) instead of fixed-size.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 00:06:13 +01:00
Ethanfel a4cb979131 Remove stale Streamlit references from comments
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:18:37 +01:00
Ethanfel 9a3f7b7b94 Remove old Streamlit UI files superseded by NiceGUI migration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:17:38 +01:00
Ethanfel d8597f201a Merge nicegui-migration: full NiceGUI web UI 2026-02-27 22:16:34 +01:00
29 changed files with 4223 additions and 2756 deletions
+3
View File
@@ -0,0 +1,3 @@
__pycache__/
.pytest_cache/
.worktrees/
+4 -1
View File
@@ -1,4 +1,7 @@
from .json_loader import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS from .project_loader import PROJECT_NODE_CLASS_MAPPINGS, PROJECT_NODE_DISPLAY_NAME_MAPPINGS
NODE_CLASS_MAPPINGS = PROJECT_NODE_CLASS_MAPPINGS
NODE_DISPLAY_NAME_MAPPINGS = PROJECT_NODE_DISPLAY_NAME_MAPPINGS
WEB_DIRECTORY = "./web" WEB_DIRECTORY = "./web"
+87
View File
@@ -0,0 +1,87 @@
"""REST API endpoints for ComfyUI to query project data from SQLite.
All endpoints are read-only. Mounted on the NiceGUI/FastAPI server.
"""
import logging
import time
from typing import Any
from fastapi import HTTPException, Query
from nicegui import app
from db import ProjectDB
logger = logging.getLogger(__name__)
# The DB instance is set by register_api_routes()
_db: ProjectDB | None = None
def register_api_routes(db: ProjectDB) -> None:
"""Register all REST API routes with the NiceGUI/FastAPI app."""
global _db
_db = db
app.add_api_route("/api/projects", _list_projects, methods=["GET"])
app.add_api_route("/api/projects/{name}/files", _list_files, methods=["GET"])
app.add_api_route("/api/projects/{name}/files/{file_name}/sequences", _list_sequences, methods=["GET"])
app.add_api_route("/api/projects/{name}/files/{file_name}/data", _get_data, methods=["GET"])
app.add_api_route("/api/projects/{name}/files/{file_name}/keys", _get_keys, methods=["GET"])
def _get_db() -> ProjectDB:
if _db is None:
raise HTTPException(status_code=503, detail="Database not initialized")
return _db
def _list_projects() -> dict[str, Any]:
db = _get_db()
projects = db.list_projects()
return {"projects": [p["name"] for p in projects]}
def _list_files(name: str) -> dict[str, Any]:
db = _get_db()
files = db.list_project_files(name)
return {"files": [{"name": f["name"], "data_type": f["data_type"]} for f in files]}
def _list_sequences(name: str, file_name: str) -> dict[str, Any]:
db = _get_db()
seqs = db.list_project_sequences(name, file_name)
return {"sequences": seqs}
def _get_data(name: str, file_name: str, seq: int = Query(default=1)) -> dict[str, Any]:
t0 = time.perf_counter()
db = _get_db()
proj = db.get_project(name)
if not proj:
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
df = db.get_data_file_by_names(name, file_name)
if not df:
raise HTTPException(status_code=404, detail=f"File '{file_name}' not found in project '{name}'")
data = db.get_sequence(df["id"], seq)
if data is None:
raise HTTPException(status_code=404, detail=f"Sequence {seq} not found")
logger.info("API _get_data %s/%s seq=%d (%d keys): %.3fs",
name, file_name, seq, len(data), time.perf_counter() - t0)
return data
def _get_keys(name: str, file_name: str, seq: int = Query(default=1)) -> dict[str, Any]:
t0 = time.perf_counter()
db = _get_db()
proj = db.get_project(name)
if not proj:
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
df = db.get_data_file_by_names(name, file_name)
if not df:
raise HTTPException(status_code=404, detail=f"File '{file_name}' not found in project '{name}'")
keys, types = db.get_sequence_keys(df["id"], seq)
total = db.count_sequences(df["id"])
logger.info("API _get_keys %s/%s seq=%d (%d keys): %.3fs",
name, file_name, seq, len(keys), time.perf_counter() - t0)
return {"keys": keys, "types": types, "total_sequences": total}
-224
View File
@@ -1,224 +0,0 @@
import streamlit as st
from pathlib import Path
# --- Import Custom Modules ---
from utils import (
load_config, save_config, load_snippets, save_snippets,
load_json, save_json, generate_templates, DEFAULTS,
KEY_BATCH_DATA, KEY_SEQUENCE_NUMBER,
resolve_path_case_insensitive,
)
from tab_batch import render_batch_processor
from tab_timeline import render_timeline_tab
from tab_comfy import render_comfy_monitor
from tab_raw import render_raw_editor
# ==========================================
# 1. PAGE CONFIGURATION
# ==========================================
st.set_page_config(layout="wide", page_title="AI Settings Manager")
# ==========================================
# 2. SESSION STATE INITIALIZATION
# ==========================================
_SESSION_DEFAULTS = {
"snippets": load_snippets,
"loaded_file": lambda: None,
"last_mtime": lambda: 0,
"ui_reset_token": lambda: 0,
"active_tab_name": lambda: "🚀 Batch Processor",
}
if 'config' not in st.session_state:
st.session_state.config = load_config()
st.session_state.current_dir = Path(st.session_state.config.get("last_dir", Path.cwd()))
for key, factory in _SESSION_DEFAULTS.items():
if key not in st.session_state:
st.session_state[key] = factory()
# ==========================================
# 3. SIDEBAR (NAVIGATOR & TOOLS)
# ==========================================
with st.sidebar:
st.header("📂 Navigator")
# --- Path Navigator ---
# Sync widget to current_dir on first load or after external change
if "nav_path_input" not in st.session_state or st.session_state.get("_sync_nav_path"):
st.session_state.nav_path_input = str(st.session_state.current_dir)
st.session_state._sync_nav_path = False
def _on_path_change():
new_path = st.session_state.nav_path_input
p = resolve_path_case_insensitive(new_path)
if p is not None and p.is_dir():
st.session_state.current_dir = p
st.session_state.config['last_dir'] = str(p)
save_config(st.session_state.current_dir, st.session_state.config['favorites'])
st.session_state.loaded_file = None
# Always resync widget to canonical path form
st.session_state._sync_nav_path = True
st.text_input("Current Path", key="nav_path_input", on_change=_on_path_change)
# --- Favorites System ---
if st.button("📌 Pin Folder", use_container_width=True):
if str(st.session_state.current_dir) not in st.session_state.config['favorites']:
st.session_state.config['favorites'].append(str(st.session_state.current_dir))
save_config(st.session_state.current_dir, st.session_state.config['favorites'])
st.rerun()
favorites = st.session_state.config['favorites']
if favorites:
def _on_fav_jump():
sel = st.session_state._fav_radio
if sel != "Select..." and sel != str(st.session_state.current_dir):
st.session_state.current_dir = Path(sel)
st.session_state._sync_nav_path = True
st.radio(
"Jump to:",
["Select..."] + favorites,
index=0,
key="_fav_radio",
label_visibility="collapsed",
on_change=_on_fav_jump,
)
# Unpin buttons for each favorite
for fav in favorites:
fc1, fc2 = st.columns([4, 1])
fc1.caption(fav)
if fc2.button("", key=f"unpin_{fav}"):
st.session_state.config['favorites'].remove(fav)
save_config(st.session_state.current_dir, st.session_state.config['favorites'])
st.rerun()
st.markdown("---")
# --- Snippet Library ---
st.subheader("🧩 Snippet Library")
with st.expander("Add New Snippet"):
snip_name = st.text_input("Name", placeholder="e.g. Cinematic")
snip_content = st.text_area("Content", placeholder="4k, high quality...")
if st.button("Save Snippet"):
if snip_name and snip_content:
st.session_state.snippets[snip_name] = snip_content
save_snippets(st.session_state.snippets)
st.success(f"Saved '{snip_name}'")
st.rerun()
if st.session_state.snippets:
st.caption("Click to Append to Prompt:")
for name, content in st.session_state.snippets.items():
col_s1, col_s2 = st.columns([4, 1])
if col_s1.button(f" {name}", use_container_width=True):
st.rerun()
if col_s2.button("🗑️", key=f"del_snip_{name}"):
del st.session_state.snippets[name]
save_snippets(st.session_state.snippets)
st.rerun()
st.markdown("---")
# --- File List & Creation ---
json_files = sorted(list(st.session_state.current_dir.glob("*.json")))
json_files = [f for f in json_files if f.name != ".editor_config.json" and f.name != ".editor_snippets.json"]
if not json_files:
if st.button("Generate Templates"):
generate_templates(st.session_state.current_dir)
st.rerun()
with st.expander("Create New JSON"):
new_filename = st.text_input("Filename", placeholder="my_prompt_vace")
if st.button("Create"):
if not new_filename.endswith(".json"): new_filename += ".json"
path = st.session_state.current_dir / new_filename
first_item = DEFAULTS.copy()
first_item[KEY_SEQUENCE_NUMBER] = 1
data = {KEY_BATCH_DATA: [first_item]}
save_json(path, data)
st.rerun()
# --- File Selector ---
selected_file_name = None
if json_files:
file_names = [f.name for f in json_files]
if 'file_selector' not in st.session_state:
st.session_state.file_selector = file_names[0]
if st.session_state.file_selector not in file_names:
st.session_state.file_selector = file_names[0]
selected_file_name = st.radio("Select File", file_names, key="file_selector")
else:
st.info("No JSON files in this folder.")
if 'file_selector' in st.session_state:
del st.session_state.file_selector
st.session_state.loaded_file = None
# --- GLOBAL MONITOR TOGGLE (NEW) ---
st.markdown("---")
show_monitor = st.checkbox("Show Comfy Monitor", value=True)
# ==========================================
# 4. MAIN APP LOGIC
# ==========================================
if selected_file_name:
file_path = st.session_state.current_dir / selected_file_name
# --- FILE LOADING & AUTO-SWITCH LOGIC ---
if st.session_state.loaded_file != str(file_path):
data, mtime = load_json(file_path)
st.session_state.data_cache = data
st.session_state.last_mtime = mtime
st.session_state.loaded_file = str(file_path)
# Clear transient states
if 'restored_indicator' in st.session_state: del st.session_state.restored_indicator
# --- AUTO-SWITCH TAB LOGIC ---
st.session_state.active_tab_name = "🚀 Batch Processor"
else:
data = st.session_state.data_cache
st.title(f"Editing: {selected_file_name}")
# --- CONTROLLED NAVIGATION ---
# Removed "🔌 Comfy Monitor" from this list
tabs_list = [
"🚀 Batch Processor",
"🕒 Timeline",
"💻 Raw Editor"
]
if st.session_state.active_tab_name not in tabs_list:
st.session_state.active_tab_name = tabs_list[0]
current_tab = st.radio(
"Navigation",
tabs_list,
key="active_tab_name",
horizontal=True,
label_visibility="collapsed"
)
st.markdown("---")
# --- RENDER EDITOR TABS ---
if current_tab == "🚀 Batch Processor":
render_batch_processor(data, file_path, json_files, st.session_state.current_dir, selected_file_name)
elif current_tab == "🕒 Timeline":
render_timeline_tab(data, file_path)
elif current_tab == "💻 Raw Editor":
render_raw_editor(data, file_path)
# --- GLOBAL PERSISTENT MONITOR ---
if show_monitor:
st.markdown("---")
with st.expander("🔌 ComfyUI Monitor", expanded=True):
render_comfy_monitor()
+596
View File
@@ -0,0 +1,596 @@
import json
import logging
import sqlite3
import time
from pathlib import Path
from typing import Any
from utils import load_json, KEY_BATCH_DATA, KEY_HISTORY_TREE
logger = logging.getLogger(__name__)
DEFAULT_DB_PATH = Path.home() / ".comfyui_json_manager" / "projects.db"
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
folder_path TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at REAL NOT NULL,
updated_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS data_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
data_type TEXT NOT NULL DEFAULT 'generic',
top_level TEXT NOT NULL DEFAULT '{}',
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
UNIQUE(project_id, name)
);
CREATE TABLE IF NOT EXISTS sequences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
data_file_id INTEGER NOT NULL REFERENCES data_files(id) ON DELETE CASCADE,
sequence_number INTEGER NOT NULL,
data TEXT NOT NULL DEFAULT '{}',
updated_at REAL NOT NULL,
UNIQUE(data_file_id, sequence_number)
);
CREATE TABLE IF NOT EXISTS history_trees (
id INTEGER PRIMARY KEY AUTOINCREMENT,
data_file_id INTEGER NOT NULL UNIQUE REFERENCES data_files(id) ON DELETE CASCADE,
tree_data TEXT NOT NULL DEFAULT '{}',
updated_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS history_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
data_file_id INTEGER NOT NULL REFERENCES data_files(id) ON DELETE CASCADE,
node_id TEXT NOT NULL,
snapshot_data TEXT NOT NULL,
updated_at REAL NOT NULL,
UNIQUE(data_file_id, node_id)
);
CREATE INDEX IF NOT EXISTS idx_data_files_project_id ON data_files(project_id);
CREATE INDEX IF NOT EXISTS idx_sequences_data_file_id ON sequences(data_file_id);
CREATE INDEX IF NOT EXISTS idx_history_snapshots_df ON history_snapshots(data_file_id);
"""
class ProjectDB:
"""SQLite database for project-based data management."""
def __init__(self, db_path: str | Path | None = None):
self.db_path = Path(db_path) if db_path else DEFAULT_DB_PATH
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self.conn = sqlite3.connect(
str(self.db_path),
check_same_thread=False,
isolation_level=None, # autocommit — explicit BEGIN/COMMIT only
)
self.conn.row_factory = sqlite3.Row
self.conn.execute("PRAGMA journal_mode=WAL")
self.conn.execute("PRAGMA foreign_keys=ON")
self.conn.executescript(SCHEMA_SQL)
self._migrate_all_lora_data()
def _migrate_all_lora_data(self) -> None:
"""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")
try:
for row in rows:
data = json.loads(row["data"])
original = row["data"]
migrated = self._migrate_lora_keys(data)
new_json = json.dumps(migrated)
if new_json != original:
self.conn.execute(
"UPDATE sequences SET data = ? WHERE id = ?",
(new_json, row["id"]),
)
updated += 1
self.conn.execute("COMMIT")
except Exception:
self.conn.execute("ROLLBACK")
raise
if updated:
logger.info("Migrated lora keys in %d/%d sequences", updated, len(rows))
def close(self):
self.conn.close()
# ------------------------------------------------------------------
# Projects CRUD
# ------------------------------------------------------------------
def create_project(self, name: str, folder_path: str, description: str = "") -> int:
now = time.time()
cur = self.conn.execute(
"INSERT INTO projects (name, folder_path, description, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?)",
(name, folder_path, description, now, now),
)
self.conn.commit()
return cur.lastrowid
def list_projects(self) -> list[dict]:
rows = self.conn.execute(
"SELECT id, name, folder_path, description, created_at, updated_at "
"FROM projects ORDER BY name"
).fetchall()
return [dict(r) for r in rows]
def list_projects_with_file_counts(self) -> list[dict]:
"""List projects with data file counts in a single query."""
rows = self.conn.execute(
"SELECT p.id, p.name, p.folder_path, p.description, p.created_at, p.updated_at, "
"COUNT(df.id) AS file_count "
"FROM projects p LEFT JOIN data_files df ON df.project_id = p.id "
"GROUP BY p.id ORDER BY p.name"
).fetchall()
return [dict(r) for r in rows]
def get_project(self, name: str) -> dict | None:
row = self.conn.execute(
"SELECT id, name, folder_path, description, created_at, updated_at "
"FROM projects WHERE name = ?",
(name,),
).fetchone()
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:
cur = self.conn.execute("DELETE FROM projects WHERE name = ?", (name,))
self.conn.commit()
return cur.rowcount > 0
# ------------------------------------------------------------------
# Data files
# ------------------------------------------------------------------
def create_data_file(
self, project_id: int, name: str, data_type: str = "generic", top_level: dict | None = None
) -> int:
now = time.time()
tl = json.dumps(top_level or {})
cur = self.conn.execute(
"INSERT INTO data_files (project_id, name, data_type, top_level, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(project_id, name, data_type, tl, now, now),
)
self.conn.commit()
return cur.lastrowid
def list_data_files(self, project_id: int) -> list[dict]:
rows = self.conn.execute(
"SELECT id, project_id, name, data_type, created_at, updated_at "
"FROM data_files WHERE project_id = ? ORDER BY name",
(project_id,),
).fetchall()
return [dict(r) for r in rows]
def count_data_files(self, project_id: int) -> int:
"""Return the number of data files for a project."""
row = self.conn.execute(
"SELECT COUNT(*) AS cnt FROM data_files WHERE project_id = ?",
(project_id,),
).fetchone()
return row["cnt"]
def get_data_file(self, project_id: int, name: str) -> dict | None:
row = self.conn.execute(
"SELECT id, project_id, name, data_type, top_level, created_at, updated_at "
"FROM data_files WHERE project_id = ? AND name = ?",
(project_id, name),
).fetchone()
if row is None:
return None
d = dict(row)
d["top_level"] = json.loads(d["top_level"])
return d
def get_data_file_by_names(self, project_name: str, file_name: str) -> dict | None:
row = self.conn.execute(
"SELECT df.id, df.project_id, df.name, df.data_type, df.top_level, "
"df.created_at, df.updated_at "
"FROM data_files df JOIN projects p ON df.project_id = p.id "
"WHERE p.name = ? AND df.name = ?",
(project_name, file_name),
).fetchone()
if row is None:
return None
d = dict(row)
d["top_level"] = json.loads(d["top_level"])
return d
# ------------------------------------------------------------------
# Sequences
# ------------------------------------------------------------------
def upsert_sequence(self, data_file_id: int, sequence_number: int, data: dict) -> None:
now = time.time()
self.conn.execute(
"INSERT INTO sequences (data_file_id, sequence_number, data, updated_at) "
"VALUES (?, ?, ?, ?) "
"ON CONFLICT(data_file_id, sequence_number) DO UPDATE SET data=excluded.data, updated_at=excluded.updated_at",
(data_file_id, sequence_number, json.dumps(data), now),
)
self.conn.commit()
@staticmethod
def _migrate_lora_keys(data: dict) -> dict:
"""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}'
str_key = f'lora {idx} {tier} strength'
raw = str(data.get(name_key, ''))
if raw.startswith('<lora:'):
inner = raw.replace('<lora:', '').replace('>', '')
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[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:
row = self.conn.execute(
"SELECT data FROM sequences WHERE data_file_id = ? AND sequence_number = ?",
(data_file_id, sequence_number),
).fetchone()
if not row:
return None
data = json.loads(row["data"])
return self._migrate_lora_keys(data)
def list_sequences(self, data_file_id: int) -> list[int]:
rows = self.conn.execute(
"SELECT sequence_number FROM sequences WHERE data_file_id = ? ORDER BY sequence_number",
(data_file_id,),
).fetchall()
return [r["sequence_number"] for r in rows]
def count_sequences(self, data_file_id: int) -> int:
"""Return the number of sequences for a data file."""
row = self.conn.execute(
"SELECT COUNT(*) AS cnt FROM sequences WHERE data_file_id = ?",
(data_file_id,),
).fetchone()
return row["cnt"]
def query_total_sequences(self, project_name: str, file_name: str) -> int:
"""Return total sequence count by project and file names."""
df = self.get_data_file_by_names(project_name, file_name)
if not df:
return 0
return self.count_sequences(df["id"])
def get_sequence_keys(self, data_file_id: int, sequence_number: int) -> tuple[list[str], list[str]]:
"""Returns (keys, types) for a sequence's data dict."""
data = self.get_sequence(data_file_id, sequence_number)
if not data:
return [], []
keys = []
types = []
for k, v in data.items():
keys.append(k)
if isinstance(v, bool):
types.append("STRING")
elif isinstance(v, int):
types.append("INT")
elif isinstance(v, float):
types.append("FLOAT")
else:
types.append("STRING")
return keys, types
def delete_sequences_for_file(self, data_file_id: int) -> None:
self.conn.execute("DELETE FROM sequences WHERE data_file_id = ?", (data_file_id,))
self.conn.commit()
# ------------------------------------------------------------------
# History trees
# ------------------------------------------------------------------
def save_history_tree(self, data_file_id: int, tree_data: dict) -> None:
"""Save history tree, extracting snapshot data into separate table.
Supports both new format (snapshots dict) and old format (nodes dict).
"""
now = time.time()
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_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:
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, eid, json.dumps(snap), now),
)
self.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",
(data_file_id, json.dumps(slim_tree), now),
)
self.conn.execute("COMMIT")
except Exception:
try:
self.conn.execute("ROLLBACK")
except Exception:
pass
raise
def get_history_tree(self, data_file_id: int) -> dict | None:
"""Load history tree metadata (without snapshot data)."""
row = self.conn.execute(
"SELECT tree_data FROM history_trees WHERE data_file_id = ?",
(data_file_id,),
).fetchone()
return json.loads(row["tree_data"]) if row else None
# ------------------------------------------------------------------
# History snapshots (per-node data, loaded on demand)
# ------------------------------------------------------------------
def get_node_snapshot(self, data_file_id: int, node_id: str) -> dict | None:
"""Load a single node's snapshot data on demand."""
row = self.conn.execute(
"SELECT snapshot_data FROM history_snapshots WHERE data_file_id = ? AND node_id = ?",
(data_file_id, node_id),
).fetchone()
return json.loads(row["snapshot_data"]) if row else None
def delete_node_snapshots(self, data_file_id: int, node_ids: set) -> None:
"""Delete snapshots for removed nodes."""
if not node_ids:
return
placeholders = ",".join("?" for _ in node_ids)
self.conn.execute(
f"DELETE FROM history_snapshots WHERE data_file_id = ? AND node_id IN ({placeholders})",
(data_file_id, *node_ids),
)
self.conn.commit()
# ------------------------------------------------------------------
# Import
# ------------------------------------------------------------------
def import_json_file(self, project_id: int, json_path: str | Path, data_type: str = "generic") -> int:
"""Import a JSON file into the database, splitting batch_data into sequences.
Safe to call repeatedly — existing data_file is updated, sequences are
replaced, and history_tree is upserted. Atomic: all-or-nothing.
"""
json_path = Path(json_path)
data, _ = load_json(json_path)
file_name = json_path.stem
top_level = {k: v for k, v in data.items() if k not in (KEY_BATCH_DATA, KEY_HISTORY_TREE)}
self.conn.execute("BEGIN IMMEDIATE")
try:
existing = self.conn.execute(
"SELECT id FROM data_files WHERE project_id = ? AND name = ?",
(project_id, file_name),
).fetchone()
if existing:
df_id = existing["id"]
now = time.time()
self.conn.execute(
"UPDATE data_files SET data_type = ?, top_level = ?, updated_at = ? WHERE id = ?",
(data_type, json.dumps(top_level), now, df_id),
)
self.conn.execute("DELETE FROM sequences WHERE data_file_id = ?", (df_id,))
else:
now = time.time()
cur = self.conn.execute(
"INSERT INTO data_files (project_id, name, data_type, top_level, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(project_id, file_name, data_type, json.dumps(top_level), now, now),
)
df_id = cur.lastrowid
# Import sequences from batch_data
batch_data = data.get(KEY_BATCH_DATA, [])
if isinstance(batch_data, list):
for item in batch_data:
if not isinstance(item, dict):
continue
seq_num = int(item.get("sequence_number", 0))
now = time.time()
self.conn.execute(
"INSERT INTO sequences (data_file_id, sequence_number, data, updated_at) "
"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),
)
# 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()
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_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, eid, json.dumps(snap), now),
)
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 (?, ?, ?) "
"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),
)
self.conn.execute("COMMIT")
return df_id
except Exception:
try:
self.conn.execute("ROLLBACK")
except Exception:
pass
raise
# ------------------------------------------------------------------
# Full data reconstruction (replaces load_json for DB-backed files)
# ------------------------------------------------------------------
def load_full_data(self, project_name: str, file_name: str) -> dict | None:
"""Reconstruct the full data dict from DB, matching load_json format.
Returns None if the project or file doesn't exist in the DB.
Result has the same structure as a JSON file: top-level keys +
batch_data list + history_tree dict.
"""
t0 = time.time()
df = self.get_data_file_by_names(project_name, file_name)
if not df:
return None
t1 = time.time()
# Start with top-level keys
data = df.get("top_level", {})
if isinstance(data, str):
data = json.loads(data)
# Load all sequences as batch_data
# Group sub-segments (>=1000) after their parent: parent = seq_num / 1000
rows = self.conn.execute(
"SELECT data FROM sequences WHERE data_file_id = ? "
"ORDER BY CASE WHEN sequence_number >= 1000 THEN sequence_number / 1000 "
"ELSE sequence_number END, "
"CASE WHEN sequence_number >= 1000 THEN 1 ELSE 0 END, "
"sequence_number",
(df["id"],),
).fetchall()
batch_data = []
for row in rows:
seq = json.loads(row["data"])
self._migrate_lora_keys(seq)
batch_data.append(seq)
data["batch_data"] = batch_data
t2 = time.time()
# Load history tree (metadata only, no snapshot data)
tree = self.get_history_tree(df["id"])
if tree:
# 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()
logger.info("load_full_data %s/%s (%d seqs): lookup=%.3fs seqs=%.3fs tree=%.3fs total=%.3fs",
project_name, file_name, len(batch_data),
t1 - t0, t2 - t1, t3 - t2, t3 - t0)
return data
# ------------------------------------------------------------------
# Query helpers (for REST API)
# ------------------------------------------------------------------
def query_sequence_data(self, project_name: str, file_name: str, sequence_number: int) -> dict | None:
"""Query a single sequence by project name, file name, and sequence number."""
df = self.get_data_file_by_names(project_name, file_name)
if not df:
return None
return self.get_sequence(df["id"], sequence_number)
def query_sequence_keys(self, project_name: str, file_name: str, sequence_number: int) -> tuple[list[str], list[str]]:
"""Query keys and types for a sequence."""
df = self.get_data_file_by_names(project_name, file_name)
if not df:
return [], []
return self.get_sequence_keys(df["id"], sequence_number)
def list_project_files(self, project_name: str) -> list[dict]:
"""List data files for a project by name."""
proj = self.get_project(project_name)
if not proj:
return []
return self.list_data_files(proj["id"])
def list_project_sequences(self, project_name: str, file_name: str) -> list[int]:
"""List sequence numbers for a file in a project."""
df = self.get_data_file_by_names(project_name, file_name)
if not df:
return []
return self.list_sequences(df["id"])
+63 -10
View File
@@ -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):
for _ in range(10):
node_id = str(uuid.uuid4())[:8] 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:
# Generate unique node ID with collision check
for _ in range(10):
new_id = str(uuid.uuid4())[:8] 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():
@@ -66,6 +76,11 @@ class HistoryTree:
return self.nodes[node_id]["data"] return self.nodes[node_id]["data"]
return None return None
def strip_snapshots(self) -> None:
"""Remove snapshot data from all nodes to free memory."""
for node in self.nodes.values():
node.pop("data", None)
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
return {"nodes": self.nodes, "branches": self.branches, "head_id": self.head_id} return {"nodes": self.nodes, "branches": self.branches, "head_id": self.head_id}
@@ -104,13 +119,42 @@ class HistoryTree:
'digraph History {', 'digraph History {',
f' rankdir={direction};', f' rankdir={direction};',
' bgcolor="white";', ' bgcolor="white";',
' splines=ortho;', ' splines=polyline;',
f' nodesep={nodesep};', f' nodesep={nodesep};',
f' ranksep={ranksep};', f' ranksep={ranksep};',
' node [shape=plain, fontname="Arial"];', ' node [shape=plain, fontname="Arial"];',
' edge [color="#888888", arrowsize=0.6, penwidth=1.0];' ' edge [color="#888888", arrowsize=0.6, penwidth=1.0];'
] ]
# Build reverse lookup: node_id -> branch name (walk each branch ancestry)
node_to_branch: dict[str, str] = {}
for b_name, tip_id in self.branches.items():
visited = set()
current = tip_id
while current and current in self.nodes:
if current in visited:
break
if current in node_to_branch:
break # this node and all ancestors already assigned
visited.add(current)
node_to_branch[current] = b_name
current = self.nodes[current].get('parent')
# Per-branch color palette (bg, border) — cycles for many branches
_branch_palette = [
('#f9f9f9', '#999999'), # grey (default/main)
('#eef4ff', '#6699cc'), # blue
('#f5eeff', '#9977cc'), # purple
('#fff0ee', '#cc7766'), # coral
('#eefff5', '#66aa88'), # teal
('#fff8ee', '#ccaa55'), # sand
]
branch_names = list(self.branches.keys())
branch_colors = {
b: _branch_palette[i % len(_branch_palette)]
for i, b in enumerate(branch_names)
}
sorted_nodes = sorted(self.nodes.values(), key=lambda x: x["timestamp"]) sorted_nodes = sorted(self.nodes.values(), key=lambda x: x["timestamp"])
# Font sizes and padding - smaller for vertical # Font sizes and padding - smaller for vertical
@@ -130,17 +174,19 @@ 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 # COLORS — per-branch tint, overridden for HEAD and tips
bg_color = "#f9f9f9" b_name = node_to_branch.get(nid)
border_color = "#999999" bg_color, border_color = branch_colors.get(
b_name, _branch_palette[0])
border_width = "1" border_width = "1"
if nid == self.head_id: if nid == self.head_id:
@@ -165,11 +211,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(']', '&#93;'))
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)
-384
View File
@@ -1,384 +0,0 @@
import json
import os
import logging
from typing import Any
logger = logging.getLogger(__name__)
KEY_BATCH_DATA = "batch_data"
MAX_DYNAMIC_OUTPUTS = 32
class AnyType(str):
"""Universal connector type that matches any ComfyUI type."""
def __ne__(self, __value: object) -> bool:
return False
any_type = AnyType("*")
try:
from server import PromptServer
from aiohttp import web
except ImportError:
PromptServer = None
def to_float(val: Any) -> float:
try:
return float(val)
except (ValueError, TypeError):
return 0.0
def to_int(val: Any) -> int:
try:
return int(float(val))
except (ValueError, TypeError):
return 0
def get_batch_item(data: dict[str, Any], sequence_number: int) -> dict[str, Any]:
"""Resolve batch item by sequence_number field, falling back to array index."""
if KEY_BATCH_DATA in data and isinstance(data[KEY_BATCH_DATA], list) and len(data[KEY_BATCH_DATA]) > 0:
# Search by sequence_number field first
for item in data[KEY_BATCH_DATA]:
if int(item.get("sequence_number", 0)) == sequence_number:
return item
# Fallback to array index
idx = max(0, min(sequence_number - 1, len(data[KEY_BATCH_DATA]) - 1))
logger.warning(f"No item with sequence_number={sequence_number}, falling back to index {idx}")
return data[KEY_BATCH_DATA][idx]
return data
# --- Shared Helper ---
def read_json_data(json_path: str) -> dict[str, Any]:
if not os.path.exists(json_path):
logger.warning(f"File not found at {json_path}")
return {}
try:
with open(json_path, 'r') as f:
data = json.load(f)
except (json.JSONDecodeError, IOError) as e:
logger.warning(f"Error reading {json_path}: {e}")
return {}
if not isinstance(data, dict):
logger.warning(f"Expected dict from {json_path}, got {type(data).__name__}")
return {}
return data
# --- API Route ---
if PromptServer is not None:
@PromptServer.instance.routes.get("/json_manager/get_keys")
async def get_keys_route(request):
json_path = request.query.get("path", "")
try:
seq = int(request.query.get("sequence_number", "1"))
except (ValueError, TypeError):
seq = 1
data = read_json_data(json_path)
target = get_batch_item(data, seq)
keys = []
types = []
if isinstance(target, dict):
for k, v in target.items():
keys.append(k)
if isinstance(v, bool):
types.append("STRING")
elif isinstance(v, int):
types.append("INT")
elif isinstance(v, float):
types.append("FLOAT")
else:
types.append("STRING")
return web.json_response({"keys": keys, "types": types})
# ==========================================
# 0. DYNAMIC NODE
# ==========================================
class JSONLoaderDynamic:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"json_path": ("STRING", {"default": "", "multiline": False}),
"sequence_number": ("INT", {"default": 1, "min": 1, "max": 9999}),
},
"optional": {
"output_keys": ("STRING", {"default": ""}),
"output_types": ("STRING", {"default": ""}),
},
}
RETURN_TYPES = tuple(any_type for _ in range(MAX_DYNAMIC_OUTPUTS))
RETURN_NAMES = tuple(f"output_{i}" for i in range(MAX_DYNAMIC_OUTPUTS))
FUNCTION = "load_dynamic"
CATEGORY = "utils/json"
OUTPUT_NODE = False
def load_dynamic(self, json_path, sequence_number, output_keys="", output_types=""):
data = read_json_data(json_path)
target = get_batch_item(data, sequence_number)
keys = [k.strip() for k in output_keys.split(",") if k.strip()] if output_keys else []
results = []
for key in keys:
val = target.get(key, "")
if isinstance(val, bool):
results.append(str(val).lower())
elif isinstance(val, int):
results.append(val)
elif isinstance(val, float):
results.append(val)
else:
results.append(str(val))
# Pad to MAX_DYNAMIC_OUTPUTS
while len(results) < MAX_DYNAMIC_OUTPUTS:
results.append("")
return tuple(results)
# ==========================================
# 1. STANDARD NODES (Single File)
# ==========================================
class JSONLoaderLoRA:
@classmethod
def INPUT_TYPES(s):
return {"required": {"json_path": ("STRING", {"default": "", "multiline": False})}}
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "STRING")
RETURN_NAMES = ("lora_1_high", "lora_1_low", "lora_2_high", "lora_2_low", "lora_3_high", "lora_3_low")
FUNCTION = "load_loras"
CATEGORY = "utils/json"
def load_loras(self, json_path):
data = read_json_data(json_path)
return (
str(data.get("lora 1 high", "")), str(data.get("lora 1 low", "")),
str(data.get("lora 2 high", "")), str(data.get("lora 2 low", "")),
str(data.get("lora 3 high", "")), str(data.get("lora 3 low", ""))
)
class JSONLoaderStandard:
@classmethod
def INPUT_TYPES(s):
return {"required": {"json_path": ("STRING", {"default": "", "multiline": False})}}
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "FLOAT", "INT", "STRING", "STRING", "STRING")
RETURN_NAMES = ("general_prompt", "general_negative", "current_prompt", "negative", "camera", "flf", "seed", "video_file_path", "reference_image_path", "flf_image_path")
FUNCTION = "load_standard"
CATEGORY = "utils/json"
def load_standard(self, json_path):
data = read_json_data(json_path)
return (
str(data.get("general_prompt", "")), str(data.get("general_negative", "")),
str(data.get("current_prompt", "")), str(data.get("negative", "")),
str(data.get("camera", "")), to_float(data.get("flf", 0.0)),
to_int(data.get("seed", 0)), str(data.get("video file path", "")),
str(data.get("reference image path", "")), str(data.get("flf image path", ""))
)
class JSONLoaderVACE:
@classmethod
def INPUT_TYPES(s):
return {"required": {"json_path": ("STRING", {"default": "", "multiline": False})}}
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "FLOAT", "INT", "INT", "INT", "INT", "STRING", "INT", "INT", "STRING", "STRING")
RETURN_NAMES = ("general_prompt", "general_negative", "current_prompt", "negative", "camera", "flf", "seed", "frame_to_skip", "input_a_frames", "input_b_frames", "reference_path", "reference_switch", "vace_schedule", "video_file_path", "reference_image_path")
FUNCTION = "load_vace"
CATEGORY = "utils/json"
def load_vace(self, json_path):
data = read_json_data(json_path)
return (
str(data.get("general_prompt", "")), str(data.get("general_negative", "")),
str(data.get("current_prompt", "")), str(data.get("negative", "")),
str(data.get("camera", "")), to_float(data.get("flf", 0.0)),
to_int(data.get("seed", 0)),
to_int(data.get("frame_to_skip", 81)), to_int(data.get("input_a_frames", 16)),
to_int(data.get("input_b_frames", 16)), str(data.get("reference path", "")),
to_int(data.get("reference switch", 1)), to_int(data.get("vace schedule", 1)),
str(data.get("video file path", "")), str(data.get("reference image path", ""))
)
# ==========================================
# 2. BATCH NODES
# ==========================================
class JSONLoaderBatchLoRA:
@classmethod
def INPUT_TYPES(s):
return {"required": {"json_path": ("STRING", {"default": "", "multiline": False}), "sequence_number": ("INT", {"default": 1, "min": 1, "max": 9999})}}
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "STRING")
RETURN_NAMES = ("lora_1_high", "lora_1_low", "lora_2_high", "lora_2_low", "lora_3_high", "lora_3_low")
FUNCTION = "load_batch_loras"
CATEGORY = "utils/json"
def load_batch_loras(self, json_path, sequence_number):
data = read_json_data(json_path)
target_data = get_batch_item(data, sequence_number)
return (
str(target_data.get("lora 1 high", "")), str(target_data.get("lora 1 low", "")),
str(target_data.get("lora 2 high", "")), str(target_data.get("lora 2 low", "")),
str(target_data.get("lora 3 high", "")), str(target_data.get("lora 3 low", ""))
)
class JSONLoaderBatchI2V:
@classmethod
def INPUT_TYPES(s):
return {"required": {"json_path": ("STRING", {"default": "", "multiline": False}), "sequence_number": ("INT", {"default": 1, "min": 1, "max": 9999})}}
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "FLOAT", "INT", "STRING", "STRING", "STRING")
RETURN_NAMES = ("general_prompt", "general_negative", "current_prompt", "negative", "camera", "flf", "seed", "video_file_path", "reference_image_path", "flf_image_path")
FUNCTION = "load_batch_i2v"
CATEGORY = "utils/json"
def load_batch_i2v(self, json_path, sequence_number):
data = read_json_data(json_path)
target_data = get_batch_item(data, sequence_number)
return (
str(target_data.get("general_prompt", "")), str(target_data.get("general_negative", "")),
str(target_data.get("current_prompt", "")), str(target_data.get("negative", "")),
str(target_data.get("camera", "")), to_float(target_data.get("flf", 0.0)),
to_int(target_data.get("seed", 0)), str(target_data.get("video file path", "")),
str(target_data.get("reference image path", "")), str(target_data.get("flf image path", ""))
)
class JSONLoaderBatchVACE:
@classmethod
def INPUT_TYPES(s):
return {"required": {"json_path": ("STRING", {"default": "", "multiline": False}), "sequence_number": ("INT", {"default": 1, "min": 1, "max": 9999})}}
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "FLOAT", "INT", "INT", "INT", "INT", "STRING", "INT", "INT", "STRING", "STRING")
RETURN_NAMES = ("general_prompt", "general_negative", "current_prompt", "negative", "camera", "flf", "seed", "frame_to_skip", "input_a_frames", "input_b_frames", "reference_path", "reference_switch", "vace_schedule", "video_file_path", "reference_image_path")
FUNCTION = "load_batch_vace"
CATEGORY = "utils/json"
def load_batch_vace(self, json_path, sequence_number):
data = read_json_data(json_path)
target_data = get_batch_item(data, sequence_number)
return (
str(target_data.get("general_prompt", "")), str(target_data.get("general_negative", "")),
str(target_data.get("current_prompt", "")), str(target_data.get("negative", "")),
str(target_data.get("camera", "")), to_float(target_data.get("flf", 0.0)),
to_int(target_data.get("seed", 0)), to_int(target_data.get("frame_to_skip", 81)),
to_int(target_data.get("input_a_frames", 16)), to_int(target_data.get("input_b_frames", 16)),
str(target_data.get("reference path", "")), to_int(target_data.get("reference switch", 1)),
to_int(target_data.get("vace schedule", 1)), str(target_data.get("video file path", "")),
str(target_data.get("reference image path", ""))
)
# ==========================================
# 3. UNIVERSAL CUSTOM NODES (1, 3, 6 Slots)
# ==========================================
class JSONLoaderCustom1:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"json_path": ("STRING", {"default": "", "multiline": False}),
"sequence_number": ("INT", {"default": 1, "min": 1, "max": 9999}),
},
"optional": { "key_1": ("STRING", {"default": "", "multiline": False}) }
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("val_1",)
FUNCTION = "load_custom"
CATEGORY = "utils/json"
def load_custom(self, json_path, sequence_number, key_1=""):
data = read_json_data(json_path)
target_data = get_batch_item(data, sequence_number)
return (str(target_data.get(key_1, "")),)
class JSONLoaderCustom3:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"json_path": ("STRING", {"default": "", "multiline": False}),
"sequence_number": ("INT", {"default": 1, "min": 1, "max": 9999}),
},
"optional": {
"key_1": ("STRING", {"default": "", "multiline": False}),
"key_2": ("STRING", {"default": "", "multiline": False}),
"key_3": ("STRING", {"default": "", "multiline": False})
}
}
RETURN_TYPES = ("STRING", "STRING", "STRING")
RETURN_NAMES = ("val_1", "val_2", "val_3")
FUNCTION = "load_custom"
CATEGORY = "utils/json"
def load_custom(self, json_path, sequence_number, key_1="", key_2="", key_3=""):
data = read_json_data(json_path)
target_data = get_batch_item(data, sequence_number)
return (
str(target_data.get(key_1, "")),
str(target_data.get(key_2, "")),
str(target_data.get(key_3, ""))
)
class JSONLoaderCustom6:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"json_path": ("STRING", {"default": "", "multiline": False}),
"sequence_number": ("INT", {"default": 1, "min": 1, "max": 9999}),
},
"optional": {
"key_1": ("STRING", {"default": "", "multiline": False}),
"key_2": ("STRING", {"default": "", "multiline": False}),
"key_3": ("STRING", {"default": "", "multiline": False}),
"key_4": ("STRING", {"default": "", "multiline": False}),
"key_5": ("STRING", {"default": "", "multiline": False}),
"key_6": ("STRING", {"default": "", "multiline": False})
}
}
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "STRING")
RETURN_NAMES = ("val_1", "val_2", "val_3", "val_4", "val_5", "val_6")
FUNCTION = "load_custom"
CATEGORY = "utils/json"
def load_custom(self, json_path, sequence_number, key_1="", key_2="", key_3="", key_4="", key_5="", key_6=""):
data = read_json_data(json_path)
target_data = get_batch_item(data, sequence_number)
return (
str(target_data.get(key_1, "")), str(target_data.get(key_2, "")),
str(target_data.get(key_3, "")), str(target_data.get(key_4, "")),
str(target_data.get(key_5, "")), str(target_data.get(key_6, ""))
)
# --- Mappings ---
NODE_CLASS_MAPPINGS = {
"JSONLoaderDynamic": JSONLoaderDynamic,
"JSONLoaderLoRA": JSONLoaderLoRA,
"JSONLoaderStandard": JSONLoaderStandard,
"JSONLoaderVACE": JSONLoaderVACE,
"JSONLoaderBatchLoRA": JSONLoaderBatchLoRA,
"JSONLoaderBatchI2V": JSONLoaderBatchI2V,
"JSONLoaderBatchVACE": JSONLoaderBatchVACE,
"JSONLoaderCustom1": JSONLoaderCustom1,
"JSONLoaderCustom3": JSONLoaderCustom3,
"JSONLoaderCustom6": JSONLoaderCustom6
}
NODE_DISPLAY_NAME_MAPPINGS = {
"JSONLoaderDynamic": "JSON Loader (Dynamic)",
"JSONLoaderLoRA": "JSON Loader (LoRAs Only)",
"JSONLoaderStandard": "JSON Loader (Standard/I2V)",
"JSONLoaderVACE": "JSON Loader (VACE Full)",
"JSONLoaderBatchLoRA": "JSON Batch Loader (LoRAs)",
"JSONLoaderBatchI2V": "JSON Batch Loader (I2V)",
"JSONLoaderBatchVACE": "JSON Batch Loader (VACE)",
"JSONLoaderCustom1": "JSON Loader (Custom 1)",
"JSONLoaderCustom3": "JSON Loader (Custom 3)",
"JSONLoaderCustom6": "JSON Loader (Custom 6)"
}
+113 -16
View File
@@ -1,4 +1,7 @@
import asyncio
import copy
import json import json
import logging
from pathlib import Path from pathlib import Path
from nicegui import ui from nicegui import ui
@@ -8,17 +11,28 @@ from utils import (
load_config, save_config, load_snippets, save_snippets, load_config, save_config, load_snippets, save_snippets,
load_json, save_json, generate_templates, DEFAULTS, load_json, save_json, generate_templates, DEFAULTS,
KEY_BATCH_DATA, KEY_SEQUENCE_NUMBER, KEY_BATCH_DATA, KEY_SEQUENCE_NUMBER,
resolve_path_case_insensitive, resolve_path_case_insensitive, sync_to_db,
) )
from tab_batch_ng import render_batch_processor from tab_batch_ng import render_batch_processor
from tab_timeline_ng import render_timeline_tab from tab_timeline_ng import render_timeline_tab
from tab_raw_ng import render_raw_editor from tab_raw_ng import render_raw_editor
from tab_comfy_ng import render_comfy_monitor from tab_comfy_ng import render_comfy_monitor
from tab_projects_ng import render_projects_tab
from db import ProjectDB
from api_routes import register_api_routes
logger = logging.getLogger(__name__)
# Single shared DB instance for both the UI and API routes
_shared_db: ProjectDB | None = None
try:
_shared_db = ProjectDB()
except Exception as e:
logger.warning(f"Failed to initialize ProjectDB: {e}")
@ui.page('/') @ui.page('/')
def index(): def index():
# -- Streamlit dark theme --
ui.dark_mode(True) ui.dark_mode(True)
ui.colors(primary='#F59E0B') ui.colors(primary='#F59E0B')
ui.add_head_html( ui.add_head_html(
@@ -144,6 +158,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;
@@ -157,7 +185,13 @@ def index():
config=config, config=config,
current_dir=Path(config.get('last_dir', str(Path.cwd()))), current_dir=Path(config.get('last_dir', str(Path.cwd()))),
snippets=load_snippets(), snippets=load_snippets(),
db_enabled=config.get('db_enabled', False),
current_project=config.get('current_project', ''),
) )
# Use the shared DB instance
state.db = _shared_db
dual_pane = {'active': False, 'state': None} dual_pane = {'active': False, 'state': None}
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -166,6 +200,9 @@ def index():
@ui.refreshable @ui.refreshable
def render_main_content(): def render_main_content():
import time as _time
_t0 = _time.perf_counter()
logger.info("render_main_content START")
max_w = '2400px' if dual_pane['active'] else '1200px' max_w = '2400px' if dual_pane['active'] else '1200px'
with ui.column().classes('w-full q-pa-md').style(f'max-width: {max_w}; margin: 0 auto'): with ui.column().classes('w-full q-pa-md').style(f'max-width: {max_w}; margin: 0 auto'):
if not state.file_path or not state.file_path.exists(): if not state.file_path or not state.file_path.exists():
@@ -179,6 +216,7 @@ def index():
ui.tab('batch', label='Batch Processor') ui.tab('batch', label='Batch Processor')
ui.tab('timeline', label='Timeline') ui.tab('timeline', label='Timeline')
ui.tab('raw', label='Raw Editor') ui.tab('raw', label='Raw Editor')
ui.tab('projects', label='Projects')
with ui.tab_panels(tabs, value='batch').classes('w-full'): with ui.tab_panels(tabs, value='batch').classes('w-full'):
with ui.tab_panel('batch'): with ui.tab_panel('batch'):
@@ -187,12 +225,16 @@ def index():
render_timeline_tab(state) render_timeline_tab(state)
with ui.tab_panel('raw'): with ui.tab_panel('raw'):
render_raw_editor(state) render_raw_editor(state)
with ui.tab_panel('projects'):
render_projects_tab(state)
if state.show_comfy_monitor: if state.show_comfy_monitor:
ui.separator() ui.separator()
with ui.expansion('ComfyUI Monitor', icon='dns').classes('w-full'): with ui.expansion('ComfyUI Monitor', icon='dns').classes('w-full'):
render_comfy_monitor(state) render_comfy_monitor(state)
logger.info("render_main_content END (%.3fs)", _time.perf_counter() - _t0)
@ui.refreshable @ui.refreshable
def _render_batch_tab_content(): def _render_batch_tab_content():
def on_toggle(e): def on_toggle(e):
@@ -234,17 +276,39 @@ def index():
current_val = pane_state.file_path.name if pane_state.file_path else None current_val = pane_state.file_path.name if pane_state.file_path else None
def on_select(e): async def on_select(e):
if not e.value: if not e.value:
return return
import time as _time
_t0 = _time.perf_counter()
logger.info("on_select START: %s", e.value)
fp = pane_state.current_dir / e.value fp = pane_state.current_dir / e.value
data, mtime = load_json(fp) file_stem = fp.stem
data = None
if pane_state.db and pane_state.db_enabled and pane_state.current_project:
data = await asyncio.to_thread(
pane_state.db.load_full_data, pane_state.current_project, file_stem)
if data is None:
data, _ = await asyncio.to_thread(load_json, fp)
if pane_state.db and pane_state.db_enabled and pane_state.current_project:
await asyncio.to_thread(
sync_to_db, pane_state.db, pane_state.current_project, fp, data)
tree = data.get('history_tree')
if tree and isinstance(tree, dict):
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 entry in backup.get('snapshots', backup.get('nodes', {})).values():
entry.pop('data', None)
pane_state.data_cache = data pane_state.data_cache = data
pane_state.last_mtime = mtime pane_state.last_mtime = fp.stat().st_mtime if fp.exists() else 0
pane_state.loaded_file = str(fp) pane_state.loaded_file = str(fp)
pane_state.file_path = fp pane_state.file_path = fp
pane_state.restored_indicator = None pane_state.restored_indicator = None
pane_state._src_cache = {'data': None, 'batch': [], 'name': None}
_render_batch_tab_content.refresh() _render_batch_tab_content.refresh()
logger.info("on_select END (%.3fs)", _time.perf_counter() - _t0)
ui.select( ui.select(
file_names, file_names,
@@ -253,19 +317,44 @@ def index():
on_change=on_select, on_change=on_select,
).classes('w-full') ).classes('w-full')
def load_file(file_name: str): async def load_file(file_name: str):
"""Load a JSON file and refresh the main content.""" """Load data from DB (fast) with JSON fallback, and refresh the main content."""
import time as _time
_t0 = _time.perf_counter()
logger.info("load_file START: %s", file_name)
fp = state.current_dir / file_name fp = state.current_dir / file_name
if state.loaded_file == str(fp): if state.loaded_file == str(fp):
return return
data, mtime = load_json(fp) file_stem = fp.stem
data = None
if state.db and state.db_enabled and state.current_project:
data = await asyncio.to_thread(
state.db.load_full_data, state.current_project, file_stem)
if data is None:
data, _ = await asyncio.to_thread(load_json, fp)
# When loading from JSON fallback and DB is enabled, sync to DB
# so snapshots are persisted, then strip from memory
if state.db and state.db_enabled and state.current_project:
await asyncio.to_thread(
sync_to_db, state.db, state.current_project, fp, data)
tree = data.get('history_tree')
if tree and isinstance(tree, dict):
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 entry in backup.get('snapshots', backup.get('nodes', {})).values():
entry.pop('data', None)
state.data_cache = data state.data_cache = data
state.last_mtime = mtime state.last_mtime = fp.stat().st_mtime if fp.exists() else 0
state.loaded_file = str(fp) state.loaded_file = str(fp)
state.file_path = fp state.file_path = fp
state.restored_indicator = None state.restored_indicator = None
state._src_cache = {'data': None, 'batch': [], 'name': None}
if state._main_rendered: if state._main_rendered:
render_main_content.refresh() render_main_content.refresh()
logger.info("load_file END (%.3fs)", _time.perf_counter() - _t0)
# Attach helpers to state so sidebar can call them # Attach helpers to state so sidebar can call them
state._load_file = load_file state._load_file = load_file
@@ -439,16 +528,16 @@ def render_sidebar(state: AppState, dual_pane: dict):
with ui.expansion('Create New JSON'): with ui.expansion('Create New JSON'):
new_fn_input = ui.input('Filename', placeholder='my_prompt_vace').classes('w-full') new_fn_input = ui.input('Filename', placeholder='my_prompt_vace').classes('w-full')
def create_new(): async def create_new():
fn = new_fn_input.value fn = new_fn_input.value
if not fn: if not fn:
return return
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]}) await asyncio.to_thread(save_json, path, {KEY_BATCH_DATA: [first_item]})
new_fn_input.set_value('') new_fn_input.set_value('')
render_file_list.refresh() render_file_list.refresh()
@@ -458,15 +547,19 @@ def render_sidebar(state: AppState, dual_pane: dict):
file_names = [f.name for f in json_files] file_names = [f.name for f in json_files]
current = Path(state.loaded_file).name if state.loaded_file else None current = Path(state.loaded_file).name if state.loaded_file else None
selected = current if current in file_names else (file_names[0] if file_names else None) selected = current if current in file_names else (file_names[0] if file_names else None)
async def _on_radio(e):
if e.value:
await state._load_file(e.value)
ui.radio( ui.radio(
file_names, file_names,
value=selected, value=selected,
on_change=lambda e: state._load_file(e.value) if e.value else None, on_change=_on_radio,
).classes('w-full') ).classes('w-full')
# Auto-load first file if nothing loaded yet # Auto-load first file if nothing loaded yet
if file_names and not state.loaded_file: if file_names and not state.loaded_file:
state._load_file(file_names[0]) asyncio.ensure_future(state._load_file(file_names[0]))
def _gen_templates(): def _gen_templates():
generate_templates(state.current_dir) generate_templates(state.current_dir)
@@ -479,7 +572,11 @@ 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)
ui.run(title='AI Settings Manager', port=8080, reload=True) # Register REST API routes for ComfyUI connectivity (uses the shared DB instance)
if _shared_db is not None:
register_api_routes(_shared_db)
ui.run(title='AI Settings Manager', port=8080, reload=False)
+307
View File
@@ -0,0 +1,307 @@
import asyncio
import json
import logging
import urllib.parse
import urllib.request
import urllib.error
from typing import Any
logger = logging.getLogger(__name__)
MAX_DYNAMIC_OUTPUTS = 32
class AnyType(str):
"""Universal connector type that matches any ComfyUI type."""
def __ne__(self, __value: object) -> bool:
return False
any_type = AnyType("*")
try:
from server import PromptServer
from aiohttp import web
except ImportError:
PromptServer = None
def to_float(val: Any) -> float:
try:
return float(val)
except (ValueError, TypeError):
return 0.0
def to_int(val: Any) -> int:
try:
return int(float(val))
except (ValueError, TypeError):
return 0
def _fetch_json(url: str) -> dict:
"""Fetch JSON from a URL using stdlib urllib.
On error, returns a dict with an "error" key describing the failure.
"""
try:
with urllib.request.urlopen(url, timeout=5) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
# HTTPError is a subclass of URLError — must be caught first
body = ""
try:
raw = e.read()
detail = json.loads(raw)
body = detail.get("detail", str(raw, "utf-8", errors="replace"))
except Exception:
body = str(e)
logger.warning(f"HTTP {e.code} from {url}: {body}")
return {"error": "http_error", "status": e.code, "message": body}
except (urllib.error.URLError, OSError) as e:
reason = str(e.reason) if hasattr(e, "reason") else str(e)
logger.warning(f"Network error fetching {url}: {reason}")
return {"error": "network_error", "message": reason}
except json.JSONDecodeError as e:
logger.warning(f"Invalid JSON from {url}: {e}")
return {"error": "parse_error", "message": str(e)}
def _fetch_data(manager_url: str, project: str, file: str, seq: int) -> dict:
"""Fetch sequence data from the NiceGUI REST API."""
p = urllib.parse.quote(project, safe='')
f = urllib.parse.quote(file, safe='')
url = f"{manager_url.rstrip('/')}/api/projects/{p}/files/{f}/data?seq={seq}"
return _fetch_json(url)
def _fetch_keys(manager_url: str, project: str, file: str, seq: int) -> dict:
"""Fetch keys/types from the NiceGUI REST API."""
p = urllib.parse.quote(project, safe='')
f = urllib.parse.quote(file, safe='')
url = f"{manager_url.rstrip('/')}/api/projects/{p}/files/{f}/keys?seq={seq}"
return _fetch_json(url)
# --- ComfyUI-side proxy endpoints (for frontend JS) ---
if PromptServer is not None:
@PromptServer.instance.routes.get("/json_manager/list_projects")
async def list_projects_proxy(request):
manager_url = request.query.get("url", "http://localhost:8080")
url = f"{manager_url.rstrip('/')}/api/projects"
data = await asyncio.to_thread(_fetch_json, url)
return web.json_response(data)
@PromptServer.instance.routes.get("/json_manager/list_project_files")
async def list_project_files_proxy(request):
manager_url = request.query.get("url", "http://localhost:8080")
project = urllib.parse.quote(request.query.get("project", ""), safe='')
url = f"{manager_url.rstrip('/')}/api/projects/{project}/files"
data = await asyncio.to_thread(_fetch_json, url)
return web.json_response(data)
@PromptServer.instance.routes.get("/json_manager/list_project_sequences")
async def list_project_sequences_proxy(request):
manager_url = request.query.get("url", "http://localhost:8080")
project = urllib.parse.quote(request.query.get("project", ""), safe='')
file_name = urllib.parse.quote(request.query.get("file", ""), safe='')
url = f"{manager_url.rstrip('/')}/api/projects/{project}/files/{file_name}/sequences"
data = await asyncio.to_thread(_fetch_json, url)
return web.json_response(data)
@PromptServer.instance.routes.get("/json_manager/get_project_keys")
async def get_project_keys_proxy(request):
manager_url = request.query.get("url", "http://localhost:8080")
project = request.query.get("project", "")
file_name = request.query.get("file", "")
try:
seq = int(request.query.get("seq", "1"))
except (ValueError, TypeError):
seq = 1
data = await asyncio.to_thread(_fetch_keys, manager_url, project, file_name, seq)
if data.get("error") in ("http_error", "network_error", "parse_error"):
status = data.get("status", 502)
return web.json_response(data, status=status)
return web.json_response(data)
# ==========================================
# 0. DYNAMIC NODE (Project-based)
# ==========================================
class ProjectLoaderDynamic:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"manager_url": ("STRING", {"default": "http://localhost:8080", "multiline": False}),
"project_name": ("STRING", {"default": "", "multiline": False}),
"file_name": ("STRING", {"default": "", "multiline": False}),
"sequence_number": ("INT", {"default": 1, "min": 1, "max": 9999}),
"refresh": (["off", "on"],),
},
"optional": {
"output_keys": ("STRING", {"default": ""}),
"output_types": ("STRING", {"default": ""}),
},
}
RETURN_TYPES = ("INT",) + tuple(any_type for _ in range(MAX_DYNAMIC_OUTPUTS))
RETURN_NAMES = ("total_sequences",) + tuple(f"output_{i}" for i in range(MAX_DYNAMIC_OUTPUTS))
FUNCTION = "load_dynamic"
CATEGORY = "utils/json/project"
OUTPUT_NODE = False
def load_dynamic(self, manager_url, project_name, file_name, sequence_number,
refresh="off", output_keys="", output_types=""):
# Fetch keys metadata (includes total_sequences count)
keys_meta = _fetch_keys(manager_url, project_name, file_name, sequence_number)
if keys_meta.get("error") in ("http_error", "network_error", "parse_error"):
msg = keys_meta.get("message", "Unknown error")
raise RuntimeError(f"Failed to fetch project keys: {msg}")
total_sequences = keys_meta.get("total_sequences", 0)
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 sequence data: {msg}")
# Parse keys — try JSON array first, fall back to comma-split for compat
keys = []
if output_keys:
try:
keys = json.loads(output_keys)
except (json.JSONDecodeError, TypeError):
keys = [k.strip() for k in output_keys.split(",") if k.strip()]
# Parse types for coercion
types = []
if output_types:
try:
types = json.loads(output_types)
except (json.JSONDecodeError, TypeError):
types = [t.strip() for t in output_types.split(",")]
results = []
for i, key in enumerate(keys):
val = data.get(key, "")
declared_type = types[i] if i < len(types) else ""
# Coerce based on declared output type when possible
if declared_type == "INT":
results.append(to_int(val))
elif declared_type == "FLOAT":
results.append(to_float(val))
elif isinstance(val, bool):
results.append(str(val).lower())
elif isinstance(val, int):
results.append(val)
elif isinstance(val, float):
results.append(val)
else:
results.append(str(val))
while len(results) < MAX_DYNAMIC_OUTPUTS:
results.append("")
return (total_sequences,) + tuple(results)
class ProjectSource:
"""Config node — holds project connection settings, outputs sequence_number."""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"manager_url": ("STRING", {"default": "http://localhost:8080", "multiline": False}),
"project_name": ("STRING", {"default": "", "multiline": False}),
"file_name": ("STRING", {"default": "", "multiline": False}),
"sequence_number": ("INT", {"default": 1, "min": 1, "max": 9999}),
"label": ("STRING", {"default": "source", "multiline": False}),
},
}
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 (sequence_number,)
class ProjectKey:
"""Single-output relay — fetches one key from a ProjectSource."""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"source_label": ("STRING", {"default": "", "multiline": False}),
"key_name": ("STRING", {"default": "", "multiline": False}),
"key_type": ("STRING", {"default": "STRING", "multiline": False}),
},
"optional": {
"manager_url": ("STRING", {"default": "http://localhost:8080", "multiline": False}),
"project_name": ("STRING", {"default": "", "multiline": False}),
"file_name": ("STRING", {"default": "", "multiline": False}),
"sequence_number": ("INT", {"default": 1, "min": 1, "max": 9999}),
},
}
RETURN_TYPES = (any_type,)
RETURN_NAMES = ("value",)
FUNCTION = "fetch_key"
CATEGORY = "utils/json/project"
OUTPUT_NODE = False
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("nan") # Always re-fetch from API
def fetch_key(self, source_label, key_name, key_type,
manager_url="http://localhost:8080", project_name="",
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")
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, "")
if key_type == "INT":
return (to_int(val),)
elif key_type == "FLOAT":
return (to_float(val),)
elif isinstance(val, bool):
return (str(val).lower(),)
elif isinstance(val, (int, float)):
return (val,)
else:
return (str(val),)
# --- Mappings ---
PROJECT_NODE_CLASS_MAPPINGS = {
"ProjectLoaderDynamic": ProjectLoaderDynamic,
"ProjectSource": ProjectSource,
"ProjectKey": ProjectKey,
}
PROJECT_NODE_DISPLAY_NAME_MAPPINGS = {
"ProjectLoaderDynamic": "Project Loader (Dynamic)",
"ProjectSource": "Project Source",
"ProjectKey": "Project Key",
}
+184
View File
@@ -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
+10 -1
View File
@@ -13,20 +13,29 @@ class AppState:
snippets: dict = field(default_factory=dict) snippets: dict = field(default_factory=dict)
file_path: Path | None = None file_path: Path | None = None
restored_indicator: str | 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) live_toggles: dict = field(default_factory=dict)
show_comfy_monitor: bool = True show_comfy_monitor: bool = True
# Project DB fields
db: Any = None
current_project: str = ""
db_enabled: bool = False
# Set at runtime by main.py / tab_comfy_ng.py # Set at runtime by main.py / tab_comfy_ng.py
_render_main: Any = None _render_main: Any = None
_load_file: Callable | None = None _load_file: Callable | None = None
_main_rendered: bool = False _main_rendered: bool = False
_live_checkboxes: dict = field(default_factory=dict) _live_checkboxes: dict = field(default_factory=dict)
_live_refreshables: dict = field(default_factory=dict) _live_refreshables: dict = field(default_factory=dict)
_src_cache: dict = field(default_factory=lambda: {'data': None, 'batch': [], 'name': None})
def create_secondary(self) -> 'AppState': def create_secondary(self) -> 'AppState':
return AppState( return AppState(
config=self.config, config=self.config,
current_dir=self.current_dir, current_dir=self.current_dir,
snippets=self.snippets, snippets=self.snippets,
db=self.db,
current_project=self.current_project,
db_enabled=self.db_enabled,
) )
-594
View File
@@ -1,594 +0,0 @@
import streamlit as st
import random
import copy
from pathlib import Path
from utils import DEFAULTS, save_json, load_json, KEY_BATCH_DATA, KEY_HISTORY_TREE, KEY_PROMPT_HISTORY, KEY_SEQUENCE_NUMBER
from history_tree import HistoryTree
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"}
SUB_SEGMENT_MULTIPLIER = 1000
def is_subsegment(seq_num):
"""Return True if seq_num is a sub-segment (>= 1000)."""
return int(seq_num) >= SUB_SEGMENT_MULTIPLIER
def parent_of(seq_num):
"""Return the parent segment number (or self if already a parent)."""
seq_num = int(seq_num)
return seq_num // SUB_SEGMENT_MULTIPLIER if is_subsegment(seq_num) else seq_num
def sub_index_of(seq_num):
"""Return the sub-index (0 if parent)."""
seq_num = int(seq_num)
return seq_num % SUB_SEGMENT_MULTIPLIER if is_subsegment(seq_num) else 0
def format_seq_label(seq_num):
"""Return display label: 'Sequence #3' or 'Sub #2.1'."""
seq_num = int(seq_num)
if is_subsegment(seq_num):
return f"Sub #{parent_of(seq_num)}.{sub_index_of(seq_num)}"
return f"Sequence #{seq_num}"
def next_sub_segment_number(batch_list, parent_seq_num):
"""Find the next available sub-segment number under a parent."""
parent_seq_num = int(parent_seq_num)
max_sub = 0
for s in batch_list:
sn = int(s.get(KEY_SEQUENCE_NUMBER, 0))
if is_subsegment(sn) and parent_of(sn) == parent_seq_num:
max_sub = max(max_sub, sub_index_of(sn))
return parent_seq_num * SUB_SEGMENT_MULTIPLIER + max_sub + 1
def find_insert_position(batch_list, parent_index, parent_seq_num):
"""Find the insert position after the parent's last existing sub-segment."""
parent_seq_num = int(parent_seq_num)
pos = parent_index + 1
while pos < len(batch_list):
sn = int(batch_list[pos].get(KEY_SEQUENCE_NUMBER, 0))
if is_subsegment(sn) and parent_of(sn) == parent_seq_num:
pos += 1
else:
break
return pos
def _render_mass_update(batch_list, data, file_path, key_prefix):
"""Render the mass update UI section."""
with st.expander("🔄 Mass Update", expanded=False):
if len(batch_list) < 2:
st.info("Need at least 2 sequences for mass update.")
return
# Source sequence selector
source_idx = st.selectbox(
"Copy from sequence:",
range(len(batch_list)),
format_func=lambda i: format_seq_label(batch_list[i].get('sequence_number', i+1)),
key=f"{key_prefix}_mass_src"
)
source_seq = batch_list[source_idx]
# Field multi-select (exclude sequence_number)
available_keys = [k for k in source_seq.keys() if k != "sequence_number"]
selected_keys = st.multiselect("Fields to copy:", available_keys, key=f"{key_prefix}_mass_fields")
if not selected_keys:
return
# Target sequence checkboxes
st.write("Apply to:")
select_all = st.checkbox("Select All", key=f"{key_prefix}_mass_all")
target_indices = []
target_cols = st.columns(min(4, len(batch_list) - 1)) if len(batch_list) > 1 else [st]
col_idx = 0
for i, seq in enumerate(batch_list):
if i == source_idx:
continue
seq_num = seq.get("sequence_number", i + 1)
with target_cols[col_idx % len(target_cols)]:
checked = select_all or st.checkbox(format_seq_label(seq_num), key=f"{key_prefix}_mass_t{i}")
if checked:
target_indices.append(i)
col_idx += 1
# Preview
if target_indices and selected_keys:
with st.expander("Preview changes", expanded=True):
for key in selected_keys:
val = source_seq.get(key, "")
display_val = str(val)[:100] + "..." if len(str(val)) > 100 else str(val)
st.caption(f"**{key}**: {display_val}")
# Apply button
if st.button("Apply Changes", type="primary", key=f"{key_prefix}_mass_apply"):
for i in target_indices:
for key in selected_keys:
batch_list[i][key] = copy.deepcopy(source_seq.get(key))
# Save with history snapshot
data[KEY_BATCH_DATA] = batch_list
htree = HistoryTree(data.get(KEY_HISTORY_TREE, {}))
snapshot_payload = copy.deepcopy(data)
if KEY_HISTORY_TREE in snapshot_payload:
del snapshot_payload[KEY_HISTORY_TREE]
htree.commit(snapshot_payload, f"Mass update: {', '.join(selected_keys)}")
data[KEY_HISTORY_TREE] = htree.to_dict()
save_json(file_path, data)
st.session_state.data_cache = data
st.session_state.ui_reset_token += 1
st.toast(f"Updated {len(target_indices)} sequences", icon="")
st.rerun()
def create_batch_callback(original_filename, current_data, current_dir):
new_name = f"batch_{original_filename}"
new_path = current_dir / new_name
if new_path.exists():
st.toast(f"File {new_name} already exists!", icon="⚠️")
return
first_item = current_data.copy()
if KEY_PROMPT_HISTORY in first_item: del first_item[KEY_PROMPT_HISTORY]
if KEY_HISTORY_TREE in first_item: del first_item[KEY_HISTORY_TREE]
first_item[KEY_SEQUENCE_NUMBER] = 1
new_data = {
KEY_BATCH_DATA: [first_item],
KEY_HISTORY_TREE: {},
KEY_PROMPT_HISTORY: []
}
save_json(new_path, new_data)
st.toast(f"Created {new_name}", icon="")
st.session_state.file_selector = new_name
def render_batch_processor(data, file_path, json_files, current_dir, selected_file_name):
is_batch_file = KEY_BATCH_DATA in data or isinstance(data, list)
if not is_batch_file:
st.warning("This is a Single file. To use Batch mode, create a copy.")
st.button("✨ Create Batch Copy", on_click=create_batch_callback, args=(selected_file_name, data, current_dir))
return
if 'restored_indicator' in st.session_state and st.session_state.restored_indicator:
st.info(f"📍 Editing Restored Version: **{st.session_state.restored_indicator}**")
batch_list = data.get(KEY_BATCH_DATA, [])
# --- ADD NEW SEQUENCE AREA ---
st.subheader("Add New Sequence")
ac1, ac2 = st.columns(2)
with ac1:
file_options = [f.name for f in json_files]
d_idx = file_options.index(selected_file_name) if selected_file_name in file_options else 0
src_name = st.selectbox("Source File:", file_options, index=d_idx, key="batch_src_file")
src_data, _ = load_json(current_dir / src_name)
with ac2:
src_batch = src_data.get(KEY_BATCH_DATA, [])
if src_batch:
seq_opts = list(range(len(src_batch)))
sel_seq_idx = st.selectbox(
"Source Sequence:",
seq_opts,
format_func=lambda i: format_seq_label(src_batch[i].get(KEY_SEQUENCE_NUMBER, i + 1)),
key="batch_src_seq"
)
else:
st.caption("Single file (no sequences)")
sel_seq_idx = None
bc1, bc2 = st.columns(2)
def add_sequence(new_item):
max_seq = 0
for s in batch_list:
sn = int(s.get(KEY_SEQUENCE_NUMBER, 0))
if not is_subsegment(sn):
max_seq = max(max_seq, sn)
new_item[KEY_SEQUENCE_NUMBER] = max_seq + 1
for k in [KEY_PROMPT_HISTORY, KEY_HISTORY_TREE, "note", "loras"]:
if k in new_item: del new_item[k]
batch_list.append(new_item)
data[KEY_BATCH_DATA] = batch_list
save_json(file_path, data)
st.session_state.ui_reset_token += 1
st.rerun()
if bc1.button(" Add Empty", use_container_width=True):
add_sequence(DEFAULTS.copy())
if bc2.button(" From Source", use_container_width=True, help=f"Import from {src_name}"):
item = DEFAULTS.copy()
if src_batch and sel_seq_idx is not None:
item.update(src_batch[sel_seq_idx])
else:
item.update(src_data)
add_sequence(item)
# --- RENDER LIST ---
st.markdown("---")
info_col, reorder_col = st.columns([3, 1])
info_col.info(f"Batch contains {len(batch_list)} sequences.")
if reorder_col.button("🔢 Sort by Number", use_container_width=True, help="Reorder sequences by sequence number"):
batch_list.sort(key=lambda s: int(s.get(KEY_SEQUENCE_NUMBER, 0)))
data[KEY_BATCH_DATA] = batch_list
save_json(file_path, data)
st.session_state.ui_reset_token += 1
st.toast("Sorted by sequence number!", icon="🔢")
st.rerun()
# --- MASS UPDATE SECTION ---
ui_reset_token = st.session_state.get("ui_reset_token", 0)
_render_mass_update(batch_list, data, file_path, f"{selected_file_name}_v{ui_reset_token}")
# Updated LoRA keys to match new logic
lora_keys = ["lora 1 high", "lora 1 low", "lora 2 high", "lora 2 low", "lora 3 high", "lora 3 low"]
standard_keys = {
"general_prompt", "general_negative", "current_prompt", "negative", "prompt", "seed", "cfg",
"camera", "flf", KEY_SEQUENCE_NUMBER
}
standard_keys.update(lora_keys)
standard_keys.update([
"frame_to_skip", "end_frame", "transition", "vace_length",
"input_a_frames", "input_b_frames", "reference switch", "vace schedule",
"reference path", "video file path", "reference image path", "flf image path"
])
VACE_MODES = [
"End Extend", "Pre Extend", "Middle Extend", "Edge Extend",
"Join Extend", "Bidirectional Extend", "Frame Interpolation",
"Replace/Inpaint", "Video Inpaint", "Keyframe",
]
VACE_FORMULAS = [
"base + A", # 0 End Extend
"base + B", # 1 Pre Extend
"base + A + B", # 2 Middle Extend
"base + A + B", # 3 Edge Extend
"base + A + B", # 4 Join Extend
"base + A + B", # 5 Bidirectional
"(B-1) * step", # 6 Frame Interpolation
"snap(source)", # 7 Replace/Inpaint
"snap(source)", # 8 Video Inpaint
"base + A + B", # 9 Keyframe
]
for i, seq in enumerate(batch_list):
seq_num = seq.get(KEY_SEQUENCE_NUMBER, i+1)
prefix = f"{selected_file_name}_seq{i}_v{st.session_state.ui_reset_token}"
if is_subsegment(seq_num):
expander_label = f"🔗 ↳ Sub #{parent_of(seq_num)}.{sub_index_of(seq_num)} ({int(seq_num)})"
else:
expander_label = f"🎬 Sequence #{seq_num}"
with st.expander(expander_label, expanded=False):
# --- ACTION ROW ---
act_c1, act_c2, act_c3, act_c4 = st.columns([1.2, 1.8, 1.2, 0.5])
# 1. Copy Source
with act_c1:
if st.button(f"📥 Copy {src_name}", key=f"{prefix}_copy", use_container_width=True):
item = DEFAULTS.copy()
if src_batch and sel_seq_idx is not None:
item.update(src_batch[sel_seq_idx])
else:
item.update(src_data)
item[KEY_SEQUENCE_NUMBER] = seq_num
for k in [KEY_PROMPT_HISTORY, KEY_HISTORY_TREE]:
if k in item: del item[k]
batch_list[i] = item
data[KEY_BATCH_DATA] = batch_list
save_json(file_path, data)
st.session_state.ui_reset_token += 1
st.toast("Copied!", icon="📥")
st.rerun()
# 2. Cloning Tools
with act_c2:
cl_1, cl_2, cl_3 = st.columns(3)
if cl_1.button("👯 Next", key=f"{prefix}_c_next", help="Clone and insert below", use_container_width=True):
new_seq = copy.deepcopy(seq)
max_sn = 0
for s in batch_list:
sn = int(s.get(KEY_SEQUENCE_NUMBER, 0))
if not is_subsegment(sn):
max_sn = max(max_sn, sn)
new_seq[KEY_SEQUENCE_NUMBER] = max_sn + 1
if not is_subsegment(seq_num):
insert_pos = find_insert_position(batch_list, i, int(seq_num))
else:
insert_pos = i + 1
batch_list.insert(insert_pos, new_seq)
data[KEY_BATCH_DATA] = batch_list
save_json(file_path, data)
st.session_state.ui_reset_token += 1
st.toast("Cloned to Next!", icon="👯")
st.rerun()
if cl_2.button("⏬ End", key=f"{prefix}_c_end", help="Clone and add to bottom", use_container_width=True):
new_seq = copy.deepcopy(seq)
max_sn = 0
for s in batch_list:
sn = int(s.get(KEY_SEQUENCE_NUMBER, 0))
if not is_subsegment(sn):
max_sn = max(max_sn, sn)
new_seq[KEY_SEQUENCE_NUMBER] = max_sn + 1
batch_list.append(new_seq)
data[KEY_BATCH_DATA] = batch_list
save_json(file_path, data)
st.session_state.ui_reset_token += 1
st.toast("Cloned to End!", icon="")
st.rerun()
if cl_3.button("🔗 Sub", key=f"{prefix}_c_sub", help="Clone as sub-segment", use_container_width=True):
new_seq = copy.deepcopy(seq)
p_seq_num = parent_of(seq_num)
# Find the parent's index in batch_list
p_idx = i
if is_subsegment(seq_num):
for pi, ps in enumerate(batch_list):
if int(ps.get(KEY_SEQUENCE_NUMBER, 0)) == p_seq_num:
p_idx = pi
break
new_seq[KEY_SEQUENCE_NUMBER] = next_sub_segment_number(batch_list, p_seq_num)
insert_pos = find_insert_position(batch_list, p_idx, p_seq_num)
batch_list.insert(insert_pos, new_seq)
data[KEY_BATCH_DATA] = batch_list
save_json(file_path, data)
st.session_state.ui_reset_token += 1
st.toast(f"Created {format_seq_label(new_seq[KEY_SEQUENCE_NUMBER])}!", icon="🔗")
st.rerun()
# 3. Promote
with act_c3:
if st.button("↖️ Promote", key=f"{prefix}_prom", help="Save as Single File", use_container_width=True):
single_data = seq.copy()
single_data[KEY_PROMPT_HISTORY] = data.get(KEY_PROMPT_HISTORY, [])
single_data[KEY_HISTORY_TREE] = data.get(KEY_HISTORY_TREE, {})
if KEY_SEQUENCE_NUMBER in single_data: del single_data[KEY_SEQUENCE_NUMBER]
save_json(file_path, single_data)
st.session_state.data_cache = single_data
st.session_state.ui_reset_token += 1
st.toast("Converted to Single!", icon="")
st.rerun()
# 4. Remove
with act_c4:
if st.button("🗑️", key=f"{prefix}_del", use_container_width=True):
batch_list.pop(i)
data[KEY_BATCH_DATA] = batch_list
save_json(file_path, data)
st.session_state.ui_reset_token += 1
st.rerun()
st.markdown("---")
c1, c2 = st.columns([2, 1])
with c1:
seq["general_prompt"] = st.text_area("General Prompt", value=seq.get("general_prompt", ""), height=60, key=f"{prefix}_gp")
seq["general_negative"] = st.text_area("General Negative", value=seq.get("general_negative", ""), height=60, key=f"{prefix}_gn")
seq["current_prompt"] = st.text_area("Specific Prompt", value=seq.get("current_prompt", ""), height=300, key=f"{prefix}_sp")
seq["negative"] = st.text_area("Specific Negative", value=seq.get("negative", ""), height=60, key=f"{prefix}_sn")
with c2:
sn_label = f"Sequence Number (↳ Sub #{parent_of(seq_num)}.{sub_index_of(seq_num)})" if is_subsegment(seq_num) else "Sequence Number"
seq[KEY_SEQUENCE_NUMBER] = st.number_input(sn_label, value=int(seq_num), key=f"{prefix}_sn_val")
s_row1, s_row2 = st.columns([3, 1])
seed_key = f"{prefix}_seed"
with s_row2:
st.write("")
st.write("")
if st.button("🎲", key=f"{prefix}_rand"):
st.session_state[seed_key] = random.randint(0, 999999999999)
st.rerun()
with s_row1:
current_seed = st.session_state.get(seed_key, int(seq.get("seed", 0)))
val = st.number_input("Seed", value=current_seed, key=seed_key)
seq["seed"] = val
seq["cfg"] = st.number_input("CFG", value=float(seq.get("cfg", DEFAULTS["cfg"])), step=0.5, format="%.1f", key=f"{prefix}_cfg")
seq["camera"] = st.text_input("Camera", value=seq.get("camera", ""), key=f"{prefix}_cam")
seq["flf"] = st.text_input("FLF", value=str(seq.get("flf", DEFAULTS["flf"])), key=f"{prefix}_flf")
seq["end_frame"] = st.number_input("End Frame", value=int(seq.get("end_frame", 0)), key=f"{prefix}_ef")
seq["video file path"] = st.text_input("Video File Path", value=seq.get("video file path", ""), key=f"{prefix}_vid")
for img_label, img_key, img_suffix in [
("Reference Image Path", "reference image path", "rip"),
("Reference Path", "reference path", "rp"),
("FLF Image Path", "flf image path", "flfi"),
]:
img_col, prev_col = st.columns([5, 1])
seq[img_key] = img_col.text_input(img_label, value=seq.get(img_key, ""), key=f"{prefix}_{img_suffix}")
img_path = Path(seq[img_key]) if seq[img_key] else None
if img_path and img_path.exists() and img_path.suffix.lower() in IMAGE_EXTENSIONS:
with prev_col.popover("👁"):
st.image(str(img_path), use_container_width=True)
with st.expander("VACE Settings"):
fts_col, fts_btn = st.columns([3, 1])
saved_fts_key = f"{prefix}_fts_saved"
if saved_fts_key not in st.session_state:
st.session_state[saved_fts_key] = int(seq.get("frame_to_skip", 81))
old_fts = st.session_state[saved_fts_key]
seq["frame_to_skip"] = fts_col.number_input("Frame to Skip", value=old_fts, key=f"{prefix}_fts")
delta = int(seq["frame_to_skip"]) - old_fts
delta_label = f"Shift ↓ ({delta:+d})" if delta != 0 else "Shift ↓ (0)"
fts_btn.write("")
fts_btn.write("")
if fts_btn.button(delta_label, key=f"{prefix}_fts_shift", help="Apply delta to all following sequences", disabled=(delta == 0)):
if delta != 0:
shifted = 0
for j in range(i + 1, len(batch_list)):
batch_list[j]["frame_to_skip"] = int(batch_list[j].get("frame_to_skip", 81)) + delta
shifted += 1
data[KEY_BATCH_DATA] = batch_list
save_json(file_path, data)
st.session_state.ui_reset_token += 1
st.toast(f"Shifted {shifted} sequences by {delta:+d}", icon="")
st.rerun()
else:
st.toast("No change to shift", icon="")
seq["transition"] = st.text_input("Transition", value=str(seq.get("transition", "1-2")), key=f"{prefix}_trans")
vs_col, vs_label = st.columns([3, 1])
sched_val = int(seq.get("vace schedule", 1))
seq["vace schedule"] = vs_col.number_input("VACE Schedule", value=sched_val, min_value=0, max_value=len(VACE_MODES) - 1, key=f"{prefix}_vsc")
mode_idx = int(seq["vace schedule"])
vs_label.write("")
vs_label.write("")
vs_label.caption(VACE_MODES[mode_idx])
with st.popover("📋 Mode Reference"):
st.markdown(
"| # | Mode | Formula |\n"
"|:--|:-----|:--------|\n"
+ "\n".join(
f"| **{j}** | {VACE_MODES[j]} | `{VACE_FORMULAS[j]}` |"
for j in range(len(VACE_MODES))
)
+ "\n\n*All totals snapped to 4n+1 (1,5,9,…,49,…,81,…)*"
)
seq["input_a_frames"] = st.number_input("Input A Frames", value=int(seq.get("input_a_frames", 16)), key=f"{prefix}_ia")
seq["input_b_frames"] = st.number_input("Input B Frames", value=int(seq.get("input_b_frames", 16)), key=f"{prefix}_ib")
input_a = int(seq.get("input_a_frames", 16))
input_b = int(seq.get("input_b_frames", 16))
stored_total = int(seq.get("vace_length", 49))
# Reverse using same mode formula that was used to store
if mode_idx == 0:
base_length = max(stored_total - input_a, 1)
elif mode_idx == 1:
base_length = max(stored_total - input_b, 1)
else:
base_length = max(stored_total - input_a - input_b, 1)
vl_col, vl_out = st.columns([3, 1])
new_base = vl_col.number_input("VACE Length", value=base_length, min_value=1, key=f"{prefix}_vl")
if mode_idx == 0: # End Extend: base + A
raw_total = new_base + input_a
elif mode_idx == 1: # Pre Extend: base + B
raw_total = new_base + input_b
else: # Most modes: base + A + B
raw_total = new_base + input_a + input_b
# Snap to 4n+1 (1,5,9,13,...,81,...) to match VACE sampler
seq["vace_length"] = ((raw_total + 2) // 4) * 4 + 1
vl_out.metric("Output", seq["vace_length"])
seq["reference switch"] = st.number_input("Reference Switch", value=int(seq.get("reference switch", 1)), key=f"{prefix}_rsw")
# --- UPDATED: LoRA Settings with Tag Wrapping ---
with st.expander("💊 LoRA Settings"):
lc1, lc2, lc3 = st.columns(3)
# Helper to render the tag wrapper UI
def render_lora_col(col_obj, lora_idx):
with col_obj:
st.caption(f"**LoRA {lora_idx}**")
# --- HIGH ---
k_high = f"lora {lora_idx} high"
raw_h = str(seq.get(k_high, ""))
# Strip tags for display
disp_h = raw_h.replace("<lora:", "").replace(">", "")
st.write("High:")
rh1, rh2, rh3 = st.columns([0.25, 1, 0.1])
rh1.markdown("<div style='text-align: right; padding-top: 8px;'><code>&lt;lora:</code></div>", unsafe_allow_html=True)
val_h = rh2.text_input(f"L{lora_idx}H", value=disp_h, key=f"{prefix}_l{lora_idx}h", label_visibility="collapsed")
rh3.markdown("<div style='padding-top: 8px;'><code>&gt;</code></div>", unsafe_allow_html=True)
if val_h:
seq[k_high] = f"<lora:{val_h}>"
else:
seq[k_high] = ""
# --- LOW ---
k_low = f"lora {lora_idx} low"
raw_l = str(seq.get(k_low, ""))
# Strip tags for display
disp_l = raw_l.replace("<lora:", "").replace(">", "")
st.write("Low:")
rl1, rl2, rl3 = st.columns([0.25, 1, 0.1])
rl1.markdown("<div style='text-align: right; padding-top: 8px;'><code>&lt;lora:</code></div>", unsafe_allow_html=True)
val_l = rl2.text_input(f"L{lora_idx}L", value=disp_l, key=f"{prefix}_l{lora_idx}l", label_visibility="collapsed")
rl3.markdown("<div style='padding-top: 8px;'><code>&gt;</code></div>", unsafe_allow_html=True)
if val_l:
seq[k_low] = f"<lora:{val_l}>"
else:
seq[k_low] = ""
render_lora_col(lc1, 1)
render_lora_col(lc2, 2)
render_lora_col(lc3, 3)
# --- CUSTOM PARAMETERS ---
st.markdown("---")
st.caption("🔧 Custom Parameters")
custom_keys = [k for k in seq.keys() if k not in standard_keys]
keys_to_remove = []
if custom_keys:
for k in custom_keys:
ck1, ck2, ck3 = st.columns([1, 2, 0.5])
ck1.text_input("Key", value=k, disabled=True, key=f"{prefix}_ck_lbl_{k}", label_visibility="collapsed")
val = ck2.text_input("Value", value=str(seq[k]), key=f"{prefix}_cv_{k}", label_visibility="collapsed")
seq[k] = val
if ck3.button("🗑️", key=f"{prefix}_cdel_{k}"):
keys_to_remove.append(k)
with st.expander(" Add Parameter"):
nk_col, nv_col = st.columns(2)
new_k = nk_col.text_input("Key", key=f"{prefix}_new_k")
new_v = nv_col.text_input("Value", key=f"{prefix}_new_v")
if st.button("Add", key=f"{prefix}_add_cust"):
if new_k and new_k not in seq:
seq[new_k] = new_v
save_json(file_path, data)
st.session_state.ui_reset_token += 1
st.rerun()
if keys_to_remove:
for k in keys_to_remove:
del seq[k]
save_json(file_path, data)
st.session_state.ui_reset_token += 1
st.rerun()
st.markdown("---")
# --- SAVE ACTIONS WITH HISTORY COMMIT ---
col_save, col_note = st.columns([1, 2])
with col_note:
commit_msg = st.text_input("Change Note (Optional)", placeholder="e.g. Added sequence 3")
with col_save:
if st.button("💾 Save & Snap", use_container_width=True):
data[KEY_BATCH_DATA] = batch_list
tree_data = data.get(KEY_HISTORY_TREE, {})
htree = HistoryTree(tree_data)
snapshot_payload = copy.deepcopy(data)
if KEY_HISTORY_TREE in snapshot_payload: del snapshot_payload[KEY_HISTORY_TREE]
htree.commit(snapshot_payload, note=commit_msg if commit_msg else "Batch Update")
data[KEY_HISTORY_TREE] = htree.to_dict()
save_json(file_path, data)
if 'restored_indicator' in st.session_state:
del st.session_state.restored_indicator
st.toast("Batch Saved & Snapshot Created!", icon="🚀")
st.rerun()
+247 -73
View File
@@ -1,18 +1,28 @@
import asyncio
import copy import copy
import json
import logging
import math
import random import random
import time
from pathlib import Path from pathlib import Path
from nicegui import ui from nicegui import ui
logger = logging.getLogger(__name__)
from state import AppState from state import AppState
from utils import ( from utils import (
DEFAULTS, save_json, load_json, DEFAULTS, save_json, load_json, sync_to_db,
KEY_BATCH_DATA, KEY_HISTORY_TREE, KEY_PROMPT_HISTORY, KEY_SEQUENCE_NUMBER, 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'} 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_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 +86,53 @@ def find_insert_position(batch_list, parent_index, parent_seq_num):
return pos return pos
# --- Auto change note ---
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 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 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'], 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}
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 +156,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,6 +168,9 @@ 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):
if not math.isfinite(v):
v = d
else:
try: try:
v = int(v) if v == int(v) else v v = int(v) if v == int(v) else v
except (OverflowError, ValueError): except (OverflowError, ValueError):
@@ -137,6 +199,8 @@ def dict_textarea(label, seq, key, **kwargs):
# ====================================================================== # ======================================================================
def render_batch_processor(state: AppState): def render_batch_processor(state: AppState):
t0 = time.perf_counter()
logger.info("render_batch_processor START")
data = state.data_cache data = state.data_cache
file_path = state.file_path file_path = state.file_path
if isinstance(data, list): if isinstance(data, list):
@@ -148,7 +212,7 @@ def render_batch_processor(state: AppState):
ui.label('This is a Single file. To use Batch mode, create a copy.').classes( ui.label('This is a Single file. To use Batch mode, create a copy.').classes(
'text-warning') 'text-warning')
def create_batch(): async def create_batch():
new_name = f'batch_{file_path.name}' new_name = f'batch_{file_path.name}'
new_path = file_path.parent / new_name new_path = file_path.parent / new_name
if new_path.exists(): if new_path.exists():
@@ -160,7 +224,9 @@ def render_batch_processor(state: AppState):
first_item[KEY_SEQUENCE_NUMBER] = 1 first_item[KEY_SEQUENCE_NUMBER] = 1
new_data = {KEY_BATCH_DATA: [first_item], KEY_HISTORY_TREE: {}, new_data = {KEY_BATCH_DATA: [first_item], KEY_HISTORY_TREE: {},
KEY_PROMPT_HISTORY: []} KEY_PROMPT_HISTORY: []}
save_json(new_path, new_data) await asyncio.to_thread(save_json, new_path, new_data)
if state.db_enabled and state.current_project and state.db:
await asyncio.to_thread(sync_to_db, state.db, state.current_project, new_path, new_data)
ui.notify(f'Created {new_name}', type='positive') ui.notify(f'Created {new_name}', type='positive')
ui.button('Create Batch Copy', icon='content_copy', on_click=create_batch) ui.button('Create Batch Copy', icon='content_copy', on_click=create_batch)
@@ -188,12 +254,16 @@ def render_batch_processor(state: AppState):
src_seq_select = ui.select([], label='Source Sequence:').classes('w-64') src_seq_select = ui.select([], label='Source Sequence:').classes('w-64')
# Track loaded source data # Track loaded source data (on state so it's cleared on file switch)
_src_cache = {'data': None, 'batch': [], 'name': None} _src_cache = state._src_cache
def _update_src(): def _update_src():
name = src_file_select.value name = src_file_select.value
if name and name != _src_cache['name']: if name and name != _src_cache['name']:
# Reuse current data if source is the same file
if name == file_path.name:
src_data = data
else:
src_data, _ = load_json(state.current_dir / name) src_data, _ = load_json(state.current_dir / name)
_src_cache['data'] = src_data _src_cache['data'] = src_data
_src_cache['batch'] = src_data.get(KEY_BATCH_DATA, []) _src_cache['batch'] = src_data.get(KEY_BATCH_DATA, [])
@@ -208,37 +278,41 @@ def render_batch_processor(state: AppState):
src_file_select.on_value_change(lambda _: _update_src()) src_file_select.on_value_change(lambda _: _update_src())
_update_src() _update_src()
def _add_sequence(new_item): async def _add_sequence(new_item):
new_item[KEY_SEQUENCE_NUMBER] = max_main_seq_number(batch_list) + 1 new_item[KEY_SEQUENCE_NUMBER] = max_main_seq_number(batch_list) + 1
for k in [KEY_PROMPT_HISTORY, KEY_HISTORY_TREE, 'note', 'loras']: for k in [KEY_PROMPT_HISTORY, KEY_HISTORY_TREE, 'note', 'loras']:
new_item.pop(k, None) new_item.pop(k, None)
batch_list.append(new_item) batch_list.append(new_item)
data[KEY_BATCH_DATA] = batch_list data[KEY_BATCH_DATA] = batch_list
save_json(file_path, data) 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:
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, snapshot)
render_sequence_list.refresh() render_sequence_list.refresh()
with ui.row().classes('q-mt-sm'): with ui.row().classes('q-mt-sm'):
def add_empty(): async def add_empty():
_add_sequence(DEFAULTS.copy()) await _add_sequence(copy.deepcopy(DEFAULTS))
def add_from_source(): async 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']))
_add_sequence(item) await _add_sequence(item)
ui.button('Add Empty', icon='add', on_click=add_empty) ui.button('Add Empty', icon='add', on_click=add_empty)
ui.button('From Source', icon='file_download', on_click=add_from_source) ui.button('From Source', icon='file_download', on_click=add_from_source)
# --- Standard / LoRA / VACE key sets --- # --- Standard / LoRA / VACE key sets ---
lora_keys = ['lora 1 high', 'lora 1 low', 'lora 2 high', 'lora 2 low', lora_keys = ['lora 1 high', 'lora 1 high strength', 'lora 1 low', 'lora 1 low strength',
'lora 3 high', 'lora 3 low'] '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 = { 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',
@@ -246,16 +320,21 @@ def render_batch_processor(state: AppState):
} }
standard_keys.update(lora_keys) standard_keys.update(lora_keys)
def sort_by_number(): async def sort_by_number():
batch_list.sort(key=lambda s: int(s.get(KEY_SEQUENCE_NUMBER, 0))) batch_list.sort(key=lambda s: int(s.get(KEY_SEQUENCE_NUMBER, 0)))
data[KEY_BATCH_DATA] = batch_list data[KEY_BATCH_DATA] = batch_list
save_json(file_path, data) 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:
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, snapshot)
ui.notify('Sorted by sequence number!', type='positive') ui.notify('Sorted by sequence number!', type='positive')
render_sequence_list.refresh() render_sequence_list.refresh()
# --- Sequence list + mass update (inside refreshable so they stay in sync) --- # --- Sequence list + mass update (inside refreshable so they stay in sync) ---
@ui.refreshable @ui.refreshable
def render_sequence_list(): def render_sequence_list():
t1 = time.perf_counter()
logger.info("render_sequence_list START (%d sequences)", len(batch_list))
# Mass update (rebuilt on refresh so checkboxes match current sequences) # Mass update (rebuilt on refresh so checkboxes match current sequences)
_render_mass_update(batch_list, data, file_path, state, render_sequence_list) _render_mass_update(batch_list, data, file_path, state, render_sequence_list)
@@ -270,8 +349,10 @@ def render_batch_processor(state: AppState):
_src_cache, src_seq_select, _src_cache, src_seq_select,
standard_keys, render_sequence_list, standard_keys, render_sequence_list,
) )
logger.info("render_sequence_list END (%.3fs)", time.perf_counter() - t1)
render_sequence_list() render_sequence_list()
logger.info("render_batch_processor END (%.3fs)", time.perf_counter() - t0)
# --- Save & Snap --- # --- Save & Snap ---
with ui.card().classes('w-full q-pa-md q-mt-lg'): with ui.card().classes('w-full q-pa-md q-mt-lg'):
@@ -279,18 +360,46 @@ def render_batch_processor(state: AppState):
commit_input = ui.input('Change Note (Optional)', commit_input = ui.input('Change Note (Optional)',
placeholder='e.g. Added sequence 3').classes('col') placeholder='e.g. Added sequence 3').classes('col')
def save_and_snap(): async def save_and_snap():
t_ss = time.perf_counter()
logger.info("save_and_snap START")
data[KEY_BATCH_DATA] = batch_list data[KEY_BATCH_DATA] = batch_list
tree_data = data.get(KEY_HISTORY_TREE, {}) tree_data = data.get(KEY_HISTORY_TREE, {})
htree = HistoryTree(tree_data) timeline = SnapshotTimeline(tree_data)
snapshot_payload = copy.deepcopy(data) note = commit_input.value if commit_input.value else _auto_change_note(timeline, batch_list, state=state, file_path=file_path)
snapshot_payload.pop(KEY_HISTORY_TREE, None) # Single serialization: json roundtrip gives us an isolated snapshot
note = commit_input.value if commit_input.value else 'Batch Update' t1 = time.perf_counter()
htree.commit(snapshot_payload, note=note) snapshot_json = json.dumps({k: v for k, v in data.items()
data[KEY_HISTORY_TREE] = htree.to_dict() if k != KEY_HISTORY_TREE})
save_json(file_path, data) snapshot_payload = json.loads(snapshot_json)
logger.info("save_and_snap snapshot %.3fs", time.perf_counter() - t1)
try:
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:
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)
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:
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)
logger.info("save_and_snap save_json %.3fs", time.perf_counter() - t1)
state.restored_indicator = None state.restored_indicator = None
commit_input.set_value('') commit_input.set_value('')
logger.info("save_and_snap END (%.3fs)", time.perf_counter() - t_ss)
ui.notify('Batch Saved & Snapshot Created!', type='positive') ui.notify('Batch Saved & Snapshot Created!', type='positive')
ui.button('Save & Snap', icon='save', on_click=save_and_snap).props('color=primary') ui.button('Save & Snap', icon='save', on_click=save_and_snap).props('color=primary')
@@ -303,29 +412,72 @@ def render_batch_processor(state: AppState):
def _render_sequence_card(i, seq, batch_list, data, file_path, state, def _render_sequence_card(i, seq, batch_list, data, file_path, state,
src_cache, src_seq_select, standard_keys, src_cache, src_seq_select, standard_keys,
refresh_list): refresh_list):
def commit(message=None): async def commit(message=None):
data[KEY_BATCH_DATA] = batch_list data[KEY_BATCH_DATA] = batch_list
save_json(file_path, data) # 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 and not did_snap:
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, snapshot)
if message: if message:
ui.notify(message, type='positive') ui.notify(message, type='positive')
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']))
@@ -380,6 +532,7 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
# Delete # Delete
def delete(idx=i): def delete(idx=i):
if idx < len(batch_list):
batch_list.pop(idx) batch_list.pop(idx)
commit() commit()
@@ -387,7 +540,7 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
ui.separator() ui.separator()
# --- Prompts + Settings (2-column like Streamlit) --- # --- Prompts + Settings (2-column) ---
with ui.splitter(value=66).classes('w-full') as splitter: with ui.splitter(value=66).classes('w-full') as splitter:
with splitter.before: with splitter.before:
dict_textarea('General Prompt', seq, 'general_prompt').classes( dict_textarea('General Prompt', seq, 'general_prompt').classes(
@@ -400,6 +553,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)})'
@@ -447,26 +603,20 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
# --- VACE Settings (full width) --- # --- VACE Settings (full width) ---
with ui.expansion('VACE Settings', icon='settings').classes('w-full'): with ui.expansion('VACE Settings', icon='settings').classes('w-full'):
_render_vace_settings(i, seq, batch_list, data, file_path, refresh_list) _render_vace_settings(i, seq, batch_list, data, file_path, state, refresh_list)
# --- LoRA Settings --- # --- LoRA Settings ---
with ui.expansion('LoRA Settings', icon='style').classes('w-full'): with ui.expansion('LoRA Settings', icon='style').classes('w-full'):
for lora_idx in range(1, 4): for lora_idx in range(1, 4):
for tier, tier_label in [('high', 'High'), ('low', 'Low')]: for tier, tier_label in [('high', 'High'), ('low', 'Low')]:
k = f'lora {lora_idx} {tier}' lora_key = f'lora {lora_idx} {tier}'
raw = str(seq.get(k, ''))
inner = raw.replace('<lora:', '').replace('>', '') lora_name = str(seq.get(lora_key, ''))
# Split "name:strength" or just "name" strength_key = f'lora {lora_idx} {tier} strength'
if ':' in inner: lora_strength = seq.get(strength_key, 1.0)
parts = inner.rsplit(':', 1)
lora_name = parts[0]
try: try:
lora_strength = float(parts[1]) lora_strength = float(lora_strength)
except ValueError: except (ValueError, TypeError):
lora_name = inner
lora_strength = 1.0
else:
lora_name = inner
lora_strength = 1.0 lora_strength = 1.0
with ui.row().classes('w-full items-center q-gutter-sm'): with ui.row().classes('w-full items-center q-gutter-sm'):
@@ -483,10 +633,9 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
format='%.1f', format='%.1f',
).props('outlined dense').style('max-width: 80px') ).props('outlined dense').style('max-width: 80px')
def _lora_sync(key=k, n_inp=name_input, s_inp=strength_input): def _lora_sync(k=lora_key, sk=strength_key, n_inp=name_input, s_inp=strength_input):
name = n_inp.value or '' seq[k] = n_inp.value or ''
strength = s_inp.value if s_inp.value is not None else 1.0 seq[sk] = float(s_inp.value) if s_inp.value is not None else 1.0
seq[key] = f'<lora:{name}:{strength:.1f}>' if name else ''
name_input.on('blur', lambda _, s=_lora_sync: s()) name_input.on('blur', lambda _, s=_lora_sync: s())
name_input.on('update:model-value', lambda _, s=_lora_sync: s()) name_input.on('update:model-value', lambda _, s=_lora_sync: s())
@@ -529,9 +678,15 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
# VACE Settings sub-section # VACE Settings sub-section
# ====================================================================== # ======================================================================
def _render_vace_settings(i, seq, batch_list, data, file_path, 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():
@@ -552,21 +707,24 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, refresh_list):
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): async 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) 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:
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, snapshot)
ui.notify(f'Shifted {shifted} sequences by {delta:+d}', type='positive') ui.notify(f'Shifted {shifted} sequences by {delta:+d}', type='positive')
refresh_list.refresh() refresh_list.refresh()
@@ -585,7 +743,7 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, refresh_list):
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])
@@ -599,10 +757,10 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, refresh_list):
'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)
@@ -621,10 +779,10 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, refresh_list):
# 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
@@ -684,7 +842,7 @@ def _render_mass_update(batch_list, data, file_path, state: AppState, refresh_li
select_all_cb.on_value_change(on_select_all) select_all_cb.on_value_change(on_select_all)
def apply_mass_update(): async def apply_mass_update():
src_idx = source_select.value src_idx = source_select.value
if src_idx is None or src_idx >= len(batch_list): if src_idx is None or src_idx >= len(batch_list):
ui.notify('Source sequence no longer exists', type='warning') ui.notify('Source sequence no longer exists', type='warning')
@@ -706,12 +864,28 @@ def _render_mass_update(batch_list, data, file_path, state: AppState, refresh_li
batch_list[idx][key] = copy.deepcopy(source_seq.get(key)) batch_list[idx][key] = copy.deepcopy(source_seq.get(key))
data[KEY_BATCH_DATA] = batch_list data[KEY_BATCH_DATA] = batch_list
htree = HistoryTree(data.get(KEY_HISTORY_TREE, {})) timeline = SnapshotTimeline(data.get(KEY_HISTORY_TREE, {}))
snapshot = copy.deepcopy(data) snapshot_json = json.dumps({k: v for k, v in data.items()
snapshot.pop(KEY_HISTORY_TREE, None) if k != KEY_HISTORY_TREE})
htree.commit(snapshot, f"Mass update: {', '.join(selected_keys)}") snapshot = json.loads(snapshot_json)
data[KEY_HISTORY_TREE] = htree.to_dict() try:
save_json(file_path, data) 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 = 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)
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] = 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') ui.notify(f'Updated {len(targets)} sequences', type='positive')
if refresh_list: if refresh_list:
refresh_list.refresh() refresh_list.refresh()
-249
View File
@@ -1,249 +0,0 @@
import streamlit as st
import requests
from PIL import Image
from io import BytesIO
import urllib.parse
import html
import time # <--- NEW IMPORT
from utils import save_config
def render_single_instance(instance_config, index, all_instances, timeout_minutes):
url = instance_config.get("url", "http://127.0.0.1:8188")
name = instance_config.get("name", f"Server {index+1}")
COMFY_URL = url.rstrip("/")
# --- TIMEOUT LOGIC ---
# Generate unique keys for session state
toggle_key = f"live_toggle_{index}"
start_time_key = f"live_start_{index}"
# Check if we need to auto-close
if st.session_state.get(toggle_key, False) and timeout_minutes > 0:
start_time = st.session_state.get(start_time_key, 0)
elapsed = time.time() - start_time
if elapsed > (timeout_minutes * 60):
st.session_state[toggle_key] = False
# We don't need st.rerun() here because the fragment loop will pick up the state change on the next pass
# but an explicit rerun makes it snappy.
st.rerun()
c_head, c_set = st.columns([3, 1])
c_head.markdown(f"### 🔌 {name}")
with c_set.popover("⚙️ Settings"):
st.caption("Press Update to apply changes!")
new_name = st.text_input("Name", value=name, key=f"name_{index}")
new_url = st.text_input("URL", value=url, key=f"url_{index}")
if new_url != url:
st.warning("⚠️ Unsaved URL! Click Update below.")
if st.button("💾 Update & Save", key=f"save_{index}", type="primary"):
all_instances[index]["name"] = new_name
all_instances[index]["url"] = new_url
st.session_state.config["comfy_instances"] = all_instances
save_config(
st.session_state.current_dir,
st.session_state.config['favorites'],
st.session_state.config
)
st.toast("Server config saved!", icon="💾")
st.rerun()
st.divider()
if st.button("🗑️ Remove Server", key=f"del_{index}"):
all_instances.pop(index)
st.session_state.config["comfy_instances"] = all_instances
save_config(
st.session_state.current_dir,
st.session_state.config['favorites'],
st.session_state.config
)
st.rerun()
# --- 1. STATUS DASHBOARD ---
with st.expander("📊 Server Status", expanded=True):
col1, col2, col3, col4 = st.columns([1, 1, 1, 1])
try:
res = requests.get(f"{COMFY_URL}/queue", timeout=1.5)
queue_data = res.json()
running_cnt = len(queue_data.get("queue_running", []))
pending_cnt = len(queue_data.get("queue_pending", []))
col1.metric("Status", "🟢 Online" if running_cnt > 0 else "💤 Idle")
col2.metric("Pending", pending_cnt)
col3.metric("Running", running_cnt)
if col4.button("🔄 Check Img", key=f"refresh_{index}", use_container_width=True):
st.session_state[f"force_img_refresh_{index}"] = True
except Exception:
col1.metric("Status", "🔴 Offline")
col2.metric("Pending", "-")
col3.metric("Running", "-")
st.error(f"Could not connect to API at {COMFY_URL}")
# --- 2. LIVE VIEW (VIA REMOTE BROWSER) ---
st.write("")
c_label, c_ctrl = st.columns([1, 2])
c_label.subheader("📺 Live View")
# Capture the toggle interaction to set start time
def on_toggle_change():
if st.session_state[toggle_key]:
st.session_state[start_time_key] = time.time()
enable_preview = c_ctrl.checkbox(
"Enable Live Preview",
value=False,
key=toggle_key,
on_change=on_toggle_change
)
if enable_preview:
# Display Countdown if timeout is active
if timeout_minutes > 0:
elapsed = time.time() - st.session_state.get(start_time_key, time.time())
remaining = (timeout_minutes * 60) - elapsed
st.caption(f"⏱️ Auto-off in: **{int(remaining)}s**")
# Height Slider
iframe_h = st.slider(
"Height (px)",
min_value=600, max_value=2500, value=1000, step=50,
key=f"h_slider_{index}"
)
# Get Configured Viewer URL
viewer_base = st.session_state.config.get("viewer_url", "")
final_src = viewer_base.strip()
# Validate URL scheme before embedding
parsed = urllib.parse.urlparse(final_src)
if final_src and parsed.scheme in ("http", "https"):
safe_src = html.escape(final_src, quote=True)
st.info(f"Viewing via Remote Browser: `{final_src}`")
st.markdown(
f"""
<iframe src="{safe_src}" width="100%" height="{iframe_h}px"
style="border: 2px solid #666; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.3);">
</iframe>
""",
unsafe_allow_html=True
)
else:
st.warning("No valid viewer URL configured. Set one in Monitor Settings below.")
else:
st.info("Live Preview is disabled.")
st.markdown("---")
# --- 3. LATEST OUTPUT ---
if st.session_state.get(f"force_img_refresh_{index}", False):
st.caption("🖼️ Most Recent Output")
try:
hist_res = requests.get(f"{COMFY_URL}/history", timeout=2)
history = hist_res.json()
if history:
last_prompt_id = list(history.keys())[-1]
outputs = history[last_prompt_id].get("outputs", {})
found_img = None
for node_id, node_output in outputs.items():
if "images" in node_output:
for img_info in node_output["images"]:
if img_info["type"] == "output":
found_img = img_info
break
if found_img: break
if found_img:
img_name = found_img['filename']
folder = found_img['subfolder']
img_type = found_img['type']
img_url = f"{COMFY_URL}/view?filename={img_name}&subfolder={folder}&type={img_type}"
img_res = requests.get(img_url)
image = Image.open(BytesIO(img_res.content))
st.image(image, caption=f"Last Output: {img_name}")
else:
st.warning("Last run had no image output.")
else:
st.info("No history found.")
st.session_state[f"force_img_refresh_{index}"] = False
except Exception as e:
st.error(f"Error fetching image: {e}")
# Check for fragment support (Streamlit 1.37+)
if hasattr(st, "fragment"):
# This decorator ensures this function re-runs every 10 seconds automatically
# allowing it to catch the timeout even if you are away from the keyboard.
@st.fragment(run_every=300)
def _monitor_fragment():
_render_content()
else:
# Fallback for older Streamlit versions (Won't auto-refresh while idle)
def _monitor_fragment():
_render_content()
def _render_content():
# --- GLOBAL SETTINGS FOR MONITOR ---
with st.expander("🔧 Monitor Settings", expanded=False):
c_set1, c_set2 = st.columns(2)
current_viewer = st.session_state.config.get("viewer_url", "")
new_viewer = c_set1.text_input("Remote Browser URL", value=current_viewer, help="e.g., http://localhost:5800")
# New Timeout Slider
current_timeout = st.session_state.config.get("monitor_timeout", 0)
new_timeout = c_set2.slider("Live Preview Timeout (Minutes)", 0, 60, value=current_timeout, help="0 = Always On. Sets how long the preview stays open before auto-closing.")
if st.button("💾 Save Monitor Settings"):
st.session_state.config["viewer_url"] = new_viewer
st.session_state.config["monitor_timeout"] = new_timeout
save_config(
st.session_state.current_dir,
st.session_state.config['favorites'],
st.session_state.config
)
st.success("Settings saved!")
st.rerun()
# --- INSTANCE MANAGEMENT ---
if "comfy_instances" not in st.session_state.config:
st.session_state.config["comfy_instances"] = [
{"name": "Main Server", "url": "http://192.168.1.100:8188"}
]
instances = st.session_state.config["comfy_instances"]
tab_names = [i["name"] for i in instances] + [" Add Server"]
tabs = st.tabs(tab_names)
timeout_val = st.session_state.config.get("monitor_timeout", 0)
for i, tab in enumerate(tabs[:-1]):
with tab:
render_single_instance(instances[i], i, instances, timeout_val)
with tabs[-1]:
st.header("Add New ComfyUI Instance")
with st.form("add_server_form"):
new_name = st.text_input("Server Name", placeholder="e.g. Render Node 2")
new_url = st.text_input("URL", placeholder="http://192.168.1.50:8188")
if st.form_submit_button("Add Instance"):
if new_name and new_url:
instances.append({"name": new_name, "url": new_url})
st.session_state.config["comfy_instances"] = instances
save_config(
st.session_state.current_dir,
st.session_state.config['favorites'],
st.session_state.config
)
st.success("Server Added!")
st.rerun()
else:
st.error("Please fill in both Name and URL.")
def render_comfy_monitor():
# We call the wrapper which decides if it's a fragment or not
_monitor_fragment()
+5 -2
View File
@@ -82,6 +82,7 @@ def render_comfy_monitor(state: AppState):
_live_refreshables = state._live_refreshables _live_refreshables = state._live_refreshables
def poll_all(): def poll_all():
try:
timeout_val = config.get('monitor_timeout', 0) timeout_val = config.get('monitor_timeout', 0)
if timeout_val > 0: if timeout_val > 0:
for key, start_time in list(state.live_toggles.items()): for key, start_time in list(state.live_toggles.items()):
@@ -91,6 +92,8 @@ def render_comfy_monitor(state: AppState):
_live_checkboxes[key].set_value(False) _live_checkboxes[key].set_value(False)
if key in _live_refreshables: if key in _live_refreshables:
_live_refreshables[key].refresh() _live_refreshables[key].refresh()
except RuntimeError:
pass # Parent slot deleted during refresh
ui.timer(300, poll_all) ui.timer(300, poll_all)
@@ -139,7 +142,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 +240,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:
+250
View File
@@ -0,0 +1,250 @@
import asyncio
import json
import logging
import sqlite3
from pathlib import Path
from nicegui import ui
from state import AppState
from db import ProjectDB
from utils import save_config, sync_to_db
logger = logging.getLogger(__name__)
def render_projects_tab(state: AppState):
"""Render the Projects management tab."""
# --- DB toggle ---
def on_db_toggle(e):
state.db_enabled = e.value
state.config['db_enabled'] = e.value
save_config(state.current_dir, state.config.get('favorites', []), state.config)
render_project_content.refresh()
ui.switch('Enable Project Database', value=state.db_enabled,
on_change=on_db_toggle).classes('q-mb-md')
@ui.refreshable
def render_project_content():
if not state.db_enabled:
ui.label('Project database is disabled. Enable it above to manage projects.').classes(
'text-caption q-pa-md')
return
if not state.db:
ui.label('Database not initialized.').classes('text-warning q-pa-md')
return
# --- Create project form ---
with ui.card().classes('w-full q-pa-md q-mb-md'):
ui.label('Create New Project').classes('section-header')
name_input = ui.input('Project Name', placeholder='my_project').classes('w-full')
desc_input = ui.input('Description (optional)', placeholder='A short description').classes('w-full')
async def create_project():
name = name_input.value.strip()
if not name:
ui.notify('Please enter a project name', type='warning')
return
try:
await asyncio.to_thread(state.db.create_project, name, str(state.current_dir), desc_input.value.strip())
name_input.set_value('')
desc_input.set_value('')
ui.notify(f'Created project "{name}"', type='positive')
render_project_list.refresh()
except Exception as e:
ui.notify(f'Error: {e}', type='negative')
ui.button('Create Project', icon='add', on_click=create_project).classes('w-full')
# --- Active project indicator ---
# Fetch once with file counts and reuse in render_project_list
_cached_projects = state.db.list_projects_with_file_counts()
if state.current_project:
# Check if active project actually exists in the database
project_exists = any(p['name'] == state.current_project for p in _cached_projects)
if project_exists:
ui.label(f'Active Project: {state.current_project}').classes(
'text-bold text-primary q-pa-sm')
else:
with ui.card().classes('w-full q-pa-sm q-mb-sm').style(
'border-left: 3px solid orange;'):
ui.label(f'Stale project reference: "{state.current_project}" '
'(not found in database)').classes('text-warning')
with ui.row().classes('q-gutter-sm'):
def clear_stale():
state.current_project = ''
state.config['current_project'] = ''
save_config(state.current_dir,
state.config.get('favorites', []),
state.config)
ui.notify('Cleared stale project reference', type='info')
render_project_content.refresh()
def recreate_project():
name = state.current_project
try:
state.db.create_project(name, str(state.current_dir))
ui.notify(f'Recreated project "{name}"', type='positive')
render_project_content.refresh()
except Exception as e:
ui.notify(f'Error: {e}', type='negative')
ui.button('Clear Reference', icon='clear',
on_click=clear_stale).props('flat dense')
ui.button('Recreate Project', icon='add_circle',
on_click=recreate_project).props('flat dense color=primary')
# --- Project list ---
@ui.refreshable
def render_project_list():
nonlocal _cached_projects
projects = state.db.list_projects_with_file_counts()
_cached_projects = projects
if not projects:
ui.label('No projects yet. Create one above.').classes('text-caption q-pa-md')
return
for proj in projects:
is_active = proj['name'] == state.current_project
card_style = 'border-left: 3px solid var(--accent);' if is_active else ''
with ui.card().classes('w-full q-pa-sm q-mb-sm').style(card_style):
with ui.row().classes('w-full items-center'):
with ui.column().classes('col'):
ui.label(proj['name']).classes('text-bold')
if proj['description']:
ui.label(proj['description']).classes('text-caption')
ui.label(f'Path: {proj["folder_path"]}').classes('text-caption')
ui.label(f'{proj["file_count"]} data file(s)').classes('text-caption')
with ui.row().classes('q-gutter-xs'):
if not is_active:
def activate(name=proj['name']):
state.current_project = name
state.config['current_project'] = name
save_config(state.current_dir,
state.config.get('favorites', []),
state.config)
ui.notify(f'Activated project "{name}"', type='positive')
render_project_list.refresh()
ui.button('Activate', icon='check_circle',
on_click=activate).props('flat dense color=primary')
else:
def deactivate():
state.current_project = ''
state.config['current_project'] = ''
save_config(state.current_dir,
state.config.get('favorites', []),
state.config)
ui.notify('Deactivated project', type='info')
render_project_list.refresh()
ui.button('Deactivate', icon='cancel',
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:
await asyncio.to_thread(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')
await asyncio.to_thread(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']):
_import_folder(state, pid, pname, render_project_list)
ui.button('Import Folder', icon='folder_open',
on_click=import_folder).props('flat dense')
async def delete_proj(name=proj['name']):
await asyncio.to_thread(state.db.delete_project, name)
if state.current_project == name:
state.current_project = ''
state.config['current_project'] = ''
save_config(state.current_dir,
state.config.get('favorites', []),
state.config)
ui.notify(f'Deleted project "{name}"', type='positive')
render_project_list.refresh()
ui.button(icon='delete',
on_click=delete_proj).props('flat dense color=negative')
render_project_list()
render_project_content()
async def _import_folder(state: AppState, project_id: int, project_name: str, refresh_fn):
"""Bulk import all .json files from current directory into a project."""
json_files = sorted(state.current_dir.glob('*.json'))
json_files = [f for f in json_files if f.name not in (
'.editor_config.json', '.editor_snippets.json')]
if not json_files:
ui.notify('No JSON files in current directory', type='warning')
return
def _do_import():
imported = 0
skipped = 0
for jf in json_files:
file_name = jf.stem
existing = state.db.get_data_file(project_id, file_name)
if existing:
skipped += 1
continue
try:
state.db.import_json_file(project_id, jf)
imported += 1
except Exception as e:
logger.warning(f"Failed to import {jf}: {e}")
return imported, skipped
imported, skipped = await asyncio.to_thread(_do_import)
msg = f'Imported {imported} file(s)'
if skipped:
msg += f', skipped {skipped} existing'
ui.notify(msg, type='positive')
refresh_fn.refresh()
-78
View File
@@ -1,78 +0,0 @@
import streamlit as st
import json
import copy
from utils import save_json, get_file_mtime, KEY_HISTORY_TREE, KEY_PROMPT_HISTORY
def render_raw_editor(data, file_path):
st.subheader(f"💻 Raw Editor: {file_path.name}")
# Toggle to hide massive history objects
# This is crucial because history trees can get huge and make the text area laggy.
col_ctrl, col_info = st.columns([1, 2])
with col_ctrl:
hide_history = st.checkbox(
"Hide History (Safe Mode)",
value=True,
help="Hides 'history_tree' and 'prompt_history' to keep the editor fast and prevent accidental deletion of version control."
)
# Prepare display data
if hide_history:
display_data = copy.deepcopy(data)
# Safely remove heavy keys for the view only
if KEY_HISTORY_TREE in display_data: del display_data[KEY_HISTORY_TREE]
if KEY_PROMPT_HISTORY in display_data: del display_data[KEY_PROMPT_HISTORY]
else:
display_data = data
# Convert to string
# ensure_ascii=False ensures emojis and special chars render correctly
try:
json_str = json.dumps(display_data, indent=4, ensure_ascii=False)
except Exception as e:
st.error(f"Error serializing JSON: {e}")
json_str = "{}"
# The Text Editor
# We use ui_reset_token in the key to force the text area to reload content on save
new_json_str = st.text_area(
"JSON Content",
value=json_str,
height=650,
key=f"raw_edit_{file_path.name}_{st.session_state.ui_reset_token}"
)
st.markdown("---")
if st.button("💾 Save Raw Changes", type="primary", use_container_width=True):
try:
# 1. Parse the text back to JSON
input_data = json.loads(new_json_str)
# 2. If we were in Safe Mode, we must merge the hidden history back in
if hide_history:
if KEY_HISTORY_TREE in data:
input_data[KEY_HISTORY_TREE] = data[KEY_HISTORY_TREE]
if KEY_PROMPT_HISTORY in data:
input_data[KEY_PROMPT_HISTORY] = data[KEY_PROMPT_HISTORY]
# 3. Save to Disk
save_json(file_path, input_data)
# 4. Update Session State
# We clear and update the existing dictionary object so other tabs see the changes
data.clear()
data.update(input_data)
# 5. Update Metadata to prevent conflict warnings
st.session_state.last_mtime = get_file_mtime(file_path)
st.session_state.ui_reset_token += 1
st.toast("Raw JSON Saved Successfully!", icon="")
st.rerun()
except json.JSONDecodeError as e:
st.error(f"❌ Invalid JSON Syntax: {e}")
st.error("Please fix the formatting errors above before saving.")
except Exception as e:
st.error(f"❌ Unexpected Error: {e}")
+9 -8
View File
@@ -1,10 +1,10 @@
import copy import asyncio
import json import json
from nicegui import ui from nicegui import ui
from state import AppState from state import AppState
from utils import save_json, get_file_mtime, KEY_HISTORY_TREE, KEY_PROMPT_HISTORY from utils import save_json, sync_to_db, get_file_mtime, KEY_HISTORY_TREE, KEY_PROMPT_HISTORY
def render_raw_editor(state: AppState): def render_raw_editor(state: AppState):
@@ -21,11 +21,10 @@ def render_raw_editor(state: AppState):
@ui.refreshable @ui.refreshable
def render_editor(): def render_editor():
# Prepare display data # Prepare display data — shallow copy, just pop keys
if hide_history.value: if hide_history.value:
display_data = copy.deepcopy(data) display_data = {k: v for k, v in data.items()
display_data.pop(KEY_HISTORY_TREE, None) if k not in (KEY_HISTORY_TREE, KEY_PROMPT_HISTORY)}
display_data.pop(KEY_PROMPT_HISTORY, None)
else: else:
display_data = data display_data = data
@@ -40,7 +39,7 @@ def render_raw_editor(state: AppState):
value=json_str, value=json_str,
).classes('w-full font-mono').props('outlined rows=30') ).classes('w-full font-mono').props('outlined rows=30')
def do_save(): async def do_save():
try: try:
input_data = json.loads(text_area.value) input_data = json.loads(text_area.value)
@@ -51,7 +50,9 @@ def render_raw_editor(state: AppState):
if KEY_PROMPT_HISTORY in data: if KEY_PROMPT_HISTORY in data:
input_data[KEY_PROMPT_HISTORY] = data[KEY_PROMPT_HISTORY] input_data[KEY_PROMPT_HISTORY] = data[KEY_PROMPT_HISTORY]
save_json(file_path, input_data) await asyncio.to_thread(save_json, file_path, input_data)
if state.db_enabled and state.current_project and state.db:
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, input_data)
data.clear() data.clear()
data.update(input_data) data.update(input_data)
-390
View File
@@ -1,390 +0,0 @@
import streamlit as st
import copy
import time
from history_tree import HistoryTree
from utils import save_json, KEY_BATCH_DATA, KEY_HISTORY_TREE
try:
from streamlit_agraph import agraph, Node, Edge, Config
AGRAPH_AVAILABLE = True
except ImportError:
AGRAPH_AVAILABLE = False
def render_timeline_tab(data, file_path):
tree_data = data.get(KEY_HISTORY_TREE, {})
if not tree_data:
st.info("No history timeline exists. Make some changes in the Editor first!")
return
htree = HistoryTree(tree_data)
# --- Initialize selection state ---
if "timeline_selected_nodes" not in st.session_state:
st.session_state.timeline_selected_nodes = set()
if 'restored_indicator' in st.session_state and st.session_state.restored_indicator:
st.info(f"📍 Editing Restored Version: **{st.session_state.restored_indicator}**")
# --- VIEW SWITCHER + SELECTION MODE ---
c_title, c_view, c_toggle = st.columns([2, 1, 0.6])
c_title.subheader("🕰️ Version History")
view_mode = c_view.radio(
"View Mode",
["🌳 Horizontal", "🌲 Vertical", "📜 Linear Log"],
horizontal=True,
label_visibility="collapsed"
)
selection_mode = c_toggle.toggle("Select to Delete", key="timeline_selection_mode")
if not selection_mode:
st.session_state.timeline_selected_nodes = set()
# --- Build sorted node list (shared by all views) ---
all_nodes = list(htree.nodes.values())
all_nodes.sort(key=lambda x: x["timestamp"], reverse=True)
# --- MULTISELECT PICKER (shown when selection mode is on) ---
if selection_mode:
def _fmt_node_option(nid):
n = htree.nodes[nid]
ts = time.strftime('%b %d %H:%M', time.localtime(n['timestamp']))
note = n.get('note', 'Step')
head = " (HEAD)" if nid == htree.head_id else ""
return f"{note}{ts} ({nid[:6]}){head}"
all_ids = [n["id"] for n in all_nodes]
current_selection = [nid for nid in all_ids if nid in st.session_state.timeline_selected_nodes]
picked = st.multiselect(
"Select nodes to delete:",
options=all_ids,
default=current_selection,
format_func=_fmt_node_option,
)
st.session_state.timeline_selected_nodes = set(picked)
c_all, c_none, _ = st.columns([1, 1, 4])
if c_all.button("Select All", use_container_width=True):
st.session_state.timeline_selected_nodes = set(all_ids)
st.rerun()
if c_none.button("Deselect All", use_container_width=True):
st.session_state.timeline_selected_nodes = set()
st.rerun()
# --- RENDER GRAPH VIEWS ---
if view_mode in ["🌳 Horizontal", "🌲 Vertical"]:
direction = "LR" if view_mode == "🌳 Horizontal" else "TB"
if AGRAPH_AVAILABLE:
# Interactive graph with streamlit-agraph
selected_set = st.session_state.timeline_selected_nodes if selection_mode else set()
clicked_node = _render_interactive_graph(htree, direction, selected_set)
if clicked_node and clicked_node in htree.nodes:
if selection_mode:
# Toggle node in selection set
if clicked_node in st.session_state.timeline_selected_nodes:
st.session_state.timeline_selected_nodes.discard(clicked_node)
else:
st.session_state.timeline_selected_nodes.add(clicked_node)
st.rerun()
else:
node = htree.nodes[clicked_node]
if clicked_node != htree.head_id:
_restore_node(data, node, htree, file_path)
else:
# Fallback to static graphviz
try:
graph_dot = htree.generate_graph(direction=direction)
if direction == "LR":
st.graphviz_chart(graph_dot, use_container_width=True)
else:
_, col_center, _ = st.columns([1, 2, 1])
with col_center:
st.graphviz_chart(graph_dot, use_container_width=True)
except Exception as e:
st.error(f"Graph Error: {e}")
st.caption("💡 Install `streamlit-agraph` for interactive click-to-restore")
# --- RENDER LINEAR LOG VIEW ---
elif view_mode == "📜 Linear Log":
st.caption("A simple chronological list of all snapshots.")
for n in all_nodes:
is_head = (n["id"] == htree.head_id)
with st.container():
if selection_mode:
c0, c1, c2, c3 = st.columns([0.3, 0.5, 4, 1])
with c0:
is_selected = n["id"] in st.session_state.timeline_selected_nodes
if st.checkbox("", value=is_selected, key=f"log_sel_{n['id']}", label_visibility="collapsed"):
st.session_state.timeline_selected_nodes.add(n["id"])
else:
st.session_state.timeline_selected_nodes.discard(n["id"])
else:
c1, c2, c3 = st.columns([0.5, 4, 1])
with c1:
st.markdown("### 📍" if is_head else "### ⚫")
with c2:
note_txt = n.get('note', 'Step')
ts = time.strftime('%b %d %H:%M', time.localtime(n['timestamp']))
if is_head:
st.markdown(f"**{note_txt}** (Current)")
else:
st.write(f"**{note_txt}**")
st.caption(f"ID: {n['id'][:6]}{ts}")
with c3:
if not is_head and not selection_mode:
if st.button("", key=f"log_rst_{n['id']}", help="Restore this version"):
_restore_node(data, n, htree, file_path)
st.divider()
# --- BATCH DELETE UI ---
if selection_mode and st.session_state.timeline_selected_nodes:
# Prune any selected IDs that no longer exist in the tree
valid_selected = st.session_state.timeline_selected_nodes & set(htree.nodes.keys())
st.session_state.timeline_selected_nodes = valid_selected
count = len(valid_selected)
if count > 0:
st.warning(f"**{count}** node{'s' if count != 1 else ''} selected for deletion.")
if st.button(f"🗑️ Delete {count} Node{'s' if count != 1 else ''}", type="primary"):
# Backup
if "history_tree_backup" not in data:
data["history_tree_backup"] = []
data["history_tree_backup"].append(copy.deepcopy(htree.to_dict()))
# Delete all selected nodes
for nid in valid_selected:
if nid in htree.nodes:
del htree.nodes[nid]
# Clean up branch tips
for b, tip in list(htree.branches.items()):
if tip in valid_selected:
del htree.branches[b]
# Reassign HEAD if deleted
if htree.head_id in valid_selected:
if htree.nodes:
fallback = sorted(htree.nodes.values(), key=lambda x: x["timestamp"])[-1]
htree.head_id = fallback["id"]
else:
htree.head_id = None
# Save and reset
data[KEY_HISTORY_TREE] = htree.to_dict()
save_json(file_path, data)
st.session_state.timeline_selected_nodes = set()
st.toast(f"Deleted {count} node{'s' if count != 1 else ''}!", icon="🗑️")
st.rerun()
st.markdown("---")
# --- NODE SELECTOR ---
col_sel, col_act = st.columns([3, 1])
def fmt_node(n):
ts = time.strftime('%b %d %H:%M', time.localtime(n['timestamp']))
return f"{n.get('note', 'Step')}{ts} ({n['id'][:6]})"
with col_sel:
current_idx = 0
for i, n in enumerate(all_nodes):
if n["id"] == htree.head_id:
current_idx = i
break
selected_node = st.selectbox(
"Select Version to Manage:",
all_nodes,
format_func=fmt_node,
index=current_idx
)
if not selected_node:
return
node_data = selected_node["data"]
# --- RESTORE ---
with col_act:
st.write(""); st.write("")
if st.button("⏪ Restore Version", type="primary", use_container_width=True):
_restore_node(data, selected_node, htree, file_path)
# --- RENAME ---
rn_col1, rn_col2 = st.columns([3, 1])
new_label = rn_col1.text_input("Rename Label", value=selected_node.get("note", ""))
if rn_col2.button("Update Label"):
selected_node["note"] = new_label
data[KEY_HISTORY_TREE] = htree.to_dict()
save_json(file_path, data)
st.rerun()
# --- DANGER ZONE ---
st.markdown("---")
with st.expander("⚠️ Danger Zone (Delete)"):
st.warning("Deleting a node cannot be undone.")
if st.button("🗑️ Delete This Node", type="primary"):
if selected_node['id'] in htree.nodes:
if "history_tree_backup" not in data:
data["history_tree_backup"] = []
data["history_tree_backup"].append(copy.deepcopy(htree.to_dict()))
del htree.nodes[selected_node['id']]
for b, tip in list(htree.branches.items()):
if tip == selected_node['id']:
del htree.branches[b]
if htree.head_id == selected_node['id']:
if htree.nodes:
fallback = sorted(htree.nodes.values(), key=lambda x: x["timestamp"])[-1]
htree.head_id = fallback["id"]
else:
htree.head_id = None
data[KEY_HISTORY_TREE] = htree.to_dict()
save_json(file_path, data)
st.toast("Node Deleted", icon="🗑️")
st.rerun()
# --- DATA PREVIEW ---
st.markdown("---")
with st.expander("🔍 Data Preview", expanded=False):
batch_list = node_data.get(KEY_BATCH_DATA, [])
if batch_list and isinstance(batch_list, list) and len(batch_list) > 0:
st.info(f"📚 This snapshot contains {len(batch_list)} sequences.")
for i, seq_data in enumerate(batch_list):
seq_num = seq_data.get("sequence_number", i + 1)
with st.expander(f"🎬 Sequence #{seq_num}", expanded=(i == 0)):
prefix = f"p_{selected_node['id']}_s{i}"
_render_preview_fields(seq_data, prefix)
else:
prefix = f"p_{selected_node['id']}_single"
_render_preview_fields(node_data, prefix)
def _render_interactive_graph(htree, direction, selected_nodes=None):
"""Render an interactive graph using streamlit-agraph. Returns clicked node id."""
if selected_nodes is None:
selected_nodes = set()
# Build reverse lookup: branch tip -> branch name(s)
tip_to_branches = {}
for b_name, tip_id in htree.branches.items():
if tip_id:
tip_to_branches.setdefault(tip_id, []).append(b_name)
sorted_nodes_list = sorted(htree.nodes.values(), key=lambda x: x["timestamp"])
nodes = []
edges = []
for n in sorted_nodes_list:
nid = n["id"]
full_note = n.get('note', 'Step')
display_note = (full_note[:20] + '..') if len(full_note) > 20 else full_note
ts = time.strftime('%b %d %H:%M', time.localtime(n['timestamp']))
# Branch label
branch_label = ""
if nid in tip_to_branches:
branch_label = f"\n[{', '.join(tip_to_branches[nid])}]"
label = f"{display_note}\n{ts}{branch_label}"
# Colors - selected nodes override to red
if nid in selected_nodes:
color = "#ff5555" # Selected for deletion - red
elif nid == htree.head_id:
color = "#ffdd44" # Current head - bright yellow
elif nid in htree.branches.values():
color = "#66dd66" # Branch tip - bright green
else:
color = "#aaccff" # Normal - light blue
nodes.append(Node(
id=nid,
label=label,
size=20,
color=color,
font={"size": 10, "color": "#ffffff"}
))
if n["parent"] and n["parent"] in htree.nodes:
edges.append(Edge(source=n["parent"], target=nid, color="#888888"))
# Config based on direction
is_horizontal = direction == "LR"
config = Config(
width="100%",
height=400 if is_horizontal else 600,
directed=True,
hierarchical=True,
physics=False,
nodeHighlightBehavior=True,
highlightColor="#ffcc00",
collapsible=False,
layout={
"hierarchical": {
"enabled": True,
"direction": "LR" if is_horizontal else "UD",
"sortMethod": "directed",
"levelSeparation": 150 if is_horizontal else 80,
"nodeSpacing": 100 if is_horizontal else 60,
}
}
)
return agraph(nodes=nodes, edges=edges, config=config)
def _restore_node(data, node, htree, file_path):
"""Restore a history node as the current version."""
node_data = node["data"]
if KEY_BATCH_DATA not in node_data and KEY_BATCH_DATA in data:
del data[KEY_BATCH_DATA]
data.update(node_data)
htree.head_id = node['id']
data[KEY_HISTORY_TREE] = htree.to_dict()
save_json(file_path, data)
st.session_state.ui_reset_token += 1
label = f"{node.get('note')} ({node['id'][:4]})"
st.session_state.restored_indicator = label
st.toast("Restored!", icon="🔄")
st.rerun()
def _render_preview_fields(item_data, prefix):
"""Render a read-only preview of prompts, settings, and LoRAs."""
# Prompts
p_col1, p_col2 = st.columns(2)
with p_col1:
st.text_area("General Positive", value=item_data.get("general_prompt", ""), height=80, disabled=True, key=f"{prefix}_gp")
val_sp = item_data.get("current_prompt", "") or item_data.get("prompt", "")
st.text_area("Specific Positive", value=val_sp, height=80, disabled=True, key=f"{prefix}_sp")
with p_col2:
st.text_area("General Negative", value=item_data.get("general_negative", ""), height=80, disabled=True, key=f"{prefix}_gn")
st.text_area("Specific Negative", value=item_data.get("negative", ""), height=80, disabled=True, key=f"{prefix}_sn")
# Settings
s_col1, s_col2, s_col3 = st.columns(3)
s_col1.text_input("Camera", value=str(item_data.get("camera", "static")), disabled=True, key=f"{prefix}_cam")
s_col2.text_input("FLF", value=str(item_data.get("flf", "0.0")), disabled=True, key=f"{prefix}_flf")
s_col3.text_input("Seed", value=str(item_data.get("seed", "-1")), disabled=True, key=f"{prefix}_seed")
# LoRAs
with st.expander("💊 LoRA Configuration", expanded=False):
l1, l2, l3 = st.columns(3)
with l1:
st.text_input("L1 Name", value=item_data.get("lora 1 high", ""), disabled=True, key=f"{prefix}_l1h")
st.text_input("L1 Str", value=str(item_data.get("lora 1 low", "")), disabled=True, key=f"{prefix}_l1l")
with l2:
st.text_input("L2 Name", value=item_data.get("lora 2 high", ""), disabled=True, key=f"{prefix}_l2h")
st.text_input("L2 Str", value=str(item_data.get("lora 2 low", "")), disabled=True, key=f"{prefix}_l2l")
with l3:
st.text_input("L3 Name", value=item_data.get("lora 3 high", ""), disabled=True, key=f"{prefix}_l3h")
st.text_input("L3 Str", value=str(item_data.get("lora 3 low", "")), disabled=True, key=f"{prefix}_l3l")
# VACE
vace_keys = ["frame_to_skip", "vace schedule", "video file path"]
if any(k in item_data for k in vace_keys):
with st.expander("🎞️ VACE / I2V Settings", expanded=False):
v1, v2, v3 = st.columns(3)
v1.text_input("Skip Frames", value=str(item_data.get("frame_to_skip", "")), disabled=True, key=f"{prefix}_fts")
v2.text_input("Schedule", value=str(item_data.get("vace schedule", "")), disabled=True, key=f"{prefix}_vsc")
v3.text_input("Video Path", value=str(item_data.get("video file path", "")), disabled=True, key=f"{prefix}_vid")
+529 -389
View File
@@ -1,431 +1,565 @@
import asyncio
import copy import copy
import json
import logging
import time import time
from nicegui import ui from nicegui import ui
from state import AppState from state import AppState
from history_tree import HistoryTree from snapshot_timeline import SnapshotTimeline, diff_snapshots
from utils import save_json, KEY_BATCH_DATA, KEY_HISTORY_TREE from utils import save_json, load_json, sync_to_db, KEY_BATCH_DATA, KEY_HISTORY_TREE
logger = logging.getLogger(__name__)
def _delete_nodes(htree, data, file_path, node_ids): # ======================================================================
"""Delete nodes with backup, branch cleanup, and head fallback.""" # Main entry point
if 'history_tree_backup' not in data: # ======================================================================
data['history_tree_backup'] = []
data['history_tree_backup'].append(copy.deepcopy(htree.to_dict()))
for nid in node_ids:
htree.nodes.pop(nid, None)
for b, tip in list(htree.branches.items()):
if tip in node_ids:
del htree.branches[b]
if htree.head_id in node_ids:
if htree.nodes:
htree.head_id = sorted(htree.nodes.values(),
key=lambda x: x['timestamp'])[-1]['id']
else:
htree.head_id = None
data[KEY_HISTORY_TREE] = htree.to_dict()
save_json(file_path, data)
def _render_selection_picker(all_nodes, htree, state, refresh_fn):
"""Multi-select picker for batch-deleting timeline nodes."""
all_ids = [n['id'] for n in all_nodes]
def fmt_option(nid):
n = htree.nodes[nid]
ts = time.strftime('%b %d %H:%M', time.localtime(n['timestamp']))
note = n.get('note', 'Step')
head = ' (HEAD)' if nid == htree.head_id else ''
return f'{note} - {ts} ({nid[:6]}){head}'
options = {nid: fmt_option(nid) for nid in all_ids}
def on_selection_change(e):
state.timeline_selected_nodes = set(e.value) if e.value else set()
ui.select(
options,
value=list(state.timeline_selected_nodes),
multiple=True,
label='Select nodes to delete:',
on_change=on_selection_change,
).classes('w-full')
with ui.row():
def select_all():
state.timeline_selected_nodes = set(all_ids)
refresh_fn()
def deselect_all():
state.timeline_selected_nodes = set()
refresh_fn()
ui.button('Select All', on_click=select_all).props('flat dense')
ui.button('Deselect All', on_click=deselect_all).props('flat dense')
def _render_graph_or_log(mode, all_nodes, htree, selected_nodes,
selection_mode_on, toggle_select_fn, restore_fn):
"""Render graph visualization or linear log view."""
if mode in ('Horizontal', 'Vertical'):
direction = 'LR' if mode == 'Horizontal' else 'TB'
with ui.card().classes('w-full q-pa-md'):
try:
graph_dot = htree.generate_graph(direction=direction)
_render_graphviz(graph_dot)
except Exception as e:
ui.label(f'Graph Error: {e}').classes('text-negative')
elif mode == 'Linear Log':
ui.label('Chronological list of all snapshots.').classes('text-caption')
for n in all_nodes:
is_head = n['id'] == htree.head_id
is_selected = n['id'] in selected_nodes
card_style = ''
if is_selected:
card_style = 'background: rgba(239, 68, 68, 0.1) !important; border-left: 3px solid var(--negative);'
elif is_head:
card_style = 'background: var(--accent-subtle) !important; border-left: 3px solid var(--accent);'
with ui.card().classes('w-full q-mb-sm').style(card_style):
with ui.row().classes('w-full items-center'):
if selection_mode_on:
ui.checkbox(
'',
value=is_selected,
on_change=lambda e, nid=n['id']: toggle_select_fn(
nid, e.value),
)
icon = 'location_on' if is_head else 'circle'
ui.icon(icon).classes(
'text-primary' if is_head else 'text-grey')
with ui.column().classes('col'):
note = n.get('note', 'Step')
ts = time.strftime('%b %d %H:%M',
time.localtime(n['timestamp']))
label = f'{note} (Current)' if is_head else note
ui.label(label).classes('text-bold')
ui.label(
f'ID: {n["id"][:6]} - {ts}').classes('text-caption')
if not is_head and not selection_mode_on:
ui.button(
'Restore',
icon='restore',
on_click=lambda node=n: restore_fn(node),
).props('flat dense color=primary')
def _render_batch_delete(htree, data, file_path, state, refresh_fn):
"""Render batch delete controls for selected timeline nodes."""
valid = state.timeline_selected_nodes & set(htree.nodes.keys())
state.timeline_selected_nodes = valid
count = len(valid)
if count == 0:
return
ui.label(
f'{count} node{"s" if count != 1 else ""} selected for deletion.'
).classes('text-warning q-mt-md')
def do_batch_delete():
current_valid = state.timeline_selected_nodes & set(htree.nodes.keys())
_delete_nodes(htree, data, file_path, current_valid)
state.timeline_selected_nodes = set()
ui.notify(
f'Deleted {len(current_valid)} node{"s" if len(current_valid) != 1 else ""}!',
type='positive')
refresh_fn()
ui.button(
f'Delete {count} Node{"s" if count != 1 else ""}',
icon='delete',
on_click=do_batch_delete,
).props('color=negative')
def _walk_branch_nodes(htree, tip_id):
"""Walk parent pointers from tip, returning nodes newest-first."""
nodes = []
current = tip_id
while current and current in htree.nodes:
nodes.append(htree.nodes[current])
current = htree.nodes[current].get('parent')
return nodes
def _find_active_branch(htree):
"""Return branch name whose tip == head_id, or None if detached."""
if not htree.head_id:
return None
for b_name, tip_id in htree.branches.items():
if tip_id == htree.head_id:
return b_name
return None
def _render_node_manager(all_nodes, htree, data, file_path, restore_fn, refresh_fn):
"""Render branch-grouped node manager with restore, rename, delete, and preview."""
ui.label('Manage Version').classes('section-header')
# --- State that survives @ui.refreshable ---
active_branch = _find_active_branch(htree)
# Default branch: active branch, or branch whose ancestry contains HEAD
default_branch = active_branch
if not default_branch and htree.head_id:
for b_name, tip_id in htree.branches.items():
for n in _walk_branch_nodes(htree, tip_id):
if n['id'] == htree.head_id:
default_branch = b_name
break
if default_branch:
break
if not default_branch and htree.branches:
default_branch = next(iter(htree.branches))
selected = {'node_id': htree.head_id, 'branch': default_branch}
# --- (a) Branch selector ---
def fmt_branch(b_name):
count = len(_walk_branch_nodes(htree, htree.branches.get(b_name)))
suffix = ' (active)' if b_name == active_branch else ''
return f'{b_name} ({count} nodes){suffix}'
branch_options = {b: fmt_branch(b) for b in htree.branches}
def on_branch_change(e):
selected['branch'] = e.value
tip = htree.branches.get(e.value)
if tip:
selected['node_id'] = tip
render_branch_nodes.refresh()
ui.select(
branch_options,
value=selected['branch'],
label='Branch:',
on_change=on_branch_change,
).classes('w-full')
# --- (b) Node list + (c) Actions panel ---
@ui.refreshable
def render_branch_nodes():
branch_name = selected['branch']
tip_id = htree.branches.get(branch_name)
nodes = _walk_branch_nodes(htree, tip_id) if tip_id else []
if not nodes:
ui.label('No nodes on this branch.').classes('text-caption q-pa-sm')
return
with ui.scroll_area().classes('w-full').style('max-height: 350px'):
for n in nodes:
nid = n['id']
is_head = nid == htree.head_id
is_tip = nid == tip_id
is_selected = nid == selected['node_id']
card_style = ''
if is_selected:
card_style = 'border-left: 3px solid var(--primary);'
elif is_head:
card_style = 'border-left: 3px solid var(--accent);'
with ui.card().classes('w-full q-mb-xs q-pa-xs').style(card_style):
with ui.row().classes('w-full items-center no-wrap'):
icon = 'location_on' if is_head else 'circle'
icon_size = 'sm' if is_head else 'xs'
ui.icon(icon, size=icon_size).classes(
'text-primary' if is_head else 'text-grey')
with ui.column().classes('col q-ml-xs').style('min-width: 0'):
note = n.get('note', 'Step')
ts = time.strftime('%b %d %H:%M',
time.localtime(n['timestamp']))
label_text = note
lbl = ui.label(label_text).classes('text-body2 ellipsis')
if is_head:
lbl.classes('text-bold')
ui.label(f'{ts} \u2022 {nid[:6]}').classes(
'text-caption text-grey')
if is_head:
ui.badge('HEAD', color='amber').props('dense')
if is_tip and not is_head:
ui.badge('tip', color='green', outline=True).props('dense')
def select_node(node_id=nid):
selected['node_id'] = node_id
render_branch_nodes.refresh()
ui.button(icon='check_circle', on_click=select_node).props(
'flat dense round size=sm'
).tooltip('Select this node')
# --- (c) Actions panel ---
sel_id = selected['node_id']
if not sel_id or sel_id not in htree.nodes:
return
sel_node = htree.nodes[sel_id]
sel_note = sel_node.get('note', 'Step')
is_head = sel_id == htree.head_id
ui.separator().classes('q-my-sm')
ui.label(f'Selected: {sel_note} ({sel_id[:6]})').classes(
'text-caption text-bold')
with ui.row().classes('w-full items-end q-gutter-sm'):
if not is_head:
def restore_selected():
if sel_id in htree.nodes:
restore_fn(htree.nodes[sel_id])
ui.button('Restore', icon='restore',
on_click=restore_selected).props('color=primary dense')
# Rename
rename_input = ui.input('Rename Label').classes('col').props('dense')
def rename_node():
if sel_id in htree.nodes and rename_input.value:
htree.nodes[sel_id]['note'] = rename_input.value
data[KEY_HISTORY_TREE] = htree.to_dict()
save_json(file_path, data)
ui.notify('Label updated', type='positive')
refresh_fn()
ui.button('Update Label', on_click=rename_node).props('flat dense')
# Danger zone
with ui.expansion('Danger Zone', icon='warning').classes(
'w-full q-mt-sm').style('border-left: 3px solid var(--negative)'):
ui.label('Deleting a node cannot be undone.').classes('text-warning')
def delete_selected():
if sel_id in htree.nodes:
_delete_nodes(htree, data, file_path, {sel_id})
ui.notify('Node Deleted', type='positive')
refresh_fn()
ui.button('Delete This Node', icon='delete',
on_click=delete_selected).props('color=negative dense')
# Data preview
with ui.expansion('Data Preview', icon='preview').classes('w-full q-mt-sm'):
_render_data_preview(sel_id, htree)
render_branch_nodes()
def render_timeline_tab(state: AppState): def render_timeline_tab(state: AppState):
t0 = time.perf_counter()
logger.info("render_timeline_tab START")
data = state.data_cache data = state.data_cache
file_path = state.file_path file_path = state.file_path
tree_data = data.get(KEY_HISTORY_TREE, {}) tree_data = data.get(KEY_HISTORY_TREE, {})
if not tree_data: if not tree_data:
ui.label('No history timeline exists. Make some changes in the Editor first!').classes( ui.label('No version history exists. Make some changes in the Editor first!').classes(
'text-subtitle1 q-pa-md') 'text-subtitle1 q-pa-md')
return return
htree = HistoryTree(tree_data) timeline = SnapshotTimeline(tree_data)
if not timeline.snapshots:
ui.label('No snapshots found in history.').classes('text-subtitle1 q-pa-md')
return
# Local UI state
ui_state = {
'selected_id': state.timeline_selected_id or timeline.current_id,
'search': '',
'filter': 'All', # All | Pinned | Auto
}
if state.restored_indicator: if state.restored_indicator:
ui.label(f'Editing Restored Version: {state.restored_indicator}').classes( ui.label(f'Editing Restored Version: {state.restored_indicator}').classes(
'text-info q-pa-sm') 'text-info q-pa-sm')
# --- View mode + Selection toggle --- ui.label('Version History').classes('text-h6 q-mb-sm')
with ui.row().classes('w-full items-center q-gutter-md q-mb-md'):
ui.label('Version History').classes('text-h6 col') # Mutable container so left/right panels can cross-reference each other's refreshables
view_mode = ui.toggle( panels: dict = {}
['Horizontal', 'Vertical', 'Linear Log'],
value='Horizontal', # ======================================================================
) # Splitter layout: 35% left (list) / 65% right (detail)
selection_mode = ui.switch('Select to Delete') # ======================================================================
with ui.splitter(value=35).classes('w-full').style('height: calc(100vh - 200px); min-height: 600px') as splitter:
# ==============================================================
# LEFT PANEL — Snapshot list
# ==============================================================
with splitter.before:
with ui.column().classes('w-full q-pa-sm').style('height: 100%'):
# Search + filter
search_input = ui.input(
placeholder='Search notes...',
).classes('w-full').props('dense outlined clearable')
with ui.row().classes('w-full q-gutter-xs'):
filter_toggle = ui.toggle(
['All', 'Pinned', 'Auto'], value='All',
).props('dense no-caps')
@ui.refreshable @ui.refreshable
def render_timeline(): def render_snapshot_list():
all_nodes = sorted(htree.nodes.values(), key=lambda x: x['timestamp'], reverse=True) _render_snapshot_list(
selected_nodes = state.timeline_selected_nodes if selection_mode.value else set() timeline, ui_state, data, file_path, state,
render_snapshot_list, panels)
if selection_mode.value: panels['list'] = render_snapshot_list
_render_selection_picker(all_nodes, htree, state, render_timeline.refresh)
_render_graph_or_log( def _on_search(e):
view_mode.value, all_nodes, htree, selected_nodes, ui_state['search'] = search_input.value or ''
selection_mode.value, _toggle_select, _restore_and_refresh) render_snapshot_list.refresh()
if selection_mode.value and state.timeline_selected_nodes: def _on_filter(e):
_render_batch_delete(htree, data, file_path, state, render_timeline.refresh) ui_state['filter'] = e.value
render_snapshot_list.refresh()
with ui.card().classes('w-full q-pa-md q-mt-md'): search_input.on('update:model-value', _on_search)
_render_node_manager( filter_toggle.on_value_change(_on_filter)
all_nodes, htree, data, file_path,
_restore_and_refresh, render_timeline.refresh)
def _toggle_select(nid, checked): render_snapshot_list()
if checked:
state.timeline_selected_nodes.add(nid)
else:
state.timeline_selected_nodes.discard(nid)
render_timeline.refresh()
def _restore_and_refresh(node): # ==============================================================
_restore_node(data, node, htree, file_path, state) # RIGHT PANEL — Detail tabs
# Refresh all tabs (batch, raw, timeline) so they pick up the restored data # ==============================================================
state._render_main.refresh() with splitter.after:
@ui.refreshable
def render_detail_panel():
_render_detail_panel(timeline, ui_state, data, file_path, state,
panels)
view_mode.on_value_change(lambda _: render_timeline.refresh()) panels['detail'] = render_detail_panel
selection_mode.on_value_change(lambda _: render_timeline.refresh()) render_detail_panel()
render_timeline()
logger.info("render_timeline_tab END (%.3fs)", time.perf_counter() - t0)
def _render_graphviz(dot_source: str): # ======================================================================
"""Render graphviz DOT source as SVG using ui.html.""" # Left panel: snapshot list
try: # ======================================================================
import graphviz
src = graphviz.Source(dot_source)
svg = src.pipe(format='svg').decode('utf-8')
ui.html(f'<div style="overflow-x: auto;">{svg}</div>')
except ImportError:
ui.label('Install graphviz Python package for graph rendering.').classes('text-warning')
ui.code(dot_source).classes('w-full')
except Exception as e:
ui.label(f'Graph rendering error: {e}').classes('text-negative')
def _render_snapshot_list(timeline, ui_state, data, file_path, state,
refresh_list, panels):
snapshots = sorted(timeline.snapshots.values(),
key=lambda s: s['timestamp'], reverse=True)
def _restore_node(data, node, htree, file_path, state: AppState): # Apply filters
"""Restore a history node as the current version.""" search_term = ui_state.get('search', '').lower()
node_data = copy.deepcopy(node['data']) filter_mode = ui_state.get('filter', 'All')
if KEY_BATCH_DATA not in node_data and KEY_BATCH_DATA in data:
del data[KEY_BATCH_DATA]
data.update(node_data)
htree.head_id = node['id']
data[KEY_HISTORY_TREE] = htree.to_dict()
save_json(file_path, data)
label = f"{node.get('note', 'Step')} ({node['id'][:4]})"
state.restored_indicator = label
ui.notify('Restored!', type='positive')
if search_term:
snapshots = [s for s in snapshots
if search_term in s.get('note', '').lower()]
if filter_mode == 'Pinned':
snapshots = [s for s in snapshots if s.get('pinned')]
elif filter_mode == 'Auto':
snapshots = [s for s in snapshots if s.get('auto')]
def _render_data_preview(nid, htree): if not snapshots:
"""Render a read-only preview of the selected node's data.""" ui.label('No snapshots match your filter.').classes('text-caption q-pa-md')
if not nid or nid not in htree.nodes:
ui.label('No node selected.').classes('text-caption')
return return
node_data = htree.nodes[nid]['data'] with ui.scroll_area().classes('w-full').style('flex: 1; min-height: 0'):
batch_list = node_data.get(KEY_BATCH_DATA, []) for snap in snapshots:
sid = snap['id']
is_current = sid == timeline.current_id
is_selected = sid == ui_state.get('selected_id')
is_pinned = snap.get('pinned', False)
is_auto = snap.get('auto', False)
if batch_list and isinstance(batch_list, list) and len(batch_list) > 0: # Card styling
ui.label(f'This snapshot contains {len(batch_list)} sequences.').classes('text-caption') border = ''
if is_current:
border = 'border-left: 4px solid #eebb00;'
if is_selected:
border = 'border-left: 4px solid #4caf50;'
bg = 'background: rgba(76,175,80,0.08) !important;' if is_selected else ''
def select_snap(snap_id=sid):
ui_state['selected_id'] = snap_id
state.timeline_selected_id = snap_id
refresh_list.refresh()
detail = panels.get('detail')
if detail is not None:
detail.refresh()
with ui.card().classes('w-full q-mb-xs q-pa-xs cursor-pointer').style(
f'{border} {bg}').on('click', select_snap):
with ui.row().classes('w-full items-center no-wrap'):
# Icon
if is_pinned:
icon_name = 'push_pin'
icon_cls = 'text-amber'
elif is_auto:
icon_name = 'bolt'
icon_cls = 'text-grey'
else:
icon_name = 'save'
icon_cls = 'text-primary'
ui.icon(icon_name, size='sm').classes(icon_cls)
# Text
with ui.column().classes('col q-ml-xs').style('min-width: 0'):
note = snap.get('note', 'Snapshot')
lbl = ui.label(note).classes('text-body2 ellipsis')
if is_current:
lbl.classes('text-bold')
ts = time.strftime('%b %d %H:%M',
time.localtime(snap['timestamp']))
seq_count = snap.get('seq_count', '?')
ui.label(f'{ts} \u00b7 {seq_count} seqs').classes(
'text-caption text-grey')
# Badges
if is_current:
ui.badge('current', color='amber').props('dense')
# Pin toggle
async def toggle_pin(snap_id=sid):
timeline.toggle_pin(snap_id)
data[KEY_HISTORY_TREE] = timeline.to_dict()
snapshot = json.loads(json.dumps(data))
await asyncio.to_thread(save_json, file_path, snapshot)
refresh_list.refresh()
pin_icon = 'push_pin' if is_pinned else 'o_push_pin'
ui.button(icon=pin_icon, on_click=toggle_pin).props(
'flat dense round size=xs').on('click.stop', lambda: None)
# ======================================================================
# Right panel: detail tabs (Preview / Compare / Cherry-pick)
# ======================================================================
def _render_detail_panel(timeline, ui_state, data, file_path, state,
panels):
sel_id = ui_state.get('selected_id')
if not sel_id or sel_id not in timeline.snapshots:
ui.label('Select a snapshot from the list.').classes('text-caption q-pa-lg')
return
def _refresh_both():
"""Refresh both list and detail panels."""
lp = panels.get('list')
dp = panels.get('detail')
if lp:
lp.refresh()
if dp:
dp.refresh()
snap = timeline.snapshots[sel_id]
note = snap.get('note', 'Snapshot')
ts = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(snap['timestamp']))
ui.label(f'{note}').classes('text-subtitle1 text-bold')
ui.label(f'{ts} \u2022 ID: {sel_id}').classes('text-caption text-grey q-mb-sm')
# Action buttons
with ui.row().classes('q-gutter-sm q-mb-sm'):
is_current = sel_id == timeline.current_id
if not is_current:
async def restore_full():
await _restore_snapshot(data, sel_id, timeline, file_path, state)
state._render_main.refresh()
ui.button('Restore Full', icon='restore',
on_click=restore_full).props('color=primary dense')
# Rename
rename_input = ui.input(placeholder='New note...').props('dense outlined').classes('w-48')
async def rename():
if rename_input.value and sel_id in timeline.snapshots:
timeline.snapshots[sel_id]['note'] = rename_input.value
data[KEY_HISTORY_TREE] = timeline.to_dict()
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:
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, snapshot)
ui.notify('Note updated', type='positive')
_refresh_both()
ui.button('Rename', on_click=rename).props('flat dense')
# Delete
async def delete_snap():
timeline.delete(sel_id)
# Clean up DB snapshots
if state.db_enabled and state.db and state.current_project:
df = state.db.get_data_file_by_names(state.current_project, file_path.stem)
if df:
state.db.delete_node_snapshots(df['id'], {sel_id})
data[KEY_HISTORY_TREE] = timeline.to_dict()
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:
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, snapshot)
ui_state['selected_id'] = timeline.current_id
state.timeline_selected_id = timeline.current_id
ui.notify('Snapshot deleted', type='positive')
_refresh_both()
ui.button(icon='delete', on_click=delete_snap).props('flat dense color=negative')
# Sub-tabs
with ui.tabs().classes('w-full') as tabs:
preview_tab = ui.tab('Preview', icon='visibility')
compare_tab = ui.tab('Compare', icon='compare')
cherry_tab = ui.tab('Cherry-pick', icon='content_paste')
with ui.tab_panels(tabs, value=preview_tab).classes('w-full'):
with ui.tab_panel(preview_tab):
_render_preview_tab(sel_id, timeline, state, file_path)
with ui.tab_panel(compare_tab):
_render_compare_tab(sel_id, timeline, data, state, file_path)
with ui.tab_panel(cherry_tab):
_render_cherry_pick_tab(sel_id, timeline, data, file_path, state,
panels)
# ======================================================================
# Tab 1: Preview
# ======================================================================
def _render_preview_tab(sel_id, timeline, state, file_path):
snap_data = _load_snapshot_data(sel_id, timeline, state, file_path)
if not snap_data:
ui.label('Snapshot data not available.').classes('text-caption text-warning')
return
batch_list = snap_data.get(KEY_BATCH_DATA, [])
if batch_list and isinstance(batch_list, list):
ui.label(f'{len(batch_list)} sequences in this snapshot.').classes('text-caption')
for i, seq_data in enumerate(batch_list): for i, seq_data in enumerate(batch_list):
seq_num = seq_data.get('sequence_number', i + 1) seq_num = seq_data.get('sequence_number', i + 1)
with ui.expansion(f'Sequence #{seq_num}', value=(i == 0)): with ui.expansion(f'Sequence #{seq_num}', value=(i == 0)):
_render_preview_fields(seq_data) _render_preview_fields(seq_data)
else: else:
_render_preview_fields(node_data) _render_preview_fields(snap_data)
# ======================================================================
# Tab 2: Compare
# ======================================================================
def _render_compare_tab(sel_id, timeline, data, state, file_path):
snap_data = _load_snapshot_data(sel_id, timeline, state, file_path)
if not snap_data:
ui.label('Snapshot data not available.').classes('text-caption text-warning')
return
old_batch = snap_data.get(KEY_BATCH_DATA, [])
new_batch = data.get(KEY_BATCH_DATA, [])
if not old_batch and not new_batch:
ui.label('No batch data to compare.').classes('text-caption')
return
diffs = diff_snapshots(old_batch, new_batch)
show_all = ui.switch('Show unchanged', value=False)
@ui.refreshable
def render_diff():
any_diff = False
for d in diffs:
if d['status'] == 'unchanged' and not show_all.value:
continue
any_diff = True
seq_num = d['seq_num']
status = d['status']
# Header styling
if status == 'added':
icon = 'add_circle'
color = 'text-positive'
label = f'Sequence #{seq_num} \u2014 ADDED (not in snapshot)'
elif status == 'removed':
icon = 'remove_circle'
color = 'text-negative'
label = f'Sequence #{seq_num} \u2014 REMOVED (not in current)'
elif status == 'changed':
icon = 'change_circle'
color = 'text-warning'
label = f'Sequence #{seq_num} \u2014 {len(d["changes"])} field{"s" if len(d["changes"]) != 1 else ""} changed'
else:
icon = 'check_circle'
color = 'text-grey'
label = f'Sequence #{seq_num} \u2014 No changes'
with ui.expansion(label, icon=icon).classes(f'w-full {color}'):
if status == 'changed' and d['changes']:
# Table of field changes
columns = [
{'name': 'field', 'label': 'Field', 'field': 'field', 'align': 'left'},
{'name': 'old', 'label': 'Snapshot', 'field': 'old', 'align': 'left'},
{'name': 'new', 'label': 'Current', 'field': 'new', 'align': 'left'},
]
rows = []
for c in d['changes']:
rows.append({
'field': c['field'],
'old': _truncate(c['old']),
'new': _truncate(c['new']),
})
ui.table(columns=columns, rows=rows, row_key='field').classes(
'w-full').props('dense flat bordered')
elif status in ('added', 'removed'):
ui.label('Entire sequence differs.').classes('text-caption')
if not any_diff:
ui.label('All sequences are identical.').classes('text-caption q-pa-md')
show_all.on_value_change(lambda _: render_diff.refresh())
render_diff()
# ======================================================================
# Tab 3: Cherry-pick Restore
# ======================================================================
def _render_cherry_pick_tab(sel_id, timeline, data, file_path, state,
panels):
snap_data = _load_snapshot_data(sel_id, timeline, state, file_path)
if not snap_data:
ui.label('Snapshot data not available.').classes('text-caption text-warning')
return
old_batch = snap_data.get(KEY_BATCH_DATA, [])
if not old_batch:
ui.label('No sequences in this snapshot.').classes('text-caption')
return
ui.label('Select sequences and fields to restore from this snapshot.').classes(
'text-caption q-mb-sm')
mode = ui.toggle(['Whole sequences', 'Selected fields'], value='Whole sequences').props(
'dense no-caps')
# Build checkboxes per sequence
seq_checks: dict[int, ui.checkbox] = {}
field_checks: dict[int, dict[str, ui.checkbox]] = {}
for seq_item in old_batch:
seq_num = int(seq_item.get('sequence_number', 0))
seq_cb = ui.checkbox(f'Sequence #{seq_num}')
seq_checks[seq_num] = seq_cb
with ui.expansion(f'Fields for #{seq_num}').classes('w-full q-ml-lg'):
field_checks[seq_num] = {}
for k in sorted(seq_item.keys()):
if k == 'sequence_number':
continue
val_str = _truncate(seq_item.get(k))
fcb = ui.checkbox(f'{k}: {val_str}')
field_checks[seq_num][k] = fcb
async def apply_cherry_pick():
current_batch = data.get(KEY_BATCH_DATA, [])
curr_by_seq = {int(s.get('sequence_number', 0)): s for s in current_batch}
old_by_seq = {int(s.get('sequence_number', 0)): s for s in old_batch}
applied = 0
for seq_num, cb in seq_checks.items():
if not cb.value:
continue
if seq_num not in old_by_seq:
continue
if mode.value == 'Whole sequences':
# Replace or add entire sequence
restored = copy.deepcopy(old_by_seq[seq_num])
if seq_num in curr_by_seq:
# Find and replace in-place
for i, s in enumerate(current_batch):
if int(s.get('sequence_number', 0)) == seq_num:
current_batch[i] = restored
break
else:
current_batch.append(restored)
applied += 1
else:
# Selected fields only
if seq_num not in curr_by_seq:
continue
target = curr_by_seq[seq_num]
fields = field_checks.get(seq_num, {})
for field_name, fcb in fields.items():
if fcb.value and field_name in old_by_seq[seq_num]:
target[field_name] = copy.deepcopy(old_by_seq[seq_num][field_name])
applied += 1
if applied == 0:
ui.notify('Nothing selected to restore.', type='warning')
return
data[KEY_BATCH_DATA] = current_batch
# Auto-snapshot noting the cherry-pick
snap_note = timeline.snapshots.get(sel_id, {}).get('note', 'unknown')
snap_json = json.dumps({k: v for k, v in data.items()
if k != KEY_HISTORY_TREE})
snap_payload = json.loads(snap_json)
timeline.record(snap_payload, note=f'Cherry-pick from "{snap_note}"')
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()
data[KEY_HISTORY_TREE] = timeline.to_dict()
save_snap = json.loads(json.dumps(data))
await asyncio.to_thread(save_json, file_path, save_snap)
ui.notify(f'Applied {applied} item{"s" if applied != 1 else ""}!', type='positive')
for p in ('list', 'detail'):
ref = panels.get(p)
if ref:
ref.refresh()
ui.button('Apply Selected', icon='check', on_click=apply_cherry_pick).props(
'color=primary q-mt-md')
# ======================================================================
# Shared helpers
# ======================================================================
def _load_snapshot_data(snap_id, timeline, state, file_path):
"""Load snapshot data from inline, DB, or disk fallback."""
snap_data = timeline.get_snapshot_data(snap_id)
if snap_data:
return snap_data
# Try DB
if 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:
snap_data = state.db.get_node_snapshot(df['id'], snap_id)
if snap_data:
return snap_data
# Disk fallback
if file_path:
try:
raw_data, _ = load_json(file_path)
tree_on_disk = raw_data.get(KEY_HISTORY_TREE, {})
# New format
entry = tree_on_disk.get('snapshots', {}).get(snap_id)
if entry and 'data' in entry:
return entry['data']
# Old format
entry = tree_on_disk.get('nodes', {}).get(snap_id)
if entry and 'data' in entry:
return entry['data']
except Exception as e:
logger.warning("Failed to load snapshot %s from disk: %s", snap_id, e)
return None
async def _restore_snapshot(data, snap_id, timeline, file_path, state):
"""Restore a snapshot as the current version (full replace)."""
snap_data = _load_snapshot_data(snap_id, timeline, state, file_path)
if not snap_data:
ui.notify('Snapshot data not available', type='negative')
return
node_data = json.loads(json.dumps(snap_data))
# Preserve history tree
preserved_tree = data.get(KEY_HISTORY_TREE)
preserved_backup = data.get('history_tree_backup')
data.clear()
data.update(node_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
timeline.current_id = snap_id
data[KEY_HISTORY_TREE] = timeline.to_dict()
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:
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, snapshot)
note = timeline.snapshots.get(snap_id, {}).get('note', 'Snapshot')
label = f"{note} ({snap_id[:4]})"
state.restored_indicator = label
ui.notify('Restored!', type='positive')
def _render_preview_fields(item_data: dict): def _render_preview_fields(item_data: dict):
@@ -449,13 +583,13 @@ def _render_preview_fields(item_data: dict):
with ui.expansion('LoRA Configuration'): with ui.expansion('LoRA Configuration'):
with ui.row().classes('w-full q-gutter-md'): with ui.row().classes('w-full q-gutter-md'):
for lora_idx in range(1, 4): for lora_idx in range(1, 4):
with ui.column(): for tier, tier_label in [('high', 'High'), ('low', 'Low')]:
ui.input(f'L{lora_idx} Name', lora_name = item_data.get(f'lora {lora_idx} {tier}', '')
value=item_data.get(f'lora {lora_idx} high', '')).props( lora_str = item_data.get(f'lora {lora_idx} {tier} strength', 1.0)
'readonly outlined dense') ui.input(f'L{lora_idx} {tier_label}',
ui.input(f'L{lora_idx} Str', value=str(lora_name)).props('readonly outlined dense')
value=str(item_data.get(f'lora {lora_idx} low', ''))).props( ui.number(f'L{lora_idx} {tier_label} Str',
'readonly outlined dense') value=float(lora_str)).props('readonly outlined dense').style('max-width: 80px')
vace_keys = ['frame_to_skip', 'vace schedule', 'video file path'] vace_keys = ['frame_to_skip', 'vace schedule', 'video file path']
if any(k in item_data for k in vace_keys): if any(k in item_data for k in vace_keys):
@@ -467,3 +601,9 @@ def _render_preview_fields(item_data: dict):
value=str(item_data.get('vace schedule', ''))).props('readonly outlined') value=str(item_data.get('vace schedule', ''))).props('readonly outlined')
ui.input('Video Path', ui.input('Video Path',
value=str(item_data.get('video file path', ''))).props('readonly outlined') value=str(item_data.get('video file path', ''))).props('readonly outlined')
def _truncate(val, max_len=60):
"""Truncate a value for display."""
s = str(val) if val is not None else ''
return (s[:max_len] + '...') if len(s) > max_len else s
+369
View File
@@ -0,0 +1,369 @@
import json
from pathlib import Path
import pytest
from db import ProjectDB
from utils import KEY_BATCH_DATA, KEY_HISTORY_TREE
@pytest.fixture
def db(tmp_path):
"""Create a fresh ProjectDB in a temp directory."""
db_path = tmp_path / "test.db"
pdb = ProjectDB(db_path)
yield pdb
pdb.close()
# ------------------------------------------------------------------
# Projects CRUD
# ------------------------------------------------------------------
class TestProjects:
def test_create_and_get(self, db):
pid = db.create_project("proj1", "/some/path", "A test project")
assert pid > 0
proj = db.get_project("proj1")
assert proj is not None
assert proj["name"] == "proj1"
assert proj["folder_path"] == "/some/path"
assert proj["description"] == "A test project"
def test_list_projects(self, db):
db.create_project("beta", "/b")
db.create_project("alpha", "/a")
projects = db.list_projects()
assert len(projects) == 2
assert projects[0]["name"] == "alpha"
assert projects[1]["name"] == "beta"
def test_get_nonexistent(self, db):
assert db.get_project("nope") is None
def test_delete_project(self, db):
db.create_project("to_delete", "/x")
assert db.delete_project("to_delete") is True
assert db.get_project("to_delete") is None
def test_delete_nonexistent(self, db):
assert db.delete_project("nope") is False
def test_unique_name_constraint(self, db):
db.create_project("dup", "/a")
with pytest.raises(Exception):
db.create_project("dup", "/b")
# ------------------------------------------------------------------
# Data files
# ------------------------------------------------------------------
class TestDataFiles:
def test_create_and_list(self, db):
pid = db.create_project("p1", "/p1")
df_id = db.create_data_file(pid, "batch_i2v", "i2v", {"extra": "meta"})
assert df_id > 0
files = db.list_data_files(pid)
assert len(files) == 1
assert files[0]["name"] == "batch_i2v"
assert files[0]["data_type"] == "i2v"
def test_get_data_file(self, db):
pid = db.create_project("p1", "/p1")
db.create_data_file(pid, "batch_i2v", "i2v", {"key": "value"})
df = db.get_data_file(pid, "batch_i2v")
assert df is not None
assert df["top_level"] == {"key": "value"}
def test_get_data_file_by_names(self, db):
pid = db.create_project("p1", "/p1")
db.create_data_file(pid, "batch_i2v", "i2v")
df = db.get_data_file_by_names("p1", "batch_i2v")
assert df is not None
assert df["name"] == "batch_i2v"
def test_get_nonexistent_data_file(self, db):
pid = db.create_project("p1", "/p1")
assert db.get_data_file(pid, "nope") is None
def test_unique_constraint(self, db):
pid = db.create_project("p1", "/p1")
db.create_data_file(pid, "batch_i2v", "i2v")
with pytest.raises(Exception):
db.create_data_file(pid, "batch_i2v", "vace")
def test_cascade_delete(self, db):
pid = db.create_project("p1", "/p1")
df_id = db.create_data_file(pid, "batch_i2v", "i2v")
db.upsert_sequence(df_id, 1, {"prompt": "hello"})
db.save_history_tree(df_id, {"nodes": {}})
db.delete_project("p1")
assert db.get_data_file(pid, "batch_i2v") is None
# ------------------------------------------------------------------
# Sequences
# ------------------------------------------------------------------
class TestSequences:
def test_upsert_and_get(self, db):
pid = db.create_project("p1", "/p1")
df_id = db.create_data_file(pid, "batch", "generic")
db.upsert_sequence(df_id, 1, {"prompt": "hello", "seed": 42})
data = db.get_sequence(df_id, 1)
assert data == {"prompt": "hello", "seed": 42}
def test_upsert_updates_existing(self, db):
pid = db.create_project("p1", "/p1")
df_id = db.create_data_file(pid, "batch", "generic")
db.upsert_sequence(df_id, 1, {"prompt": "v1"})
db.upsert_sequence(df_id, 1, {"prompt": "v2"})
data = db.get_sequence(df_id, 1)
assert data["prompt"] == "v2"
def test_list_sequences(self, db):
pid = db.create_project("p1", "/p1")
df_id = db.create_data_file(pid, "batch", "generic")
db.upsert_sequence(df_id, 3, {"a": 1})
db.upsert_sequence(df_id, 1, {"b": 2})
db.upsert_sequence(df_id, 2, {"c": 3})
seqs = db.list_sequences(df_id)
assert seqs == [1, 2, 3]
def test_get_nonexistent_sequence(self, db):
pid = db.create_project("p1", "/p1")
df_id = db.create_data_file(pid, "batch", "generic")
assert db.get_sequence(df_id, 99) is None
def test_get_sequence_keys(self, db):
pid = db.create_project("p1", "/p1")
df_id = db.create_data_file(pid, "batch", "generic")
db.upsert_sequence(df_id, 1, {
"prompt": "hello",
"seed": 42,
"cfg": 1.5,
"flag": True,
})
keys, types = db.get_sequence_keys(df_id, 1)
assert "prompt" in keys
assert "seed" in keys
idx_prompt = keys.index("prompt")
idx_seed = keys.index("seed")
idx_cfg = keys.index("cfg")
idx_flag = keys.index("flag")
assert types[idx_prompt] == "STRING"
assert types[idx_seed] == "INT"
assert types[idx_cfg] == "FLOAT"
assert types[idx_flag] == "STRING" # bools -> STRING
def test_get_sequence_keys_nonexistent(self, db):
pid = db.create_project("p1", "/p1")
df_id = db.create_data_file(pid, "batch", "generic")
keys, types = db.get_sequence_keys(df_id, 99)
assert keys == []
assert types == []
def test_delete_sequences_for_file(self, db):
pid = db.create_project("p1", "/p1")
df_id = db.create_data_file(pid, "batch", "generic")
db.upsert_sequence(df_id, 1, {"a": 1})
db.upsert_sequence(df_id, 2, {"b": 2})
db.delete_sequences_for_file(df_id)
assert db.list_sequences(df_id) == []
def test_count_sequences(self, db):
pid = db.create_project("p1", "/p1")
df_id = db.create_data_file(pid, "batch", "generic")
assert db.count_sequences(df_id) == 0
db.upsert_sequence(df_id, 1, {"a": 1})
db.upsert_sequence(df_id, 2, {"b": 2})
db.upsert_sequence(df_id, 3, {"c": 3})
assert db.count_sequences(df_id) == 3
def test_query_total_sequences(self, db):
pid = db.create_project("p1", "/p1")
df_id = db.create_data_file(pid, "batch", "generic")
db.upsert_sequence(df_id, 1, {"a": 1})
db.upsert_sequence(df_id, 2, {"b": 2})
assert db.query_total_sequences("p1", "batch") == 2
def test_query_total_sequences_nonexistent(self, db):
assert db.query_total_sequences("nope", "nope") == 0
# ------------------------------------------------------------------
# History trees
# ------------------------------------------------------------------
class TestHistoryTrees:
def test_save_and_get(self, db):
pid = db.create_project("p1", "/p1")
df_id = db.create_data_file(pid, "batch", "generic")
tree = {"nodes": {"abc": {"id": "abc"}}, "head_id": "abc"}
db.save_history_tree(df_id, tree)
result = db.get_history_tree(df_id)
assert result == tree
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, {"snapshots": {}, "v": 1})
db.save_history_tree(df_id, {"snapshots": {}, "v": 2})
result = db.get_history_tree(df_id)
assert result == {"snapshots": {}, "v": 2}
def test_get_nonexistent(self, db):
pid = db.create_project("p1", "/p1")
df_id = db.create_data_file(pid, "batch", "generic")
assert db.get_history_tree(df_id) is None
# ------------------------------------------------------------------
# Import
# ------------------------------------------------------------------
class TestImport:
def test_import_json_file(self, db, tmp_path):
pid = db.create_project("p1", "/p1")
json_path = tmp_path / "batch_prompt_i2v.json"
data = {
KEY_BATCH_DATA: [
{"sequence_number": 1, "prompt": "hello", "seed": 42},
{"sequence_number": 2, "prompt": "world", "seed": 99},
],
KEY_HISTORY_TREE: {"nodes": {}, "head_id": None},
}
json_path.write_text(json.dumps(data))
df_id = db.import_json_file(pid, json_path, "i2v")
assert df_id > 0
seqs = db.list_sequences(df_id)
assert seqs == [1, 2]
s1 = db.get_sequence(df_id, 1)
assert s1["prompt"] == "hello"
assert s1["seed"] == 42
tree = db.get_history_tree(df_id)
assert tree == {"nodes": {}, "head_id": None}
def test_import_file_name_from_stem(self, db, tmp_path):
pid = db.create_project("p1", "/p1")
json_path = tmp_path / "my_batch.json"
json_path.write_text(json.dumps({KEY_BATCH_DATA: [{"sequence_number": 1}]}))
db.import_json_file(pid, json_path)
df = db.get_data_file(pid, "my_batch")
assert df is not None
def test_import_no_batch_data(self, db, tmp_path):
pid = db.create_project("p1", "/p1")
json_path = tmp_path / "simple.json"
json_path.write_text(json.dumps({"prompt": "flat file"}))
df_id = db.import_json_file(pid, json_path)
seqs = db.list_sequences(df_id)
assert seqs == []
def test_reimport_updates_existing(self, db, tmp_path):
"""Re-importing the same file should update data, not crash."""
pid = db.create_project("p1", "/p1")
json_path = tmp_path / "batch.json"
# First import
data_v1 = {KEY_BATCH_DATA: [{"sequence_number": 1, "prompt": "v1"}]}
json_path.write_text(json.dumps(data_v1))
df_id_1 = db.import_json_file(pid, json_path, "i2v")
# Second import (same file, updated data)
data_v2 = {KEY_BATCH_DATA: [{"sequence_number": 1, "prompt": "v2"}, {"sequence_number": 2, "prompt": "new"}]}
json_path.write_text(json.dumps(data_v2))
df_id_2 = db.import_json_file(pid, json_path, "vace")
# Should reuse the same data_file row
assert df_id_1 == df_id_2
# Data type should be updated
df = db.get_data_file(pid, "batch")
assert df["data_type"] == "vace"
# Sequences should reflect v2
seqs = db.list_sequences(df_id_2)
assert seqs == [1, 2]
s1 = db.get_sequence(df_id_2, 1)
assert s1["prompt"] == "v2"
def test_import_skips_non_dict_batch_items(self, db, tmp_path):
"""Non-dict elements in batch_data should be silently skipped, not crash."""
pid = db.create_project("p1", "/p1")
json_path = tmp_path / "mixed.json"
data = {KEY_BATCH_DATA: [
{"sequence_number": 1, "prompt": "valid"},
"not a dict",
42,
None,
{"sequence_number": 3, "prompt": "also valid"},
]}
json_path.write_text(json.dumps(data))
df_id = db.import_json_file(pid, json_path)
seqs = db.list_sequences(df_id)
assert seqs == [1, 3]
def test_import_atomic_on_error(self, db, tmp_path):
"""If import fails partway, no partial data should be committed."""
pid = db.create_project("p1", "/p1")
json_path = tmp_path / "batch.json"
data = {KEY_BATCH_DATA: [{"sequence_number": 1, "prompt": "hello"}]}
json_path.write_text(json.dumps(data))
db.import_json_file(pid, json_path)
# Now try to import with bad data that will cause an error
# (overwrite the file with invalid sequence_number that causes int() to fail)
bad_data = {KEY_BATCH_DATA: [{"sequence_number": "not_a_number", "prompt": "bad"}]}
json_path.write_text(json.dumps(bad_data))
with pytest.raises(ValueError):
db.import_json_file(pid, json_path)
# Original data should still be intact (rollback worked)
df = db.get_data_file(pid, "batch")
assert df is not None
s1 = db.get_sequence(df["id"], 1)
assert s1["prompt"] == "hello"
# ------------------------------------------------------------------
# Query helpers
# ------------------------------------------------------------------
class TestQueryHelpers:
def test_query_sequence_data(self, db):
pid = db.create_project("myproject", "/mp")
df_id = db.create_data_file(pid, "batch_i2v", "i2v")
db.upsert_sequence(df_id, 1, {"prompt": "test", "seed": 7})
result = db.query_sequence_data("myproject", "batch_i2v", 1)
assert result == {"prompt": "test", "seed": 7}
def test_query_sequence_data_not_found(self, db):
assert db.query_sequence_data("nope", "nope", 1) is None
def test_query_sequence_keys(self, db):
pid = db.create_project("myproject", "/mp")
df_id = db.create_data_file(pid, "batch_i2v", "i2v")
db.upsert_sequence(df_id, 1, {"prompt": "test", "seed": 7})
keys, types = db.query_sequence_keys("myproject", "batch_i2v", 1)
assert "prompt" in keys
assert "seed" in keys
def test_list_project_files(self, db):
pid = db.create_project("p1", "/p1")
db.create_data_file(pid, "file_a", "i2v")
db.create_data_file(pid, "file_b", "vace")
files = db.list_project_files("p1")
assert len(files) == 2
def test_list_project_sequences(self, db):
pid = db.create_project("p1", "/p1")
df_id = db.create_data_file(pid, "batch", "generic")
db.upsert_sequence(df_id, 1, {})
db.upsert_sequence(df_id, 2, {})
seqs = db.list_project_sequences("p1", "batch")
assert seqs == [1, 2]
-165
View File
@@ -1,165 +0,0 @@
import json
import os
import pytest
from json_loader import (
to_float, to_int, get_batch_item, read_json_data,
JSONLoaderDynamic, MAX_DYNAMIC_OUTPUTS,
)
class TestToFloat:
def test_valid(self):
assert to_float("3.14") == 3.14
assert to_float(5) == 5.0
def test_invalid(self):
assert to_float("abc") == 0.0
def test_none(self):
assert to_float(None) == 0.0
class TestToInt:
def test_valid(self):
assert to_int("7") == 7
assert to_int(3.9) == 3
def test_invalid(self):
assert to_int("xyz") == 0
def test_none(self):
assert to_int(None) == 0
class TestGetBatchItem:
def test_lookup_by_sequence_number_field(self):
data = {"batch_data": [
{"sequence_number": 1, "a": "first"},
{"sequence_number": 5, "a": "fifth"},
{"sequence_number": 3, "a": "third"},
]}
assert get_batch_item(data, 5) == {"sequence_number": 5, "a": "fifth"}
assert get_batch_item(data, 3) == {"sequence_number": 3, "a": "third"}
def test_fallback_to_index(self):
data = {"batch_data": [{"a": 1}, {"a": 2}, {"a": 3}]}
assert get_batch_item(data, 2) == {"a": 2}
def test_clamp_high(self):
data = {"batch_data": [{"a": 1}, {"a": 2}]}
assert get_batch_item(data, 99) == {"a": 2}
def test_clamp_low(self):
data = {"batch_data": [{"a": 1}, {"a": 2}]}
assert get_batch_item(data, 0) == {"a": 1}
def test_no_batch_data(self):
data = {"key": "val"}
assert get_batch_item(data, 1) == data
class TestReadJsonData:
def test_missing_file(self, tmp_path):
assert read_json_data(str(tmp_path / "nope.json")) == {}
def test_invalid_json(self, tmp_path):
p = tmp_path / "bad.json"
p.write_text("{broken")
assert read_json_data(str(p)) == {}
def test_non_dict_json(self, tmp_path):
p = tmp_path / "list.json"
p.write_text(json.dumps([1, 2, 3]))
assert read_json_data(str(p)) == {}
def test_valid(self, tmp_path):
p = tmp_path / "ok.json"
p.write_text(json.dumps({"key": "val"}))
assert read_json_data(str(p)) == {"key": "val"}
class TestJSONLoaderDynamic:
def _make_json(self, tmp_path, data):
p = tmp_path / "test.json"
p.write_text(json.dumps(data))
return str(p)
def test_known_keys(self, tmp_path):
path = self._make_json(tmp_path, {"name": "alice", "age": 30, "score": 9.5})
loader = JSONLoaderDynamic()
result = loader.load_dynamic(path, 1, output_keys="name,age,score")
assert result[0] == "alice"
assert result[1] == 30
assert result[2] == 9.5
def test_empty_output_keys(self, tmp_path):
path = self._make_json(tmp_path, {"name": "alice"})
loader = JSONLoaderDynamic()
result = loader.load_dynamic(path, 1, output_keys="")
assert len(result) == MAX_DYNAMIC_OUTPUTS
assert all(v == "" for v in result)
def test_pads_to_max(self, tmp_path):
path = self._make_json(tmp_path, {"a": "1", "b": "2"})
loader = JSONLoaderDynamic()
result = loader.load_dynamic(path, 1, output_keys="a,b")
assert len(result) == MAX_DYNAMIC_OUTPUTS
assert result[0] == "1"
assert result[1] == "2"
assert all(v == "" for v in result[2:])
def test_type_preservation_int(self, tmp_path):
path = self._make_json(tmp_path, {"count": 42})
loader = JSONLoaderDynamic()
result = loader.load_dynamic(path, 1, output_keys="count")
assert result[0] == 42
assert isinstance(result[0], int)
def test_type_preservation_float(self, tmp_path):
path = self._make_json(tmp_path, {"rate": 3.14})
loader = JSONLoaderDynamic()
result = loader.load_dynamic(path, 1, output_keys="rate")
assert result[0] == 3.14
assert isinstance(result[0], float)
def test_type_preservation_str(self, tmp_path):
path = self._make_json(tmp_path, {"label": "hello"})
loader = JSONLoaderDynamic()
result = loader.load_dynamic(path, 1, output_keys="label")
assert result[0] == "hello"
assert isinstance(result[0], str)
def test_bool_becomes_string(self, tmp_path):
path = self._make_json(tmp_path, {"flag": True, "off": False})
loader = JSONLoaderDynamic()
result = loader.load_dynamic(path, 1, output_keys="flag,off")
assert result[0] == "true"
assert result[1] == "false"
assert isinstance(result[0], str)
def test_missing_key_returns_empty_string(self, tmp_path):
path = self._make_json(tmp_path, {"a": "1"})
loader = JSONLoaderDynamic()
result = loader.load_dynamic(path, 1, output_keys="a,nonexistent")
assert result[0] == "1"
assert result[1] == ""
def test_missing_file_returns_all_empty(self, tmp_path):
loader = JSONLoaderDynamic()
result = loader.load_dynamic(str(tmp_path / "nope.json"), 1, output_keys="a,b")
assert len(result) == MAX_DYNAMIC_OUTPUTS
assert result[0] == ""
assert result[1] == ""
def test_batch_data(self, tmp_path):
path = self._make_json(tmp_path, {
"batch_data": [
{"sequence_number": 1, "x": "first"},
{"sequence_number": 2, "x": "second"},
]
})
loader = JSONLoaderDynamic()
result = loader.load_dynamic(path, 2, output_keys="x")
assert result[0] == "second"
+354
View File
@@ -0,0 +1,354 @@
import json
from unittest.mock import patch, MagicMock
from io import BytesIO
import pytest
from project_loader import (
ProjectLoaderDynamic,
_fetch_json,
_fetch_data,
_fetch_keys,
MAX_DYNAMIC_OUTPUTS,
)
def _mock_urlopen(data: dict):
"""Create a mock context manager for urllib.request.urlopen."""
response = MagicMock()
response.read.return_value = json.dumps(data).encode()
response.__enter__ = lambda s: s
response.__exit__ = MagicMock(return_value=False)
return response
class TestFetchHelpers:
def test_fetch_json_success(self):
data = {"key": "value"}
with patch("project_loader.urllib.request.urlopen", return_value=_mock_urlopen(data)):
result = _fetch_json("http://example.com/api")
assert result == data
def test_fetch_json_network_error(self):
with patch("project_loader.urllib.request.urlopen", side_effect=OSError("connection refused")):
result = _fetch_json("http://example.com/api")
assert result["error"] == "network_error"
assert "connection refused" in result["message"]
def test_fetch_json_http_error(self):
import urllib.error
err = urllib.error.HTTPError(
"http://example.com/api", 404, "Not Found", {},
BytesIO(json.dumps({"detail": "Project 'x' not found"}).encode())
)
with patch("project_loader.urllib.request.urlopen", side_effect=err):
result = _fetch_json("http://example.com/api")
assert result["error"] == "http_error"
assert result["status"] == 404
assert "not found" in result["message"].lower()
def test_fetch_data_builds_url(self):
data = {"prompt": "hello"}
with patch("project_loader.urllib.request.urlopen", return_value=_mock_urlopen(data)) as mock:
result = _fetch_data("http://localhost:8080", "proj1", "batch_i2v", 1)
assert result == data
called_url = mock.call_args[0][0]
assert "/api/projects/proj1/files/batch_i2v/data?seq=1" in called_url
def test_fetch_keys_builds_url(self):
data = {"keys": ["prompt"], "types": ["STRING"]}
with patch("project_loader.urllib.request.urlopen", return_value=_mock_urlopen(data)) as mock:
result = _fetch_keys("http://localhost:8080", "proj1", "batch_i2v", 1)
assert result == data
called_url = mock.call_args[0][0]
assert "/api/projects/proj1/files/batch_i2v/keys?seq=1" in called_url
def test_fetch_data_strips_trailing_slash(self):
data = {"prompt": "hello"}
with patch("project_loader.urllib.request.urlopen", return_value=_mock_urlopen(data)) as mock:
_fetch_data("http://localhost:8080/", "proj1", "file1", 1)
called_url = mock.call_args[0][0]
assert "//api" not in called_url
def test_fetch_data_encodes_special_chars(self):
"""Project/file names with spaces or special chars should be percent-encoded."""
data = {"prompt": "hello"}
with patch("project_loader.urllib.request.urlopen", return_value=_mock_urlopen(data)) as mock:
_fetch_data("http://localhost:8080", "my project", "batch file", 1)
called_url = mock.call_args[0][0]
assert "my%20project" in called_url
assert "batch%20file" in called_url
assert " " not in called_url.split("?")[0] # no raw spaces in path
class TestProjectLoaderDynamic:
def _keys_meta(self, total=5):
return {"keys": [], "types": [], "total_sequences": total}
def test_load_dynamic_with_keys(self):
data = {"prompt": "hello", "seed": 42, "cfg": 1.5}
node = ProjectLoaderDynamic()
with patch("project_loader._fetch_keys", return_value=self._keys_meta()):
with patch("project_loader._fetch_data", return_value=data):
result = node.load_dynamic(
"http://localhost:8080", "proj1", "batch_i2v", 1,
output_keys="prompt,seed,cfg"
)
assert result[0] == 5 # total_sequences
assert result[1] == "hello"
assert result[2] == 42
assert result[3] == 1.5
assert len(result) == MAX_DYNAMIC_OUTPUTS + 1
def test_load_dynamic_with_json_encoded_keys(self):
"""JSON-encoded output_keys should be parsed correctly."""
import json as _json
data = {"my,key": "comma_val", "normal": "ok"}
node = ProjectLoaderDynamic()
keys_json = _json.dumps(["my,key", "normal"])
with patch("project_loader._fetch_keys", return_value=self._keys_meta()):
with patch("project_loader._fetch_data", return_value=data):
result = node.load_dynamic(
"http://localhost:8080", "proj1", "batch_i2v", 1,
output_keys=keys_json
)
assert result[1] == "comma_val"
assert result[2] == "ok"
def test_load_dynamic_type_coercion(self):
"""output_types should coerce values to declared types."""
import json as _json
data = {"seed": "42", "cfg": "1.5", "prompt": "hello"}
node = ProjectLoaderDynamic()
keys_json = _json.dumps(["seed", "cfg", "prompt"])
types_json = _json.dumps(["INT", "FLOAT", "STRING"])
with patch("project_loader._fetch_keys", return_value=self._keys_meta()):
with patch("project_loader._fetch_data", return_value=data):
result = node.load_dynamic(
"http://localhost:8080", "proj1", "batch_i2v", 1,
output_keys=keys_json, output_types=types_json
)
assert result[1] == 42 # string "42" coerced to int
assert result[2] == 1.5 # string "1.5" coerced to float
assert result[3] == "hello" # string stays string
def test_load_dynamic_empty_keys(self):
node = ProjectLoaderDynamic()
with patch("project_loader._fetch_keys", return_value=self._keys_meta()):
with patch("project_loader._fetch_data", return_value={"prompt": "hello"}):
result = node.load_dynamic(
"http://localhost:8080", "proj1", "batch_i2v", 1,
output_keys=""
)
# Slot 0 is total_sequences (INT), rest are empty strings
assert result[0] == 5
assert all(v == "" for v in result[1:])
def test_load_dynamic_missing_key(self):
node = ProjectLoaderDynamic()
with patch("project_loader._fetch_keys", return_value=self._keys_meta()):
with patch("project_loader._fetch_data", return_value={"prompt": "hello"}):
result = node.load_dynamic(
"http://localhost:8080", "proj1", "batch_i2v", 1,
output_keys="nonexistent"
)
assert result[1] == ""
def test_load_dynamic_bool_becomes_string(self):
node = ProjectLoaderDynamic()
with patch("project_loader._fetch_keys", return_value=self._keys_meta()):
with patch("project_loader._fetch_data", return_value={"flag": True}):
result = node.load_dynamic(
"http://localhost:8080", "proj1", "batch_i2v", 1,
output_keys="flag"
)
assert result[1] == "true"
def test_load_dynamic_returns_total_sequences(self):
"""total_sequences should be the first output from keys metadata."""
node = ProjectLoaderDynamic()
with patch("project_loader._fetch_keys", return_value={"keys": [], "types": [], "total_sequences": 42}):
with patch("project_loader._fetch_data", return_value={}):
result = node.load_dynamic(
"http://localhost:8080", "proj1", "batch_i2v", 1,
output_keys=""
)
assert result[0] == 42
def test_load_dynamic_raises_on_network_error(self):
"""Network errors from _fetch_keys should raise RuntimeError."""
node = ProjectLoaderDynamic()
error_resp = {"error": "network_error", "message": "Connection refused"}
with patch("project_loader._fetch_keys", return_value=error_resp):
with pytest.raises(RuntimeError, match="Failed to fetch project keys"):
node.load_dynamic("http://localhost:8080", "proj1", "batch", 1)
def test_load_dynamic_raises_on_data_fetch_error(self):
"""Network errors from _fetch_data should raise RuntimeError."""
node = ProjectLoaderDynamic()
error_resp = {"error": "http_error", "status": 404, "message": "Sequence not found"}
with patch("project_loader._fetch_keys", return_value=self._keys_meta()):
with patch("project_loader._fetch_data", return_value=error_resp):
with pytest.raises(RuntimeError, match="Failed to fetch sequence data"):
node.load_dynamic("http://localhost:8080", "proj1", "batch", 1)
def test_input_types_has_manager_url(self):
inputs = ProjectLoaderDynamic.INPUT_TYPES()
assert "manager_url" in inputs["required"]
assert "project_name" in inputs["required"]
assert "file_name" in inputs["required"]
assert "sequence_number" in inputs["required"]
def test_category(self):
assert ProjectLoaderDynamic.CATEGORY == "utils/json/project"
class TestProjectSource:
def test_input_types(self):
from project_loader import ProjectSource
inputs = ProjectSource.INPUT_TYPES()
assert "manager_url" in inputs["required"]
assert "project_name" in inputs["required"]
assert "file_name" in inputs["required"]
assert "sequence_number" in inputs["required"]
assert "label" in inputs["required"]
def test_outputs_sequence_number(self):
from project_loader import ProjectSource
assert ProjectSource.RETURN_TYPES == ("INT",)
assert ProjectSource.RETURN_NAMES == ("sequence_number",)
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=42,
label="my_source"
)
assert result == (42,)
def test_category(self):
from project_loader import ProjectSource
assert ProjectSource.CATEGORY == "utils/json/project"
class TestProjectKey:
def test_input_types(self):
from project_loader import ProjectKey
inputs = ProjectKey.INPUT_TYPES()
assert "source_label" in inputs["required"]
assert "key_name" in inputs["required"]
assert "key_type" in inputs["required"]
def test_single_output(self):
from project_loader import ProjectKey
assert len(ProjectKey.RETURN_TYPES) == 1
assert len(ProjectKey.RETURN_NAMES) == 1
def test_fetch_key_string(self):
from project_loader import ProjectKey
node = ProjectKey()
data = {"prompt": "hello", "seed": 42}
with patch("project_loader._fetch_data", return_value=data):
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 == ("hello",)
def test_fetch_key_int_coercion(self):
from project_loader import ProjectKey
node = ProjectKey()
data = {"seed": "42"}
with patch("project_loader._fetch_data", return_value=data):
result = node.fetch_key(
source_label="my_source",
key_name="seed",
key_type="INT",
manager_url="http://localhost:8080",
project_name="proj1",
file_name="batch_i2v",
sequence_number=1,
)
assert result == (42,)
def test_fetch_key_float_coercion(self):
from project_loader import ProjectKey
node = ProjectKey()
data = {"cfg": "1.5"}
with patch("project_loader._fetch_data", return_value=data):
result = node.fetch_key(
source_label="my_source",
key_name="cfg",
key_type="FLOAT",
manager_url="http://localhost:8080",
project_name="proj1",
file_name="batch_i2v",
sequence_number=1,
)
assert result == (1.5,)
def test_fetch_key_missing_key(self):
from project_loader import ProjectKey
node = ProjectKey()
with patch("project_loader._fetch_data", return_value={}):
result = node.fetch_key(
source_label="my_source",
key_name="nonexistent",
key_type="STRING",
manager_url="http://localhost:8080",
project_name="proj1",
file_name="batch_i2v",
sequence_number=1,
)
assert result == ("",)
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):
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
assert ProjectKey.CATEGORY == "utils/json/project"
class TestNodeMappings:
def test_mappings_exist(self):
from project_loader import PROJECT_NODE_CLASS_MAPPINGS, PROJECT_NODE_DISPLAY_NAME_MAPPINGS
assert "ProjectLoaderDynamic" in PROJECT_NODE_CLASS_MAPPINGS
assert "ProjectSource" in PROJECT_NODE_CLASS_MAPPINGS
assert "ProjectKey" in PROJECT_NODE_CLASS_MAPPINGS
assert len(PROJECT_NODE_CLASS_MAPPINGS) == 3
assert len(PROJECT_NODE_DISPLAY_NAME_MAPPINGS) == 3
+159
View File
@@ -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([], []) == []
+220 -12
View File
@@ -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,
@@ -47,10 +49,19 @@ DEFAULTS = {
"reference path": "", "reference path": "",
"flf image path": "", "flf image path": "",
# --- LoRAs --- # --- LoRAs (name as STRING, strength as FLOAT) ---
"lora 1 high": "", "lora 1 low": "", "lora 1 high": "",
"lora 2 high": "", "lora 2 low": "", "lora 1 high strength": 1.0,
"lora 3 high": "", "lora 3 low": "" "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 high strength": 1.0,
"lora 3 low": "",
"lora 3 low strength": 1.0
} }
CONFIG_FILE = Path(".editor_config.json") CONFIG_FILE = Path(".editor_config.json")
@@ -112,14 +123,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,27 +145,96 @@ 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 _migrate_lora_keys(data: dict) -> None:
"""Split combined lora 'name:strength' into separate name and strength keys.
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):
continue
for idx in range(1, 4):
for tier in ('high', 'low'):
name_key = f'lora {idx} {tier}'
str_key = f'lora {idx} {tier} strength'
raw = str(item.get(name_key, ''))
if raw.startswith('<lora:'):
# Legacy <lora:Name:0.5> format
inner = raw.replace('<lora:', '').replace('>', '')
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[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]: def load_json(path: str | Path) -> tuple[dict[str, Any], float]:
t0 = time.time()
path = Path(path) path = Path(path)
if not path.exists(): if not path.exists():
return DEFAULTS.copy(), 0 return DEFAULTS.copy(), 0
try: try:
with open(path, 'r') as f: with open(path, 'r') as f:
data = json.load(f) data = json.load(f)
return data, path.stat().st_mtime t1 = time.time()
_migrate_lora_keys(data)
t2 = time.time()
mtime = path.stat().st_mtime
logger.info("load_json %s: read=%.3fs migrate=%.3fs total=%.3fs",
path.name, t1 - t0, t2 - t1, t2 - t0)
return data, mtime
except Exception as e: except Exception as e:
logger.error(f"Error loading JSON: {e}") logger.error(f"Error loading JSON: {e}")
return DEFAULTS.copy(), 0 return DEFAULTS.copy(), 0
def save_json(path: str | Path, data: dict[str, Any]) -> None: def save_json(path: str | Path, data: dict[str, Any]) -> None:
t0 = time.time()
path = Path(path) path = Path(path)
tmp = path.with_suffix('.json.tmp') tmp = path.with_suffix('.json.tmp')
with open(tmp, 'w') as f: with open(tmp, 'w') as f:
json.dump(data, f, indent=4) json.dump(data, f, indent=4)
os.replace(tmp, path) os.replace(tmp, path)
logger.info("save_json %s: %.3fs", path.name, time.time() - t0)
def snapshot_data(data: dict[str, Any]) -> dict[str, Any]:
"""Create a thread-safe deep copy via JSON roundtrip.
Must be called on the main thread before passing data to asyncio.to_thread,
to avoid 'dict changed size during iteration' when the UI mutates data.
"""
return json.loads(json.dumps(data))
def get_file_mtime(path: str | Path) -> float: def get_file_mtime(path: str | Path) -> float:
"""Returns the modification time of a file, or 0 if it doesn't exist.""" """Returns the modification time of a file, or 0 if it doesn't exist."""
@@ -160,12 +243,137 @@ def get_file_mtime(path: str | Path) -> float:
return path.stat().st_mtime return path.stat().st_mtime
return 0 return 0
def sync_to_db(db, project_name: str, file_path: Path, data: dict) -> None:
"""Dual-write helper: sync JSON data to the project database.
Resolves (or creates) the data_file, upserts all sequences from batch_data,
and saves the history_tree. All writes happen in a single transaction.
"""
t0 = time.time()
if not db or not project_name:
return
try:
proj = db.get_project(project_name)
if not proj:
return
file_name = Path(file_path).stem
# Use a single transaction for atomicity
db.conn.execute("BEGIN IMMEDIATE")
try:
now = time.time()
df = db.get_data_file(proj["id"], file_name)
top_level = {k: v for k, v in data.items()
if k not in (KEY_BATCH_DATA, KEY_HISTORY_TREE)}
if not df:
cur = db.conn.execute(
"INSERT INTO data_files (project_id, name, data_type, top_level, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(proj["id"], file_name, "generic", json.dumps(top_level), now, now),
)
df_id = cur.lastrowid
else:
df_id = df["id"]
# Update top_level metadata
db.conn.execute(
"UPDATE data_files SET top_level = ?, updated_at = ? WHERE id = ?",
(json.dumps(top_level), now, df_id),
)
# Sync sequences
batch_data = data.get(KEY_BATCH_DATA, [])
if isinstance(batch_data, list):
new_seq_nums = set()
for item in batch_data:
if not isinstance(item, dict):
continue
seq_num = int(item.get(KEY_SEQUENCE_NUMBER, 0))
new_seq_nums.add(seq_num)
db.conn.execute(
"INSERT INTO sequences (data_file_id, sequence_number, data, updated_at) "
"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),
)
# Remove sequences that no longer exist
if new_seq_nums:
placeholders = ','.join('?' * len(new_seq_nums))
db.conn.execute(
f"DELETE FROM sequences WHERE data_file_id = ? AND sequence_number NOT IN ({placeholders})",
(df_id, *new_seq_nums),
)
else:
db.conn.execute("DELETE FROM sequences WHERE data_file_id = ?", (df_id,))
# 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):
# 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_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, eid, json.dumps(snap), now),
)
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
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_ids),
)
else:
db.conn.execute(
"DELETE FROM history_snapshots WHERE data_file_id = ?",
(df_id,),
)
db.conn.execute("COMMIT")
except Exception:
try:
db.conn.execute("ROLLBACK")
except Exception:
pass
raise
except Exception as e:
logger.warning(f"sync_to_db failed: {e}")
return
batch_count = len(data.get(KEY_BATCH_DATA, []))
logger.info("sync_to_db %s (%d seqs): %.3fs",
Path(file_path).name, batch_count, time.time() - t0)
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]})
-140
View File
@@ -1,140 +0,0 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
app.registerExtension({
name: "json.manager.dynamic",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== "JSONLoaderDynamic") return;
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
origOnNodeCreated?.apply(this, arguments);
// Hide internal widgets (managed by JS)
for (const name of ["output_keys", "output_types"]) {
const w = this.widgets?.find(w => w.name === name);
if (w) { w.type = "hidden"; w.computeSize = () => [0, -4]; }
}
// Remove all 32 default outputs from Python RETURN_TYPES
while (this.outputs.length > 0) {
this.removeOutput(0);
}
// Add Refresh button
this.addWidget("button", "Refresh Outputs", null, () => {
this.refreshDynamicOutputs();
});
this.setSize(this.computeSize());
};
nodeType.prototype.refreshDynamicOutputs = async function () {
const pathWidget = this.widgets?.find(w => w.name === "json_path");
const seqWidget = this.widgets?.find(w => w.name === "sequence_number");
if (!pathWidget?.value) return;
try {
const resp = await api.fetchApi(
`/json_manager/get_keys?path=${encodeURIComponent(pathWidget.value)}&sequence_number=${seqWidget?.value || 1}`
);
const { keys, types } = await resp.json();
// Store keys and types in hidden widgets for persistence
const okWidget = this.widgets?.find(w => w.name === "output_keys");
if (okWidget) okWidget.value = keys.join(",");
const otWidget = this.widgets?.find(w => w.name === "output_types");
if (otWidget) otWidget.value = types.join(",");
// Build a map of current output names to slot indices
const oldSlots = {};
for (let i = 0; i < this.outputs.length; i++) {
oldSlots[this.outputs[i].name] = i;
}
// Build new outputs, reusing existing slots to preserve links
const newOutputs = [];
for (let k = 0; k < keys.length; k++) {
const key = keys[k];
const type = types[k] || "*";
if (key in oldSlots) {
// Reuse existing slot object (keeps links intact)
const slot = this.outputs[oldSlots[key]];
slot.type = type;
newOutputs.push(slot);
delete oldSlots[key];
} else {
// New key — create a fresh slot
newOutputs.push({ name: key, type: type, links: null });
}
}
// Disconnect links on slots that are being removed
for (const name in oldSlots) {
const idx = oldSlots[name];
if (this.outputs[idx]?.links?.length) {
for (const linkId of [...this.outputs[idx].links]) {
this.graph?.removeLink(linkId);
}
}
}
// Reassign the outputs array and fix link slot indices
this.outputs = newOutputs;
// Update link origin_slot to match new positions
if (this.graph) {
for (let i = 0; i < this.outputs.length; i++) {
const links = this.outputs[i].links;
if (!links) continue;
for (const linkId of links) {
const link = this.graph.links[linkId];
if (link) link.origin_slot = i;
}
}
}
this.setSize(this.computeSize());
app.graph.setDirtyCanvas(true, true);
} catch (e) {
console.error("[JSONLoaderDynamic] Refresh failed:", e);
}
};
// Restore state on workflow load
const origOnConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (info) {
origOnConfigure?.apply(this, arguments);
// Hide internal widgets
for (const name of ["output_keys", "output_types"]) {
const w = this.widgets?.find(w => w.name === name);
if (w) { w.type = "hidden"; w.computeSize = () => [0, -4]; }
}
const okWidget = this.widgets?.find(w => w.name === "output_keys");
const otWidget = this.widgets?.find(w => w.name === "output_types");
const keys = okWidget?.value
? okWidget.value.split(",").filter(k => k.trim())
: [];
const types = otWidget?.value
? otWidget.value.split(",")
: [];
// On load, LiteGraph already restored serialized outputs with links.
// Rename and set types to match stored state (preserves links).
for (let i = 0; i < this.outputs.length && i < keys.length; i++) {
this.outputs[i].name = keys[i].trim();
if (types[i]) this.outputs[i].type = types[i];
}
// Remove any extra outputs beyond the key count
while (this.outputs.length > keys.length) {
this.removeOutput(this.outputs.length - 1);
}
this.setSize(this.computeSize());
};
},
});
+261
View File
@@ -0,0 +1,261 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
app.registerExtension({
name: "json.manager.project.dynamic",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== "ProjectLoaderDynamic") return;
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
origOnNodeCreated?.apply(this, arguments);
// Hide internal widgets (managed by JS)
for (const name of ["output_keys", "output_types"]) {
const w = this.widgets?.find(w => w.name === name);
if (w) { w.type = "hidden"; w.computeSize = () => [0, -4]; }
}
// Do NOT remove default outputs synchronously here.
// During graph loading, ComfyUI creates all nodes (firing onNodeCreated)
// before configuring them. Other nodes (e.g. Kijai Set/Get) may resolve
// links to our outputs during their configure step. If we remove outputs
// here, those nodes find no output slot and error out.
//
// Instead, defer cleanup: for loaded workflows onConfigure sets _configured
// before this runs; for new nodes the defaults are cleaned up.
this._configured = false;
// Add Refresh button
this.addWidget("button", "Refresh Outputs", null, () => {
this.refreshDynamicOutputs();
});
// Auto-refresh with 500ms debounce on widget changes
this._refreshTimer = null;
const autoRefreshWidgets = ["project_name", "file_name", "sequence_number", "refresh"];
for (const widgetName of autoRefreshWidgets) {
const w = this.widgets?.find(w => w.name === widgetName);
if (w) {
const origCallback = w.callback;
const node = this;
w.callback = function (...args) {
origCallback?.apply(this, args);
clearTimeout(node._refreshTimer);
node._refreshTimer = setTimeout(() => {
node.refreshDynamicOutputs();
}, 500);
};
}
}
queueMicrotask(() => {
if (!this._configured) {
// New node (not loading) — remove the Python default outputs
// and add only the fixed total_sequences slot
while (this.outputs.length > 0) {
this.removeOutput(0);
}
this.addOutput("total_sequences", "INT");
this.setSize(this.computeSize());
app.graph?.setDirtyCanvas(true, true);
}
});
};
nodeType.prototype._setStatus = function (status, message) {
const baseTitle = "Project Loader (Dynamic)";
if (status === "ok") {
this.title = baseTitle;
this.color = undefined;
this.bgcolor = undefined;
} else if (status === "error") {
this.title = baseTitle + " - ERROR";
this.color = "#ff4444";
this.bgcolor = "#331111";
if (message) this.title = baseTitle + ": " + message;
} else if (status === "loading") {
this.title = baseTitle + " - Loading...";
}
app.graph?.setDirtyCanvas(true, true);
};
nodeType.prototype.refreshDynamicOutputs = async function () {
const urlWidget = this.widgets?.find(w => w.name === "manager_url");
const projectWidget = this.widgets?.find(w => w.name === "project_name");
const fileWidget = this.widgets?.find(w => w.name === "file_name");
const seqWidget = this.widgets?.find(w => w.name === "sequence_number");
if (!urlWidget?.value || !projectWidget?.value || !fileWidget?.value) return;
this._setStatus("loading");
try {
const resp = await api.fetchApi(
`/json_manager/get_project_keys?url=${encodeURIComponent(urlWidget.value)}&project=${encodeURIComponent(projectWidget.value)}&file=${encodeURIComponent(fileWidget.value)}&seq=${seqWidget?.value || 1}`
);
if (!resp.ok) {
let errorMsg = `HTTP ${resp.status}`;
try {
const errData = await resp.json();
if (errData.message) errorMsg = errData.message;
} catch (_) {}
this._setStatus("error", errorMsg);
return;
}
const data = await resp.json();
const keys = data.keys;
const types = data.types;
// If the API returned an error or missing data, keep existing outputs and links intact
if (data.error || !Array.isArray(keys) || !Array.isArray(types)) {
const errMsg = data.error ? data.message || data.error : "Missing keys/types";
this._setStatus("error", errMsg);
return;
}
// Store keys and types in hidden widgets for persistence (JSON)
const okWidget = this.widgets?.find(w => w.name === "output_keys");
if (okWidget) okWidget.value = JSON.stringify(keys);
const otWidget = this.widgets?.find(w => w.name === "output_types");
if (otWidget) otWidget.value = JSON.stringify(types);
// Slot 0 is always total_sequences (INT) — ensure it exists
if (this.outputs.length === 0 || this.outputs[0].name !== "total_sequences") {
this.outputs.unshift({ name: "total_sequences", type: "INT", links: null });
}
this.outputs[0].type = "INT";
// Build a map of current dynamic output names to slot indices (skip slot 0)
const oldSlots = {};
for (let i = 1; i < this.outputs.length; i++) {
oldSlots[this.outputs[i].name] = i;
}
// Build new dynamic outputs, reusing existing slots to preserve links
const newOutputs = [this.outputs[0]]; // Keep total_sequences at slot 0
for (let k = 0; k < keys.length; k++) {
const key = keys[k];
const type = types[k] || "*";
if (key in oldSlots) {
const slot = this.outputs[oldSlots[key]];
slot.type = type;
slot.label = key;
newOutputs.push(slot);
delete oldSlots[key];
} else {
newOutputs.push({ name: key, label: key, type: type, links: null });
}
}
// Disconnect links on slots that are being removed
for (const name in oldSlots) {
const idx = oldSlots[name];
if (this.outputs[idx]?.links?.length) {
for (const linkId of [...this.outputs[idx].links]) {
this.graph?.removeLink(linkId);
}
}
}
// Reassign the outputs array and fix link slot indices
this.outputs = newOutputs;
if (this.graph) {
for (let i = 0; i < this.outputs.length; i++) {
const links = this.outputs[i].links;
if (!links) continue;
for (const linkId of links) {
const link = this.graph.links[linkId];
if (link) link.origin_slot = i;
}
}
}
this._setStatus("ok");
this.setSize(this.computeSize());
app.graph?.setDirtyCanvas(true, true);
} catch (e) {
console.error("[ProjectLoaderDynamic] Refresh failed:", e);
this._setStatus("error", "Server unreachable");
}
};
// Restore state on workflow load
const origOnConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (info) {
origOnConfigure?.apply(this, arguments);
this._configured = true;
// Hide internal widgets
for (const name of ["output_keys", "output_types"]) {
const w = this.widgets?.find(w => w.name === name);
if (w) { w.type = "hidden"; w.computeSize = () => [0, -4]; }
}
const okWidget = this.widgets?.find(w => w.name === "output_keys");
const otWidget = this.widgets?.find(w => w.name === "output_types");
let keys = [];
let types = [];
if (okWidget?.value) {
try { keys = JSON.parse(okWidget.value); } catch (_) {
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)
if (this.outputs.length === 0 || this.outputs[0].name !== "total_sequences") {
this.outputs.unshift({ name: "total_sequences", type: "INT", links: null });
const node = this;
queueMicrotask(() => {
if (!node.graph) return;
for (const output of node.outputs) {
output.links = null;
}
for (const linkId in node.graph.links) {
const link = node.graph.links[linkId];
if (!link || link.origin_id !== node.id) continue;
link.origin_slot += 1;
const output = node.outputs[link.origin_slot];
if (output) {
if (!output.links) output.links = [];
output.links.push(link.id);
}
}
app.graph?.setDirtyCanvas(true, true);
});
}
this.outputs[0].type = "INT";
this.outputs[0].name = "total_sequences";
if (keys.length > 0) {
for (let i = 0; i < keys.length; i++) {
const slotIdx = i + 1;
if (slotIdx < this.outputs.length) {
this.outputs[slotIdx].name = keys[i].trim();
this.outputs[slotIdx].label = keys[i].trim();
if (types[i]) this.outputs[slotIdx].type = types[i];
}
}
while (this.outputs.length > keys.length + 1) {
this.removeOutput(this.outputs.length - 1);
}
} else if (this.outputs.length > 1) {
// Widget values empty but serialized dynamic outputs exist — sync widgets
const dynamicOutputs = this.outputs.slice(1);
if (okWidget) okWidget.value = JSON.stringify(dynamicOutputs.map(o => o.name));
if (otWidget) otWidget.value = JSON.stringify(dynamicOutputs.map(o => o.type));
}
this.setSize(this.computeSize());
};
},
});
+275
View File
@@ -0,0 +1,275 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
app.registerExtension({
name: "json.manager.project.key",
// Re-sync all ProjectKey nodes from their sources before queueing
// This fixes stale config when the user edits a ProjectSource after
// a ProjectKey already selected it.
async beforeQueuePrompt() {
if (!app.graph?._nodes) return;
for (const node of app.graph._nodes) {
if (node.type === "ProjectKey" && node._syncFromSource) {
node._syncFromSource();
}
}
},
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);
this._configured = false;
// 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) hideWidget(w);
}
// 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] || "";
// 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) {
// New node — set output to a generic slot
if (this.outputs.length === 0) {
this.addOutput("value", "*");
}
this.setSize(this.computeSize());
}
});
};
// --- Find all ProjectSource nodes and their labels (deduplicated) ---
nodeType.prototype._getSourceLabels = function () {
const seen = new Set();
const labels = [];
if (!this.graph) return labels;
for (const node of this.graph._nodes) {
if (node.type === "ProjectSource") {
const lw = node.widgets?.find(w => w.name === "label");
if (lw?.value && !seen.has(lw.value)) {
seen.add(lw.value);
labels.push(lw.value);
} else if (lw?.value && seen.has(lw.value)) {
console.warn(`[ProjectKey] Duplicate source label "${lw.value}" (node ${node.id}) — only first will be used`);
}
}
}
return labels;
};
// --- Find the ProjectSource node matching a label ---
nodeType.prototype._findSource = function (label) {
if (!this.graph || !label) return null;
for (const node of this.graph._nodes) {
if (node.type === "ProjectSource") {
const lw = node.widgets?.find(w => w.name === "label");
if (lw?.value === label) return node;
}
}
return null;
};
// --- Copy config from source node into hidden widgets ---
nodeType.prototype._syncFromSource = function () {
const srcWidget = this.widgets?.find(w => w.name === "source_label");
const source = this._findSource(srcWidget?.value);
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;
console.log(`[ProjectKey] _syncFromSource id=${this.id}: ${name}="${src.value}"`);
}
}
};
// --- Fetch keys from API and populate key_name dropdown ---
nodeType.prototype._refreshKeys = async function () {
const urlW = this.widgets?.find(w => w.name === "manager_url");
const projW = this.widgets?.find(w => w.name === "project_name");
const fileW = this.widgets?.find(w => w.name === "file_name");
const seqW = this.widgets?.find(w => w.name === "sequence_number");
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(
`/json_manager/get_project_keys?url=${encodeURIComponent(urlW.value)}&project=${encodeURIComponent(projW.value)}&file=${encodeURIComponent(fileW.value)}&seq=${seqW?.value || 1}`
);
if (!resp.ok) return;
const data = await resp.json();
if (data.error || !Array.isArray(data.keys)) return;
// Store keys/types for lookup
this._availableKeys = data.keys;
this._availableTypes = data.types;
// 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;
// Selection is sticky: user must change it manually
this._applyKeySelection();
}
} catch (e) {
console.error("[ProjectKey] Failed to refresh keys:", e);
}
};
// --- Update output slot based on selected key ---
nodeType.prototype._applyKeySelection = function () {
const keyWidget = this.widgets?.find(w => w.name === "key_name");
if (!keyWidget?.value) return;
const keyIdx = (this._availableKeys || []).indexOf(keyWidget.value);
const keyType = keyIdx >= 0 ? (this._availableTypes[keyIdx] || "*") : "*";
// Update hidden key_type widget
const ktWidget = this.widgets?.find(w => w.name === "key_type");
if (ktWidget) ktWidget.value = keyType;
// Update output slot
if (this.outputs.length > 0) {
this.outputs[0].name = keyWidget.value;
this.outputs[0].label = keyWidget.value;
this.outputs[0].type = keyType;
}
this.title = keyWidget.value ? `Key: ${keyWidget.value}` : "Project Key";
this.setSize(this.computeSize());
app.graph?.setDirtyCanvas(true, true);
};
// --- 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);
const srcWidget = this.widgets?.find(w => w.name === "source_label");
if (srcWidget) {
srcWidget.options.values = this._getSourceLabels();
}
// Sync config values from source (synchronous, safe)
this._syncFromSource();
};
// --- Restore state on workflow load ---
const origOnConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (info) {
origOnConfigure?.apply(this, arguments);
this._configured = true;
// 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) hideWidget(w);
}
// 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") {
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") {
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 (finalKeyWidget?.value) {
this.title = `Key: ${finalKeyWidget.value}`;
}
// Restore output slot name from saved key_name
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;
}
this.setSize(this.computeSize());
// Deferred: sync from source and refresh key dropdown once graph is ready
const node = this;
queueMicrotask(() => {
node._syncFromSource();
node._refreshKeys();
});
};
},
});
+158
View File
@@ -0,0 +1,158 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
app.registerExtension({
name: "json.manager.project.source",
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 origCallback = labelWidget.callback;
labelWidget.callback = function (...args) {
origCallback?.apply(this, args);
node.title = labelWidget.value
? `Source: ${labelWidget.value}`
: "Project Source";
app.graph?.setDirtyCanvas(true, true);
};
// Set initial title
if (labelWidget.value) {
this.title = `Source: ${labelWidget.value}`;
}
}
};
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);
});
};
},
});