8 Commits

Author SHA1 Message Date
Ethanfel 3c5d2fc4e0 feat: configurable path replacements for ComfyUI Docker mount differences
Added 'Path Replacements' section in the Projects tab. Each entry is a
from→to string substitution applied to project_path output, fixing
casing mismatches between Docker containers (e.g. Davinci → davinci).
Stored in .editor_config.json under 'path_replacements'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 16:19:10 +02:00
Ethanfel e6d260eb1a fix: resolve project folder_path to actual filesystem casing
Uses resolve_path_case_insensitive so stored paths like '/Davinci/...'
are returned as '/davinci/...' if that's the real casing on disk.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 16:16:53 +02:00
Ethanfel 8dafee9f6d fix: read sw.value directly for thumbnail opacity — e.args is a list not bool 2026-04-04 14:49:34 +02:00
Ethanfel b405427a6b feat: dim frame thumbnail when its switch is toggled off
Thumbnail opacity is 1.0 when the frame switch is on, 0.25 when off.
Initial state reflects the current logic index bit, and updates live
on toggle without requiring a page refresh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 14:41:49 +02:00
Ethanfel c771fa3451 feat: split frame strength into high/low noise strength per frame
Each frame path now has two strength fields instead of one:
'start frame high strength', 'start frame low strength' (and same
for middle/end). Migration splits old single 'X frame strength' into
both new keys using the same value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 14:34:03 +02:00
Ethanfel 4b40d0f50c feat: remove cfg, flf, end_frame fields
These fields are no longer needed. Removed from DEFAULTS, UI widgets,
standard_keys set, and timeline preview. Added _migrate_remove_keys()
migration so existing JSON files are cleaned up transparently on load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 14:24:57 +02:00
Ethanfel 97c755316b fix: inject frame strength defaults in API so they appear in ProjectKey dropdown
start/middle/end frame strength are set via setdefault in the UI but
only persist after save. API now injects them with default 1.0 so
ProjectKey can use them immediately without requiring a save first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 14:21:32 +02:00
Ethanfel d1e32e5fc4 feat: resolution series, frame paths, live node values & UX improvements
Key features:
- 8 resolution slots with per-slot randomizable seed; ProjectResolution outputs seed
- Frame paths (start/middle/end) with strength float + logic index switch
- Logic index read-only, driven by frame path switches (binary bit-field)
- BinaryIndexDecoder node: INT index → 3 BOOLEAN outputs with live value display
- ProjectKey live value display on output dot (INT=blue, FLOAT=green, BOOL=green/grey)
- ProjectSource auto-fills active project from Manager, outputs project_path
- ProjectKey highlights sibling nodes sharing the same key_name on select
- Computed keys: start_name/middle_name/end_name (Path.stem of frame paths)
- Image preview via /api/image-preview endpoint; click-to-open dialog with raw <img>
- Import folder scans project folder_path; DB moved to project directory
- Key renames: reference path→middle frame path, flf image path→end frame path,
  reference image path→start frame path (with auto-migration in load_json)
