mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-23 03:31:17 -03:00
Compare commits
5 Commits
a9e5ee7e79
...
4199c30fec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4199c30fec | ||
|
|
4a8084cdbc | ||
|
|
6263e6848c | ||
|
|
58c266ad07 | ||
|
|
2939813e1a |
32
README.md
32
README.md
@@ -185,12 +185,25 @@ The Save Image Node supports dynamic filename generation using pattern codes. Yo
|
|||||||
|
|
||||||
#### Available Pattern Codes
|
#### Available Pattern Codes
|
||||||
|
|
||||||
|
##### Cross-Node Placeholders (ComfyUI Standard)
|
||||||
|
|
||||||
|
- `%NodeTitle.WidgetName%` - Reference any widget value from any node in your workflow, for example:
|
||||||
|
- `%KSampler.seed%` - The seed from a KSampler node
|
||||||
|
- `%Empty Latent Image.width%` - The width from an Empty Latent Image node
|
||||||
|
- `%KSampler.steps%` - The steps value from a KSampler node
|
||||||
|
- Nodes are matched by their "Node name for S&R" property, then by their title
|
||||||
|
|
||||||
|
##### Generation Metadata Placeholders (LoRA Manager)
|
||||||
|
|
||||||
- `%seed%` - Inserts the generation seed number
|
- `%seed%` - Inserts the generation seed number
|
||||||
- `%width%` - Inserts the image width
|
- `%width%` - Inserts the image width
|
||||||
- `%height%` - Inserts the image height
|
- `%height%` - Inserts the image height
|
||||||
- `%pprompt:N%` - Inserts the positive prompt (limited to N characters)
|
- `%pprompt:N%` - Inserts the positive prompt (limited to N characters)
|
||||||
- `%nprompt:N%` - Inserts the negative prompt (limited to N characters)
|
- `%nprompt:N%` - Inserts the negative prompt (limited to N characters)
|
||||||
- `%model:N%` - Inserts the model/checkpoint name (limited to N characters)
|
- `%model:N%` - Inserts the model/checkpoint name (limited to N characters)
|
||||||
|
|
||||||
|
##### Date/Time Placeholders
|
||||||
|
|
||||||
- `%date%` - Inserts current date/time as "yyyyMMddhhmmss"
|
- `%date%` - Inserts current date/time as "yyyyMMddhhmmss"
|
||||||
- `%date:FORMAT%` - Inserts date using custom format with:
|
- `%date:FORMAT%` - Inserts date using custom format with:
|
||||||
- `yyyy` - 4-digit year
|
- `yyyy` - 4-digit year
|
||||||
@@ -209,8 +222,25 @@ The Save Image Node supports dynamic filename generation using pattern codes. Yo
|
|||||||
- `%date:yyyy-MM-dd%` → `2025-04-28`
|
- `%date:yyyy-MM-dd%` → `2025-04-28`
|
||||||
- `%pprompt:20%_%seed%` → `beautiful landscape_1234567890`
|
- `%pprompt:20%_%seed%` → `beautiful landscape_1234567890`
|
||||||
- `%model%_%date:yyMMdd%_%seed%` → `dreamshaper_v8_250428_1234567890`
|
- `%model%_%date:yyMMdd%_%seed%` → `dreamshaper_v8_250428_1234567890`
|
||||||
|
- `%KSampler.seed%` → `1234567890` (resolved from the KSampler node's widget)
|
||||||
|
- `%Empty Latent Image.width%x%Empty Latent Image.height%` → `512x768`
|
||||||
|
- `%KSampler.seed%_%KSampler.steps%` → `1234567890_25`
|
||||||
|
|
||||||
You can combine multiple patterns to create detailed, organized filenames for your generated images.
|
You can combine multiple patterns to create detailed, organized filenames for your generated images. Cross-node and metadata placeholders can be mixed freely — for example: `%KSampler.seed%_%model%_%date:yyyyMMdd%`.
|
||||||
|
|
||||||
|
##### Organizing Images into Subdirectories
|
||||||
|
|
||||||
|
Including a path separator (`/` on all platforms) in the filename prefix creates subdirectories automatically, which is especially powerful when combined with placeholders:
|
||||||
|
|
||||||
|
| Pattern | Result |
|
||||||
|
|---|---|
|
||||||
|
| `%date:yyyy-MM-dd%/%seed%` | Saves to `2025-04-28/1234567890.png` |
|
||||||
|
| `%model%/%date:yyMMdd%_%seed%` | Saves to `dreamshaper_v8/250428_1234567890.png` |
|
||||||
|
| `%KSampler.seed%/%model%` | Saves to `1234567890/dreamshaper_v8.png` |
|
||||||
|
| `%date:yyyy/MM/dd%/%seed%` | Saves to `2025/04/28/1234567890.png` (nested year/month/day) |
|
||||||
|
| `%model%/training/%seed%` | Saves to `dreamshaper_v8/training/1234567890.png` |
|
||||||
|
|
||||||
|
> **Note**: The subdirectory is created relative to your ComfyUI output directory (configurable via `--output-directory`). Characters invalid for folder names are automatically replaced with underscores.
|
||||||
|
|
||||||
### Standalone Mode
|
### Standalone Mode
|
||||||
|
|
||||||
|
|||||||
@@ -1398,6 +1398,21 @@
|
|||||||
"versionDeleted": "Version gelöscht"
|
"versionDeleted": "Version gelöscht"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"metadataFetchSummary": {
|
||||||
|
"title": "Metadaten abrufen — Zusammenfassung",
|
||||||
|
"statSuccess": "Erfolgreich",
|
||||||
|
"statFailed": "Fehlgeschlagen",
|
||||||
|
"statSkipped": "Übersprungen",
|
||||||
|
"statTotal": "Gesamt geprüft",
|
||||||
|
"statDuration": "Dauer",
|
||||||
|
"successMessage": "Alle {count} {type}s erfolgreich aktualisiert!",
|
||||||
|
"failedItems": "Fehlgeschlagene Elemente ({count})",
|
||||||
|
"close": "Schließen",
|
||||||
|
"copyReport": "Bericht kopieren",
|
||||||
|
"downloadCsv": "CSV herunterladen",
|
||||||
|
"columnModelName": "Modellname",
|
||||||
|
"columnError": "Fehler"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1957,7 +1972,9 @@
|
|||||||
"bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben",
|
"bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben",
|
||||||
"exampleImagesDownloadSuccess": "Beispielbilder erfolgreich heruntergeladen!",
|
"exampleImagesDownloadSuccess": "Beispielbilder erfolgreich heruntergeladen!",
|
||||||
"exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}",
|
"exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}",
|
||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}",
|
||||||
|
"copiedToClipboard": "In die Zwischenablage kopiert",
|
||||||
|
"downloadStarted": "Download gestartet"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
@@ -1398,6 +1398,21 @@
|
|||||||
"versionDeleted": "Version deleted"
|
"versionDeleted": "Version deleted"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"metadataFetchSummary": {
|
||||||
|
"title": "Metadata Fetch Summary",
|
||||||
|
"statSuccess": "Success",
|
||||||
|
"statFailed": "Failed",
|
||||||
|
"statSkipped": "Skipped",
|
||||||
|
"statTotal": "Total Scanned",
|
||||||
|
"statDuration": "Duration",
|
||||||
|
"successMessage": "All {count} {type}s updated successfully!",
|
||||||
|
"failedItems": "Failed Items ({count})",
|
||||||
|
"close": "Close",
|
||||||
|
"copyReport": "Copy Report",
|
||||||
|
"downloadCsv": "Download CSV",
|
||||||
|
"columnModelName": "Model Name",
|
||||||
|
"columnError": "Error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1957,7 +1972,9 @@
|
|||||||
"bulkMoveSuccess": "Successfully moved {successCount} {type}s",
|
"bulkMoveSuccess": "Successfully moved {successCount} {type}s",
|
||||||
"exampleImagesDownloadSuccess": "Successfully downloaded example images!",
|
"exampleImagesDownloadSuccess": "Successfully downloaded example images!",
|
||||||
"exampleImagesDownloadFailed": "Failed to download example images: {message}",
|
"exampleImagesDownloadFailed": "Failed to download example images: {message}",
|
||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}",
|
||||||
|
"copiedToClipboard": "Copied to clipboard",
|
||||||
|
"downloadStarted": "Download started"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
@@ -2052,4 +2069,4 @@
|
|||||||
"retry": "Retry"
|
"retry": "Retry"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1398,6 +1398,21 @@
|
|||||||
"versionDeleted": "Versión eliminada"
|
"versionDeleted": "Versión eliminada"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"metadataFetchSummary": {
|
||||||
|
"title": "Resumen de obtención de metadatos",
|
||||||
|
"statSuccess": "Éxito",
|
||||||
|
"statFailed": "Fallido",
|
||||||
|
"statSkipped": "Omitido",
|
||||||
|
"statTotal": "Total escaneado",
|
||||||
|
"statDuration": "Duración",
|
||||||
|
"successMessage": "¡Todos los {count} {type}s actualizados correctamente!",
|
||||||
|
"failedItems": "Elementos fallidos ({count})",
|
||||||
|
"close": "Cerrar",
|
||||||
|
"copyReport": "Copiar informe",
|
||||||
|
"downloadCsv": "Descargar CSV",
|
||||||
|
"columnModelName": "Nombre del modelo",
|
||||||
|
"columnError": "Error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1957,7 +1972,9 @@
|
|||||||
"bulkMoveSuccess": "Movidos exitosamente {successCount} {type}s",
|
"bulkMoveSuccess": "Movidos exitosamente {successCount} {type}s",
|
||||||
"exampleImagesDownloadSuccess": "¡Imágenes de ejemplo descargadas exitosamente!",
|
"exampleImagesDownloadSuccess": "¡Imágenes de ejemplo descargadas exitosamente!",
|
||||||
"exampleImagesDownloadFailed": "Error al descargar imágenes de ejemplo: {message}",
|
"exampleImagesDownloadFailed": "Error al descargar imágenes de ejemplo: {message}",
|
||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}",
|
||||||
|
"copiedToClipboard": "Copiado al portapapeles",
|
||||||
|
"downloadStarted": "Descarga iniciada"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
@@ -1398,6 +1398,21 @@
|
|||||||
"versionDeleted": "Version supprimée"
|
"versionDeleted": "Version supprimée"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"metadataFetchSummary": {
|
||||||
|
"title": "Récapitulatif de la récupération des métadonnées",
|
||||||
|
"statSuccess": "Réussi",
|
||||||
|
"statFailed": "Échoué",
|
||||||
|
"statSkipped": "Ignoré",
|
||||||
|
"statTotal": "Total scanné",
|
||||||
|
"statDuration": "Durée",
|
||||||
|
"successMessage": "Tous les {count} {type}s mis à jour avec succès !",
|
||||||
|
"failedItems": "Éléments échoués ({count})",
|
||||||
|
"close": "Fermer",
|
||||||
|
"copyReport": "Copier le rapport",
|
||||||
|
"downloadCsv": "Télécharger CSV",
|
||||||
|
"columnModelName": "Nom du modèle",
|
||||||
|
"columnError": "Erreur"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1957,7 +1972,9 @@
|
|||||||
"bulkMoveSuccess": "{successCount} {type}s déplacés avec succès",
|
"bulkMoveSuccess": "{successCount} {type}s déplacés avec succès",
|
||||||
"exampleImagesDownloadSuccess": "Images d'exemple téléchargées avec succès !",
|
"exampleImagesDownloadSuccess": "Images d'exemple téléchargées avec succès !",
|
||||||
"exampleImagesDownloadFailed": "Échec du téléchargement des images d'exemple : {message}",
|
"exampleImagesDownloadFailed": "Échec du téléchargement des images d'exemple : {message}",
|
||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}",
|
||||||
|
"copiedToClipboard": "Copié dans le presse-papiers",
|
||||||
|
"downloadStarted": "Téléchargement démarré"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
@@ -1398,6 +1398,21 @@
|
|||||||
"versionDeleted": "הגרסה נמחקה"
|
"versionDeleted": "הגרסה נמחקה"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"metadataFetchSummary": {
|
||||||
|
"title": "סיכום שליפת מטא-דאטה",
|
||||||
|
"statSuccess": "הצלחה",
|
||||||
|
"statFailed": "נכשל",
|
||||||
|
"statSkipped": "דולג",
|
||||||
|
"statTotal": "סה\"כ נסרק",
|
||||||
|
"statDuration": "משך",
|
||||||
|
"successMessage": "כל {count} {type}s עודכנו בהצלחה!",
|
||||||
|
"failedItems": "פריטים נכשלים ({count})",
|
||||||
|
"close": "סגור",
|
||||||
|
"copyReport": "העתק דוח",
|
||||||
|
"downloadCsv": "הורד CSV",
|
||||||
|
"columnModelName": "שם המודל",
|
||||||
|
"columnError": "שגיאה"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1957,7 +1972,9 @@
|
|||||||
"bulkMoveSuccess": "הועברו בהצלחה {successCount} {type}s",
|
"bulkMoveSuccess": "הועברו בהצלחה {successCount} {type}s",
|
||||||
"exampleImagesDownloadSuccess": "תמונות הדוגמה הורדו בהצלחה!",
|
"exampleImagesDownloadSuccess": "תמונות הדוגמה הורדו בהצלחה!",
|
||||||
"exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}",
|
"exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}",
|
||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}",
|
||||||
|
"copiedToClipboard": "הועתק ללוח",
|
||||||
|
"downloadStarted": "ההורדה החלה"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
@@ -1398,6 +1398,21 @@
|
|||||||
"versionDeleted": "バージョンを削除しました"
|
"versionDeleted": "バージョンを削除しました"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"metadataFetchSummary": {
|
||||||
|
"title": "メタデータ取得サマリー",
|
||||||
|
"statSuccess": "成功",
|
||||||
|
"statFailed": "失敗",
|
||||||
|
"statSkipped": "スキップ",
|
||||||
|
"statTotal": "スキャン合計",
|
||||||
|
"statDuration": "所要時間",
|
||||||
|
"successMessage": "すべての{count}件の{type}を正常に更新しました",
|
||||||
|
"failedItems": "失敗したアイテム ({count})",
|
||||||
|
"close": "閉じる",
|
||||||
|
"copyReport": "レポートをコピー",
|
||||||
|
"downloadCsv": "CSVをダウンロード",
|
||||||
|
"columnModelName": "モデル名",
|
||||||
|
"columnError": "エラー"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1957,7 +1972,9 @@
|
|||||||
"bulkMoveSuccess": "{successCount} {type}が正常に移動されました",
|
"bulkMoveSuccess": "{successCount} {type}が正常に移動されました",
|
||||||
"exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!",
|
"exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!",
|
||||||
"exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}",
|
"exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}",
|
||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}",
|
||||||
|
"copiedToClipboard": "クリップボードにコピーしました",
|
||||||
|
"downloadStarted": "ダウンロードを開始しました"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
@@ -1398,6 +1398,21 @@
|
|||||||
"versionDeleted": "버전이 삭제되었습니다"
|
"versionDeleted": "버전이 삭제되었습니다"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"metadataFetchSummary": {
|
||||||
|
"title": "메타데이터 가져오기 요약",
|
||||||
|
"statSuccess": "성공",
|
||||||
|
"statFailed": "실패",
|
||||||
|
"statSkipped": "건너뜀",
|
||||||
|
"statTotal": "총 스캔",
|
||||||
|
"statDuration": "소요 시간",
|
||||||
|
"successMessage": "모든 {count}개 {type}이(가) 성공적으로 업데이트되었습니다",
|
||||||
|
"failedItems": "실패한 항목 ({count})",
|
||||||
|
"close": "닫기",
|
||||||
|
"copyReport": "보고서 복사",
|
||||||
|
"downloadCsv": "CSV 다운로드",
|
||||||
|
"columnModelName": "모델 이름",
|
||||||
|
"columnError": "오류"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1957,7 +1972,9 @@
|
|||||||
"bulkMoveSuccess": "{successCount}개 {type}이(가) 성공적으로 이동되었습니다",
|
"bulkMoveSuccess": "{successCount}개 {type}이(가) 성공적으로 이동되었습니다",
|
||||||
"exampleImagesDownloadSuccess": "예시 이미지가 성공적으로 다운로드되었습니다!",
|
"exampleImagesDownloadSuccess": "예시 이미지가 성공적으로 다운로드되었습니다!",
|
||||||
"exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}",
|
"exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}",
|
||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}",
|
||||||
|
"copiedToClipboard": "클립보드에 복사됨",
|
||||||
|
"downloadStarted": "다운로드 시작됨"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
@@ -1398,6 +1398,21 @@
|
|||||||
"versionDeleted": "Версия удалена"
|
"versionDeleted": "Версия удалена"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"metadataFetchSummary": {
|
||||||
|
"title": "Сводка получения метаданных",
|
||||||
|
"statSuccess": "Успешно",
|
||||||
|
"statFailed": "Ошибка",
|
||||||
|
"statSkipped": "Пропущено",
|
||||||
|
"statTotal": "Всего проверено",
|
||||||
|
"statDuration": "Длительность",
|
||||||
|
"successMessage": "Все {count} {type}s успешно обновлены",
|
||||||
|
"failedItems": "Ошибочные элементы ({count})",
|
||||||
|
"close": "Закрыть",
|
||||||
|
"copyReport": "Копировать отчет",
|
||||||
|
"downloadCsv": "Скачать CSV",
|
||||||
|
"columnModelName": "Имя модели",
|
||||||
|
"columnError": "Ошибка"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1957,7 +1972,9 @@
|
|||||||
"bulkMoveSuccess": "Успешно перемещено {successCount} {type}s",
|
"bulkMoveSuccess": "Успешно перемещено {successCount} {type}s",
|
||||||
"exampleImagesDownloadSuccess": "Примеры изображений успешно загружены!",
|
"exampleImagesDownloadSuccess": "Примеры изображений успешно загружены!",
|
||||||
"exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}",
|
"exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}",
|
||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}",
|
||||||
|
"copiedToClipboard": "Скопировано в буфер обмена",
|
||||||
|
"downloadStarted": "Загрузка начата"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
@@ -1398,6 +1398,21 @@
|
|||||||
"versionDeleted": "版本已删除"
|
"versionDeleted": "版本已删除"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"metadataFetchSummary": {
|
||||||
|
"title": "元数据获取摘要",
|
||||||
|
"statSuccess": "成功",
|
||||||
|
"statFailed": "失败",
|
||||||
|
"statSkipped": "已跳过",
|
||||||
|
"statTotal": "总计扫描",
|
||||||
|
"statDuration": "耗时",
|
||||||
|
"successMessage": "全部 {count} 个 {type} 更新成功!",
|
||||||
|
"failedItems": "失败项目 ({count})",
|
||||||
|
"close": "关闭",
|
||||||
|
"copyReport": "复制报告",
|
||||||
|
"downloadCsv": "下载 CSV",
|
||||||
|
"columnModelName": "模型名称",
|
||||||
|
"columnError": "错误"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1957,7 +1972,9 @@
|
|||||||
"bulkMoveSuccess": "成功移动 {successCount} 个 {type}",
|
"bulkMoveSuccess": "成功移动 {successCount} 个 {type}",
|
||||||
"exampleImagesDownloadSuccess": "示例图片下载成功!",
|
"exampleImagesDownloadSuccess": "示例图片下载成功!",
|
||||||
"exampleImagesDownloadFailed": "示例图片下载失败:{message}",
|
"exampleImagesDownloadFailed": "示例图片下载失败:{message}",
|
||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}",
|
||||||
|
"copiedToClipboard": "已复制到剪贴板",
|
||||||
|
"downloadStarted": "下载已开始"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
@@ -1398,6 +1398,21 @@
|
|||||||
"versionDeleted": "已刪除此版本"
|
"versionDeleted": "已刪除此版本"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"metadataFetchSummary": {
|
||||||
|
"title": "元資料獲取摘要",
|
||||||
|
"statSuccess": "成功",
|
||||||
|
"statFailed": "失敗",
|
||||||
|
"statSkipped": "已跳過",
|
||||||
|
"statTotal": "總計掃描",
|
||||||
|
"statDuration": "耗時",
|
||||||
|
"successMessage": "全部 {count} 個 {type} 更新成功!",
|
||||||
|
"failedItems": "失敗項目 ({count})",
|
||||||
|
"close": "關閉",
|
||||||
|
"copyReport": "複製報告",
|
||||||
|
"downloadCsv": "下載 CSV",
|
||||||
|
"columnModelName": "模型名稱",
|
||||||
|
"columnError": "錯誤"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1957,7 +1972,9 @@
|
|||||||
"bulkMoveSuccess": "已成功移動 {successCount} 個 {type}",
|
"bulkMoveSuccess": "已成功移動 {successCount} 個 {type}",
|
||||||
"exampleImagesDownloadSuccess": "範例圖片下載成功!",
|
"exampleImagesDownloadSuccess": "範例圖片下載成功!",
|
||||||
"exampleImagesDownloadFailed": "下載範例圖片失敗:{message}",
|
"exampleImagesDownloadFailed": "下載範例圖片失敗:{message}",
|
||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}",
|
||||||
|
"copiedToClipboard": "已複製到剪貼簿",
|
||||||
|
"downloadStarted": "下載已開始"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
@@ -436,5 +436,14 @@ class LoraManager:
|
|||||||
try:
|
try:
|
||||||
logger.info("LoRA Manager: Cleaning up services")
|
logger.info("LoRA Manager: Cleaning up services")
|
||||||
|
|
||||||
|
# Cancel any in-flight scanner initialization tasks so thread-pool
|
||||||
|
# workers (e.g. _initialize_cache_sync) can break out of their loops
|
||||||
|
# when the server shuts down (e.g. Ctrl+C on WSL).
|
||||||
|
for name in ("lora_scanner", "checkpoint_scanner", "embedding_scanner"):
|
||||||
|
scanner = ServiceRegistry.get_service_sync(name)
|
||||||
|
if scanner is not None and hasattr(scanner, "cancel_task"):
|
||||||
|
scanner.cancel_task()
|
||||||
|
logger.debug("LoRA Manager: Cancelled %s", name)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error during cleanup: {e}", exc_info=True)
|
logger.error(f"Error during cleanup: {e}", exc_info=True)
|
||||||
|
|||||||
@@ -298,7 +298,12 @@ class SaveImageLM:
|
|||||||
key = parts[0]
|
key = parts[0]
|
||||||
|
|
||||||
if key == "seed" and "seed" in metadata_dict:
|
if key == "seed" and "seed" in metadata_dict:
|
||||||
filename = filename.replace(segment, str(metadata_dict.get("seed", "")))
|
seed_value = metadata_dict.get("seed")
|
||||||
|
if seed_value is not None:
|
||||||
|
filename = filename.replace(segment, str(seed_value))
|
||||||
|
else:
|
||||||
|
# Fallback if seed was not captured by metadata collector
|
||||||
|
filename = filename.replace(segment, "0")
|
||||||
elif key == "width" and "size" in metadata_dict:
|
elif key == "width" and "size" in metadata_dict:
|
||||||
size = metadata_dict.get("size", "x")
|
size = metadata_dict.get("size", "x")
|
||||||
w = size.split("x")[0] if isinstance(size, str) else size[0]
|
w = size.split("x")[0] if isinstance(size, str) else size[0]
|
||||||
|
|||||||
@@ -289,9 +289,10 @@ class MetadataSyncService:
|
|||||||
|
|
||||||
error_msg = (
|
error_msg = (
|
||||||
f"Error fetching metadata: {resolved_error} "
|
f"Error fetching metadata: {resolved_error} "
|
||||||
f"(model_name={model_data.get('model_name', '')})"
|
f"(file={os.path.basename(file_path)}, sha256={sha256})"
|
||||||
)
|
)
|
||||||
if is_expected_offline_error(resolved_error):
|
is_model_not_found = "Model not found" in resolved_error
|
||||||
|
if is_expected_offline_error(resolved_error) or is_model_not_found:
|
||||||
logger.info(error_msg)
|
logger.info(error_msg)
|
||||||
else:
|
else:
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
|
|||||||
@@ -1067,8 +1067,11 @@ class ModelScanner:
|
|||||||
|
|
||||||
model_data = self._build_cache_entry(metadata, folder=normalized_folder)
|
model_data = self._build_cache_entry(metadata, folder=normalized_folder)
|
||||||
|
|
||||||
# Compute SHA256 hash when metadata provided none (e.g., CivitAI API response has empty hashes)
|
# Compute SHA256 hash when metadata provided none (e.g., CivitAI API response has empty hashes).
|
||||||
if not model_data.get('sha256') and file_path:
|
# Respect hash_status='pending' (set by CheckpointScanner for large models) to defer
|
||||||
|
# hash calculation until on-demand — avoids reading entire checkpoint files at startup.
|
||||||
|
hash_status = model_data.get('hash_status', '')
|
||||||
|
if not model_data.get('sha256') and hash_status != 'pending' and file_path:
|
||||||
try:
|
try:
|
||||||
logger.info(f"Computing SHA256 hash for {file_path} (was empty from metadata)")
|
logger.info(f"Computing SHA256 hash for {file_path} (was empty from metadata)")
|
||||||
sha256 = await calculate_sha256(file_path)
|
sha256 = await calculate_sha256(file_path)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from typing import Any, Dict, List, Optional, Protocol, Sequence
|
from typing import Any, Dict, List, Optional, Protocol, Sequence
|
||||||
|
|
||||||
from ..metadata_sync_service import MetadataSyncService
|
from ..metadata_sync_service import MetadataSyncService
|
||||||
@@ -62,16 +63,35 @@ class BulkMetadataRefreshUseCase:
|
|||||||
]
|
]
|
||||||
|
|
||||||
total_to_process = len(to_process)
|
total_to_process = len(to_process)
|
||||||
|
initial_skipped = total_models - total_to_process # models excluded from fetch queue
|
||||||
processed = 0
|
processed = 0
|
||||||
success = 0
|
success = 0
|
||||||
|
skipped_count = initial_skipped
|
||||||
|
handled_count = initial_skipped
|
||||||
needs_resort = False
|
needs_resort = False
|
||||||
|
start_time = time.monotonic()
|
||||||
|
failures: List[Dict[str, str]] = []
|
||||||
|
|
||||||
self._service.scanner.reset_cancellation()
|
self._service.scanner.reset_cancellation()
|
||||||
|
|
||||||
async def emit(status: str, **extra: Any) -> None:
|
async def emit(status: str, **extra: Any) -> None:
|
||||||
if progress_callback is None:
|
if progress_callback is None:
|
||||||
return
|
return
|
||||||
payload = {"status": status, "total": total_to_process, "processed": processed, "success": success}
|
payload = {
|
||||||
|
"status": status,
|
||||||
|
"total": total_models,
|
||||||
|
"processed": processed,
|
||||||
|
"success": success,
|
||||||
|
"failure_count": len(failures),
|
||||||
|
"skipped_count": skipped_count,
|
||||||
|
"handled": handled_count,
|
||||||
|
"elapsed_seconds": int(time.monotonic() - start_time),
|
||||||
|
}
|
||||||
|
# Only include full failure details in terminal emits (completed,
|
||||||
|
# cancelled, rate_limited) to avoid serializing the list on every
|
||||||
|
# per-model progress update.
|
||||||
|
if failures and status in ("completed", "cancelled", "rate_limited"):
|
||||||
|
payload["failures"] = failures
|
||||||
payload.update(extra)
|
payload.update(extra)
|
||||||
await progress_callback.on_progress(payload)
|
await progress_callback.on_progress(payload)
|
||||||
|
|
||||||
@@ -84,7 +104,7 @@ class BulkMetadataRefreshUseCase:
|
|||||||
if self._service.scanner.is_cancelled():
|
if self._service.scanner.is_cancelled():
|
||||||
self._logger.info("Bulk metadata refresh cancelled by user")
|
self._logger.info("Bulk metadata refresh cancelled by user")
|
||||||
await emit("cancelled", processed=processed, success=success)
|
await emit("cancelled", processed=processed, success=success)
|
||||||
return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models}
|
return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models, "failures": failures, "failure_count": len(failures), "skipped_count": skipped_count, "elapsed_seconds": int(time.monotonic() - start_time)}
|
||||||
try:
|
try:
|
||||||
original_name = model.get("model_name")
|
original_name = model.get("model_name")
|
||||||
|
|
||||||
@@ -104,17 +124,23 @@ class BulkMetadataRefreshUseCase:
|
|||||||
model["hash_status"] = "completed"
|
model["hash_status"] = "completed"
|
||||||
else:
|
else:
|
||||||
self._logger.error(f"Failed to calculate hash for {file_path}")
|
self._logger.error(f"Failed to calculate hash for {file_path}")
|
||||||
|
failures.append({"name": model.get("model_name", file_path or "Unknown"), "error": "Failed to calculate hash"})
|
||||||
processed += 1
|
processed += 1
|
||||||
|
handled_count += 1
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
self._logger.warning(f"Scanner does not support lazy hash calculation for {file_path}")
|
self._logger.warning(f"Scanner does not support lazy hash calculation for {file_path}")
|
||||||
|
skipped_count += 1
|
||||||
processed += 1
|
processed += 1
|
||||||
|
handled_count += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Skip models without valid hash
|
# Skip models without valid hash
|
||||||
if not model.get("sha256"):
|
if not model.get("sha256"):
|
||||||
self._logger.warning(f"Skipping model without hash: {file_path}")
|
self._logger.warning(f"Skipping model without hash: {file_path}")
|
||||||
|
skipped_count += 1
|
||||||
processed += 1
|
processed += 1
|
||||||
|
handled_count += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
await MetadataManager.hydrate_model_data(model)
|
await MetadataManager.hydrate_model_data(model)
|
||||||
@@ -130,7 +156,16 @@ class BulkMetadataRefreshUseCase:
|
|||||||
else:
|
else:
|
||||||
consecutive_rate_limits = 0
|
consecutive_rate_limits = 0
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
current_name = model.get("model_name", file_path or "Unknown")
|
||||||
|
failures.append({"name": current_name, "error": error_msg or "Unknown error"})
|
||||||
|
self._logger.warning("Failed to fetch metadata for %s: %s", current_name, error_msg)
|
||||||
|
|
||||||
if consecutive_rate_limits >= RATE_LIMIT_ABORT_THRESHOLD:
|
if consecutive_rate_limits >= RATE_LIMIT_ABORT_THRESHOLD:
|
||||||
|
# The current model was attempted and failed due to rate limiting;
|
||||||
|
# count it before aborting so the summary is consistent.
|
||||||
|
processed += 1
|
||||||
|
handled_count += 1
|
||||||
self._logger.warning(
|
self._logger.warning(
|
||||||
"Bulk metadata refresh aborted: %d consecutive rate limits detected. "
|
"Bulk metadata refresh aborted: %d consecutive rate limits detected. "
|
||||||
"Processed %d/%d models.",
|
"Processed %d/%d models.",
|
||||||
@@ -140,8 +175,6 @@ class BulkMetadataRefreshUseCase:
|
|||||||
)
|
)
|
||||||
await emit(
|
await emit(
|
||||||
"rate_limited",
|
"rate_limited",
|
||||||
processed=processed,
|
|
||||||
success=success,
|
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
@@ -149,6 +182,10 @@ class BulkMetadataRefreshUseCase:
|
|||||||
"processed": processed,
|
"processed": processed,
|
||||||
"updated": success,
|
"updated": success,
|
||||||
"total": total_models,
|
"total": total_models,
|
||||||
|
"failures": failures,
|
||||||
|
"failure_count": len(failures),
|
||||||
|
"skipped_count": skipped_count,
|
||||||
|
"elapsed_seconds": int(time.monotonic() - start_time),
|
||||||
}
|
}
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
@@ -156,6 +193,7 @@ class BulkMetadataRefreshUseCase:
|
|||||||
if original_name != model.get("model_name"):
|
if original_name != model.get("model_name"):
|
||||||
needs_resort = True
|
needs_resort = True
|
||||||
processed += 1
|
processed += 1
|
||||||
|
handled_count += 1
|
||||||
await emit(
|
await emit(
|
||||||
"processing",
|
"processing",
|
||||||
processed=processed,
|
processed=processed,
|
||||||
@@ -164,6 +202,9 @@ class BulkMetadataRefreshUseCase:
|
|||||||
)
|
)
|
||||||
except Exception as exc: # pragma: no cover - logging path
|
except Exception as exc: # pragma: no cover - logging path
|
||||||
processed += 1
|
processed += 1
|
||||||
|
handled_count += 1
|
||||||
|
current_name = model.get("model_name", model.get("file_path", "Unknown"))
|
||||||
|
failures.append({"name": current_name, "error": str(exc)})
|
||||||
self._logger.error(
|
self._logger.error(
|
||||||
"Error fetching CivitAI data for %s: %s",
|
"Error fetching CivitAI data for %s: %s",
|
||||||
model.get("file_path"),
|
model.get("file_path"),
|
||||||
@@ -180,7 +221,7 @@ class BulkMetadataRefreshUseCase:
|
|||||||
f"{success} of {processed} processed {self._service.model_type}s (total: {total_models})"
|
f"{success} of {processed} processed {self._service.model_type}s (total: {total_models})"
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"success": True, "message": message, "processed": processed, "updated": success, "total": total_models}
|
return {"success": True, "message": message, "processed": processed, "updated": success, "total": total_models, "failures": failures, "failure_count": len(failures), "skipped_count": skipped_count, "elapsed_seconds": int(time.monotonic() - start_time)}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_in_skip_path(folder: str, skip_paths: List[str]) -> bool:
|
def _is_in_skip_path(folder: str, skip_paths: List[str]) -> bool:
|
||||||
|
|||||||
@@ -34,12 +34,21 @@ def _get_hash_chunk_size_bytes() -> int:
|
|||||||
|
|
||||||
|
|
||||||
async def calculate_sha256(file_path: str) -> str:
|
async def calculate_sha256(file_path: str) -> str:
|
||||||
"""Calculate SHA256 hash of a file (full file content)."""
|
"""Calculate SHA256 hash of a file (full file content).
|
||||||
|
|
||||||
|
Uses ``posix_fadvise`` with ``POSIX_FADV_DONTNEED`` to avoid polluting the OS page
|
||||||
|
cache — critical on WSL where cached file pages live inside the VM and are not
|
||||||
|
accounted for in guest ``used`` memory, causing VmmemWSL to balloon.
|
||||||
|
"""
|
||||||
sha256_hash = hashlib.sha256()
|
sha256_hash = hashlib.sha256()
|
||||||
chunk_size = _get_hash_chunk_size_bytes()
|
chunk_size = _get_hash_chunk_size_bytes()
|
||||||
with open(file_path, "rb") as f:
|
with open(file_path, "rb") as f:
|
||||||
|
fd = f.fileno()
|
||||||
for byte_block in iter(lambda: f.read(chunk_size), b""):
|
for byte_block in iter(lambda: f.read(chunk_size), b""):
|
||||||
sha256_hash.update(byte_block)
|
sha256_hash.update(byte_block)
|
||||||
|
# Evict pages after reading so the data doesn't linger in the kernel page
|
||||||
|
# cache — on WSL this otherwise appears as unreclaimable VmmemWSL growth.
|
||||||
|
os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
|
||||||
return sha256_hash.hexdigest()
|
return sha256_hash.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
196
static/css/components/metadata-refresh-result.css
Normal file
196
static/css/components/metadata-refresh-result.css
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
/* Metadata Refresh Result Modal — component styles only */
|
||||||
|
|
||||||
|
.metadata-refresh-result-modal {
|
||||||
|
max-width: 700px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-summary-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 130px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card > i {
|
||||||
|
font-size: 1.25em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-value {
|
||||||
|
font-weight: var(--weight-bold);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
color: var(--lora-text);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-success {
|
||||||
|
border-left-color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-success > i {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-failure {
|
||||||
|
border-left-color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-failure > i {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-skipped {
|
||||||
|
border-left-color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-skipped > i {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-total {
|
||||||
|
border-left-color: var(--color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-total > i {
|
||||||
|
color: var(--color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-time {
|
||||||
|
border-left-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-time > i {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-failures-section {
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-failures-section h4 {
|
||||||
|
margin: 0 0 var(--space-2) 0;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--color-error);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-failures-section h4 i {
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure-table-wrapper {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure-table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border-bottom: 2px solid var(--lora-border);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
text-align: left;
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure-table td {
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border-bottom: 1px solid var(--lora-border);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure-table tr:hover td {
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure-index {
|
||||||
|
width: 30px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure-name {
|
||||||
|
max-width: 300px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure-error {
|
||||||
|
color: var(--color-error);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-success-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
border-left: 4px solid var(--color-success);
|
||||||
|
color: var(--lora-text);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-success-message i {
|
||||||
|
font-size: 1.2em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .failure-table th {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .failure-table td {
|
||||||
|
border-bottom-color: var(--lora-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .failure-table tr:hover td {
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
@import 'components/statistics.css'; /* Add statistics component */
|
@import 'components/statistics.css'; /* Add statistics component */
|
||||||
@import 'components/sidebar.css'; /* Add sidebar component */
|
@import 'components/sidebar.css'; /* Add sidebar component */
|
||||||
@import 'components/media-viewer.css';
|
@import 'components/media-viewer.css';
|
||||||
|
@import 'components/metadata-refresh-result.css';
|
||||||
|
|
||||||
.initialization-notice {
|
.initialization-notice {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -547,6 +547,14 @@ export class BaseModelApiClient {
|
|||||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||||
ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
|
ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
|
||||||
|
|
||||||
|
// Wait for WebSocket connection to establish
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
ws.onopen = resolve;
|
||||||
|
ws.onerror = reject;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now that we're connected, set up the message/error handlers
|
||||||
|
// for the actual operation (separate from connection errors)
|
||||||
const operationComplete = new Promise((resolve, reject) => {
|
const operationComplete = new Promise((resolve, reject) => {
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
@@ -556,25 +564,39 @@ export class BaseModelApiClient {
|
|||||||
loading.setStatus('Starting metadata fetch...');
|
loading.setStatus('Starting metadata fetch...');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'processing':
|
case 'processing': {
|
||||||
const percent = ((data.processed / data.total) * 100).toFixed(1);
|
const handled = data.handled || data.processed;
|
||||||
|
const percent = ((handled / data.total) * 100).toFixed(1);
|
||||||
loading.setProgress(percent);
|
loading.setProgress(percent);
|
||||||
loading.setStatus(
|
let statusText = `Processing (${handled}/${data.total}) ${data.current_name || ''}`;
|
||||||
`Processing (${data.processed}/${data.total}) ${data.current_name}`
|
if (data.failure_count > 0) {
|
||||||
);
|
statusText += ` | ❌ ${data.failure_count} failed`;
|
||||||
|
}
|
||||||
|
if (data.skipped_count > 0) {
|
||||||
|
statusText += ` | ⏭️ ${data.skipped_count} skipped`;
|
||||||
|
}
|
||||||
|
loading.setStatus(statusText);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'completed':
|
case 'completed': {
|
||||||
loading.setProgress(100);
|
loading.setProgress(100);
|
||||||
loading.setStatus(
|
let summaryText = `Completed: Updated ${data.success} of ${data.processed} ${this.apiConfig.config.displayName}s`;
|
||||||
`Completed: Updated ${data.success} of ${data.processed} ${this.apiConfig.config.displayName}s`
|
if (data.failure_count > 0) {
|
||||||
);
|
summaryText += ` | ❌ ${data.failure_count} failed`;
|
||||||
|
}
|
||||||
|
if (data.skipped_count > 0) {
|
||||||
|
summaryText += ` | ⏭️ ${data.skipped_count} skipped`;
|
||||||
|
}
|
||||||
|
summaryText += ` (⏱ ${data.elapsed_seconds || '?'}s)`;
|
||||||
|
loading.setStatus(summaryText);
|
||||||
resolve(data);
|
resolve(data);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'cancelled':
|
case 'cancelled':
|
||||||
loading.setStatus('Operation cancelled by user');
|
loading.setStatus('Operation cancelled by user');
|
||||||
resolve(data); // Consider it complete but marked as cancelled
|
resolve(data);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'error':
|
case 'error':
|
||||||
@@ -588,12 +610,6 @@ export class BaseModelApiClient {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for WebSocket connection to establish
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
ws.onopen = resolve;
|
|
||||||
ws.onerror = reject;
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(this.apiConfig.endpoints.fetchAllCivitai, {
|
const response = await fetch(this.apiConfig.endpoints.fetchAllCivitai, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -608,10 +624,10 @@ export class BaseModelApiClient {
|
|||||||
const finalData = await operationComplete;
|
const finalData = await operationComplete;
|
||||||
|
|
||||||
resetAndReload(false);
|
resetAndReload(false);
|
||||||
if (finalData && finalData.status === 'cancelled') {
|
|
||||||
showToast('toast.api.operationCancelledPartial', { success: finalData.success, total: finalData.total }, 'info');
|
// Show result summary with failure details
|
||||||
} else {
|
if (finalData) {
|
||||||
showToast('toast.api.metadataUpdateComplete', {}, 'success');
|
this._showMetadataRefreshResult(finalData);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching metadata:', error);
|
console.error('Error fetching metadata:', error);
|
||||||
@@ -627,6 +643,210 @@ export class BaseModelApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_showMetadataRefreshResult(data) {
|
||||||
|
const { success, total } = data;
|
||||||
|
|
||||||
|
if (data.status === 'cancelled') {
|
||||||
|
showToast('toast.api.operationCancelledPartial', { success, total }, 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._showFailureDetailsModal(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
_showFailureDetailsModal(data) {
|
||||||
|
const { failures = [], success, processed, total, failure_count, skipped_count, elapsed_seconds } = data;
|
||||||
|
|
||||||
|
// Build failure list HTML
|
||||||
|
const failureRows = failures.map((f, i) =>
|
||||||
|
`<tr>
|
||||||
|
<td class="failure-index">${i + 1}</td>
|
||||||
|
<td class="failure-name" title="${this._escapeHtml(f.name)}">${this._escapeHtml(f.name)}</td>
|
||||||
|
<td class="failure-error">${this._escapeHtml(f.error || 'Unknown')}</td>
|
||||||
|
</tr>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const modalHtml = `
|
||||||
|
<div id="metadataRefreshResultModal" class="modal" style="display: block;">
|
||||||
|
<div class="modal-content metadata-refresh-result-modal">
|
||||||
|
<button class="close" data-action="close-modal">×</button>
|
||||||
|
|
||||||
|
<h2><i class="fas fa-sync-alt"></i> ${translate('modals.metadataFetchSummary.title', {}, 'Metadata Fetch Summary')}</h2>
|
||||||
|
|
||||||
|
<div class="refresh-summary-stats">
|
||||||
|
<div class="stat-card stat-card-success">
|
||||||
|
<i class="fas fa-check-circle"></i>
|
||||||
|
<div class="stat-card-body">
|
||||||
|
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statSuccess', {}, 'Success')}</span>
|
||||||
|
<span class="stat-card-value">${success}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-card-failure">
|
||||||
|
<i class="fas fa-times-circle"></i>
|
||||||
|
<div class="stat-card-body">
|
||||||
|
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statFailed', {}, 'Failed')}</span>
|
||||||
|
<span class="stat-card-value">${failure_count}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-card-skipped">
|
||||||
|
<i class="fas fa-forward"></i>
|
||||||
|
<div class="stat-card-body">
|
||||||
|
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statSkipped', {}, 'Skipped')}</span>
|
||||||
|
<span class="stat-card-value">${skipped_count}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-card-total">
|
||||||
|
<i class="fas fa-database"></i>
|
||||||
|
<div class="stat-card-body">
|
||||||
|
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statTotal', {}, 'Total Scanned')}</span>
|
||||||
|
<span class="stat-card-value">${total || processed}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-card-time">
|
||||||
|
<i class="fas fa-clock"></i>
|
||||||
|
<div class="stat-card-body">
|
||||||
|
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statDuration', {}, 'Duration')}</span>
|
||||||
|
<span class="stat-card-value">${elapsed_seconds}s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${failure_count > 0 ? `
|
||||||
|
<div class="refresh-failures-section">
|
||||||
|
<h4><i class="fas fa-exclamation-triangle"></i> ${translate('modals.metadataFetchSummary.failedItems', { count: failure_count }, 'Failed Items (' + failure_count + ')')}</h4>
|
||||||
|
<div class="failure-table-wrapper">
|
||||||
|
<table class="failure-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>${translate('modals.metadataFetchSummary.columnModelName', {}, 'Model Name')}</th>
|
||||||
|
<th>${translate('modals.metadataFetchSummary.columnError', {}, 'Error')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${failureRows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="refresh-success-message">
|
||||||
|
<i class="fas fa-check-circle"></i> ${translate('modals.metadataFetchSummary.successMessage', { count: success, type: this.apiConfig.config.displayName }, 'All ' + success + ' ' + this.apiConfig.config.displayName + 's updated successfully!')}
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="cancel-btn" data-action="close-modal">${translate('modals.metadataFetchSummary.close', {}, 'Close')}</button>
|
||||||
|
${failure_count > 0 ? `
|
||||||
|
<button class="secondary-btn" data-action="copy-report"><i class="fas fa-copy"></i> ${translate('modals.metadataFetchSummary.copyReport', {}, 'Copy Report')}</button>
|
||||||
|
<button class="secondary-btn" data-action="download-csv"><i class="fas fa-download"></i> ${translate('modals.metadataFetchSummary.downloadCsv', {}, 'Download CSV')}</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const existing = document.getElementById('metadataRefreshResultModal');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.innerHTML = modalHtml;
|
||||||
|
const modal = container.firstElementChild;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
const action = e.target.closest('[data-action]')?.dataset.action;
|
||||||
|
if (!action) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'close-modal':
|
||||||
|
modal.remove();
|
||||||
|
break;
|
||||||
|
case 'copy-report':
|
||||||
|
BaseModelApiClient._copyRefreshReport(e.target.closest('[data-action]'), data);
|
||||||
|
break;
|
||||||
|
case 'download-csv':
|
||||||
|
BaseModelApiClient._downloadRefreshReport(data);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
static _copyRefreshReport(btn, data) {
|
||||||
|
const { failures = [], success, processed, total, failure_count, skipped_count, elapsed_seconds } = data;
|
||||||
|
const lines = [
|
||||||
|
'=== Metadata Refresh Report ===',
|
||||||
|
`Date: ${new Date().toLocaleString()}`,
|
||||||
|
`Duration: ${elapsed_seconds}s`,
|
||||||
|
`Total scanned: ${total || processed}`,
|
||||||
|
`Successfully updated: ${success}`,
|
||||||
|
`Failed: ${failure_count}`,
|
||||||
|
`Skipped: ${skipped_count}`,
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
if (failure_count > 0) {
|
||||||
|
lines.push('--- Failed Items ---');
|
||||||
|
failures.forEach((f, i) => {
|
||||||
|
lines.push(`${i + 1}. ${f.name || 'Unknown'} — ${f.error || 'Unknown error'}`);
|
||||||
|
});
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
lines.push('====================');
|
||||||
|
|
||||||
|
const text = lines.join('\n');
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
showToast('toast.api.copiedToClipboard', {}, 'success');
|
||||||
|
if (btn) {
|
||||||
|
const origHTML = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||||
|
setTimeout(() => { btn.innerHTML = origHTML; }, 2000);
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
// Fallback
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
showToast('toast.api.copiedToClipboard', {}, 'success');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static _downloadRefreshReport(data) {
|
||||||
|
const { failures = [], success, processed, total, failure_count, skipped_count, elapsed_seconds } = data;
|
||||||
|
|
||||||
|
// CSV header
|
||||||
|
let csv = 'Model Name,Error\n';
|
||||||
|
failures.forEach(f => {
|
||||||
|
const name = (f.name || 'Unknown').replace(/"/g, '""');
|
||||||
|
const error = (f.error || 'Unknown').replace(/"/g, '""');
|
||||||
|
csv += `"${name}","${error}"\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add summary as trailing comments
|
||||||
|
csv += `\n# Summary: ${success} success, ${failure_count} failed, ${skipped_count} skipped, ${elapsed_seconds}s\n`;
|
||||||
|
csv += `# Total scanned: ${total || processed}\n`;
|
||||||
|
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `metadata-refresh-failures-${Date.now()}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
showToast('toast.api.downloadStarted', {}, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
async refreshBulkModelMetadata(filePaths) {
|
async refreshBulkModelMetadata(filePaths) {
|
||||||
if (!filePaths || filePaths.length === 0) {
|
if (!filePaths || filePaths.length === 0) {
|
||||||
throw new Error('No file paths provided');
|
throw new Error('No file paths provided');
|
||||||
|
|||||||
135
web/comfyui/save_image_extra_output.js
Normal file
135
web/comfyui/save_image_extra_output.js
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { app } from "../../scripts/app.js";
|
||||||
|
import { chainCallback, getAllGraphNodes, getWidgetByName } from "./utils.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date string using the given pattern (e.g. "yyyy-MM-dd").
|
||||||
|
* Supports: yyyy, yy, MM, M, dd, d, hh, h, mm, m, ss, s
|
||||||
|
*/
|
||||||
|
function formatDate(text, date) {
|
||||||
|
const pad = (n, len) => n.toString().padStart(len, "0");
|
||||||
|
// Order matters: longer patterns first to avoid partial substring matches.
|
||||||
|
// The original ComfyUI frontend uses the same ordered-alternation approach.
|
||||||
|
return text
|
||||||
|
.replace(/yyyy/g, () => date.getFullYear().toString())
|
||||||
|
.replace(/yy/g, () => pad(date.getFullYear() % 100, 2))
|
||||||
|
.replace(/MM/g, () => pad(date.getMonth() + 1, 2))
|
||||||
|
.replace(/M/g, () => (date.getMonth() + 1).toString())
|
||||||
|
.replace(/dd/g, () => pad(date.getDate(), 2))
|
||||||
|
.replace(/d/g, () => date.getDate().toString())
|
||||||
|
.replace(/hh/g, () => pad(date.getHours(), 2))
|
||||||
|
.replace(/h/g, () => date.getHours().toString())
|
||||||
|
.replace(/mm/g, () => pad(date.getMinutes(), 2))
|
||||||
|
.replace(/m/g, () => date.getMinutes().toString())
|
||||||
|
.replace(/ss/g, () => pad(date.getSeconds(), 2))
|
||||||
|
.replace(/s/g, () => date.getSeconds().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve %NodeTitle.WidgetName% placeholders in a string using the current graph.
|
||||||
|
*
|
||||||
|
* Patterns supported:
|
||||||
|
* %NodeTitle.WidgetName% – widget value from a node (by title or "Node name for S&R")
|
||||||
|
* %date:format% – current date/time formatted (e.g. %date:yyyy-MM-dd%)
|
||||||
|
* %width%, %height% – left as-is, handled by the backend
|
||||||
|
*
|
||||||
|
* All other %text% patterns are passed through unchanged (they may be handled by
|
||||||
|
* the backend's format_filename, e.g. %seed%, %model%, %pprompt%).
|
||||||
|
*/
|
||||||
|
function applyTextReplacements(value) {
|
||||||
|
if (!value || typeof value !== "string" || !value.includes("%")) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all nodes from the entire graph hierarchy (including subgraphs)
|
||||||
|
const allNodes = getAllGraphNodes(app.graph);
|
||||||
|
|
||||||
|
return value.replace(/%([^%]+)%/g, function (match, text) {
|
||||||
|
const split = text.split(".");
|
||||||
|
if (split.length !== 2) {
|
||||||
|
// Handle %date:format% patterns
|
||||||
|
if (split[0].startsWith("date:")) {
|
||||||
|
return formatDate(split[0].substring(5), new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
// %width% and %height% are left for the backend to handle
|
||||||
|
if (text !== "width" && text !== "height") {
|
||||||
|
console.warn(
|
||||||
|
"[Save Image (LoraManager)] Unknown placeholder: %" + text + "%"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try finding the node by its "Node name for S&R" property first
|
||||||
|
let nodes = allNodes
|
||||||
|
.filter((n) => n.node.properties?.["Node name for S&R"] === split[0])
|
||||||
|
.map((n) => n.node);
|
||||||
|
|
||||||
|
// Fall back to matching by node title
|
||||||
|
if (!nodes.length) {
|
||||||
|
nodes = allNodes
|
||||||
|
.filter((n) => n.node.title === split[0])
|
||||||
|
.map((n) => n.node);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nodes.length) {
|
||||||
|
console.warn(
|
||||||
|
"[Save Image (LoraManager)] Node not found: " + split[0]
|
||||||
|
);
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodes.length > 1) {
|
||||||
|
console.warn(
|
||||||
|
"[Save Image (LoraManager)] Multiple nodes matched '" +
|
||||||
|
split[0] +
|
||||||
|
"', using first match"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = nodes[0];
|
||||||
|
const widget = node.widgets?.find((w) => w.name === split[1]);
|
||||||
|
if (!widget) {
|
||||||
|
console.warn(
|
||||||
|
"[Save Image (LoraManager)] Widget '" +
|
||||||
|
split[1] +
|
||||||
|
"' not found on node " +
|
||||||
|
split[0]
|
||||||
|
);
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize the value: replace characters invalid for filenames
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
return ((widget.value ?? "") + "").replaceAll(
|
||||||
|
/[/?<>\\:*|"\x00-\x1F\x7F]/g,
|
||||||
|
"_"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.registerExtension({
|
||||||
|
name: "LoraManager.SaveImageExtraOutput",
|
||||||
|
|
||||||
|
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||||
|
if (nodeData.name !== "Save Image (LoraManager)") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
||||||
|
// Find the filename_prefix widget
|
||||||
|
const widget = getWidgetByName(this, "filename_prefix");
|
||||||
|
if (!widget) {
|
||||||
|
console.warn(
|
||||||
|
"[Save Image (LoraManager)] filename_prefix widget not found"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override serialization to resolve %NodeTitle.WidgetName% placeholders
|
||||||
|
widget.serializeValue = () => {
|
||||||
|
return applyTextReplacements(widget.value);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user