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:
@@ -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
|
||||
Reference in New Issue
Block a user