This commit is contained in:
2026-07-29 17:12:45 -07:00
20 changed files with 1967 additions and 616 deletions

180
arc-export/.gitignore vendored Normal file
View 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
View 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"

View File

@@ -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.

BIN
img/ai-technology.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
img/nude.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

1036
lora-info-sheet.html Normal file

File diff suppressed because one or more lines are too long

629
lora-trigger-sheet Executable file
View 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&hellip;</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 &middot; <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">&#9650;</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&hellip;');
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 ? '&#9650;' : '&#9660;';
}}
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&hellip;';
}} 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 &times;';
}}
}}
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("&", "&amp;")
s = s.replace("<", "&lt;")
s = s.replace(">", "&gt;")
s = s.replace('"', "&quot;")
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()

View File

@@ -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
View 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"

View File

@@ -0,0 +1,5 @@
{\rtf1\ansi\ansicpg1252\cocoartf2709
\cocoatextscaling0\cocoaplatform0{\fonttbl}
{\colortbl;\red255\green255\blue255;}
{\*\expandedcolortbl;;}
}

View File

@@ -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
View 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}"

View File

@@ -0,0 +1,5 @@
{\rtf1\ansi\ansicpg1252\cocoartf2759
\cocoatextscaling0\cocoaplatform0{\fonttbl}
{\colortbl;\red255\green255\blue255;}
{\*\expandedcolortbl;;}
}

View File

@@ -0,0 +1,5 @@
{\rtf1\ansi\ansicpg1252\cocoartf2759
\cocoatextscaling0\cocoaplatform0{\fonttbl}
{\colortbl;\red255\green255\blue255;}
{\*\expandedcolortbl;;}
}

View File

@@ -0,0 +1,5 @@
{\rtf1\ansi\ansicpg1252\cocoartf2759
\cocoatextscaling0\cocoaplatform0{\fonttbl}
{\colortbl;\red255\green255\blue255;}
{\*\expandedcolortbl;;}
}

View File

@@ -0,0 +1,5 @@
{\rtf1\ansi\ansicpg1252\cocoartf2759
\cocoatextscaling0\cocoaplatform0{\fonttbl}
{\colortbl;\red255\green255\blue255;}
{\*\expandedcolortbl;;}
}