Files
DemonGatanjieu-Anomalous_Mo…/api/model_media.py
T
DemonGatanjieuandClaude Opus 5.5 4f46676452 Model editor redone, with a source link; inferred information says so
The editor is rebuilt (it was inline styles everywhere): the cover large
with its choices under it (the current one, Civitai's own when the user
replaced it, the newest pictures, upload, the whole gallery), Name, Source
link and Notes with a word each, Reset / Cancel / Save at the foot. Nothing
is written before Save; fields left as they were are not sent. The source
link (asked for in the "not found" list's hint, but nowhere to type it) is
kept in the user layer; update_metadata refuses one that is no http(s)
address. GET /anomalous/model_covers tells the editor Civitai's kept cover.

A model Civitai does not know: its card reads "推断 <base>", its details
open with a note on what was read from the file and a button to add a name
and link (instead of the English placeholder description), and the scan
page says an online scan infers what Civitai does not find.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:26:14 +08:00

179 lines
7.6 KiB
Python

"""Model preview lookup and bounded custom-cover operations."""
import asyncio
import filecmp
import os
import shutil
import urllib.parse
from aiohttp import web
import folder_paths
from .model_constants import CIVITAI_BACKUP_SUFFIXES, MEDIA_EXTENSIONS, PREVIEW_SUFFIXES
from .trash import move_to_trash
from .utils import require_filename, resolve_folder_subdir, resolve_within
def _cache_token(file_path):
try:
return os.stat(file_path).st_mtime_ns
except OSError:
return 0
async def _handle_custom_cover(target_dir, filename, save_func, source_ext='.png'):
base_name = os.path.splitext(filename)[0]
# Always save custom covers with a .preview.[ext] suffix so standard nodes recognize them as covers.
if source_ext.startswith('.preview.'):
preview_ext = source_ext
else:
preview_ext = f".preview{source_ext}"
dest_path = os.path.join(target_dir, f"{base_name}{preview_ext}")
# Only one cover is active: the others go to the Recycle Bin (a failure keeps them).
older = [os.path.join(target_dir, f"{base_name}{ext}") for ext in PREVIEW_SUFFIXES]
try:
move_to_trash(*[path for path in older if path != dest_path])
except OSError as e:
print(f"[Anomalous Browser] Old cover kept: {e}")
await save_func(dest_path)
async def api_set_custom_cover(request):
try:
data = await request.json()
folder_type = data.get('type', 'checkpoints')
subfolder = data.get('subfolder', '/')
filename = data.get('filename', '')
source_image = data.get('source_image', '')
try: path_idx = int(data.get('path_idx', 0))
except: path_idx = 0
try:
filename = require_filename(filename)
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
output_dir = folder_paths.get_output_directory()
src_path = resolve_within(output_dir, source_image)
except (ValueError, KeyError):
return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400)
if not os.path.exists(src_path):
return web.json_response({"status": "error", "message": "Source image not found in output directory"})
source_ext = os.path.splitext(src_path)[1].lower()
if source_ext not in {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.mov', '.avi'}:
return web.json_response({"status": "error", "message": "Unsupported cover format"}, status=415)
async def save_copy(dest_path):
import shutil
import asyncio
await asyncio.to_thread(shutil.copy2, src_path, dest_path)
await _handle_custom_cover(target_dir, filename, save_copy, source_ext)
return web.json_response({"status": "success"})
except Exception as e:
return web.json_response({"status": "error", "message": str(e)})
async def api_upload_custom_cover(request):
try:
data = await request.post()
folder_type = data.get('type', 'checkpoints')
subfolder = data.get('subfolder', '/')
filename = data.get('filename', '')
try: path_idx = int(data.get('path_idx', 0))
except: path_idx = 0
image_field = data.get('image')
try:
filename = require_filename(filename)
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
except (ValueError, KeyError):
return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400)
if image_field is None:
return web.json_response({"status": "error", "message": "Image is required"}, status=400)
image_data = image_field.file.read()
if len(image_data) > 100 * 1024 * 1024:
return web.json_response({"status": "error", "message": "Cover file is too large"}, status=413)
upload_filename = image_field.filename
source_ext = os.path.splitext(upload_filename)[1].lower()
if source_ext not in {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.mov', '.avi'}:
return web.json_response({"status": "error", "message": "Unsupported cover format"}, status=415)
async def save_upload(dest_path):
def write_file():
with open(dest_path, 'wb') as f:
f.write(image_data)
import asyncio
await asyncio.to_thread(write_file)
await _handle_custom_cover(target_dir, filename, save_upload, source_ext)
return web.json_response({"status": "success"})
except Exception as e:
return web.json_response({"status": "error", "message": str(e)})
def _preview_url_for_model(folder_type, path_idx, base_dir, file_path):
root = os.path.dirname(file_path)
filename = os.path.basename(file_path)
base_name = os.path.splitext(filename)[0]
preview_file = next(
(
base_name + suffix
for suffix in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS
if os.path.isfile(os.path.join(root, base_name + suffix))
),
None,
)
if not preview_file:
return ""
return _media_url(folder_type, path_idx, base_dir, os.path.join(root, preview_file))
def _media_url(folder_type, path_idx, base_dir, media_path):
"""The /anomalous/image URL of a file beside a model, with a token that changes with it."""
rel_subfolder = os.path.relpath(os.path.dirname(media_path), base_dir)
if rel_subfolder == '.':
rel_subfolder = '/'
q_type = urllib.parse.quote(folder_type)
q_idx = str(path_idx)
q_sub = urllib.parse.quote(rel_subfolder)
q_file = urllib.parse.quote(os.path.basename(media_path))
version = _cache_token(media_path)
return f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}&filename={q_file}&t={version}"
def _model_covers(folder_type, path_idx, base_dir, file_path):
"""Civitai's own image a scan kept (<model>.civitai_bak.*) as a URL, or "", and whether the
cover shown is another picture (the user's): a cover is Civitai's when it is a copy of it."""
base = os.path.splitext(file_path)[0]
saved = next((base + suffix for suffix in CIVITAI_BACKUP_SUFFIXES if os.path.isfile(base + suffix)), "")
shown = next((base + suffix for suffix in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS if os.path.isfile(base + suffix)), "")
try:
custom = bool(shown) and not (saved and filecmp.cmp(shown, saved, shallow=False))
except OSError:
custom = bool(shown)
return {"civitai": _media_url(folder_type, path_idx, base_dir, saved) if saved else "", "custom": custom}
async def api_model_covers(request):
"""GET /anomalous/model_covers?type&path_idx&subfolder&filename - for the model editor: the
Civitai cover it can bring back, and whether the one shown is the user's."""
try:
folder_type = request.query.get('type', '')
path_idx = int(request.query.get('path_idx', 0))
filename = require_filename(request.query.get('filename', ''))
base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, request.query.get('subfolder', '/'))
file_path = resolve_within(target_dir, filename)
except (ValueError, KeyError, TypeError):
return web.json_response({"error": "Invalid model location"}, status=400)
if not os.path.isfile(file_path):
return web.json_response({"error": "Model not found"}, status=404)
return web.json_response(await asyncio.to_thread(_model_covers, folder_type, path_idx, base_dir, file_path))