Publish current ComfyUI O1Key code baseline

Replace the prior release tree with the current plugin, frontend, tests, and documentation. Document retired node IDs and the public Gitea update source.
This commit is contained in:
Jony
2026-09-24 19:56:48 +08:00
parent 3e337722ab
commit ba920f2b66
183 changed files with 49496 additions and 9934 deletions
+257
View File
@@ -0,0 +1,257 @@
"""Shared Seedance asset creation and safe ID reuse.
The cache deliberately stores only content fingerprints and provider asset IDs.
Upload URLs, local paths, credentials, and response envelopes never cross this
boundary.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import os
import re
import threading
import time
import uuid
from collections.abc import Awaitable, Callable
from typing import Any
from ..clients.seedance_element_client import SeedanceElementClient
_ROUTE_REQUEST_TYPES = {
"overseas_hc": "hc",
"domestic": "doubao",
}
_CACHE_VERSION = 1
_MAX_CACHE_ENTRIES = 512
_SAFE_ASSET_ID = re.compile(r"^[A-Za-z0-9._:-]{1,256}$")
_CACHE_LOCKS: dict[tuple[int, str, str], asyncio.Lock] = {}
_FILE_LOCKS: dict[str, threading.Lock] = {}
_FILE_LOCKS_GUARD = threading.Lock()
def seedance_asset_request_type(route: str) -> str:
"""Map the unified video route to the material API request type."""
try:
return _ROUTE_REQUEST_TYPES[str(route).strip()]
except KeyError:
raise ValueError(f"不支持的 Seedance 素材线路:{route}") from None
def seedance_asset_fingerprint(path: str, request_type: str, asset_type: str) -> str:
"""Hash content plus the provider namespace used to create the asset."""
digest = hashlib.sha256()
digest.update(str(request_type).strip().lower().encode("utf-8"))
digest.update(b"\0")
digest.update(str(asset_type).strip().lower().encode("utf-8"))
digest.update(b"\0")
with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
class SeedanceAssetCache:
"""Small atomic cache containing no provider URLs or local media paths."""
def __init__(self, path: str):
self.path = os.path.abspath(path)
with _FILE_LOCKS_GUARD:
self._lock = _FILE_LOCKS.setdefault(self.path, threading.Lock())
def _ensure_parent(self) -> None:
parent = os.path.dirname(self.path)
if parent:
os.makedirs(parent, exist_ok=True)
def _read_unlocked(self) -> dict[str, dict[str, Any]]:
if not os.path.isfile(self.path):
return {}
try:
with open(self.path, "r", encoding="utf-8") as handle:
payload = json.load(handle)
except (OSError, json.JSONDecodeError):
return {}
if not isinstance(payload, dict) or payload.get("version") != _CACHE_VERSION:
return {}
entries = payload.get("entries")
return entries if isinstance(entries, dict) else {}
def get(self, fingerprint: str, request_type: str, asset_type: str) -> str | None:
with self._lock:
item = self._read_unlocked().get(fingerprint)
if not isinstance(item, dict):
return None
if item.get("request_type") != request_type or item.get("asset_type") != asset_type:
return None
asset_id = str(item.get("asset_id") or "").strip()
return asset_id if _SAFE_ASSET_ID.fullmatch(asset_id) else None
def put(self, fingerprint: str, asset_id: str, request_type: str, asset_type: str) -> None:
with self._lock:
entries = self._read_unlocked()
entries[fingerprint] = {
"asset_id": str(asset_id),
"request_type": request_type,
"asset_type": asset_type,
"updated_at": int(time.time()),
}
if len(entries) > _MAX_CACHE_ENTRIES:
entries = dict(
sorted(
entries.items(),
key=lambda pair: int(pair[1].get("updated_at") or 0),
reverse=True,
)[:_MAX_CACHE_ENTRIES]
)
self._ensure_parent()
temporary = f"{self.path}.{uuid.uuid4().hex}.tmp"
try:
with open(temporary, "w", encoding="utf-8") as handle:
json.dump(
{"version": _CACHE_VERSION, "entries": entries},
handle,
ensure_ascii=False,
indent=2,
)
os.replace(temporary, self.path)
finally:
if os.path.exists(temporary):
try:
os.remove(temporary)
except OSError:
pass
def discard(self, fingerprint: str) -> None:
with self._lock:
entries = self._read_unlocked()
if fingerprint not in entries:
return
entries.pop(fingerprint, None)
self._ensure_parent()
temporary = f"{self.path}.{uuid.uuid4().hex}.tmp"
try:
with open(temporary, "w", encoding="utf-8") as handle:
json.dump(
{"version": _CACHE_VERSION, "entries": entries},
handle,
ensure_ascii=False,
indent=2,
)
os.replace(temporary, self.path)
finally:
if os.path.exists(temporary):
try:
os.remove(temporary)
except OSError:
pass
class SeedanceAssetService:
"""Create Active assets through the same service used by the node and panel."""
def __init__(
self,
*,
base_url: str | None = None,
client: SeedanceElementClient | None = None,
cache_path: str | None = None,
):
self.client = client or SeedanceElementClient(base_url=base_url)
self.cache = SeedanceAssetCache(cache_path) if cache_path else None
async def create_from_url(
self,
*,
name: str,
asset_url: str | None = None,
asset_url_factory: Callable[[], Awaitable[str]] | None = None,
asset_type: str,
request_type: str,
fingerprint: str | None = None,
) -> dict[str, Any]:
normalized_type = str(asset_type).strip().lower()
normalized_request = str(request_type).strip().lower()
async def resolve_asset_url() -> str:
value = asset_url
if value is None and asset_url_factory is not None:
value = await asset_url_factory()
if not value or not str(value).startswith("https://"):
raise ValueError("创建 Seedance 素材必须使用 HTTPS 上传地址")
return str(value)
def finalized(result: dict[str, Any], *, reused: bool) -> dict[str, Any]:
asset_id = str(result.get("Id") or "").strip()
if not _SAFE_ASSET_ID.fullmatch(asset_id):
raise RuntimeError("Seedance 素材已激活但未返回安全有效的 ID")
value = dict(result)
value["Id"] = asset_id
value["_reused"] = reused
return value
if not fingerprint or self.cache is None:
result = await self.client.create_hc_asset_and_wait(
name=name,
asset_url=await resolve_asset_url(),
asset_type=asset_type,
request_type=normalized_request,
)
return finalized(result, reused=False)
loop = asyncio.get_running_loop()
lock_key = (id(loop), self.cache.path, fingerprint)
lock = _CACHE_LOCKS.setdefault(lock_key, asyncio.Lock())
async with lock:
cached_id = await asyncio.to_thread(
self.cache.get,
fingerprint,
normalized_request,
normalized_type,
)
if cached_id:
try:
cached = await self.client.get_hc_asset(
cached_id,
request_type=normalized_request,
)
except RuntimeError as exc:
if "(404)" not in str(exc) and "(410)" not in str(exc):
raise
cached = None
if cached is not None:
if str(cached.get("Status") or "").strip().lower() == "active":
result = dict(cached)
result["Id"] = cached_id
return finalized(result, reused=True)
try:
await asyncio.to_thread(self.cache.discard, fingerprint)
except OSError:
pass
result = await self.client.create_hc_asset_and_wait(
name=name,
asset_url=await resolve_asset_url(),
asset_type=asset_type,
request_type=normalized_request,
)
result = finalized(result, reused=False)
asset_id = result["Id"]
try:
await asyncio.to_thread(
self.cache.put,
fingerprint,
asset_id,
normalized_request,
normalized_type,
)
except OSError:
# A local reuse optimization must not turn an already-created
# provider asset into a failed video job.
pass
return result