feat(script-loader): add auto mode, file dropdown, quote-stripping

UX fixes driven by cross-machine testing feedback:

- mode="auto" (new default) picks the filled input automatically, so users
  no longer get "mode=file requires json_file" when they filled json_path
  without switching the mode dropdown.
- json_file now renders as a dropdown of .json files under ComfyUI's
  input/ directory (falls back to plain STRING if folder_paths or the
  directory is unavailable).
- Surrounding quotes on json_path/json_file are stripped automatically —
  Windows "Copy as Path" pastes quoted strings and that used to surface
  as an opaque "file not found".
- Clearer errors for "no input" / "multiple inputs" in auto mode, and a
  hint on path-not-found pointing at the quote issue.

Tests: 117 -> 121 (4 new auto-mode cases).
This commit is contained in:
tack
2026-04-23 16:57:39 +08:00
parent af9d5cdedf
commit de394c3f26
2 changed files with 97 additions and 6 deletions
+63 -5
View File
@@ -8,6 +8,8 @@ from ..core.normalizer import normalize_script
from ..core.types import COMIC_SCRIPT_TYPE, SolvedScript
from ..core.validator import validate
MODE_OPTIONS = ["auto", "file", "path", "inline"]
def _summary(script: SolvedScript) -> str:
total_panels = sum(len(p.panels) for p in script.pages)
@@ -20,6 +22,27 @@ def _summary(script: SolvedScript) -> str:
)
def _strip_surrounding_quotes(s: str) -> str:
s = s.strip()
if len(s) >= 2 and s[0] == s[-1] and s[0] in ('"', "'"):
return s[1:-1].strip()
return s
def _list_input_json_files() -> list[str]:
try:
import folder_paths # type: ignore
input_dir = Path(folder_paths.get_input_directory())
except Exception:
return []
if not input_dir.exists():
return []
return sorted(
str(p.relative_to(input_dir)).replace("\\", "/")
for p in input_dir.rglob("*.json")
)
def _resolve_file_mode(json_file: str) -> Path:
import folder_paths # type: ignore # ComfyUI-provided at runtime
input_dir = Path(folder_paths.get_input_directory())
@@ -31,6 +54,23 @@ def _resolve_file_mode(json_file: str) -> Path:
return candidate
def _resolve_auto_mode(json_file: str, json_path: str, json_text: str) -> str:
filled = [name for name, val in
(("inline", json_text), ("path", json_path), ("file", json_file))
if val.strip()]
if not filled:
raise ValueError(
"mode=auto: no input provided — fill json_file (dropdown), "
"json_path (absolute path), or json_text (inline JSON)."
)
if len(filled) > 1:
raise ValueError(
f"mode=auto: multiple inputs filled ({', '.join(filled)}). "
"Clear the ones you don't want, or set mode explicitly."
)
return filled[0]
class ComicScriptLoader:
"""Load a comic script JSON, validate it, and emit a SolvedScript."""
@@ -41,27 +81,45 @@ class ComicScriptLoader:
@classmethod
def INPUT_TYPES(cls):
json_files = _list_input_json_files()
file_widget = (json_files, {"tooltip": "Pick a .json from ComfyUI input dir; used when mode=file."}) \
if json_files else \
("STRING", {"default": "", "multiline": False,
"tooltip": "Relative path inside ComfyUI input dir; used when mode=file."})
return {
"required": {
"mode": (["file", "path", "inline"], {"default": "file"}),
"mode": (MODE_OPTIONS, {"default": "auto",
"tooltip": "auto = use whichever field below is filled."}),
},
"optional": {
"json_file": ("STRING", {"default": "", "multiline": False,
"tooltip": "Relative path inside ComfyUI input dir; used when mode=file."}),
"json_file": file_widget,
"json_path": ("STRING", {"default": "", "multiline": False,
"tooltip": "Absolute / relative path to JSON; used when mode=path."}),
"tooltip": "Absolute / relative path to JSON (surrounding quotes are stripped); used when mode=path."}),
"json_text": ("STRING", {"default": "", "multiline": True,
"tooltip": "Inline JSON text; used when mode=inline."}),
},
}
def load(self, mode: str, json_file: str = "", json_path: str = "", json_text: str = ""):
json_path = _strip_surrounding_quotes(json_path)
json_file = _strip_surrounding_quotes(json_file)
if mode == "auto":
mode = _resolve_auto_mode(json_file, json_path, json_text)
if mode == "inline":
raw = json_text
elif mode == "path":
if not json_path:
raise ValueError("mode=path requires json_path")
raw = Path(json_path).read_text(encoding="utf-8")
p = Path(json_path)
if not p.exists():
raise FileNotFoundError(
f"json_path not found: {p} "
"(tip: Windows 'Copy as Path' adds quotes — those are stripped automatically, "
"but check for typos or wrong drive letter.)"
)
raw = p.read_text(encoding="utf-8")
elif mode == "file":
if not json_file:
raise ValueError("mode=file requires json_file")
@@ -14,7 +14,8 @@ def test_input_types_declares_modes():
inputs = ComicScriptLoader.INPUT_TYPES()
required = inputs["required"]
assert "mode" in required
assert set(required["mode"][0]) == {"file", "path", "inline"}
assert set(required["mode"][0]) == {"auto", "file", "path", "inline"}
assert required["mode"][1]["default"] == "auto"
optional = inputs.get("optional", {})
assert "json_file" in optional
assert "json_path" in optional
@@ -56,6 +57,38 @@ def test_load_from_file_mode(stub_folder_paths):
assert script.job_id == "basic"
def test_auto_mode_picks_path_when_only_path_filled(tmp_path: Path):
target = tmp_path / "script.json"
shutil.copy(FIXTURES / "basic.json", target)
node = ComicScriptLoader()
script, _ = node.load(mode="auto", json_path=str(target), json_file="", json_text="")
assert script.job_id == "basic"
def test_auto_mode_strips_surrounding_double_quotes(tmp_path: Path):
target = tmp_path / "script.json"
shutil.copy(FIXTURES / "basic.json", target)
node = ComicScriptLoader()
quoted = f'"{target}"'
script, _ = node.load(mode="auto", json_path=quoted, json_file="", json_text="")
assert script.job_id == "basic"
def test_auto_mode_with_no_input_errors_clearly():
node = ComicScriptLoader()
with pytest.raises(ValueError, match="no input provided"):
node.load(mode="auto", json_file="", json_path="", json_text="")
def test_auto_mode_with_multiple_inputs_errors_clearly(tmp_path: Path):
target = tmp_path / "script.json"
shutil.copy(FIXTURES / "basic.json", target)
node = ComicScriptLoader()
text = (FIXTURES / "minimal.json").read_text(encoding="utf-8")
with pytest.raises(ValueError, match="multiple inputs"):
node.load(mode="auto", json_path=str(target), json_text=text, json_file="")
def test_invalid_json_raises_with_readable_message():
node = ComicScriptLoader()
text = (FIXTURES / "minimal.json").read_text(encoding="utf-8")