From c57e6ccc2d26c30731a3dbfca550c7a1475f30c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8B=E3=81=BF=E3=81=AA=E3=83=BC=E5=85=88=E7=94=9F?= Date: Sat, 15 Aug 2026 03:27:32 +0900 Subject: [PATCH] feat: Implement Tag Transfer Directory functionality for checkpoint export/import --- RELEASE_NOTES.md | 5 +- doc/CheckpointTagExportImport.ja.md | 10 ++- doc/CheckpointTagExportImport.md | 8 ++ doc/HOW_TO_USE.md | 1 + suite_nodes.py | 128 +++++++++++++++++++++++----- web/checkpoint_handpicker_suite.js | 122 ++++++++++++++++++++++++-- 6 files changed, 245 insertions(+), 29 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f9b8a05..24c4dc4 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -10,6 +10,7 @@ The new standalone `Checkpoint Tag Export / Import` management node backs up eva - Export and Import run directly from the node and do not use the ComfyUI queue. - Export files are written atomically under `output/CheckpointHandpickerSuite/`. +- An optional `Tag Transfer Directory` field accepts an existing full local or network path, allowing several PCs to share exports without manual copies. A blank field uses the default output location. - Portable matching uses checkpoint file name and file size, not directories, drives, or timestamps. - Import fills only currently untagged checkpoints. Existing equal tags are unchanged, and different tags are reported as conflicts without being overwritten. - Missing and ambiguous checkpoints are skipped safely. @@ -19,13 +20,15 @@ The new standalone `Checkpoint Tag Export / Import` management node backs up eva Import automatically selects the greatest timestamp string in a matching export filename. Timestamp validity and future dates are intentionally not checked, so renaming a file to a future timestamp can explicitly prioritize it. If the selected latest JSON is invalid, Import stops instead of falling back to an older export. +Custom transfer directories are never created automatically and never fall back silently to the default. Export uses unique temporary files and a same-directory reservation so simultaneous writers sharing a directory do not publish partial JSON files or overwrite the same timestamped export. + ## Compatibility Existing workflows, nodes, and socket types are unchanged. The export document format starts at `format_version: 1` and is versioned independently from the package. ## Upgrade notes -Update the custom node and restart ComfyUI. Add `Checkpoint Tag Export / Import` anywhere on the canvas; it has no inputs or outputs and does not participate in workflow execution. +Update the custom node and restart ComfyUI. Add `Checkpoint Tag Export / Import` anywhere on the canvas; it has no connectable sockets and does not participate in workflow execution. --- diff --git a/doc/CheckpointTagExportImport.ja.md b/doc/CheckpointTagExportImport.ja.md index 0c42591..76a5134 100644 --- a/doc/CheckpointTagExportImport.ja.md +++ b/doc/CheckpointTagExportImport.ja.md @@ -2,7 +2,13 @@ `Checkpoint Tag Export / Import`は、HandpickerSuiteで蓄積したCheckpoint評価をバックアップ・復元するための独立した管理ノードです。 -入力・出力はなく、ComfyUI Queueにも接続しません。キャンバス上へ単独で配置して使用します。 +接続用の入力・出力ソケットはなく、ComfyUI Queueにも接続しません。キャンバス上へ単独で配置して使用します。 + +## Tag Transfer Directory + +ディレクトリ入力欄はWorkflowへ保存されます。空欄では`Default: output/CheckpointHandpickerSuite/`が薄いplaceholderとして表示され、そのデフォルト位置を使用します。 + +カスタム値には、すでに存在するフルパスを指定します。ローカルの絶対パスとUNC/ネットワークパスを使用できます。指定したディレクトリが存在しない場合はエラーとなり、自動作成もデフォルト位置へのfallbackも行いません。ExportとImportは常に同じ指定先を使用し、その直下だけを検索します。 ## Export @@ -27,6 +33,8 @@ Exportされるのは評価済みCheckpointだけです。各recordには次の JSONは一時ファイルへ完全に書き込んだ後、正式なファイル名へ変更します。 +一時ファイル名は毎回一意で、同じ共有ディレクトリへ複数PCから同時にExportしても同じ最終ファイル名を上書きしないよう短時間の予約ファイルを使用します。 + ## Import 別環境へ移行する場合は、ExportしたJSONをImport先の`output/CheckpointHandpickerSuite/`へコピーして`Import`を押します。 diff --git a/doc/CheckpointTagExportImport.md b/doc/CheckpointTagExportImport.md index e4f66ec..0d00717 100644 --- a/doc/CheckpointTagExportImport.md +++ b/doc/CheckpointTagExportImport.md @@ -2,6 +2,12 @@ `Checkpoint Tag Export / Import` is a standalone management node for backing up and restoring HandpickerSuite checkpoint evaluations. +## Tag Transfer Directory + +The directory field is saved with the workflow. Its empty value displays `Default: output/CheckpointHandpickerSuite/` as a placeholder and uses that default directory. + +A custom value must be an existing full path. Local absolute paths and UNC/network paths are supported. A missing custom directory is reported as an error: it is not created, and the operation does not fall back to the default. Export and Import always use the same selected directory and search only its top level. + ## Export Click `Export` to create: @@ -25,6 +31,8 @@ Paths and checkpoint file timestamps are intentionally omitted. A missing checkp The JSON is fully written to a temporary file before it is renamed to its final name. +Temporary files are unique, and a short-lived reservation prevents simultaneous exports from different PCs sharing the directory from choosing the same final filename. + ## Import Copy an exported JSON file into `output/CheckpointHandpickerSuite/` and click `Import`. diff --git a/doc/HOW_TO_USE.md b/doc/HOW_TO_USE.md index 2fe0f3a..e727913 100644 --- a/doc/HOW_TO_USE.md +++ b/doc/HOW_TO_USE.md @@ -31,6 +31,7 @@ In many cases, you can add `Checkpoint Name Cycler` in front of your checkpoint Add the standalone `Checkpoint Tag Export / Import` node anywhere on the canvas. It has no inputs or outputs and never uses the ComfyUI queue. - Click `Export` to write the currently evaluated tags to `output/CheckpointHandpickerSuite/`. +- Leave `Tag Transfer Directory` blank to use that default. Enter an existing full local path or UNC/network path to share exports between PCs; custom directories are never created automatically. - Copy an exported JSON file into the same directory on another ComfyUI installation, then click `Import`. - Import matches checkpoints by file name and file size. Directories, drives, and file timestamps may differ. - Existing tags are never overwritten. Untagged matches are imported, equal tags are unchanged, and different tags are reported as conflicts. diff --git a/suite_nodes.py b/suite_nodes.py index 801d18a..a039870 100644 --- a/suite_nodes.py +++ b/suite_nodes.py @@ -54,7 +54,7 @@ except Exception: DELETE_SCRIPT_DIR = OUTPUT_DIR / "CheckpointHandpickerSuite" / "delete_scripts" DELETE_QUEUE_PATH = DELETE_SCRIPT_DIR / "checkpoint_delete_queue.jsonl" DELETE_SCRIPT_PATH = DELETE_SCRIPT_DIR / "delete_reserved_checkpoints.py" -TAG_EXPORT_DIR = OUTPUT_DIR / "CheckpointHandpickerSuite" +DEFAULT_TAG_TRANSFER_DIR = OUTPUT_DIR / "CheckpointHandpickerSuite" TAG_TRANSFER_FORMAT_VERSION = 1 TAG_EXPORT_FILE_RE = re.compile(r"^checkpoint_tags_(\d{8}_\d{6})(?:_(\d+))?\.json$") TAG_TRANSFER_DETAIL_LIMIT = 20 @@ -207,11 +207,28 @@ def _tag_export_filename_key(path: Path): return match.group(1), int(match.group(2) or 0) -def _latest_tag_export_path() -> Path | None: - if not TAG_EXPORT_DIR.exists(): +def _resolve_tag_transfer_directory(raw_value, *, create_default: bool) -> Path: + value = str(raw_value or "").strip() + if not value: + path = DEFAULT_TAG_TRANSFER_DIR + if create_default: + path.mkdir(parents=True, exist_ok=True) + return path + path = Path(value) + if not path.is_absolute(): + raise ValueError("Tag Transfer Directory must be a full path.") + if not path.exists(): + raise ValueError(f"Tag Transfer Directory does not exist.\n\n{path}") + if not path.is_dir(): + raise ValueError(f"Tag Transfer Directory is not a directory.\n\n{path}") + return path + + +def _latest_tag_export_path(directory: Path) -> Path | None: + if not directory.exists() or not directory.is_dir(): return None candidates = [] - for path in TAG_EXPORT_DIR.iterdir(): + for path in directory.iterdir(): if not path.is_file(): continue key = _tag_export_filename_key(path) @@ -220,18 +237,51 @@ def _latest_tag_export_path() -> Path | None: return max(candidates, key=lambda item: item[0])[1] if candidates else None -def _next_tag_export_path() -> Path: - TAG_EXPORT_DIR.mkdir(parents=True, exist_ok=True) +def _tag_export_candidate_path(directory: Path, stamp: str, suffix: int) -> Path: + filename = f"checkpoint_tags_{stamp}.json" if suffix == 0 else f"checkpoint_tags_{stamp}_{suffix}.json" + return directory / filename + + +def _write_tag_export(directory: Path, data: dict) -> Path: + if not directory.exists() or not directory.is_dir(): + raise ValueError(f"Tag Transfer Directory does not exist.\n\n{directory}") stamp = datetime.now().astimezone().strftime("%Y%m%d_%H%M%S") - base = TAG_EXPORT_DIR / f"checkpoint_tags_{stamp}.json" - if not base.exists() and not base.with_suffix(base.suffix + ".tmp").exists(): - return base - suffix = 2 + suffix = 0 while True: - candidate = TAG_EXPORT_DIR / f"checkpoint_tags_{stamp}_{suffix}.json" - if not candidate.exists() and not candidate.with_suffix(candidate.suffix + ".tmp").exists(): + candidate = _tag_export_candidate_path(directory, stamp, suffix) + reservation = directory / f".{candidate.name}.lock" + if candidate.exists(): + suffix = 2 if suffix == 0 else suffix + 1 + continue + try: + reservation_handle = reservation.open("x", encoding="utf-8") + except FileExistsError: + suffix = 2 if suffix == 0 else suffix + 1 + continue + temporary = directory / f".{candidate.name}.{uuid.uuid4().hex}.tmp" + try: + reservation_handle.write(f"pid={os.getpid()}\ncreated_at={_now_iso()}\n") + reservation_handle.close() + if candidate.exists(): + suffix = 2 if suffix == 0 else suffix + 1 + continue + with temporary.open("x", encoding="utf-8", newline="\n") as handle: + json.dump(data, handle, ensure_ascii=False, indent=2) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, candidate) return candidate - suffix += 1 + finally: + if not reservation_handle.closed: + reservation_handle.close() + try: + temporary.unlink(missing_ok=True) + except Exception: + logger.warning("Failed to remove temporary tag export file: %s", temporary) + try: + reservation.unlink(missing_ok=True) + except Exception: + logger.warning("Failed to remove tag export reservation: %s", reservation) def _checkpoint_candidates_for_relpath(relpath: str) -> list[Path]: @@ -974,6 +1024,9 @@ def _format_tag_export_message(path: Path, payload: dict, diagnostics: dict) -> "", "File:", path.name, + "", + "Directory:", + str(path.parent), ] _append_limited_detail(lines, "Ambiguous", ambiguous, lambda item: [ f"- {item.get('file_name', '(unknown)')}", @@ -1000,6 +1053,9 @@ def _format_tag_import_message(source: Path, result: dict) -> str: "Source:", source.name, "", + "Directory:", + str(source.parent), + "", f"Imported: {len(imported)}", f"Unchanged: {len(unchanged)}", f"Conflicts: {len(conflicts)}", @@ -2439,14 +2495,18 @@ class CheckpointStatusTagger: class CheckpointTagExportImport: @classmethod def INPUT_TYPES(cls): - return {"required": {}} + return { + "required": { + "tag_transfer_directory": ("STRING", {"default": "", "multiline": False}), + } + } RETURN_TYPES = () FUNCTION = "noop" CATEGORY = "checkpoint/handpicker" OUTPUT_NODE = False - def noop(self): + def noop(self, tag_transfer_directory=""): return () @@ -2719,7 +2779,7 @@ async def list_checkpoints(_request): @routes.post(f"/{EXTENSION_PREFIX}/tag_transfer/export") -async def tag_transfer_export(_request): +async def tag_transfer_export(request): if not _TAG_TRANSFER_OPERATION_LOCK.acquire(blocking=False): return web.json_response({ "ok": False, @@ -2727,12 +2787,23 @@ async def tag_transfer_export(_request): "message": "Another tag Export / Import operation is already running.", }, status=409) try: + try: + request_data = await request.json() + except Exception: + request_data = {} + try: + directory = _resolve_tag_transfer_directory(request_data.get("directory", ""), create_default=True) + except ValueError as exc: + return web.json_response({ + "ok": False, + "error": str(exc), + "message": f"Export failed.\n\n{exc}", + }, status=400) # Snapshot the evaluation DB at operation start. Later Tagger changes are # intentionally not included in this export. snapshot = _load_status_db() payload, diagnostics = await asyncio.to_thread(_build_tag_export, snapshot) - path = _next_tag_export_path() - await asyncio.to_thread(_safe_json_write, path, payload) + path = await asyncio.to_thread(_write_tag_export, directory, payload) message = _format_tag_export_message(path, payload, diagnostics) logger.info( "[CheckpointHandpickerSuite] Tag export completed: exported=%s ambiguous=%s failed=%s file=%s", @@ -2745,6 +2816,7 @@ async def tag_transfer_export(_request): "ok": True, "message": message, "file_name": path.name, + "directory": str(directory), "exported": len(payload["evaluations"]), "ambiguous": len(diagnostics["ambiguous"]), "failed": len(diagnostics["failed"]), @@ -2761,7 +2833,7 @@ async def tag_transfer_export(_request): @routes.post(f"/{EXTENSION_PREFIX}/tag_transfer/import") -async def tag_transfer_import(_request): +async def tag_transfer_import(request): if not _TAG_TRANSFER_OPERATION_LOCK.acquire(blocking=False): return web.json_response({ "ok": False, @@ -2769,12 +2841,25 @@ async def tag_transfer_import(_request): "message": "Another tag Export / Import operation is already running.", }, status=409) try: - source = _latest_tag_export_path() + try: + request_data = await request.json() + except Exception: + request_data = {} + try: + directory = _resolve_tag_transfer_directory(request_data.get("directory", ""), create_default=False) + except ValueError as exc: + return web.json_response({ + "ok": False, + "error": str(exc), + "message": f"Import failed.\n\n{exc}", + }, status=400) + source = _latest_tag_export_path(directory) if source is None: return web.json_response({ "ok": False, "error": "No tag export file was found.", - "message": "Import failed.\n\nNo tag export file was found.", + "directory": str(directory), + "message": f"Import failed.\n\nNo tag export file was found.\n\nDirectory:\n{directory}", }, status=404) try: prepared = await asyncio.to_thread(_prepare_tag_import, source) @@ -2795,6 +2880,7 @@ async def tag_transfer_import(_request): "ok": True, "message": message, "file_name": source.name, + "directory": str(directory), "changes": [ { "ckpt_name_str": record["relpath"], diff --git a/web/checkpoint_handpicker_suite.js b/web/checkpoint_handpicker_suite.js index 98a5ac7..b5924a4 100644 --- a/web/checkpoint_handpicker_suite.js +++ b/web/checkpoint_handpicker_suite.js @@ -17,6 +17,8 @@ const SELECTOR_CLASS = "CheckpointListSelector"; const CYCLER_CLASS = "CheckpointNameCycler"; const TAGGER_CLASS = "CheckpointStatusTagger"; const TAG_TRANSFER_CLASS = "CheckpointTagExportImport"; +const TAG_TRANSFER_DIRECTORY_WIDGET = "tag_transfer_directory"; +const TAG_TRANSFER_DIRECTORY_PLACEHOLDER = "Default: output/CheckpointHandpickerSuite/"; const PREVIEW_CLASSES = new Set(["EphemeralPreview", "ImageDirPreview"]); const HPS_TAB_ID = (globalThis.crypto?.randomUUID?.() || `tab-${Date.now()}-${Math.random().toString(36).slice(2)}`); @@ -2151,15 +2153,112 @@ function setupSelectorNode(nodeType) { } // ---------- Tag Export / Import ---------- +function tagTransferDirectoryValue(node) { + const inputValue = node?.__hpsTagTransferDirectoryUI?.input?.value; + if (inputValue !== undefined) return String(inputValue); + return String(getWidget(node, TAG_TRANSFER_DIRECTORY_WIDGET)?.value ?? ""); +} + +function hideTagTransferDirectorySavedWidget(widget) { + if (!widget) return; + widget.type = "hidden"; + widget.hidden = true; + widget.disabled = true; + widget.serialize = true; + widget.options = { ...(widget.options || {}), hidden: true }; + widget.computeSize = () => [0, -4]; + widget.draw = () => {}; + for (const element of [widget.element, widget.inputEl, widget.domWidget?.element, widget.options?.element]) { + if (!(element instanceof HTMLElement)) continue; + element.hidden = true; + element.setAttribute("aria-hidden", "true"); + element.style.setProperty("display", "none", "important"); + } +} + +function buildTagTransferDirectoryUI(node) { + const savedWidget = getWidget(node, TAG_TRANSFER_DIRECTORY_WIDGET); + if (!savedWidget) return; + if (node.__hpsTagTransferDirectoryUI) { + node.__hpsTagTransferDirectoryUI.input.value = String(savedWidget.value ?? ""); + return; + } + savedWidget.label = "Tag Transfer Directory (full path only / network paths OK)"; + savedWidget.options = { ...(savedWidget.options || {}), placeholder: TAG_TRANSFER_DIRECTORY_PLACEHOLDER }; + if (typeof node.addDOMWidget !== "function") return; + + const root = document.createElement("div"); + root.style.display = "flex"; + root.style.flexDirection = "column"; + root.style.gap = "4px"; + root.style.width = "100%"; + root.style.height = "58px"; + root.style.boxSizing = "border-box"; + root.style.padding = "2px 0"; + + const label = document.createElement("label"); + label.textContent = "Tag Transfer Directory (full path only / network paths OK)"; + label.style.fontSize = "11px"; + label.style.color = "rgba(230,230,230,0.88)"; + label.style.userSelect = "none"; + + const input = document.createElement("input"); + input.type = "text"; + input.spellcheck = false; + input.value = String(savedWidget.value ?? ""); + input.placeholder = TAG_TRANSFER_DIRECTORY_PLACEHOLDER; + input.style.width = "100%"; + input.style.height = "30px"; + input.style.boxSizing = "border-box"; + input.style.padding = "5px 8px"; + input.style.border = "1px solid rgba(200,220,245,0.42)"; + input.style.borderRadius = "4px"; + input.style.background = "rgba(20,20,20,0.72)"; + input.style.color = "inherit"; + input.style.fontFamily = "monospace"; + input.style.fontSize = "11px"; + root.append(label, input); + + for (const eventName of ["pointerdown", "mousedown", "mouseup", "click", "dblclick", "keydown", "keyup"]) { + input.addEventListener(eventName, (event) => event.stopPropagation()); + } + input.addEventListener("input", () => { + savedWidget.value = input.value; + savedWidget.callback?.(input.value); + node.graph?.change?.(); + app.graph?.setDirtyCanvas?.(true, true); + }); + input.addEventListener("change", () => { + savedWidget.value = input.value; + }); + + try { + const domWidget = node.addDOMWidget("tag_transfer_directory_editor", "hps-tag-transfer-directory", root, { + serialize: false, + hideOnZoom: false, + getValue: () => input.value, + setValue: (value) => { + input.value = String(value ?? ""); + savedWidget.value = input.value; + }, + }); + domWidget.computeSize = (width) => [width, 62]; + node.__hpsTagTransferDirectoryUI = { root, label, input, domWidget }; + hideTagTransferDirectorySavedWidget(savedWidget); + } catch (error) { + console.warn("[CheckpointHandpickerSuite] failed to create Tag Transfer Directory editor", error); + } +} + function tagTransferRects(node) { const margin = 8; const gap = 8; const buttonW = 118; const buttonH = 28; return { - exportButton: { x: margin, y: 8, w: buttonW, h: buttonH }, - importButton: { x: margin + buttonW + gap, y: 8, w: buttonW, h: buttonH }, - result: { x: margin, y: 48, w: Math.max(1, node.size[0] - margin * 2), h: Math.max(1, node.size[1] - 58) }, + exportButton: { x: margin, y: 76, w: buttonW, h: buttonH }, + importButton: { x: margin + buttonW + gap, y: 76, w: buttonW, h: buttonH }, + result: { x: margin, y: 116, w: Math.max(1, node.size[0] - margin * 2), h: Math.max(1, node.size[1] - 126) }, }; } @@ -2201,7 +2300,11 @@ async function runTagTransfer(node, operation) { node.__hpsTagTransferResult = operation === "export" ? "Exporting..." : "Importing..."; app.graph?.setDirtyCanvas?.(true, true); try { - const response = await api.fetchApi(`/${EXTENSION_PREFIX}/tag_transfer/${operation}`, { method: "POST" }); + const response = await api.fetchApi(`/${EXTENSION_PREFIX}/tag_transfer/${operation}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ directory: tagTransferDirectoryValue(node) }), + }); let result = null; try { result = await response.json(); @@ -2227,17 +2330,24 @@ async function runTagTransfer(node, operation) { } function setupTagTransferNode(nodeType) { - installMinSize(nodeType, 420, 250); + installMinSize(nodeType, 420, 320); installCursorCapture(); const origCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { const result = origCreated ? origCreated.apply(this, arguments) : undefined; - ensureSize(this, 420, 250); + ensureSize(this, 420, 320); + buildTagTransferDirectoryUI(this); this.__hpsTagTransferBusy = false; this.__hpsTagTransferResult = "Ready."; this.__hpsTagTransferScroll = 0; return result; }; + const origConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function () { + const result = origConfigure ? origConfigure.apply(this, arguments) : undefined; + setTimeout(() => buildTagTransferDirectoryUI(this), 0); + return result; + }; const origDraw = nodeType.prototype.onDrawBackground; nodeType.prototype.onDrawBackground = function (ctx) { if (origDraw) origDraw.apply(this, arguments);