Files
DemonGatanjieuandClaude Opus 5.5 93cf8f122a Recipe packages: tell the receiver about the cover; unstretched cover preview
The import dialog said "with 1 picture" for a package with a cover and one
model thumbnail: the cover lives inside recipe.json as a small image, not as
an asset file. Inspect now returns has_cover and model_pictures, and the
dialog says "a cover, N model thumbnails".

The save dialog's cover preview stretched the chosen image to the dialog's
width; it now keeps its proportions (object-fit: contain).

Checked live: a recipe with an output-image cover and a LoRA thumbnail,
exported and imported with the output image moved away, shows both on the
new card and its detail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 01:14:29 +08:00

502 lines
22 KiB
Python

"""Recipe packages (配方包): one Workflow Recipe as a .zip to give to someone else — the recipe,
its cover, its models' thumbnails and, when asked, its versions — and taking one in as a new
recipe card. Bounded: entry names, count, sizes, compression ratio and checksums are checked
before anything is written; an import never replaces a recipe (a taken name gets " (2)")."""
import hashlib
import io
import asyncio
import json
import os
import shutil
import stat
import tempfile
import time
import urllib.parse
import uuid
import zipfile
from aiohttp import web
from . import recipe_images, recipe_schema, recipe_store
from .recipe_store import get_recipes_dir
from .utils import require_filename, resolve_within
PACKAGE_VERSION = 1
MAX_UPLOAD_BYTES = 32 * 1024 * 1024
MAX_ENTRY_COUNT = 256
MAX_ENTRY_BYTES = 16 * 1024 * 1024
MAX_EXPANDED_BYTES = 64 * 1024 * 1024
MAX_COMPRESSION_RATIO = 100
MAX_HISTORY_ENTRIES = 100
MAX_JSON_DEPTH = 64
MAX_INSPECTIONS = 4
INSPECTION_TTL_SECONDS = 10 * 60
ALLOWED_COMPRESSION = {zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED}
_INSPECTIONS = {}
def _json_depth(value, depth=0):
if depth > MAX_JSON_DEPTH:
return depth
if isinstance(value, dict):
return max((_json_depth(item, depth + 1) for item in value.values()), default=depth)
if isinstance(value, list):
return max((_json_depth(item, depth + 1) for item in value), default=depth)
return depth
def _sha256(data):
return hashlib.sha256(data).hexdigest()
def _safe_archive_name(name):
if not isinstance(name, str) or not name or len(name) > 240:
raise ValueError("Invalid package entry name")
if chr(92) in name or name.startswith("/") or ":" in name:
raise ValueError("Invalid package entry name")
parts = name.split("/")
if any(not part or part in (".", "..") for part in parts):
raise ValueError("Invalid package entry name")
return name
def _entry_is_symlink(info):
return stat.S_ISLNK((info.external_attr >> 16) & 0xFFFF)
def _read_zip_entry(archive, name, maximum=MAX_ENTRY_BYTES):
info = archive.getinfo(name)
if info.file_size > maximum:
raise ValueError("Package entry is too large")
data = archive.read(info)
if len(data) != info.file_size:
raise ValueError("Package entry size mismatch")
return data
def _validate_zip(raw):
if not isinstance(raw, bytes) or len(raw) > MAX_UPLOAD_BYTES:
raise ValueError("Package upload is too large")
try:
archive = zipfile.ZipFile(io.BytesIO(raw), "r")
except (OSError, zipfile.BadZipFile) as error:
raise ValueError("Invalid recipe package") from error
infos = archive.infolist()
if len(infos) > MAX_ENTRY_COUNT:
raise ValueError("Too many package entries")
names = []
expanded = 0
for info in infos:
name = _safe_archive_name(info.filename)
if name in names or info.is_dir() or _entry_is_symlink(info):
raise ValueError("Invalid or duplicate package entry")
if info.compress_type not in ALLOWED_COMPRESSION or info.flag_bits & 0x1:
raise ValueError("Unsupported package compression")
if info.file_size > MAX_ENTRY_BYTES:
raise ValueError("Package entry is too large")
if info.compress_size and info.file_size / info.compress_size > MAX_COMPRESSION_RATIO:
raise ValueError("Package compression ratio is unsafe")
expanded += info.file_size
if expanded > MAX_EXPANDED_BYTES:
raise ValueError("Package expands beyond the safety limit")
names.append(name)
if "manifest.json" not in names or "recipe.json" not in names:
raise ValueError("Package is missing manifest or recipe")
return archive, names
def _parse_json(data, label):
try:
value = json.loads(data.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise ValueError(f"Invalid {label}") from error
if _json_depth(value) > MAX_JSON_DEPTH:
raise ValueError(f"{label} is nested too deeply")
return value
def _asset_id_from_reference(reference):
asset_id = reference.get("preview", {}).get("snapshot_asset_id") if isinstance(reference, dict) else None
if not isinstance(asset_id, str) or not asset_id.endswith(".webp"):
return None
return require_filename(asset_id)
def _cover_asset_id(recipe):
presentation = recipe.get("presentation", {}) if isinstance(recipe, dict) else {}
asset_id = presentation.get("cover_asset_id") if isinstance(presentation, dict) else None
if not isinstance(asset_id, str) or not asset_id.startswith("cover-") or not asset_id.endswith(".webp"):
return None
return require_filename(asset_id)
def _picture_summary(recipe):
"""What a package shows the receiver: (has a cover, how many model thumbnails). The cover
is usually the small image inside recipe.json (`thumbnail`), not an asset file."""
thumbnail = recipe.get("thumbnail")
has_cover = bool(_cover_asset_id(recipe)) or (
isinstance(thumbnail, str) and thumbnail.lower().startswith(recipe_schema.SAFE_THUMBNAIL_PREFIXES))
references = recipe.get("params", {}).get("model_references", [])
models = {_asset_id_from_reference(item) for item in references if isinstance(item, dict)} if isinstance(references, list) else set()
models.discard(None)
return has_cover, len(models)
def _validate_manifest(manifest, archive, names):
if not isinstance(manifest, dict) or manifest.get("package_version") != PACKAGE_VERSION:
raise ValueError("Unsupported recipe package version")
declared = manifest.get("entries", [])
if not isinstance(declared, list) or len(declared) > MAX_ENTRY_COUNT:
raise ValueError("Invalid package manifest")
declared_map = {}
for item in declared:
if not isinstance(item, dict):
raise ValueError("Invalid package manifest entry")
name = _safe_archive_name(item.get("name"))
if name in declared_map or name not in names:
raise ValueError("Package manifest does not match archive")
declared_map[name] = item
for name in names:
if name == "manifest.json":
continue
item = declared_map.get(name)
if not item:
raise ValueError("Package contains undeclared data")
data = _read_zip_entry(archive, name)
if item.get("sha256") != _sha256(data) or item.get("size") != len(data):
raise ValueError("Package checksum mismatch")
def _sanitize_recipe_for_export(recipe, include_snapshots=True, include_identity=True, include_model_notes=True):
value = json.loads(json.dumps(recipe, ensure_ascii=False))
# A source_image points into the exporting machine's output directory and
# is not portable package data. The bounded thumbnail remains the cover.
value["source_image"] = None
for reference in value.get("params", {}).get("model_references", []):
if not include_model_notes:
reference.pop("user_note", None)
if not include_snapshots:
reference.pop("preview", None)
if not include_identity:
identity = reference.get("identity")
if isinstance(identity, dict):
for key in ("sha256", "size", "provenance"):
identity.pop(key, None)
identity["status"] = "unverified"
return value
def _referenced_asset_ids(recipe, include_model_previews=True):
result = set()
cover_asset = _cover_asset_id(recipe)
if cover_asset:
result.add(cover_asset)
if include_model_previews:
references = recipe.get("params", {}).get("model_references", [])
for reference in references if isinstance(references, list) else []:
asset_id = _asset_id_from_reference(reference)
if asset_id:
result.add(asset_id)
return result
def _add_zip_entry(archive, entries, name, data, media_type):
name = _safe_archive_name(name)
archive.writestr(name, data)
entries.append({"name": name, "size": len(data), "sha256": _sha256(data), "media_type": media_type})
def _recipe_history_files(recipes_dir, filename):
history_dir = recipe_store._history_dir(recipes_dir, filename)
files = []
if not os.path.isdir(history_dir):
return files
with os.scandir(history_dir) as entries:
for entry in entries:
if entry.is_file() and entry.name.endswith(".json"):
files.append(require_filename(entry.name))
files.sort()
return files[:MAX_HISTORY_ENTRIES]
def _build_export(raw_recipe, recipes_dir, filename, options):
include_snapshots = options.get("include_snapshots", True) is True
include_history = options.get("include_history") is True
include_identity = options.get("include_identity", True) is True
include_model_notes = options.get("include_model_notes", True) is True
recipe = _sanitize_recipe_for_export(raw_recipe, include_snapshots, include_identity, include_model_notes)
history_recipes = []
if include_history:
for history_name in _recipe_history_files(recipes_dir, filename):
history_path = resolve_within(recipe_store._history_dir(recipes_dir, filename), history_name)
with open(history_path, "rb") as history_file:
data = history_file.read(MAX_ENTRY_BYTES + 1)
if len(data) > MAX_ENTRY_BYTES:
raise ValueError("Historical recipe is too large")
history_recipe = _parse_json(data, "historical recipe")
history_recipes.append((history_name, _sanitize_recipe_for_export(
history_recipe,
include_snapshots,
include_identity,
include_model_notes,
)))
asset_ids = _referenced_asset_ids(recipe, include_snapshots)
for _, history_recipe in history_recipes:
asset_ids.update(_referenced_asset_ids(history_recipe, include_snapshots))
entries = []
output = io.BytesIO()
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
recipe_bytes = json.dumps(recipe, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
_add_zip_entry(archive, entries, "recipe.json", recipe_bytes, "application/json")
if asset_ids:
assets_dir = recipe_images._recipe_assets_dir(recipes_dir, filename)
for asset_id in sorted(asset_ids):
asset_path = resolve_within(assets_dir, asset_id)
if not os.path.isfile(asset_path):
continue
with open(asset_path, "rb") as asset_file:
data = asset_file.read(MAX_ENTRY_BYTES + 1)
if len(data) > MAX_ENTRY_BYTES or not data.startswith(b"RIFF") or data[8:12] != b"WEBP":
continue
_add_zip_entry(archive, entries, f"assets/{asset_id}", data, "image/webp")
if include_history:
for history_name, history_recipe in history_recipes:
data = json.dumps(history_recipe, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
_add_zip_entry(archive, entries, f"history/{history_name}", data, "application/json")
manifest = {
"package_version": PACKAGE_VERSION,
"recipe_schema_version": recipe.get("schema_version", 1),
"exported_at": int(time.time() * 1000),
"options": {
"include_snapshots": include_snapshots,
"include_history": include_history,
"include_identity": include_identity,
"include_model_notes": include_model_notes,
},
"entries": entries,
}
manifest_bytes = json.dumps(manifest, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
archive.writestr("manifest.json", manifest_bytes)
return output.getvalue()
def _validate_recipe_assets(archive, recipes, names):
referenced = set()
for recipe in recipes:
referenced.update(_referenced_asset_ids(recipe))
for asset_id in referenced:
name = f"assets/{asset_id}"
if name not in names:
raise ValueError("Recipe references a missing snapshot asset")
data = _read_zip_entry(archive, name)
if not data.startswith(b"RIFF") or data[8:12] != b"WEBP":
raise ValueError("Snapshot asset is not a WebP image")
for name in names:
if name.startswith("assets/") and (name[7:] not in referenced or not name.endswith(".webp")):
raise ValueError("Package contains an unreferenced asset")
def _inspect_package(raw):
archive, names = _validate_zip(raw)
with archive:
manifest = _parse_json(_read_zip_entry(archive, "manifest.json"), "manifest")
_validate_manifest(manifest, archive, names)
recipe = _parse_json(_read_zip_entry(archive, "recipe.json"), "recipe")
recipe_schema._normalise_recipe(recipe)
history_names = [name for name in names if name.startswith("history/")]
if len(history_names) > MAX_HISTORY_ENTRIES:
raise ValueError("Too many history entries")
history_recipes = []
for name in history_names:
history = _parse_json(_read_zip_entry(archive, name), "historical recipe")
recipe_schema._normalise_recipe(history)
history_recipes.append(history)
_validate_recipe_assets(archive, [recipe, *history_recipes], names)
return {
"manifest": manifest,
"recipe": recipe,
"history_names": history_names,
"asset_names": [name for name in names if name.startswith("assets/")],
}
def _prune_inspections():
now = time.time()
for token, record in list(_INSPECTIONS.items()):
if now - record["created"] > INSPECTION_TTL_SECONDS:
_INSPECTIONS.pop(token, None)
while len(_INSPECTIONS) >= MAX_INSPECTIONS:
oldest = min(_INSPECTIONS, key=lambda token: _INSPECTIONS[token]["created"])
_INSPECTIONS.pop(oldest, None)
def _existing_recipe_names(recipes_dir):
return [item["data"].get("name", "") for item in recipe_store._list_recipes(recipes_dir)]
def _unique_name(name, existing):
base = name.strip() or "Imported Workflow Recipe"
if base not in existing:
return base
for index in range(2, 1000):
candidate = f"{base} ({index})"
if candidate not in existing:
return candidate
raise ValueError("Could not create a unique recipe name")
def _copy_imported_assets(archive, inspection, staging, source_recipe, target_recipe):
asset_dir = os.path.join(staging, "assets")
os.makedirs(asset_dir, exist_ok=True)
for name in inspection["asset_names"]:
asset_id = require_filename(name.split("/", 1)[1])
data = _read_zip_entry(archive, name)
with open(resolve_within(asset_dir, asset_id), "wb") as asset_file:
asset_file.write(data)
source_refs = {
(
item.get("node_id"),
item.get("widget_index"),
item.get("category"),
item.get("saved_value"),
): item
for item in source_recipe.get("params", {}).get("model_references", [])
if isinstance(item, dict)
}
for target in target_recipe.get("params", {}).get("model_references", []):
source = source_refs.get((target.get("node_id"), target.get("widget_index"), target.get("category"), target.get("saved_value")))
asset_id = _asset_id_from_reference(source or {})
if asset_id and os.path.isfile(resolve_within(asset_dir, asset_id)):
target["preview"] = source["preview"]
def _write_staged_json(path, value):
with open(path, "w", encoding="utf-8", newline="\n") as output:
json.dump(value, output, ensure_ascii=False, separators=(",", ":"))
def _commit_import(record, payload):
"""Writes the inspected package as a new recipe (never over one): (filename, name)."""
recipes_dir = get_recipes_dir()
package_recipe = json.loads(json.dumps(record["recipe"], ensure_ascii=False))
name_override = payload.get("name")
if name_override is not None:
if not isinstance(name_override, str) or not name_override.strip():
raise ValueError("Invalid imported recipe name")
package_recipe["name"] = name_override.strip()
package_recipe["name"] = _unique_name(package_recipe.get("name", ""), _existing_recipe_names(recipes_dir))
normalized = recipe_schema._normalise_recipe(package_recipe)
normalized["presentation"]["imported"] = True
filename = f"recipe_{int(time.time())}_{uuid.uuid4().hex[:8]}.json"
normalized = recipe_schema._enrich_recipe(normalized)
staging = tempfile.mkdtemp(prefix=".recipe-import-", dir=recipes_dir)
final_path = resolve_within(recipes_dir, filename)
final_assets = recipe_images._recipe_assets_dir(recipes_dir, filename)
final_history = recipe_store._history_dir(recipes_dir, filename)
installed = []
try:
archive, _ = _validate_zip(record["raw"])
with archive:
_copy_imported_assets(archive, record, staging, record["recipe"], normalized)
_write_staged_json(os.path.join(staging, "recipe.json"), normalized)
if record["history_names"]:
history_stage = os.path.join(staging, "history")
os.makedirs(history_stage, exist_ok=True)
for name in record["history_names"]:
history = _parse_json(_read_zip_entry(archive, name), "historical recipe")
_write_staged_json(os.path.join(history_stage, require_filename(name.split("/", 1)[1])), history)
# The images first, the recipe last: a recipe card never shows without its pictures.
staged_assets = os.path.join(staging, "assets")
if os.listdir(staged_assets):
os.makedirs(os.path.dirname(final_assets), exist_ok=True)
os.replace(staged_assets, final_assets)
installed.append(final_assets)
if record["history_names"]:
os.makedirs(os.path.dirname(final_history), exist_ok=True)
os.replace(os.path.join(staging, "history"), final_history)
installed.append(final_history)
os.replace(os.path.join(staging, "recipe.json"), final_path)
except Exception:
for path in installed: # this import's own new folders; nothing of the user's
shutil.rmtree(path, ignore_errors=True)
raise
finally:
shutil.rmtree(staging, ignore_errors=True)
return filename, normalized.get("name", "")
def _download_name(recipe, filename):
"""The .zip's name for the browser: the recipe's name, safe on every system."""
name = "".join("_" if char in '<>:"/\\|?*' or ord(char) < 32 else char for char in str(recipe.get("name") or "")).strip(" .")
return f"{name or os.path.splitext(filename)[0]}.anomalous-recipe.zip"
async def api_export_recipe_package(request):
try:
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
if not filename.endswith(".json"):
raise ValueError("Invalid recipe filename")
recipes_dir = get_recipes_dir()
recipe = await asyncio.to_thread(recipe_store._read_recipe, resolve_within(recipes_dir, filename))
package = await asyncio.to_thread(_build_export, recipe, recipes_dir, filename, payload)
except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid export request"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Recipe not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not export recipe"}, status=500)
name = _download_name(recipe, filename)
return web.Response(body=package, content_type="application/zip", headers={
"Content-Disposition": f"attachment; filename=\"recipe.anomalous-recipe.zip\"; filename*=UTF-8''{urllib.parse.quote(name)}",
})
async def api_import_recipe_package_inspect(request):
try:
raw = await request.content.read(MAX_UPLOAD_BYTES + 1)
report = await asyncio.to_thread(_inspect_package, raw)
except (ValueError, OSError, zipfile.BadZipFile):
return web.json_response({"status": "error", "message": "Invalid recipe package"}, status=400)
_prune_inspections()
token = uuid.uuid4().hex
_INSPECTIONS[token] = {"created": time.time(), "raw": raw, **report}
return web.json_response({
"status": "success",
"token": token,
"recipe": {"name": report["recipe"].get("name", ""), "tags": report["recipe"].get("tags", [])},
"asset_count": len(report["asset_names"]),
"has_cover": _picture_summary(report["recipe"])[0],
"model_pictures": _picture_summary(report["recipe"])[1],
"history_count": len(report["history_names"]),
"existing_names": _existing_recipe_names(get_recipes_dir()),
"imported_name": _unique_name(report["recipe"].get("name", ""), _existing_recipe_names(get_recipes_dir())),
})
async def api_import_recipe_package_commit(request):
try:
payload = await request.json()
token = payload.get("token")
if not isinstance(token, str) or token not in _INSPECTIONS:
raise ValueError("Invalid inspection token")
record = _INSPECTIONS.pop(token)
if time.time() - record["created"] > INSPECTION_TTL_SECONDS:
raise ValueError("Inspection token expired")
filename, name = await asyncio.to_thread(_commit_import, record, payload)
except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Could not commit recipe package"}, status=400)
except OSError:
return web.json_response({"status": "error", "message": "Could not commit recipe package"}, status=500)
return web.json_response({"status": "success", "filename": filename, "name": name})