- Timeline preview shows resolutions and custom fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 14:13:10 +02:00
5 changed files with 123 additions and 31 deletions
+32 -2
View File
@@ -13,7 +13,7 @@ from fastapi.responses import FileResponse
from nicegui import app from nicegui import app
from db import ProjectDB from db import ProjectDB
from utils import load_json, load_config, KEY_BATCH_DATA, KEY_SEQUENCE_NUMBER from utils import load_json, load_config, resolve_path_case_insensitive, KEY_BATCH_DATA, KEY_SEQUENCE_NUMBER
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -58,7 +58,17 @@ def _get_project(name: str) -> dict[str, Any]:
proj = db.get_project(name) proj = db.get_project(name)
if not proj: if not proj:
raise HTTPException(status_code=404, detail=f"Project '{name}' not found") raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
return {"name": proj["name"], "folder_path": proj["folder_path"], folder_path = proj["folder_path"]
resolved = resolve_path_case_insensitive(folder_path)
if resolved:
folder_path = str(resolved)
# Apply configured path replacements (e.g. Docker mount casing differences)
config = load_config()
for rep in config.get("path_replacements", []):
src, dst = rep.get("from", ""), rep.get("to", "")
if src:
folder_path = folder_path.replace(src, dst)
return {"name": proj["name"], "folder_path": folder_path,
"description": proj.get("description", "")} "description": proj.get("description", "")}
@@ -94,6 +104,17 @@ def _get_data(name: str, file_name: str, seq: int = Query(default=1)) -> dict[st
if match is None: if match is None:
raise HTTPException(status_code=404, detail=f"Sequence {seq} not found") raise HTTPException(status_code=404, detail=f"Sequence {seq} not found")
result = dict(match) result = dict(match)
# Inject strength defaults if not yet saved to JSON
for key, default in (
("start frame high strength", 1.0),
("start frame low strength", 1.0),
("middle frame high strength", 1.0),
("middle frame low strength", 1.0),
("end frame high strength", 1.0),
("end frame low strength", 1.0),
):
result.setdefault(key, default)
# Computed stem names from frame paths
for out_key, src_key in ( for out_key, src_key in (
("start_name", "start frame path"), ("start_name", "start frame path"),
("middle_name", "middle frame path"), ("middle_name", "middle frame path"),
@@ -124,6 +145,15 @@ def _get_keys(name: str, file_name: str, seq: int = Query(default=1)) -> dict[st
types.append("FLOAT") types.append("FLOAT")
else: else:
types.append("STRING") types.append("STRING")
# Injected defaults — always present even if not yet saved to JSON
for key in (
"start frame high strength", "start frame low strength",
"middle frame high strength", "middle frame low strength",
"end frame high strength", "end frame low strength",
):
if key not in match:
keys.append(key)
types.append("FLOAT")
# Computed keys derived from frame paths # Computed keys derived from frame paths
for out_key, src_key in ( for out_key, src_key in (
("start_name", "start frame path"), ("start_name", "start frame path"),
+23 -21
View File
@@ -314,12 +314,12 @@ def render_batch_processor(state: AppState):
'lora 3 high', 'lora 3 high strength', 'lora 3 low', 'lora 3 low strength'] 'lora 3 high', 'lora 3 high strength', 'lora 3 low', 'lora 3 low strength']
standard_keys = { standard_keys = {
'name', 'mode', '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', 'camera', KEY_SEQUENCE_NUMBER,
'frame_to_skip', 'end_frame', 'logic index', 'transition', 'vace_length', 'frame_to_skip', 'logic index', 'transition', 'vace_length',
'input_a_frames', 'input_b_frames', 'reference switch', 'vace schedule', 'input_a_frames', 'input_b_frames', 'reference switch', 'vace schedule',
'start frame path', 'start frame strength', 'start frame path', 'start frame high strength', 'start frame low strength',
'middle frame path', 'middle frame strength', 'middle frame path', 'middle frame high strength', 'middle frame low strength',
'end frame path', 'end frame strength', 'end frame path', 'end frame high strength', 'end frame low strength',
'video file path', 'video file path',
} }
standard_keys.update(lora_keys) standard_keys.update(lora_keys)
@@ -560,15 +560,17 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
# --- Frame paths (start / middle / end) --- # --- Frame paths (start / middle / end) ---
logic_val = int(seq.get('logic index', 0)) logic_val = int(seq.get('logic index', 0))
for bit, img_label, img_key, str_key in [ for bit, img_label, img_key, hi_key, lo_key in [
(0, 'Start Frame', 'start frame path', 'start frame strength'), (0, 'Start Frame', 'start frame path', 'start frame high strength', 'start frame low strength'),
(1, 'Middle Frame', 'middle frame path', 'middle frame strength'), (1, 'Middle Frame', 'middle frame path', 'middle frame high strength', 'middle frame low strength'),
(2, 'End Frame', 'end frame path', 'end frame strength'), (2, 'End Frame', 'end frame path', 'end frame high strength', 'end frame low strength'),
]: ]:
ui.label(img_label).classes('text-caption text-weight-bold q-mt-sm') ui.label(img_label).classes('text-caption text-weight-bold q-mt-sm')
is_on = bool((logic_val >> bit) & 1)
with ui.row().classes('w-full items-center no-wrap q-mt-xs'): with ui.row().classes('w-full items-center no-wrap q-mt-xs'):
inp = dict_input(ui.input, 'Path', seq, img_key).classes( inp = dict_input(ui.input, 'Path', seq, img_key).classes(
'col').props('outlined dense input-style="text-align: right"') 'col').props('outlined dense input-style="text-align: right"')
thumb = None
img_path = Path(seq.get(img_key, '')) if seq.get(img_key) else None img_path = Path(seq.get(img_key, '')) if seq.get(img_key) else None
if (img_path and img_path.exists() and if (img_path and img_path.exists() and
img_path.suffix.lower() in IMAGE_EXTENSIONS): img_path.suffix.lower() in IMAGE_EXTENSIONS):
@@ -576,16 +578,22 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
with ui.dialog() as img_dlg, ui.card().style('max-width:90vw; padding:0'): with ui.dialog() as img_dlg, ui.card().style('max-width:90vw; padding:0'):
ui.html(f'<img src="{img_url}" ' ui.html(f'<img src="{img_url}" '
f'style="max-width:80vw;max-height:80vh;display:block">') f'style="max-width:80vw;max-height:80vh;display:block">')
ui.html( thumb = ui.html(
f'<img src="{img_url}" ' f'<img src="{img_url}" '
f'style="width:36px;height:36px;object-fit:cover;' f'style="width:36px;height:36px;object-fit:cover;'
f'border-radius:4px;cursor:pointer;flex-shrink:0">' f'border-radius:4px;cursor:pointer;flex-shrink:0;'
f'opacity:{"1.0" if is_on else "0.25"}">'
).on('click', img_dlg.open) ).on('click', img_dlg.open)
str_inp = dict_number('Strength', seq, str_key, default=1.0, sw = ui.switch(value=is_on)
step=0.05, format='%.2f').style(
'width:80px').props('outlined dense')
sw = ui.switch(value=bool((logic_val >> bit) & 1))
frame_switches.append(sw) frame_switches.append(sw)
if thumb is not None:
sw.on('update:model-value',
lambda e, t=thumb, s=sw: t.style(f'opacity: {"1.0" if s.value else "0.25"}'))
with ui.row().classes('w-full no-wrap q-mt-xs q-gutter-xs'):
dict_number('High', seq, hi_key, default=1.0,
step=0.05, format='%.2f').classes('col').props('outlined dense')
dict_number('Low', seq, lo_key, default=1.0,
step=0.05, format='%.2f').classes('col').props('outlined dense')
with splitter.after: with splitter.after:
# Mode # Mode
@@ -610,13 +618,7 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
ui.button(icon='casino', on_click=randomize_seed).props('flat') ui.button(icon='casino', on_click=randomize_seed).props('flat')
# CFG
dict_number('CFG', seq, 'cfg', default=DEFAULTS['cfg'],
step=0.5, format='%.1f').props('outlined').classes('w-full')
dict_input(ui.input, 'Camera', seq, 'camera').props('outlined').classes('w-full') dict_input(ui.input, 'Camera', seq, 'camera').props('outlined').classes('w-full')
dict_input(ui.input, 'FLF', seq, 'flf').props('outlined').classes('w-full')
ef_input = dict_number('End Frame', seq, 'end_frame').props('outlined').classes('w-full')
seq.setdefault('logic index', 0) seq.setdefault('logic index', 0)
li_input = dict_number('Logic Index', seq, 'logic index').props('outlined readonly').classes('w-full') li_input = dict_number('Logic Index', seq, 'logic index').props('outlined readonly').classes('w-full')
with li_input: with li_input:
+42
View File
@@ -59,6 +59,48 @@ def render_projects_tab(state: AppState):
ui.button('Create Project', icon='add', on_click=create_project).classes('w-full') ui.button('Create Project', icon='add', on_click=create_project).classes('w-full')
# --- Path replacements (for ComfyUI Docker path differences) ---
with ui.card().classes('w-full q-pa-md q-mb-md'):
ui.label('ComfyUI Path Replacements').classes('section-header')
ui.label('Applied to project_path output — use to fix Docker mount casing differences.'
).classes('text-caption q-mb-sm')
replacements: list[dict] = state.config.get('path_replacements', [])
@ui.refreshable
def render_replacements():
for idx, rep in enumerate(replacements):
with ui.row().classes('w-full items-center no-wrap q-gutter-xs'):
ui.input('From', value=rep.get('from', '')).classes('col').props(
'outlined dense').on('update:model-value',
lambda e, i=idx: _update_replacement(i, 'from', e.args))
ui.label('').classes('text-caption')
ui.input('To', value=rep.get('to', '')).classes('col').props(
'outlined dense').on('update:model-value',
lambda e, i=idx: _update_replacement(i, 'to', e.args))
ui.button(icon='delete', on_click=lambda i=idx: _remove_replacement(i)
).props('flat dense color=negative')
def _update_replacement(idx, field, value):
replacements[idx][field] = value
state.config['path_replacements'] = replacements
save_config(state.current_dir, state.config.get('favorites', []), state.config)
def _remove_replacement(idx):
replacements.pop(idx)
state.config['path_replacements'] = replacements
save_config(state.current_dir, state.config.get('favorites', []), state.config)
render_replacements.refresh()
def _add_replacement():
replacements.append({'from': '', 'to': ''})
state.config['path_replacements'] = replacements
save_config(state.current_dir, state.config.get('favorites', []), state.config)
render_replacements.refresh()
render_replacements()
ui.button('Add Replacement', icon='add', on_click=_add_replacement).props('flat dense')
# --- Active project indicator --- # --- Active project indicator ---
# Fetch once with file counts and reuse in render_project_list # Fetch once with file counts and reuse in render_project_list
_cached_projects = state.db.list_projects_with_file_counts() _cached_projects = state.db.list_projects_with_file_counts()
+1 -2
View File
@@ -577,7 +577,6 @@ def _render_preview_fields(item_data: dict):
with ui.row().classes('w-full q-gutter-md'): with ui.row().classes('w-full q-gutter-md'):
ui.input('Camera', value=str(item_data.get('camera', 'static'))).props('readonly outlined') ui.input('Camera', value=str(item_data.get('camera', 'static'))).props('readonly outlined')
ui.input('FLF', value=str(item_data.get('flf', '0.0'))).props('readonly outlined')
ui.input('Seed', value=str(item_data.get('seed', '-1'))).props('readonly outlined') ui.input('Seed', value=str(item_data.get('seed', '-1'))).props('readonly outlined')
with ui.expansion('LoRA Configuration'): with ui.expansion('LoRA Configuration'):
@@ -617,7 +616,7 @@ def _render_preview_fields(item_data: dict):
known_keys = { known_keys = {
'sequence_number', 'general_prompt', 'general_negative', 'current_prompt', 'prompt', 'sequence_number', 'general_prompt', 'general_negative', 'current_prompt', 'prompt',
'negative', 'camera', 'flf', 'seed', 'resolutions', 'negative', 'camera', 'seed', 'resolutions',
'frame_to_skip', 'vace schedule', 'video file path', 'middle frame path', 'end frame path', 'start frame path', 'frame_to_skip', 'vace schedule', 'video file path', 'middle frame path', 'end frame path', 'start frame path',
'logic index', 'logic index',
} }
+25 -6
View File
@@ -28,16 +28,13 @@ DEFAULTS = {
"current_prompt": "", "current_prompt": "",
"negative": "", "negative": "",
"seed": -1, "seed": -1,
"cfg": 1.5,
# --- Settings --- # --- Settings ---
"mode": 0, "mode": 0,
"camera": "static", "camera": "static",
"flf": 0.0,
# --- I2V / VACE Specifics --- # --- I2V / VACE Specifics ---
"frame_to_skip": 81, "frame_to_skip": 81,
"end_frame": 0,
"logic index": 0, "logic index": 0,
"transition": "1-2", "transition": "1-2",
"vace_length": 49, "vace_length": 49,
@@ -47,11 +44,14 @@ DEFAULTS = {
"reference switch": 1, "reference switch": 1,
"video file path": "", "video file path": "",
"start frame path": "", "start frame path": "",
"start frame strength": 1.0, "start frame high strength": 1.0,
"start frame low strength": 1.0,
"middle frame path": "", "middle frame path": "",
"middle frame strength": 1.0, "middle frame high strength": 1.0,
"middle frame low strength": 1.0,
"end frame path": "", "end frame path": "",
"end frame strength": 1.0, "end frame high strength": 1.0,
"end frame low strength": 1.0,
# --- LoRAs (name as STRING, strength as FLOAT) --- # --- LoRAs (name as STRING, strength as FLOAT) ---
"lora 1 high": "", "lora 1 high": "",
@@ -154,6 +154,17 @@ def save_snippets(snippets):
json.dump(snippets, f, indent=4) json.dump(snippets, f, indent=4)
os.replace(tmp, SNIPPETS_FILE) os.replace(tmp, SNIPPETS_FILE)
_REMOVED_KEYS = {"cfg", "flf", "end_frame"}
def _migrate_remove_keys(data: dict) -> None:
"""Drop keys that have been removed from the schema."""
for item in data.get(KEY_BATCH_DATA, []):
if not isinstance(item, dict):
continue
for k in _REMOVED_KEYS:
item.pop(k, None)
def _migrate_key_renames(data: dict) -> None: def _migrate_key_renames(data: dict) -> None:
"""Rename legacy keys to their current names.""" """Rename legacy keys to their current names."""
for item in data.get(KEY_BATCH_DATA, []): for item in data.get(KEY_BATCH_DATA, []):
@@ -165,6 +176,13 @@ def _migrate_key_renames(data: dict) -> None:
item['end frame path'] = item.pop('flf image path') item['end frame path'] = item.pop('flf image path')
if 'reference image path' in item and 'start frame path' not in item: if 'reference image path' in item and 'start frame path' not in item:
item['start frame path'] = item.pop('reference image path') item['start frame path'] = item.pop('reference image path')
# Split old single strength into high+low
for prefix in ('start frame', 'middle frame', 'end frame'):
old_key = f'{prefix} strength'
if old_key in item:
val = item.pop(old_key)
item.setdefault(f'{prefix} high strength', val)
item.setdefault(f'{prefix} low strength', val)
def _migrate_lora_keys(data: dict) -> None: def _migrate_lora_keys(data: dict) -> None:
@@ -225,6 +243,7 @@ def load_json(path: str | Path) -> tuple[dict[str, Any], float]:
with open(path, 'r') as f: with open(path, 'r') as f:
data = json.load(f) data = json.load(f)
t1 = time.time() t1 = time.time()
_migrate_remove_keys(data)
_migrate_key_renames(data) _migrate_key_renames(data)
_migrate_lora_keys(data) _migrate_lora_keys(data)
t2 = time.time() t2 = time.time()