fuck
This commit is contained in:
180
arc-export/.gitignore
vendored
Normal file
180
arc-export/.gitignore
vendored
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# General
|
||||||
|
.DS_Store
|
||||||
|
.Trashes
|
||||||
|
.Spotlight-V100
|
||||||
|
Thumbs.db
|
||||||
|
$RECYCLE.BIN/
|
||||||
|
|
||||||
|
# Bookmarks
|
||||||
|
bookmarks/
|
||||||
|
arc_bookmarks_*
|
||||||
|
|
||||||
|
# VsCode
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
# Idea
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Project
|
||||||
|
poetry.lock
|
||||||
|
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
#pdm.lock
|
||||||
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
|
# in version control.
|
||||||
|
# https://pdm.fming.dev/#use-with-ide
|
||||||
|
.pdm.toml
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
7
build-lora-sheet
Executable file
7
build-lora-sheet
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Generate LoRA trigger word sheet to ~/git/ai/comfyui/lora-info-sheet.html
|
||||||
|
|
||||||
|
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
"$DIR/lora-trigger-sheet" \
|
||||||
|
"$HOME/mnt/nimo.loc/ComfyUI/models/loras" \
|
||||||
|
"$HOME/git/ai/comfyui/lora-info-sheet.html"
|
||||||
78
comfyui-sync
78
comfyui-sync
@@ -1,78 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
OUTPUT_SOURCE="$HOME/ComfyUI/output"
|
|
||||||
VIDEO_SOURCE="$HOME/ComfyUI/videos"
|
|
||||||
AUDIO_SOURCE="$HOME/ComfyUI/output/audio"
|
|
||||||
SHARE_DIR="/mini.nas/miniShare1/Pictures"
|
|
||||||
STATE_FILE="$HOME/.local/state/comfyui-sync.state"
|
|
||||||
NEATCLI="$(command -v /home/linuxbrew/.linuxbrew/bin/neatcli || true)"
|
|
||||||
RENAME_SCRIPT="$HOME/git/schmeeve-toolz/rename-ai-snaps"
|
|
||||||
ORGANIZE_SCRIPT="$HOME/git/schmeeve-toolz/organize-images"
|
|
||||||
|
|
||||||
mkdir -p "$(dirname "$STATE_FILE")"
|
|
||||||
|
|
||||||
if [ ! -d "$SHARE_DIR" ]; then
|
|
||||||
echo "[comfyui-sync] ERROR: Share dir $SHARE_DIR does not exist"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[comfyui-sync] Starting at $(date)"
|
|
||||||
|
|
||||||
current_files=$( {
|
|
||||||
find "$OUTPUT_SOURCE" -maxdepth 1 -type f -printf '%f\n' 2>/dev/null
|
|
||||||
find "$VIDEO_SOURCE" -maxdepth 1 -type f -printf '%f\n' 2>/dev/null
|
|
||||||
find "$AUDIO_SOURCE" -maxdepth 1 -type f -printf '%f\n' 2>/dev/null
|
|
||||||
} | sort)
|
|
||||||
|
|
||||||
if [ -f "$STATE_FILE" ]; then
|
|
||||||
new_files=$(comm -13 <(sort "$STATE_FILE") <(echo "$current_files"))
|
|
||||||
else
|
|
||||||
echo "[comfyui-sync] First run — all files considered new."
|
|
||||||
new_files="$current_files"
|
|
||||||
fi
|
|
||||||
|
|
||||||
new_count=$(echo "$new_files" | grep -c '[^[:space:]]' || true)
|
|
||||||
|
|
||||||
if [ "$new_count" -gt 0 ]; then
|
|
||||||
echo "[comfyui-sync] Copying $new_count new file(s)..."
|
|
||||||
while IFS= read -r f; do
|
|
||||||
[ -z "$f" ] && continue
|
|
||||||
if [ -f "$OUTPUT_SOURCE/$f" ]; then
|
|
||||||
cp "$OUTPUT_SOURCE/$f" "$SHARE_DIR/"
|
|
||||||
elif [ -f "$VIDEO_SOURCE/$f" ]; then
|
|
||||||
cp "$VIDEO_SOURCE/$f" "$SHARE_DIR/"
|
|
||||||
elif [ -f "$AUDIO_SOURCE/$f" ]; then
|
|
||||||
cp "$AUDIO_SOURCE/$f" "$SHARE_DIR/"
|
|
||||||
fi
|
|
||||||
done <<<"$new_files"
|
|
||||||
|
|
||||||
echo "$current_files" >"$STATE_FILE"
|
|
||||||
echo "[comfyui-sync] State file updated."
|
|
||||||
else
|
|
||||||
echo "[comfyui-sync] No new files to copy."
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$NEATCLI" ]; then
|
|
||||||
echo "[comfyui-sync] Running neatcli organize --by-type -e $SHARE_DIR"
|
|
||||||
"$NEATCLI" organize --by-type -e "$SHARE_DIR"
|
|
||||||
else
|
|
||||||
echo "[comfyui-sync] WARNING: neatcli not found, skipping organize step"
|
|
||||||
fi
|
|
||||||
|
|
||||||
IMAGES_DIR="$SHARE_DIR/Images"
|
|
||||||
if [ -d "$IMAGES_DIR" ] && [ -x "$RENAME_SCRIPT" ]; then
|
|
||||||
echo "[comfyui-sync] Running rename-ai-snaps on $IMAGES_DIR"
|
|
||||||
cd "$IMAGES_DIR" && "$RENAME_SCRIPT" . --no-interactive
|
|
||||||
elif [ ! -x "$RENAME_SCRIPT" ]; then
|
|
||||||
echo "[comfyui-sync] WARNING: rename-ai-snaps not found, skipping rename step"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -d "$IMAGES_DIR" ] && [ -x "$ORGANIZE_SCRIPT" ]; then
|
|
||||||
echo "[comfyui-sync] Running organize-images on $IMAGES_DIR"
|
|
||||||
"$ORGANIZE_SCRIPT" "$IMAGES_DIR" --execute
|
|
||||||
elif [ ! -x "$ORGANIZE_SCRIPT" ]; then
|
|
||||||
echo "[comfyui-sync] WARNING: organize-images not found, skipping organize step"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[comfyui-sync] Done at $(date)"
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
img/ai-technology.png
Normal file
BIN
img/ai-technology.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
img/nude.png
Normal file
BIN
img/nude.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
1036
lora-info-sheet.html
Normal file
1036
lora-info-sheet.html
Normal file
File diff suppressed because one or more lines are too long
629
lora-trigger-sheet
Executable file
629
lora-trigger-sheet
Executable file
@@ -0,0 +1,629 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate a LoRA trigger word dictionary HTML page from .safetensors metadata.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
lora-trigger-sheet # default: ~/mnt/nimo.loc/ComfyUI/models/loras
|
||||||
|
lora-trigger-sheet /path/to/loras # specify directory
|
||||||
|
lora-trigger-sheet . # current directory
|
||||||
|
lora-trigger-sheet /path/to/loras /path/to/output # specify both input and output
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import time
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.parse import quote
|
||||||
|
from urllib.request import urlopen, Request
|
||||||
|
|
||||||
|
|
||||||
|
def read_safetensors_meta(filepath):
|
||||||
|
try:
|
||||||
|
with open(filepath, "rb") as f:
|
||||||
|
header_len = struct.unpack("<Q", f.read(8))[0]
|
||||||
|
raw = f.read(header_len)
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Metadata is inside "__metadata__" key
|
||||||
|
if "__metadata__" in parsed:
|
||||||
|
return parsed["__metadata__"]
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def get_tool(meta):
|
||||||
|
if meta is None:
|
||||||
|
return "error"
|
||||||
|
|
||||||
|
if meta.get("ss_sd_scripts_commit_hash"):
|
||||||
|
return "sd-scripts"
|
||||||
|
|
||||||
|
net = (meta.get("ss_training_network") or meta.get("ss_network_module") or "").lower()
|
||||||
|
if "kohya" in net:
|
||||||
|
return "kohya"
|
||||||
|
if "sd-scripts" in net or "sd_scripts" in net:
|
||||||
|
return "sd-scripts"
|
||||||
|
|
||||||
|
if meta.get("ss_learning_rate"):
|
||||||
|
return "sd-scripts"
|
||||||
|
|
||||||
|
if net:
|
||||||
|
return net
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def get_base_model(meta):
|
||||||
|
if meta is None:
|
||||||
|
return "error"
|
||||||
|
|
||||||
|
base = (meta.get("ss_base_model_version")
|
||||||
|
or meta.get("ss_base_model")
|
||||||
|
or meta.get("modelspec.architecture", "")
|
||||||
|
.replace("/lora", "")
|
||||||
|
or "")
|
||||||
|
if base:
|
||||||
|
return base
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def get_tags(meta):
|
||||||
|
if meta is None:
|
||||||
|
return None, 0
|
||||||
|
|
||||||
|
raw = meta.get("ss_tag_frequency")
|
||||||
|
if not raw:
|
||||||
|
return None, 0
|
||||||
|
|
||||||
|
# ss_tag_frequency is often a JSON string; parse if needed
|
||||||
|
if isinstance(raw, str):
|
||||||
|
try:
|
||||||
|
raw = json.loads(raw)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return None, 0
|
||||||
|
|
||||||
|
tags = {}
|
||||||
|
total = 0
|
||||||
|
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
for category, tag_dict in raw.items():
|
||||||
|
if isinstance(tag_dict, dict):
|
||||||
|
for tag, count in tag_dict.items():
|
||||||
|
total += count
|
||||||
|
tags[tag] = tags.get(tag, 0) + count
|
||||||
|
elif isinstance(tag_dict, (int, float)):
|
||||||
|
total += tag_dict
|
||||||
|
|
||||||
|
if not tags:
|
||||||
|
if all(isinstance(v, (int, float)) for v in raw.values()):
|
||||||
|
tags = raw
|
||||||
|
total = sum(raw.values())
|
||||||
|
else:
|
||||||
|
return None, 0
|
||||||
|
|
||||||
|
return tags, total
|
||||||
|
|
||||||
|
|
||||||
|
def shorten_base_model(name):
|
||||||
|
name = re.sub(r"[-_][Vv]\d+(?:[._-]\d+)?$", "", name)
|
||||||
|
name = re.sub(r"[-_][Vv]\d+[a-z]?\d*$", "", name)
|
||||||
|
replacements = {
|
||||||
|
"stable-diffusion-xl-v1-base": "sdxl_base_v1-0",
|
||||||
|
"stable diffusion xl base v1.0": "sdxl_base_v1-0",
|
||||||
|
"sdxl base v1.0": "sdxl_base_v1-0",
|
||||||
|
"sdxl base v0.9": "sdxl_base_v0-9",
|
||||||
|
"flux.1": "flux1",
|
||||||
|
"flux": "flux1",
|
||||||
|
}
|
||||||
|
for old, new in replacements.items():
|
||||||
|
if name.lower() == old.lower() or name.lower().startswith(old.lower()):
|
||||||
|
return new
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def html_id():
|
||||||
|
import hashlib, time
|
||||||
|
return hashlib.md5(str(time.monotonic_ns()).encode()).hexdigest()[:8]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Source URL lookup cache ---
|
||||||
|
|
||||||
|
CACHE_DIR = os.path.expanduser("~/.cache")
|
||||||
|
CACHE_FILE = os.path.join(CACHE_DIR, "lora-sources.json")
|
||||||
|
|
||||||
|
|
||||||
|
def load_source_cache():
|
||||||
|
if os.path.exists(CACHE_FILE):
|
||||||
|
try:
|
||||||
|
with open(CACHE_FILE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_source_cache(cache):
|
||||||
|
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||||
|
with open(CACHE_FILE, "w") as f:
|
||||||
|
json.dump(cache, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def file_blake2b_hash(filepath):
|
||||||
|
h = hashlib.blake2b(digest_size=32)
|
||||||
|
with open(filepath, "rb") as f:
|
||||||
|
while True:
|
||||||
|
chunk = f.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_json(url, timeout=10):
|
||||||
|
req = Request(url, headers={"User-Agent": "lora-trigger-sheet/1.0"})
|
||||||
|
with urlopen(req, timeout=timeout) as resp:
|
||||||
|
return json.loads(resp.read().decode())
|
||||||
|
|
||||||
|
|
||||||
|
# URL pattern for extracting embedded links from free-text fields
|
||||||
|
_URL_RE = re.compile(r'https?://\S+', re.IGNORECASE)
|
||||||
|
|
||||||
|
# Metadata fields that may contain a source URL, checked in order
|
||||||
|
_META_URL_FIELDS = [
|
||||||
|
"modelspec.source",
|
||||||
|
"source",
|
||||||
|
"url",
|
||||||
|
"model_url",
|
||||||
|
"ss_training_comment",
|
||||||
|
"note",
|
||||||
|
"description",
|
||||||
|
"comment",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Map hostname → display label
|
||||||
|
_SOURCE_LABELS = {
|
||||||
|
"civitai.com": "CivitAI",
|
||||||
|
"huggingface.co": "HuggingFace",
|
||||||
|
"modelscope.cn": "ModelScope",
|
||||||
|
"tensor.art": "Tensor.Art",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _label_for_url(url):
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
host = urlparse(url).netloc.lstrip("www.")
|
||||||
|
for domain, label in _SOURCE_LABELS.items():
|
||||||
|
if host == domain or host.endswith("." + domain):
|
||||||
|
return label
|
||||||
|
return host or "Link"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_meta_url(meta):
|
||||||
|
"""Return (url, label) from embedded metadata fields, or (None, None)."""
|
||||||
|
if not meta:
|
||||||
|
return None, None
|
||||||
|
for field in _META_URL_FIELDS:
|
||||||
|
val = meta.get(field)
|
||||||
|
if not val or not isinstance(val, str):
|
||||||
|
continue
|
||||||
|
# Field may be a bare URL or free text containing one
|
||||||
|
m = _URL_RE.search(val.strip())
|
||||||
|
if m:
|
||||||
|
url = m.group(0).rstrip(".,;)")
|
||||||
|
return url, _label_for_url(url)
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_source(filepath, filename, cache, meta=None):
|
||||||
|
now = int(time.time())
|
||||||
|
cached = cache.get(filename)
|
||||||
|
if cached:
|
||||||
|
url = cached.get("url")
|
||||||
|
label = cached.get("label")
|
||||||
|
last = cached.get("last_checked", 0)
|
||||||
|
if (now - last) < 86400 * 30:
|
||||||
|
return (url, label) if url else (None, None)
|
||||||
|
|
||||||
|
# 0. Embedded URL in safetensors __metadata__ (free, no network call)
|
||||||
|
url, label = _extract_meta_url(meta)
|
||||||
|
if url:
|
||||||
|
cache[filename] = {"url": url, "label": label, "last_checked": now}
|
||||||
|
return url, label
|
||||||
|
|
||||||
|
# CivitAI model-version lookup by BLAKE2b file hash
|
||||||
|
try:
|
||||||
|
h = file_blake2b_hash(filepath)
|
||||||
|
data = _fetch_json(
|
||||||
|
f"https://civitai.com/api/v1/model-versions/by-hash/{h}"
|
||||||
|
)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
model_id = data.get("modelId")
|
||||||
|
version_id = data.get("id")
|
||||||
|
if model_id:
|
||||||
|
url = f"https://civitai.com/models/{model_id}"
|
||||||
|
if version_id:
|
||||||
|
url += f"?modelVersionId={version_id}"
|
||||||
|
cache[filename] = {
|
||||||
|
"url": url, "label": "CivitAI",
|
||||||
|
"model_name": data.get("name", ""),
|
||||||
|
"last_checked": now,
|
||||||
|
}
|
||||||
|
return url, "CivitAI"
|
||||||
|
except (HTTPError, URLError, json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback: HuggingFace model search by filename
|
||||||
|
try:
|
||||||
|
q = (
|
||||||
|
filename.replace(".safetensors", "")
|
||||||
|
.replace("_", " ")
|
||||||
|
.replace("-", " ")
|
||||||
|
)
|
||||||
|
data = _fetch_json(
|
||||||
|
"https://huggingface.co/api/models"
|
||||||
|
f"?search={quote(q)}&sort=downloads&direction=-1&limit=3"
|
||||||
|
)
|
||||||
|
if isinstance(data, list) and len(data) > 0:
|
||||||
|
model_id = data[0].get("modelId", "")
|
||||||
|
if model_id:
|
||||||
|
url = f"https://huggingface.co/{model_id}"
|
||||||
|
cache[filename] = {
|
||||||
|
"url": url, "label": "HuggingFace",
|
||||||
|
"model_name": model_id, "last_checked": now,
|
||||||
|
}
|
||||||
|
return url, "HuggingFace"
|
||||||
|
except (HTTPError, URLError, json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback: ModelScope model search by filename
|
||||||
|
try:
|
||||||
|
q = (
|
||||||
|
filename.replace(".safetensors", "")
|
||||||
|
.replace("_", " ")
|
||||||
|
.replace("-", " ")
|
||||||
|
)
|
||||||
|
data = _fetch_json(
|
||||||
|
f"https://www.modelscope.cn/api/v1/models?Name={quote(q)}&PageSize=3&PageNumber=1"
|
||||||
|
)
|
||||||
|
models = (data or {}).get("Data", {}).get("Models") or []
|
||||||
|
if models:
|
||||||
|
m = models[0]
|
||||||
|
model_id = m.get("Path") or m.get("Name", "")
|
||||||
|
if model_id:
|
||||||
|
url = f"https://www.modelscope.cn/models/{model_id}"
|
||||||
|
cache[filename] = {
|
||||||
|
"url": url, "label": "ModelScope",
|
||||||
|
"model_name": model_id, "last_checked": now,
|
||||||
|
}
|
||||||
|
return url, "ModelScope"
|
||||||
|
except (HTTPError, URLError, json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
cache[filename] = {"url": None, "label": None, "last_checked": now}
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def generate_html(loras, sources=None):
|
||||||
|
if sources is None:
|
||||||
|
sources = {}
|
||||||
|
rows = []
|
||||||
|
no_trigger_files = []
|
||||||
|
|
||||||
|
for fname, meta in loras:
|
||||||
|
tool = get_tool(meta)
|
||||||
|
base = shorten_base_model(get_base_model(meta))
|
||||||
|
tags, total = get_tags(meta)
|
||||||
|
|
||||||
|
fname_no_ext = re.sub(r"\.safetensors$", "", fname)
|
||||||
|
|
||||||
|
if tags:
|
||||||
|
sorted_tags = sorted(tags.items(), key=lambda x: -x[1])
|
||||||
|
top8 = sorted_tags[:8]
|
||||||
|
rest = sorted_tags[8:]
|
||||||
|
has_rest = len(rest) > 0
|
||||||
|
|
||||||
|
top8_html = "".join(
|
||||||
|
f"<span class=\"tg\"><code>{_e(t)}</code> <span class=\"c\">({c}x)</span></span>"
|
||||||
|
for t, c in top8
|
||||||
|
)
|
||||||
|
rest_html = ""
|
||||||
|
more_id = ""
|
||||||
|
if has_rest:
|
||||||
|
more_id = "m_" + html_id()
|
||||||
|
more_count = sum(c for _, c in rest)
|
||||||
|
rest_html = " <span class=\"ml\" onclick=\"t('" + more_id + "',this)\" data-n=\"" + str(more_count) + "\">" + str(more_count) + " more…</span>"
|
||||||
|
rest_list = "".join(
|
||||||
|
f"<span class=\"tg\"><code>{_e(t)}</code> <span class=\"c\">({c}x)</span></span>"
|
||||||
|
for t, c in rest
|
||||||
|
)
|
||||||
|
rest_html += "\n <span class=\"ht\" id=\"" + more_id + "\">" + rest_list + "</span>"
|
||||||
|
|
||||||
|
triggers = top8_html + rest_html
|
||||||
|
else:
|
||||||
|
no_trigger_files.append((fname_no_ext, tool, base))
|
||||||
|
triggers = "<span class=\"n\">(none)</span>"
|
||||||
|
|
||||||
|
src_url, src_label = sources.get(fname, (None, None))
|
||||||
|
rows.append((fname_no_ext, tool, base, triggers, src_url or "", src_label or ""))
|
||||||
|
|
||||||
|
num_files = len(rows)
|
||||||
|
|
||||||
|
table_rows = ""
|
||||||
|
for fname, tool, base, triggers, src_url, src_label in rows:
|
||||||
|
if src_url:
|
||||||
|
src_cell = f'<a href="{_e(src_url)}" target="_blank" rel="noopener">{_e(src_label)}</a>'
|
||||||
|
else:
|
||||||
|
src_cell = '<span class="n">—</span>'
|
||||||
|
table_rows += (
|
||||||
|
f" <tr>\n"
|
||||||
|
f" <td class=\"fn\">{_e(fname)}</td>\n"
|
||||||
|
f" <td class=\"tl\">{_e(tool)}</td>\n"
|
||||||
|
f" <td class=\"bm\">{_e(base)}</td>\n"
|
||||||
|
f" <td class=\"tr\">{triggers}</td>\n"
|
||||||
|
f" <td class=\"sr\">{src_cell}</td>\n"
|
||||||
|
f" </tr>\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
html = f"""<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<title>LoRA Info Sheet</title>
|
||||||
|
<link rel="icon" type="image/png" href="@img/nude.png">
|
||||||
|
<style>
|
||||||
|
*,*::before,*::after{{box-sizing:border-box;margin:0;padding:0}}
|
||||||
|
body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0d1117;color:#c9d1d9;padding:24px;line-height:1.5;font-size:14px}}
|
||||||
|
h1{{font-size:1.5rem;margin-bottom:4px;color:#f0f6fc}}
|
||||||
|
.sub{{color:#8b949e;margin-bottom:16px;font-size:0.85rem}}
|
||||||
|
.bar{{display:flex;gap:12px;margin-bottom:12px;flex-wrap:wrap;align-items:center}}
|
||||||
|
#q{{flex:1;min-width:200px;padding:8px 12px;border:1px solid #30363d;border-radius:6px;background:#161b22;color:#c9d1d9;font-size:0.85rem;outline:none}}
|
||||||
|
#q:focus{{border-color:#58a6ff}}
|
||||||
|
#cnt{{color:#8b949e;font-size:0.85rem}}
|
||||||
|
table{{width:100%;border-collapse:collapse;font-size:0.82rem}}
|
||||||
|
th{{text-align:left;padding:9px 10px;border-bottom:2px solid #30363d;color:#f0f6fc;font-weight:600;cursor:pointer;user-select:none;white-space:nowrap;background:#161b22;position:sticky;top:0;z-index:1}}
|
||||||
|
th:hover{{color:#58a6ff}}
|
||||||
|
th .ar{{margin-left:3px;color:#58a6ff}}
|
||||||
|
td{{padding:7px 10px;border-bottom:1px solid #21262d;vertical-align:top;word-break:break-word}}
|
||||||
|
tr:hover td{{background:#161b22}}
|
||||||
|
.fn{{color:#58a6ff;font-weight:500;white-space:nowrap}}
|
||||||
|
.tl,.bm{{color:#8b949e}}
|
||||||
|
.tr code{{background:#1f2937;padding:1px 5px;border-radius:3px;font-size:0.78rem;color:#f0c674;word-break:break-word}}
|
||||||
|
.c{{color:#8b949e;font-size:0.72rem;margin-right:3px}}
|
||||||
|
.n{{color:#484f58;font-style:italic}}
|
||||||
|
.ml{{color:#58a6ff;cursor:pointer;font-size:0.78rem;text-decoration:none;border-bottom:1px dotted #58a6ff}}
|
||||||
|
.ml:hover{{border-bottom:1px solid #58a6ff}}
|
||||||
|
.ht{{display:none}}
|
||||||
|
.dr td{{background:#161b22;padding:10px 14px;border-bottom:2px solid #30363d}}
|
||||||
|
.dr code{{background:#1f2937;padding:1px 5px;border-radius:3px;font-size:0.78rem;color:#f0c674}}
|
||||||
|
.tg{{display:inline-block;margin:1px 6px 1px 0}}
|
||||||
|
mark.hl{{background:#264f78;color:#f0f6fc;border-radius:2px;padding:0 2px}}
|
||||||
|
.sr a{{color:#58a6ff;text-decoration:none;font-size:0.78rem}}
|
||||||
|
.c0{{width:24%}}.c1{{width:8%}}.c2{{width:12%}}.c3{{width:48%}}.c4{{width:8%}}
|
||||||
|
@media(max-width:768px){{.c1,.c2,.c4,td:nth-child(2),td:nth-child(3),td:nth-child(5){{display:none}}.c0{{width:30%}}.c3{{width:70%}}}}
|
||||||
|
.foot{{margin-top:20px;color:#8b949e}}
|
||||||
|
.foot h2{{font-size:1rem;color:#f0f6fc;margin-bottom:6px}}
|
||||||
|
.foot ul{{list-style:none;columns:3;font-size:0.82rem}}
|
||||||
|
.foot li{{padding:1px 0}}
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<h1>LoRA Info Sheet</h1>
|
||||||
|
<p class="sub">{num_files} LoRA files · <span id="vc">{num_files}</span> visible</p>
|
||||||
|
<div class="bar"><input type="text" id="q" placeholder="Search filename, trigger, base model..." autofocus> <span id="cnt">{num_files} / {num_files}</span></div>
|
||||||
|
<table><thead><tr>
|
||||||
|
<th class="c0" onclick="s(0)">File <span class="ar">▲</span></th>
|
||||||
|
<th class="c1" onclick="s(1)">Tool <span class="ar"></span></th>
|
||||||
|
<th class="c2" onclick="s(2)">Base <span class="ar"></span></th>
|
||||||
|
<th class="c3" onclick="s(3)">Trigger(s) <span class="ar"></span></th>
|
||||||
|
<th class="c4" onclick="s(4)">Source <span class="ar"></span></th>
|
||||||
|
</tr></thead><tbody id="b">
|
||||||
|
{table_rows}</tbody></table>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let col = 0, desc = true;
|
||||||
|
const b = document.getElementById('b');
|
||||||
|
const q = document.getElementById('q');
|
||||||
|
const cnt = document.getElementById('cnt');
|
||||||
|
const vc = document.getElementById('vc');
|
||||||
|
|
||||||
|
function s(i){{
|
||||||
|
document.querySelectorAll('.dr').forEach(r => r.remove());
|
||||||
|
document.querySelectorAll('.ml').forEach(el => el.innerHTML = el.getAttribute('data-n') + ' more…');
|
||||||
|
if (col == i) desc = !desc; else {{ col = i; desc = i == 0; }}
|
||||||
|
let rows = [...b.children];
|
||||||
|
rows.sort((a,c)=>{{
|
||||||
|
let av = a.children[col].textContent.trim().toLowerCase();
|
||||||
|
let cv = c.children[col].textContent.trim().toLowerCase();
|
||||||
|
if (col == 0 || col == 3) {{
|
||||||
|
let am = parseInt(av.match(/\\d+/)?.[0]||'0');
|
||||||
|
let cm = parseInt(cv.match(/\\d+/)?.[0]||'0');
|
||||||
|
if (!isNaN(am) && !isNaN(cm)) return desc ? cm-am : am-cm;
|
||||||
|
}}
|
||||||
|
return desc ? av.localeCompare(cv) : cv.localeCompare(av);
|
||||||
|
}});
|
||||||
|
rows.forEach(r => b.appendChild(r));
|
||||||
|
let arrows = document.querySelectorAll('.ar');
|
||||||
|
arrows.forEach(a => a.innerHTML = '');
|
||||||
|
document.querySelectorAll('th')[col+1].querySelector('.ar').innerHTML = desc ? '▲' : '▼';
|
||||||
|
}}
|
||||||
|
|
||||||
|
function filterTags(el, v) {{
|
||||||
|
let tgs = el.querySelectorAll('.tg');
|
||||||
|
if (!v || tgs.length === 0) {{
|
||||||
|
tgs.forEach(tg => tg.style.display = '');
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
let match = false;
|
||||||
|
tgs.forEach(tg => {{
|
||||||
|
if (tg.textContent.toLowerCase().includes(v)) {{
|
||||||
|
tg.style.display = '';
|
||||||
|
match = true;
|
||||||
|
}} else {{
|
||||||
|
tg.style.display = 'none';
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
if (!match) tgs.forEach(tg => tg.style.display = '');
|
||||||
|
}}
|
||||||
|
|
||||||
|
function t(id,el){{
|
||||||
|
let sp = document.getElementById(id);
|
||||||
|
let tr = el.closest('tr');
|
||||||
|
let nxt = tr.nextElementSibling;
|
||||||
|
if (nxt && nxt.classList.contains('dr')){{
|
||||||
|
nxt.remove();
|
||||||
|
el.innerHTML = el.getAttribute('data-n') + ' more…';
|
||||||
|
}} else {{
|
||||||
|
let dr = document.createElement('tr');
|
||||||
|
dr.className = 'dr';
|
||||||
|
let td = document.createElement('td');
|
||||||
|
td.colSpan = 5;
|
||||||
|
td.innerHTML = sp.innerHTML;
|
||||||
|
dr.appendChild(td);
|
||||||
|
tr.after(dr);
|
||||||
|
el.innerHTML = 'close ×';
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
function highlight(el, str) {{
|
||||||
|
if (!str) {{
|
||||||
|
el.childNodes.forEach(c => {{
|
||||||
|
if (c.nodeType === 3) return;
|
||||||
|
if (c.classList) c.classList.remove('hl');
|
||||||
|
if (c.querySelectorAll) c.querySelectorAll('.hl').forEach(h => h.classList.remove('hl'));
|
||||||
|
}});
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
el.childNodes.forEach(c => {{
|
||||||
|
if (c.nodeType === 3 && c.textContent.trim()) {{
|
||||||
|
let idx = c.textContent.toLowerCase().indexOf(str);
|
||||||
|
if (idx === -1) return;
|
||||||
|
let span = document.createElement('span');
|
||||||
|
let before = c.textContent.slice(0, idx);
|
||||||
|
let match = c.textContent.slice(idx, idx + str.length);
|
||||||
|
let after = c.textContent.slice(idx + str.length);
|
||||||
|
let mk = document.createElement('mark');
|
||||||
|
mk.className = 'hl';
|
||||||
|
mk.textContent = match;
|
||||||
|
if (before) span.appendChild(document.createTextNode(before));
|
||||||
|
span.appendChild(mk);
|
||||||
|
if (after) span.appendChild(document.createTextNode(after));
|
||||||
|
el.replaceChild(span, c);
|
||||||
|
}} else if (c.nodeType === 1) {{
|
||||||
|
highlight(c, str);
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
|
||||||
|
let _qt;
|
||||||
|
q.addEventListener('input', ()=>{{
|
||||||
|
clearTimeout(_qt);
|
||||||
|
_qt = setTimeout(()=>{{
|
||||||
|
let v = q.value.toLowerCase();
|
||||||
|
let rows = b.children;
|
||||||
|
let vis = 0;
|
||||||
|
document.querySelectorAll('.hl').forEach(h => {{
|
||||||
|
let p = h.parentNode;
|
||||||
|
let txt = document.createTextNode(h.textContent);
|
||||||
|
p.replaceChild(txt, h);
|
||||||
|
p.normalize();
|
||||||
|
}});
|
||||||
|
for (let r of rows) {{
|
||||||
|
if (r.classList.contains('dr')) continue;
|
||||||
|
let txt = r.textContent.toLowerCase();
|
||||||
|
let ht = r.querySelector('.ht');
|
||||||
|
if (ht) txt += ' ' + ht.textContent.toLowerCase();
|
||||||
|
let ok = txt.includes(v);
|
||||||
|
r.style.display = ok ? '' : 'none';
|
||||||
|
filterTags(r, v);
|
||||||
|
let nxt = r.nextElementSibling;
|
||||||
|
if (nxt && nxt.classList.contains('dr')) {{
|
||||||
|
nxt.style.display = ok ? '' : 'none';
|
||||||
|
filterTags(nxt, v);
|
||||||
|
}}
|
||||||
|
if (ok && v) highlight(r, v);
|
||||||
|
if (ok) vis++;
|
||||||
|
}}
|
||||||
|
cnt.textContent = vis + ' / ' + rows.length;
|
||||||
|
vc.textContent = vis;
|
||||||
|
}}, 700);
|
||||||
|
}});
|
||||||
|
</script>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
def _e(s):
|
||||||
|
"""Escape HTML entities."""
|
||||||
|
s = str(s)
|
||||||
|
s = s.replace("&", "&")
|
||||||
|
s = s.replace("<", "<")
|
||||||
|
s = s.replace(">", ">")
|
||||||
|
s = s.replace('"', """)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = sys.argv[1:]
|
||||||
|
|
||||||
|
if len(args) >= 1:
|
||||||
|
target = args[0]
|
||||||
|
else:
|
||||||
|
target = os.path.expanduser("~/mnt/nimo.loc/ComfyUI/models/loras")
|
||||||
|
|
||||||
|
target = os.path.abspath(os.path.expanduser(target))
|
||||||
|
|
||||||
|
if not os.path.isdir(target):
|
||||||
|
print(f"Error: {target} is not a directory", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
safetensors = sorted([
|
||||||
|
f for f in os.listdir(target)
|
||||||
|
if f.endswith(".safetensors") and not f.startswith("._")
|
||||||
|
])
|
||||||
|
|
||||||
|
if not safetensors:
|
||||||
|
print(f"No .safetensors files found in {target}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
loras = []
|
||||||
|
for fname in safetensors:
|
||||||
|
fpath = os.path.join(target, fname)
|
||||||
|
meta = read_safetensors_meta(fpath)
|
||||||
|
loras.append((fname, meta))
|
||||||
|
|
||||||
|
# Look up source URLs with disk-backed cache
|
||||||
|
cache = load_source_cache()
|
||||||
|
sources = {}
|
||||||
|
total = len(loras)
|
||||||
|
for i, (fname, meta) in enumerate(loras, 1):
|
||||||
|
fpath = os.path.join(target, fname)
|
||||||
|
url, label = lookup_source(fpath, fname, cache, meta)
|
||||||
|
if url:
|
||||||
|
sources[fname] = (url, label)
|
||||||
|
if i % 10 == 0 or i == total:
|
||||||
|
print(f" source lookup: {i}/{total}", file=sys.stderr)
|
||||||
|
save_source_cache(cache)
|
||||||
|
|
||||||
|
html = generate_html(loras, sources)
|
||||||
|
|
||||||
|
if len(args) >= 2:
|
||||||
|
out_path = os.path.abspath(os.path.expanduser(args[1]))
|
||||||
|
else:
|
||||||
|
out_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lora-info-sheet.html")
|
||||||
|
|
||||||
|
with open(out_path, "w") as f:
|
||||||
|
f.write(html)
|
||||||
|
|
||||||
|
out_count = sum(1 for v in sources.values() if v[0])
|
||||||
|
print(f"Generated {out_path} with {len(loras)} LoRA entries ({out_count} with source links)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -105,7 +105,7 @@ done
|
|||||||
echo -e "\n\n\n"
|
echo -e "\n\n\n"
|
||||||
echo "** Quitting Applications **"
|
echo "** Quitting Applications **"
|
||||||
|
|
||||||
prg=( "Zen Browser" "Opera" "Opera" "Opera" "Opera" "Opera" "MenubarX" "App Store" "WhatsApp" ".Agenda" "Steam" ".Loopback" ".Slack" "Boom 3D" "Paw" "DYMO Label" "QuickTime Player" "System Information" "Permute" "Activity Monitor" ".Transmit" ".Mail" ".Edison Mail" ".Airmail" "Setapp" ".Messages" "News" "zoom.us" "Preview" "Arq" "Carbon Copy Cloner" "Safari" "Vivaldi" "Firefox" "firefox" "Music" "Microsoft Edge" ".Parallels Desktop" ".Paw" ".Creative Cloud" "Things" ".Microsoft Word" ".Microsoft Excel" ".Microsoft PowerPoint" ".Xcode" "Simulator" "Audio Hijack" "Microsoft AutoUpdate" ".ToothFairy" "Creative Cloud" "Arc" "Brave Browser" "Safari Technology Preview" "YouTube" "SiriusXM" "Xcode.app" ".Xcode-beta" "Simulator" "Podcasts" "Microsoft Remote Desktop" "zen" "RustDesk")
|
prg=( "Zen Browser" "Opera" "Opera" "Opera" "Opera" "Opera" "Brave Browser" "MenubarX" "App Store" "WhatsApp" ".Agenda" "Steam" ".Loopback" ".Slack" "Boom 3D" "Paw" "DYMO Label" "QuickTime Player" "System Information" "Permute" "Activity Monitor" ".Transmit" ".Mail" ".Edison Mail" ".Airmail" "Setapp" ".Messages" "News" "zoom.us" "Preview" "Arq" "Carbon Copy Cloner" "Safari" "Vivaldi" "Firefox" "firefox" "Music" "Microsoft Edge" ".Parallels Desktop" ".Paw" ".Creative Cloud" "Things" ".Microsoft Word" ".Microsoft Excel" ".Microsoft PowerPoint" ".Xcode" "Simulator" "Audio Hijack" "Microsoft AutoUpdate" ".ToothFairy" "Creative Cloud" "Arc" "Brave Browser" "Safari Technology Preview" "YouTube" "SiriusXM" "Xcode.app" ".Xcode-beta" "Simulator" "Podcasts" "Microsoft Remote Desktop" "zen" "RustDesk")
|
||||||
#prg=( "Safari Technology Preview" )
|
#prg=( "Safari Technology Preview" )
|
||||||
killprg=( "runningboardd" "Brave Browser" "Microsoft Edge" )
|
killprg=( "runningboardd" "Brave Browser" "Microsoft Edge" )
|
||||||
for app in "${prg[@]}"
|
for app in "${prg[@]}"
|
||||||
|
|||||||
9
push-newer-pics
Executable file
9
push-newer-pics
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# push-newer-pics — Wrapper for sync-newer-images with default paths.
|
||||||
|
# Copies new images/videos from ~/Pictures → ~/mnt/mini.nas/miniShare1/Pictures
|
||||||
|
#
|
||||||
|
# Usage: push-newer-pics [--help] [-u|--update]
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
exec "${SCRIPT_DIR}/sync-newer-images" "$@" "$HOME/mnt/mini.nas/miniShare1/Pictures" "$HOME/Pictures"
|
||||||
5
quit-app.app/Contents/Resources/description.rtfd/TXT.rtf
Normal file
5
quit-app.app/Contents/Resources/description.rtfd/TXT.rtf
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{\rtf1\ansi\ansicpg1252\cocoartf2709
|
||||||
|
\cocoatextscaling0\cocoaplatform0{\fonttbl}
|
||||||
|
{\colortbl;\red255\green255\blue255;}
|
||||||
|
{\*\expandedcolortbl;;}
|
||||||
|
}
|
||||||
537
rename-ai-snaps
537
rename-ai-snaps
@@ -1,537 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
rename-ai-snaps — Scan PNGs for AI prompt metadata and propose descriptive filenames.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
./rename-ai-snaps [path] [--no-interactive]
|
|
||||||
|
|
||||||
Scans directory (default ~/Pictures) for ComfyUI PNGs, reads their embedded prompt,
|
|
||||||
extracts short keyword descriptions, and interactively renames them to:
|
|
||||||
schmeeve-AI-{keywords}.png
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
try:
|
|
||||||
from PIL import Image
|
|
||||||
except ImportError:
|
|
||||||
print("Error: Pillow (PIL) is required. Install with: pip install Pillow")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# ── stopwords and filter sets ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
QUALITY_TAGS = {
|
|
||||||
"score_6_up", "score_7_up", "score_8_up", "score_9",
|
|
||||||
"score_6", "score_7", "score_8",
|
|
||||||
"masterpiece", "best quality", "good quality", "normal quality",
|
|
||||||
"high quality", "highly detailed", "very detailed", "extreme detail",
|
|
||||||
"very_aesthetic", "absurdres", "8k", "4k",
|
|
||||||
"photorealistic", "photograph",
|
|
||||||
"depth of field", "solo focus", "cinematic",
|
|
||||||
"newest", "amazing", "stunning",
|
|
||||||
}
|
|
||||||
|
|
||||||
QUALITY_WORDS = {
|
|
||||||
"best", "good", "high", "top", "ultra", "super",
|
|
||||||
"mega", "hyper", "extreme", "extra", "ultimate",
|
|
||||||
}
|
|
||||||
|
|
||||||
TECHNICAL_WORDS = {
|
|
||||||
"detailed", "focus", "quality", "aesthetic", "realistic",
|
|
||||||
"cinematic", "lighting", "rendering", "shading", "texture",
|
|
||||||
"newest", "absurdres",
|
|
||||||
}
|
|
||||||
|
|
||||||
STOP_WORDS = {
|
|
||||||
"the", "a", "an", "of", "in", "on", "at", "to", "for", "with",
|
|
||||||
"and", "or", "is", "are", "was", "were", "be", "been", "being",
|
|
||||||
"have", "has", "had", "do", "does", "did", "will", "would",
|
|
||||||
"could", "should", "may", "might", "can", "shall", "this",
|
|
||||||
"that", "these", "those", "it", "its", "by", "from", "as",
|
|
||||||
"into", "through", "during", "before", "after", "above", "below",
|
|
||||||
"between", "out", "off", "over", "under", "again", "further",
|
|
||||||
"then", "once", "here", "there", "when", "where", "why", "how",
|
|
||||||
"all", "each", "every", "both", "few", "more", "most", "other",
|
|
||||||
"some", "such", "no", "nor", "not", "only", "own", "same", "so",
|
|
||||||
"than", "too", "very", "just", "about", "up", "down",
|
|
||||||
"make", "get", "set", "put", "take", "give", "show", "use",
|
|
||||||
"like", "look", "see", "want", "need", "let", "close", "full",
|
|
||||||
"add", "new", "one", "two", "five",
|
|
||||||
"also", "well", "back", "still", "even", "much",
|
|
||||||
"you", "your", "my", "me", "we", "our", "they", "them", "their",
|
|
||||||
}
|
|
||||||
|
|
||||||
GENERIC_WORDS = {
|
|
||||||
"man", "men", "guy", "guys", "boy", "boys", "woman", "women",
|
|
||||||
"girl", "girls", "people", "person", "human", "figure",
|
|
||||||
"photo", "image", "picture", "shot", "view", "pose", "posing",
|
|
||||||
"face", "head", "body", "skin", "hair", "eyes", "hand", "hands",
|
|
||||||
"dark", "light", "bright", "color", "colour",
|
|
||||||
}
|
|
||||||
|
|
||||||
NEGATIVE_INDICATORS = {
|
|
||||||
"deformed", "distorted", "disfigured", "poorly drawn", "bad anatomy",
|
|
||||||
"extra digits", "missing digits", "extra limbs", "missing limbs",
|
|
||||||
"ugly", "tiling", "low quality", "worst quality", "normal quality",
|
|
||||||
"lowres", "monochrome", "grayscale", "text", "watermark",
|
|
||||||
"branding", "border", "cropped", "signature", "username",
|
|
||||||
"error", "mutation", "mutated", "out of frame", "duplicate", "cloned",
|
|
||||||
"body out of frame", "bad hands", "bad face", "blurry",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def spinner():
|
|
||||||
chars = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
||||||
i = 0
|
|
||||||
while True:
|
|
||||||
yield chars[i % len(chars)]
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
|
|
||||||
def is_negative_text(text):
|
|
||||||
"""Heuristic: is this text block a negative prompt?"""
|
|
||||||
lower = text.lower()
|
|
||||||
score = 0
|
|
||||||
for ind in NEGATIVE_INDICATORS:
|
|
||||||
if ind in lower:
|
|
||||||
score += 1
|
|
||||||
return score >= 2
|
|
||||||
|
|
||||||
|
|
||||||
def is_quality_only(text):
|
|
||||||
"""Heuristic: does this text block contain only quality/technical tags?"""
|
|
||||||
lower = text.lower()
|
|
||||||
# Split into words, strip weighting syntax
|
|
||||||
words = re.findall(r"[a-z_]+", lower)
|
|
||||||
if not words:
|
|
||||||
return False
|
|
||||||
meaningful = sum(1 for w in words if w not in QUALITY_TAGS and len(w) > 2)
|
|
||||||
return meaningful == 0
|
|
||||||
|
|
||||||
|
|
||||||
def extract_prompts(filepath):
|
|
||||||
"""Return (positive_prompt, negative_prompt) from a ComfyUI PNG."""
|
|
||||||
try:
|
|
||||||
img = Image.open(filepath)
|
|
||||||
except Exception:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
if "prompt" not in img.info:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = json.loads(img.info["prompt"])
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
# Gather all text fields from all nodes
|
|
||||||
candidates = []
|
|
||||||
for node in data.values():
|
|
||||||
inputs = node.get("inputs", {})
|
|
||||||
# Check all common text-carrying fields
|
|
||||||
for field in ("text", "prompt", "positive", "negative", "value", "string"):
|
|
||||||
val = inputs.get(field, "")
|
|
||||||
if isinstance(val, str) and len(val.strip()) > 3:
|
|
||||||
candidates.append(val.strip())
|
|
||||||
|
|
||||||
# Second pass: resolve node references (e.g. ["node_id", 0])
|
|
||||||
for node in data.values():
|
|
||||||
inputs = node.get("inputs", {})
|
|
||||||
for field in ("text", "prompt", "positive", "negative", "value", "string"):
|
|
||||||
val = inputs.get(field)
|
|
||||||
if isinstance(val, list) and len(val) == 2 and isinstance(val[0], str):
|
|
||||||
ref_node = data.get(val[0], {})
|
|
||||||
# Check if the referenced node has a 'value' or 'text' field
|
|
||||||
ref_inputs = ref_node.get("inputs", {})
|
|
||||||
for rf in ("value", "text", "string"):
|
|
||||||
rv = ref_inputs.get(rf, "")
|
|
||||||
if isinstance(rv, str) and len(rv.strip()) > 3:
|
|
||||||
candidates.append(rv.strip())
|
|
||||||
break
|
|
||||||
|
|
||||||
if not candidates:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
# Split into positive vs negative
|
|
||||||
positives = [c for c in candidates if not is_negative_text(c)]
|
|
||||||
negatives = [c for c in candidates if is_negative_text(c)]
|
|
||||||
|
|
||||||
# Further filter: quality-only texts are not useful for naming
|
|
||||||
positives = [c for c in positives if not is_quality_only(c)]
|
|
||||||
|
|
||||||
pos = max(positives, key=len) if positives else None
|
|
||||||
neg = max(negatives, key=len) if negatives else None
|
|
||||||
return pos, neg
|
|
||||||
|
|
||||||
|
|
||||||
def extract_all_candidates(text):
|
|
||||||
"""Extract all candidate keywords with position index for frequency scoring."""
|
|
||||||
if not text:
|
|
||||||
return []
|
|
||||||
|
|
||||||
text_lower = text.lower()
|
|
||||||
|
|
||||||
# Strip weighting/parenthetical syntax: (word:1.2), [word], {word}, (word)
|
|
||||||
cleaned = re.sub(r"[\[\(\{][^\]\)\}]*[\]\)\}]", "", text_lower)
|
|
||||||
|
|
||||||
# Split on commas, periods, semicolons, exclamation/question marks
|
|
||||||
segments = re.split(r"[,.;:!?]+", cleaned)
|
|
||||||
|
|
||||||
# Collect meaningful keywords, preserving position order
|
|
||||||
seen = set()
|
|
||||||
candidates = []
|
|
||||||
position = 0
|
|
||||||
|
|
||||||
for seg in segments:
|
|
||||||
seg = seg.strip()
|
|
||||||
if not seg or len(seg) < 4:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Extract individual words from segment
|
|
||||||
words = re.findall(r"[a-zA-Z_]+", seg)
|
|
||||||
good = []
|
|
||||||
for w in words:
|
|
||||||
wl = w.lower().strip("_")
|
|
||||||
# Skip short words, stop words, quality tags, negative indicators
|
|
||||||
if len(wl) < 3:
|
|
||||||
continue
|
|
||||||
if wl in STOP_WORDS or wl in GENERIC_WORDS:
|
|
||||||
continue
|
|
||||||
if wl in QUALITY_TAGS or wl in QUALITY_WORDS:
|
|
||||||
continue
|
|
||||||
if wl in TECHNICAL_WORDS:
|
|
||||||
continue
|
|
||||||
if wl in NEGATIVE_INDICATORS:
|
|
||||||
continue
|
|
||||||
if wl.startswith("score") or wl.startswith("step"):
|
|
||||||
continue
|
|
||||||
if wl.isdigit():
|
|
||||||
continue
|
|
||||||
good.append(wl)
|
|
||||||
|
|
||||||
if good:
|
|
||||||
# Take up to 2 unseen keywords from this segment
|
|
||||||
taken = 0
|
|
||||||
for g in good:
|
|
||||||
if g not in seen and taken < 2:
|
|
||||||
candidates.append((g, position))
|
|
||||||
seen.add(g)
|
|
||||||
taken += 1
|
|
||||||
position += 1
|
|
||||||
|
|
||||||
return candidates
|
|
||||||
|
|
||||||
|
|
||||||
def select_best_keywords(candidates, freq_map, total_images, max_keywords=5):
|
|
||||||
"""Select most discriminating keywords by rarity and position.
|
|
||||||
|
|
||||||
Keywords that appear in fewer images across the batch score higher.
|
|
||||||
Later-position keywords get a small bonus for tiebreaking.
|
|
||||||
When total_images <= 1, falls back to first-N selection.
|
|
||||||
"""
|
|
||||||
if not candidates:
|
|
||||||
return []
|
|
||||||
|
|
||||||
if total_images <= 1:
|
|
||||||
return [kw for kw, _ in candidates[:max_keywords]]
|
|
||||||
|
|
||||||
scored = []
|
|
||||||
for kw, pos in candidates:
|
|
||||||
freq = freq_map.get(kw, 1)
|
|
||||||
# Rarity: 1.0 = appears in 1 image, 0.0 = appears in all images
|
|
||||||
rarity = 1.0 - ((freq - 1) / max(total_images - 1, 1))
|
|
||||||
# Position bonus: slight preference for later words (up to 0.3)
|
|
||||||
max_pos = max(len(candidates), 1)
|
|
||||||
pos_bonus = (pos / max_pos) * 0.3
|
|
||||||
score = rarity + pos_bonus
|
|
||||||
scored.append((score, kw, pos))
|
|
||||||
|
|
||||||
# Sort by score desc, then by original position asc for stability
|
|
||||||
scored.sort(key=lambda x: (-x[0], x[2]))
|
|
||||||
|
|
||||||
selected = []
|
|
||||||
seen = set()
|
|
||||||
for _, kw, _ in scored:
|
|
||||||
if kw not in seen:
|
|
||||||
selected.append(kw)
|
|
||||||
seen.add(kw)
|
|
||||||
if len(selected) >= max_keywords:
|
|
||||||
break
|
|
||||||
|
|
||||||
return selected
|
|
||||||
|
|
||||||
|
|
||||||
def keywords_to_name(keywords):
|
|
||||||
"""Convert keyword list to 'schmeeve-AI-{keywords}.png' name."""
|
|
||||||
if not keywords:
|
|
||||||
return None
|
|
||||||
|
|
||||||
desc = "-".join(keywords)
|
|
||||||
desc = re.sub(r"[^a-z0-9-]", "-", desc)
|
|
||||||
desc = re.sub(r"-+", "-", desc).strip("-")
|
|
||||||
|
|
||||||
# Truncate at 40 chars, breaking at a word boundary
|
|
||||||
if len(desc) > 40:
|
|
||||||
desc = desc[:40].rstrip("-")
|
|
||||||
if "-" in desc:
|
|
||||||
truncated = "-".join(desc.split("-")[:-1])
|
|
||||||
if truncated and len(truncated) > 10:
|
|
||||||
desc = truncated
|
|
||||||
|
|
||||||
return f"schmeeve-AI-{desc}.png" if desc and len(desc) > 3 else None
|
|
||||||
|
|
||||||
|
|
||||||
def propose_name(filepath, freq_map=None, total_images=1):
|
|
||||||
"""Propose 'schmeeve-AI-{keywords}.png' or None.
|
|
||||||
|
|
||||||
When freq_map and total_images > 1 are provided, uses frequency-aware
|
|
||||||
keyword selection that favors rare (discriminating) words.
|
|
||||||
"""
|
|
||||||
pos, neg = extract_prompts(filepath)
|
|
||||||
source = pos or neg
|
|
||||||
if not source:
|
|
||||||
return None
|
|
||||||
|
|
||||||
candidates = extract_all_candidates(source)
|
|
||||||
if not candidates:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if freq_map and total_images > 1:
|
|
||||||
keywords = select_best_keywords(candidates, freq_map, total_images)
|
|
||||||
else:
|
|
||||||
keywords = [kw for kw, _ in candidates[:5]]
|
|
||||||
|
|
||||||
return keywords_to_name(keywords)
|
|
||||||
|
|
||||||
|
|
||||||
# ── main ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def main():
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Rename AI-generated PNGs based on embedded prompt metadata.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"path", nargs="?", default=os.path.expanduser("~/Pictures"),
|
|
||||||
help="Directory to scan for PNG files (default: ~/Pictures)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"-n", "--no-interactive", action="store_true",
|
|
||||||
help="Auto-rename without prompting",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
scan_dir = Path(args.path).expanduser().resolve()
|
|
||||||
if not scan_dir.is_dir():
|
|
||||||
print(f"Error: {scan_dir} is not a directory")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
pngs = sorted(scan_dir.glob("*.png"))
|
|
||||||
|
|
||||||
# ── Phase 1: Collect candidates and build frequency map ──
|
|
||||||
sys.stdout.write(" Analyzing PNGs")
|
|
||||||
sys.stdout.flush()
|
|
||||||
spin = spinner()
|
|
||||||
image_data = {}
|
|
||||||
word_images = {}
|
|
||||||
for p in pngs:
|
|
||||||
sys.stdout.write(f"\r {next(spin)} Analyzing PNGs")
|
|
||||||
sys.stdout.flush()
|
|
||||||
# Skip already-renamed files
|
|
||||||
if p.name.startswith("schmeeve-AI-"):
|
|
||||||
continue
|
|
||||||
pos, neg = extract_prompts(str(p))
|
|
||||||
source = pos or neg
|
|
||||||
if source:
|
|
||||||
candidates = extract_all_candidates(source)
|
|
||||||
if candidates:
|
|
||||||
image_data[p] = candidates
|
|
||||||
for kw, _ in candidates:
|
|
||||||
if kw not in word_images:
|
|
||||||
word_images[kw] = set()
|
|
||||||
word_images[kw].add(p)
|
|
||||||
time.sleep(0.02)
|
|
||||||
|
|
||||||
freq_map = {kw: len(images) for kw, images in word_images.items()}
|
|
||||||
total_images = len(image_data)
|
|
||||||
|
|
||||||
# ── Phase 1b: Propose names with frequency-aware selection ──
|
|
||||||
raw_proposals = {}
|
|
||||||
for p, candidates in image_data.items():
|
|
||||||
if total_images > 1:
|
|
||||||
keywords = select_best_keywords(candidates, freq_map, total_images)
|
|
||||||
else:
|
|
||||||
keywords = [kw for kw, _ in candidates[:5]]
|
|
||||||
name = keywords_to_name(keywords)
|
|
||||||
if name:
|
|
||||||
raw_proposals[p] = name
|
|
||||||
|
|
||||||
# Clear the spinner line
|
|
||||||
sys.stdout.write("\r" + " " * 60 + "\r")
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
# Deduplicate proposed names; use short mtime tag for collisions
|
|
||||||
proposals = {}
|
|
||||||
name_counts = {}
|
|
||||||
for p, name in raw_proposals.items():
|
|
||||||
base = name
|
|
||||||
if base in name_counts:
|
|
||||||
name_counts[base] += 1
|
|
||||||
else:
|
|
||||||
name_counts[base] = 1
|
|
||||||
proposals[p] = name
|
|
||||||
|
|
||||||
for p, name in list(proposals.items()):
|
|
||||||
if name_counts[name] > 1:
|
|
||||||
ts = time.strftime("%m%d-%H%M%S", time.localtime(p.stat().st_mtime))
|
|
||||||
stem = name.rsplit(".", 1)[0]
|
|
||||||
proposals[p] = f"{stem}-{ts}.png"
|
|
||||||
|
|
||||||
# ── Phase 2: Display proposed names ──
|
|
||||||
if not proposals:
|
|
||||||
print(" No renamable PNGs found.")
|
|
||||||
return
|
|
||||||
|
|
||||||
for i, (old_path, new_name) in enumerate(proposals.items(), 1):
|
|
||||||
old = old_path.name
|
|
||||||
stem = old_path.stem
|
|
||||||
ext = old_path.suffix
|
|
||||||
# Truncate old name for display
|
|
||||||
old_display = old if len(old) < 50 else old[:22] + "…" + old[-25:]
|
|
||||||
print(f" {i:>3}. {old_display}")
|
|
||||||
print(f" → {new_name}")
|
|
||||||
|
|
||||||
print()
|
|
||||||
print(f" {len(proposals)} file(s) to rename.\n")
|
|
||||||
|
|
||||||
# ── Phase 3: Rename (interactive or auto) ──
|
|
||||||
if args.no_interactive:
|
|
||||||
renamed = 0
|
|
||||||
for old_path, new_name in proposals.items():
|
|
||||||
new_path = old_path.with_name(new_name)
|
|
||||||
if new_path.exists():
|
|
||||||
stem = new_path.stem
|
|
||||||
counter = 1
|
|
||||||
while new_path.exists():
|
|
||||||
new_path = old_path.with_name(f"{stem}_{counter}{old_path.suffix}")
|
|
||||||
counter += 1
|
|
||||||
try:
|
|
||||||
old_path.rename(new_path)
|
|
||||||
renamed += 1
|
|
||||||
except FileNotFoundError:
|
|
||||||
print(f" SKIPPED (not found): {old_path.name}")
|
|
||||||
print(f" Renamed {renamed} file(s).")
|
|
||||||
else:
|
|
||||||
renamed = 0
|
|
||||||
skipped = 0
|
|
||||||
items = list(proposals.items())
|
|
||||||
i = 0
|
|
||||||
while i < len(items):
|
|
||||||
old_path, new_name = items[i]
|
|
||||||
old = old_path.name
|
|
||||||
new = new_name
|
|
||||||
|
|
||||||
print(f"\n [{i+1}/{len(items)}]")
|
|
||||||
print(f" Current: {old}")
|
|
||||||
print(f" New: {new}")
|
|
||||||
remaining = len(items) - i - 1
|
|
||||||
rlabel = f"rename all {remaining}" if remaining else ""
|
|
||||||
sys.stdout.write(f" [Enter]=rename [e]=edit [s]=skip{' [a]=' + rlabel if rlabel else ''} [q]=quit: ")
|
|
||||||
sys.stdout.flush()
|
|
||||||
choice = sys.stdin.readline().strip().lower()
|
|
||||||
|
|
||||||
if choice == "q":
|
|
||||||
remaining = len(items) - i - 1
|
|
||||||
if remaining:
|
|
||||||
print(f" Skipping remaining {remaining} file(s).")
|
|
||||||
break
|
|
||||||
elif choice == "s":
|
|
||||||
skipped += 1
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
elif choice == "e":
|
|
||||||
sys.stdout.write(f" Edit name (will be prefixed 'schmeeve-AI-'): ")
|
|
||||||
sys.stdout.flush()
|
|
||||||
custom = sys.stdin.readline().strip()
|
|
||||||
if custom:
|
|
||||||
# Sanitize
|
|
||||||
custom_desc = re.sub(r"[^a-z0-9-]", "-", custom.lower())
|
|
||||||
custom_desc = re.sub(r"-+", "-", custom_desc).strip("-")
|
|
||||||
if custom_desc:
|
|
||||||
new = f"schmeeve-AI-{custom_desc}.png"
|
|
||||||
else:
|
|
||||||
print(" Invalid name, skipping.")
|
|
||||||
skipped += 1
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
# empty = skip
|
|
||||||
skipped += 1
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
# Fall through to rename
|
|
||||||
elif choice == "":
|
|
||||||
pass # rename with proposed name
|
|
||||||
elif choice == "a":
|
|
||||||
# Rename current and all remaining without further prompts
|
|
||||||
for j in range(i, len(items)):
|
|
||||||
p, n = items[j]
|
|
||||||
np = p.with_name(n)
|
|
||||||
if np.exists():
|
|
||||||
stem = np.stem
|
|
||||||
counter = 1
|
|
||||||
while np.exists():
|
|
||||||
np = p.with_name(f"{stem}_{counter}{p.suffix}")
|
|
||||||
counter += 1
|
|
||||||
try:
|
|
||||||
p.rename(np)
|
|
||||||
except FileNotFoundError:
|
|
||||||
print(f" SKIPPED (not found): {p.name}")
|
|
||||||
renamed += len(items) - i
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
print(f" Unknown option '{choice}', skipping.")
|
|
||||||
skipped += 1
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Perform rename
|
|
||||||
new_path = old_path.with_name(new)
|
|
||||||
if new_path.exists():
|
|
||||||
stem = new_path.stem
|
|
||||||
counter = 1
|
|
||||||
while new_path.exists():
|
|
||||||
new_path = old_path.with_name(f"{stem}_{counter}{old_path.suffix}")
|
|
||||||
counter += 1
|
|
||||||
print(f" (file existed, saved as {new_path.name})")
|
|
||||||
try:
|
|
||||||
old_path.rename(new_path)
|
|
||||||
renamed += 1
|
|
||||||
except FileNotFoundError:
|
|
||||||
print(f" SKIPPED (not found): {old_path.name}")
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
print(f"\n Renamed: {renamed} Skipped: {skipped}")
|
|
||||||
|
|
||||||
# One more pass: also look at .jpg? (maybe later)
|
|
||||||
remaining_ai = 0
|
|
||||||
for f in scan_dir.glob("*.jpg"):
|
|
||||||
try:
|
|
||||||
img = Image.open(f)
|
|
||||||
if "prompt" in img.info:
|
|
||||||
remaining_ai += 1
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if remaining_ai:
|
|
||||||
print(f" Note: {remaining_ai} JPEG(s) with AI metadata found (not yet supported).")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
80
sync-newer-images
Executable file
80
sync-newer-images
Executable file
@@ -0,0 +1,80 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# sync-newer-images — For each top-level image or video in SRC, copy it to
|
||||||
|
# DEST if it doesn't already exist anywhere under DEST.
|
||||||
|
# Works on Linux and macOS (bash 3.2+ compatible).
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: sync-newer-images [options] <destination> <source>
|
||||||
|
|
||||||
|
For each top-level image or video in source, copy it into destination if no
|
||||||
|
file with the same name exists anywhere under destination.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-u, --update If a matching file already exists under destination,
|
||||||
|
copy over it only if the content differs (uses cmp).
|
||||||
|
-h, --help Show this help message and exit
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
update_mode=0
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-h|--help) usage ;;
|
||||||
|
-u|--update) update_mode=1; shift ;;
|
||||||
|
*) break ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ $# -lt 2 ]; then
|
||||||
|
echo "Error: destination and source are required" >&2
|
||||||
|
echo "Usage: sync-newer-images [options] <destination> <source>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
DEST="$1"
|
||||||
|
SRC="$2"
|
||||||
|
|
||||||
|
if [ ! -d "$DEST" ]; then
|
||||||
|
echo "Error: destination '$DEST' is not a directory" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$SRC" ]; then
|
||||||
|
echo "Error: source '$SRC' is not a directory" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build deduplicated, newline-separated list of all basenames under DEST.
|
||||||
|
basenames=$(find "$DEST" -type f 2>/dev/null | sed 's|.*/||' | sort -u)
|
||||||
|
|
||||||
|
copied=0
|
||||||
|
|
||||||
|
for srcfile in "$SRC"/*; do
|
||||||
|
[ -f "$srcfile" ] || continue
|
||||||
|
|
||||||
|
ext="${srcfile##*.}"
|
||||||
|
case "$(echo "$ext" | tr '[:upper:]' '[:lower:]')" in
|
||||||
|
jpg|jpeg|png|gif|heic|webp|bmp|tiff|tif) ;;
|
||||||
|
mov|mp4|m4v|avi|mkv|3gp) ;;
|
||||||
|
*) continue ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
filename="${srcfile##*/}"
|
||||||
|
|
||||||
|
if ! printf '%s\n' "$basenames" | grep -qxF "$filename"; then
|
||||||
|
cp -v "$srcfile" "$DEST/"
|
||||||
|
copied=$((copied + 1))
|
||||||
|
elif [ "$update_mode" -eq 1 ]; then
|
||||||
|
destfile=$(find "$DEST" -type f -name "$filename" 2>/dev/null | head -1)
|
||||||
|
if [ -n "$destfile" ] && ! cmp -s "$srcfile" "$destfile"; then
|
||||||
|
cp -v "$srcfile" "$destfile"
|
||||||
|
copied=$((copied + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Copied ${copied} image(s)/video(s) from ${SRC} into ${DEST}"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{\rtf1\ansi\ansicpg1252\cocoartf2759
|
||||||
|
\cocoatextscaling0\cocoaplatform0{\fonttbl}
|
||||||
|
{\colortbl;\red255\green255\blue255;}
|
||||||
|
{\*\expandedcolortbl;;}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{\rtf1\ansi\ansicpg1252\cocoartf2759
|
||||||
|
\cocoatextscaling0\cocoaplatform0{\fonttbl}
|
||||||
|
{\colortbl;\red255\green255\blue255;}
|
||||||
|
{\*\expandedcolortbl;;}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{\rtf1\ansi\ansicpg1252\cocoartf2759
|
||||||
|
\cocoatextscaling0\cocoaplatform0{\fonttbl}
|
||||||
|
{\colortbl;\red255\green255\blue255;}
|
||||||
|
{\*\expandedcolortbl;;}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{\rtf1\ansi\ansicpg1252\cocoartf2759
|
||||||
|
\cocoatextscaling0\cocoaplatform0{\fonttbl}
|
||||||
|
{\colortbl;\red255\green255\blue255;}
|
||||||
|
{\*\expandedcolortbl;;}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user