diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..b533a3db7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Dependencies and local package stores +node_modules/ +.pnpm-store/ +.npm/ + +# Python caches and local environments +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.venv/ +venv/ +env/ +python/Lib/ +python/Scripts/ +python/Include/ +python/tcl/ +python/share/ + +# Build outputs and temporary workspaces +.build/ +build/ +dist/ +release/ +target/ +src-tauri/target/ +src-tauri/gen/ + +# Local runtime data, logs, and generated output +data/ +output/ +logs/ +*.log +tmp/ +temp/ +CLI/**/output/ + +# Local development records and backups +backup/ +进度快照/ +避坑指南/ +案例/ +api文档/ diff --git a/README.md b/README.md index 1260b13a3..9e25033ce 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # Infinite-Canvas -Supports comfyui/API calls/modelscope calls +在线 API 图像、视频与对话创作工作台 + +## Windows x64 便携版 + +解压发布 ZIP 后运行 `Canvas.exe`。程序资源位于 `app`,所有设置、数据库、媒体、缓存和日志只写入同级 `data`;升级时先从托盘退出,再替换 `Canvas.exe` 与 `app`,不要删除 `data`。默认监听 3000 端口并允许可信局域网访问,新设备须通过托盘“配对设备”生成的一次性配对码授权。首版不支持自动更新,也不应将端口映射到公网。 配套的chrome采集插件已经上线:https://chromewebstore.google.com/detail/infinite-canvas-%E5%9B%BE%E5%83%8F%E8%A7%86%E9%A2%91%E6%96%87%E5%AD%97%E6%8A%93%E5%8F%96%E5%B7%A5/ajfhnbklbmpfaaookhfakohabnpmlcic?authuser=0&hl=en @@ -27,9 +31,10 @@ https://www.fhl.mom/register?aff=86L574B4T2N9 (包含codex和GPT image 2模 3. 火山引擎调用(人脸认证还在修复bug) 4. Modelscope免费LLM模型和图像模型调用 5. 即梦CLI调用,可直接调用即梦高级会员的积分,支持文生图/图生图/文生视频/图生视频 -6. 支持调用本地局域网的ComfyUI -7. 扩展图片/360全景图预览截图/视频帧抽取/循环节点等诸多功能 -8. tools文件夹中,增加了chrome批量采集到素材库的插件,PS直连画布调用所有功能的插件 +6. 扩展图片/360全景图预览截图/视频帧抽取/循环节点等诸多功能 +7. tools文件夹中,增加了chrome批量采集到素材库的插件,PS直连画布调用所有功能的插件 +8. 电商专用工作台:统一高品质生成,支持换衣、动作迁移、道具替换、角度、背景与最多 14 张角色化参考图的全能模式;素材和结果跨标签持久化,支持拖拽上传、全屏划像对比、人工质量验收与正式导出 +9. 作品管理:集中浏览生成作品,支持搜索、类型筛选、收藏、下载,并可随时使用全屏划像对比核对细节 -------- diff --git a/VERSION b/VERSION index 2fefd2b94..b668c3b2c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2026.06.29 \ No newline at end of file +1.0.16 diff --git a/backend_entry.py b/backend_entry.py new file mode 100644 index 000000000..7d44544b3 --- /dev/null +++ b/backend_entry.py @@ -0,0 +1,15 @@ +"""PyInstaller/Tauri sidecar entry point.""" + +import os +import sys + +ENTRY_DIR = os.path.dirname(os.path.abspath(__file__)) +if ENTRY_DIR not in sys.path: + sys.path.insert(0, ENTRY_DIR) + +from canvas_core.runtime import run_uvicorn +from main import app + + +if __name__ == "__main__": + run_uvicorn(app) diff --git a/canvas-backend.spec b/canvas-backend.spec new file mode 100644 index 000000000..4a82c284a --- /dev/null +++ b/canvas-backend.spec @@ -0,0 +1,44 @@ +# -*- mode: python ; coding: utf-8 -*- + +from PyInstaller.utils.hooks import collect_submodules + + +hiddenimports = collect_submodules("uvicorn") + ["multipart"] + +a = Analysis( + ["backend_entry.py"], + pathex=["."], + binaries=[], + datas=[], + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=["tkinter", "pytest"], + noarchive=False, + optimize=1, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name="canvas-backend", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=False, + disable_windowed_traceback=False, +) + +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=False, + name="canvas-backend", +) diff --git a/canvas_core/__init__.py b/canvas_core/__init__.py new file mode 100644 index 000000000..d27acd0a0 --- /dev/null +++ b/canvas_core/__init__.py @@ -0,0 +1,2 @@ +"""Canvas desktop/runtime infrastructure shared by the web backend and host.""" + diff --git a/canvas_core/auth.py b/canvas_core/auth.py new file mode 100644 index 000000000..e12f28b27 --- /dev/null +++ b/canvas_core/auth.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import hashlib +import hmac +import secrets +import threading +import time +import uuid +from dataclasses import dataclass +from typing import Optional + +from .database import CanvasDatabase + + +SESSION_COOKIE = "canvas_session" + + +def token_hash(token: str) -> str: + return hashlib.sha256(str(token or "").encode("utf-8")).hexdigest() + + +@dataclass(frozen=True) +class AuthIdentity: + device_id: str + name: str + client_type: str + persistent: bool + + def public(self) -> dict[str, object]: + return { + "device_id": self.device_id, + "name": self.name, + "client_type": self.client_type, + "persistent": self.persistent, + } + + +class AuthManager: + def __init__(self, database: CanvasDatabase, desktop_token: str = "", pair_ttl_seconds: int = 300) -> None: + self.database = database + self._desktop_token = str(desktop_token or "") + self._pair_ttl_seconds = pair_ttl_seconds + self._pair_code_hash = "" + self._pair_expires_at = 0 + self._runtime_sessions: dict[str, AuthIdentity] = {} + self._last_touches: dict[str, int] = {} + self._lock = threading.RLock() + + @staticmethod + def new_token() -> str: + return secrets.token_urlsafe(32) + + def consume_desktop_token(self, supplied: str) -> tuple[str, AuthIdentity]: + with self._lock: + expected = self._desktop_token + if not expected or not hmac.compare_digest(expected, str(supplied or "")): + raise PermissionError("桌面启动令牌无效或已使用") + self._desktop_token = "" + token = self.new_token() + identity = AuthIdentity("desktop-runtime", "Canvas 桌面端", "desktop", False) + self._runtime_sessions[token_hash(token)] = identity + return token, identity + + def create_pair_code(self) -> tuple[str, int]: + code = f"{secrets.randbelow(1_000_000):06d}" + expires_at = int(time.time() * 1000) + self._pair_ttl_seconds * 1000 + with self._lock: + self._pair_code_hash = token_hash(code) + self._pair_expires_at = expires_at + return code, expires_at + + def pair(self, code: str, name: str, client_type: str = "browser") -> tuple[str, AuthIdentity]: + now = int(time.time() * 1000) + with self._lock: + supplied_hash = token_hash(str(code or "").strip()) + valid = ( + self._pair_code_hash + and now <= self._pair_expires_at + and hmac.compare_digest(self._pair_code_hash, supplied_hash) + ) + if not valid: + raise PermissionError("配对码无效或已过期") + self._pair_code_hash = "" + self._pair_expires_at = 0 + safe_type = str(client_type or "browser").strip().lower() + if safe_type not in {"browser", "chrome", "photoshop", "plugin"}: + safe_type = "browser" + safe_name = " ".join(str(name or "").split())[:80] or "已配对设备" + device_id = uuid.uuid4().hex + token = self.new_token() + self.database.create_paired_device(device_id, safe_name, token_hash(token), {"client_type": safe_type}) + return token, AuthIdentity(device_id, safe_name, safe_type, True) + + def authenticate(self, token: str, touch: bool = True) -> Optional[AuthIdentity]: + raw = str(token or "").strip() + if not raw: + return None + digest = token_hash(raw) + with self._lock: + runtime = self._runtime_sessions.get(digest) + if runtime: + return runtime + record = self.database.paired_device_by_hash(digest) + if not record: + return None + identity = AuthIdentity(record["id"], record["name"], record["client_type"], True) + if touch: + self._touch(identity.device_id) + return identity + + def _touch(self, device_id: str) -> None: + now = int(time.time() * 1000) + with self._lock: + if now - self._last_touches.get(device_id, 0) < 60_000: + return + self._last_touches[device_id] = now + self.database.touch_paired_device(device_id, now) + + def list_devices(self) -> list[dict[str, object]]: + return self.database.list_paired_devices() + + def revoke(self, device_id: str) -> bool: + return self.database.revoke_paired_device(str(device_id or "")) + + @staticmethod + def bearer_token(authorization: str) -> str: + scheme, _, value = str(authorization or "").partition(" ") + return value.strip() if scheme.lower() == "bearer" else "" diff --git a/canvas_core/data_layout.py b/canvas_core/data_layout.py new file mode 100644 index 000000000..560852fd2 --- /dev/null +++ b/canvas_core/data_layout.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import json +import os +import tempfile +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from .paths import AppPaths + + +def atomic_write_json(path: Path, payload: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_name, path) + except Exception: + try: + os.remove(temp_name) + except OSError: + pass + raise + + +@dataclass(frozen=True) +class DataLayout: + root: Path + manifest: Path + config: Path + app_config: Path + secret_env: Path + database: Path + database_file: Path + media: Path + media_input: Path + media_generated: Path + media_library: Path + media_uploads: Path + exports: Path + workflows: Path + workflow_custom: Path + workflow_overrides: Path + cache: Path + cache_previews: Path + cache_downloads: Path + logs: Path + run: Path + backups: Path + temp: Path + + @classmethod + def from_app_paths(cls, paths: AppPaths) -> "DataLayout": + root = paths.data_root + config = root / "config" + database = root / "database" + media = root / "media" + workflows = root / "workflows" + cache = root / "cache" + return cls( + root=root, + manifest=root / "manifest.json", + config=config, + app_config=config / "app.json", + secret_env=config / "secrets.env", + database=database, + database_file=database / "canvas.db", + media=media, + media_input=media / "input", + media_generated=media / "generated", + media_library=media / "library", + media_uploads=media / "uploads", + exports=root / "exports", + workflows=workflows, + workflow_custom=workflows / "custom", + workflow_overrides=workflows / "overrides", + cache=cache, + cache_previews=cache / "previews", + cache_downloads=cache / "downloads", + logs=root / "logs", + run=root / "run", + backups=root / "backups", + temp=root / "temp", + ) + + def ensure(self) -> None: + directories = ( + self.root, + self.config, + self.database, + self.media_input, + self.media_generated, + self.media_library, + self.media_uploads, + self.exports, + self.workflow_custom, + self.workflow_overrides, + self.cache_previews, + self.cache_downloads, + self.logs, + self.run, + self.backups, + self.temp, + ) + for directory in directories: + directory.mkdir(parents=True, exist_ok=True) + if not self.app_config.exists(): + atomic_write_json( + self.app_config, + { + "host": "0.0.0.0", + "port": 3000, + "lan_enabled": True, + "cache_max_bytes": 10 * 1024 * 1024 * 1024, + "created_at": int(time.time() * 1000), + }, + ) + + def manifest_payload(self) -> dict[str, Any]: + if not self.manifest.exists(): + return {} + try: + with self.manifest.open("r", encoding="utf-8") as handle: + payload = json.load(handle) + return payload if isinstance(payload, dict) else {} + except (OSError, json.JSONDecodeError): + return {} + diff --git a/canvas_core/database.py b/canvas_core/database.py new file mode 100644 index 000000000..3f72bd8f3 --- /dev/null +++ b/canvas_core/database.py @@ -0,0 +1,638 @@ +from __future__ import annotations + +import json +import sqlite3 +import threading +import time +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Iterable, Iterator, Optional + + +def _json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + + +def _payload(raw: Optional[str], default: Any = None) -> Any: + if raw is None: + return default + try: + return json.loads(raw) + except (TypeError, json.JSONDecodeError): + return default + + +class RevisionConflict(RuntimeError): + def __init__(self, revision: int, value: Any): + super().__init__("revision conflict") + self.revision = int(revision) + self.value = value + + +class ClosingSqliteConnection(sqlite3.Connection): + """Make ``with connection`` close the file handle after commit/rollback.""" + + def __exit__(self, exc_type, exc_value, traceback): + try: + return super().__exit__(exc_type, exc_value, traceback) + finally: + self.close() + + +class CanvasDatabase: + SCHEMA_VERSION = 2 + + def __init__(self, path: Path): + self.path = Path(path) + self._schema_lock = threading.Lock() + + def connect(self) -> sqlite3.Connection: + connection = sqlite3.connect( + str(self.path), + timeout=5.0, + isolation_level=None, + factory=ClosingSqliteConnection, + ) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys=ON") + connection.execute("PRAGMA busy_timeout=5000") + connection.execute("PRAGMA synchronous=NORMAL") + return connection + + @contextmanager + def transaction(self, immediate: bool = False) -> Iterator[sqlite3.Connection]: + connection = self.connect() + try: + connection.execute("BEGIN IMMEDIATE" if immediate else "BEGIN") + yield connection + connection.commit() + except Exception: + connection.rollback() + raise + finally: + connection.close() + + def initialize(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + with self._schema_lock, self.connect() as connection: + connection.execute("PRAGMA journal_mode=WAL") + connection.executescript( + """ + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS providers ( + id TEXT PRIMARY KEY, + sort_order INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + payload_json TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + sort_order INTEGER NOT NULL DEFAULT 0, + payload_json TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS canvases ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL DEFAULT 'default', + kind TEXT NOT NULL DEFAULT 'classic', + title TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL, + deleted_at INTEGER NOT NULL DEFAULT 0, + revision INTEGER NOT NULL DEFAULT 1, + payload_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_canvases_project ON canvases(project_id, deleted_at, updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_canvases_kind ON canvases(kind, deleted_at, updated_at DESC); + CREATE TABLE IF NOT EXISTS conversations ( + id TEXT NOT NULL, + user_id TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + payload_json TEXT NOT NULL, + PRIMARY KEY(user_id, id) + ); + CREATE INDEX IF NOT EXISTS idx_conversations_user ON conversations(user_id, updated_at DESC); + CREATE TABLE IF NOT EXISTS generation_history ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL DEFAULT 'zimage', + created_at REAL NOT NULL, + payload_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_generation_history_kind ON generation_history(kind, created_at DESC); + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + status TEXT NOT NULL DEFAULT '', + updated_at REAL NOT NULL, + payload_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_tasks_kind_status ON tasks(kind, status, updated_at DESC); + CREATE TABLE IF NOT EXISTS library_documents ( + kind TEXT PRIMARY KEY, + payload_json TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS shared_folders ( + id TEXT PRIMARY KEY, + payload_json TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS paired_devices ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL, + revoked_at INTEGER NOT NULL DEFAULT 0, + payload_json TEXT NOT NULL DEFAULT '{}' + ); + CREATE TABLE IF NOT EXISTS kv_documents ( + namespace TEXT NOT NULL, + key TEXT NOT NULL, + payload_json TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + updated_at INTEGER NOT NULL, + PRIMARY KEY(namespace, key) + ); + CREATE TABLE IF NOT EXISTS secret_values ( + key TEXT PRIMARY KEY, + encrypted_value BLOB NOT NULL, + updated_at INTEGER NOT NULL + ); + """ + ) + connection.execute( + "INSERT OR IGNORE INTO schema_migrations(version,name,applied_at) VALUES(?,?,?)", + (1, "initial-desktop-schema", int(time.time() * 1000)), + ) + connection.execute( + "INSERT OR IGNORE INTO schema_migrations(version,name,applied_at) VALUES(?,?,?)", + (2, "dpapi-secrets-and-device-auth", int(time.time() * 1000)), + ) + + def pragma_summary(self) -> dict[str, Any]: + with self.connect() as connection: + return { + "journal_mode": connection.execute("PRAGMA journal_mode").fetchone()[0], + "foreign_keys": connection.execute("PRAGMA foreign_keys").fetchone()[0], + "busy_timeout": connection.execute("PRAGMA busy_timeout").fetchone()[0], + "synchronous": connection.execute("PRAGMA synchronous").fetchone()[0], + "schema_version": self.SCHEMA_VERSION, + } + + def get_document(self, namespace: str, key: str, default: Any = None) -> Any: + with self.connect() as connection: + row = connection.execute( + "SELECT payload_json FROM kv_documents WHERE namespace=? AND key=?", (namespace, key) + ).fetchone() + return _payload(row["payload_json"], default) if row else default + + def put_document(self, namespace: str, key: str, value: Any) -> int: + now = int(time.time() * 1000) + with self.transaction(immediate=True) as connection: + row = connection.execute( + "SELECT revision FROM kv_documents WHERE namespace=? AND key=?", (namespace, key) + ).fetchone() + revision = int(row["revision"] if row else 0) + 1 + connection.execute( + """INSERT INTO kv_documents(namespace,key,payload_json,revision,updated_at) VALUES(?,?,?,?,?) + ON CONFLICT(namespace,key) DO UPDATE SET payload_json=excluded.payload_json, + revision=excluded.revision,updated_at=excluded.updated_at""", + (namespace, key, _json(value), revision, now), + ) + return revision + + def get_setting(self, key: str, default: Any = None) -> dict[str, Any]: + with self.connect() as connection: + row = connection.execute( + "SELECT value_json,revision,updated_at FROM app_settings WHERE key=?", (key,) + ).fetchone() + if not row: + return {"value": default, "revision": 0, "updated_at": 0} + return { + "value": _payload(row["value_json"], default), + "revision": int(row["revision"]), + "updated_at": int(row["updated_at"]), + } + + def save_setting(self, key: str, value: Any, base_revision: int = 0, only_if_empty: bool = False) -> dict[str, Any]: + now = int(time.time() * 1000) + with self.transaction(immediate=True) as connection: + row = connection.execute( + "SELECT value_json,revision,updated_at FROM app_settings WHERE key=?", (key,) + ).fetchone() + current_revision = int(row["revision"] if row else 0) + current_value = _payload(row["value_json"], None) if row else None + if only_if_empty and row and current_value not in (None, {}, [], ""): + return {"value": current_value, "revision": current_revision, "updated_at": int(row["updated_at"])} + if base_revision and base_revision != current_revision: + raise RevisionConflict(current_revision, current_value) + revision = current_revision + 1 + connection.execute( + """INSERT INTO app_settings(key,value_json,revision,updated_at) VALUES(?,?,?,?) + ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json, + revision=excluded.revision,updated_at=excluded.updated_at""", + (key, _json(value), revision, now), + ) + return {"value": value, "revision": revision, "updated_at": now} + + def next_revision(self, topic: str, entity_id: str = "global") -> int: + key = f"_revision:{topic}:{entity_id}" + with self.transaction(immediate=True) as connection: + row = connection.execute("SELECT revision FROM app_settings WHERE key=?", (key,)).fetchone() + revision = int(row["revision"] if row else 0) + 1 + connection.execute( + """INSERT INTO app_settings(key,value_json,revision,updated_at) VALUES(?,?,?,?) + ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json, + revision=excluded.revision,updated_at=excluded.updated_at""", + (key, _json({"topic": topic, "entity_id": entity_id}), revision, int(time.time() * 1000)), + ) + return revision + + def save_secret_blob(self, key: str, encrypted_value: bytes) -> None: + with self.transaction(immediate=True) as connection: + connection.execute( + """INSERT INTO secret_values(key,encrypted_value,updated_at) VALUES(?,?,?) + ON CONFLICT(key) DO UPDATE SET encrypted_value=excluded.encrypted_value, + updated_at=excluded.updated_at""", + (key, sqlite3.Binary(encrypted_value), int(time.time() * 1000)), + ) + + def load_secret_blob(self, key: str) -> Optional[bytes]: + with self.connect() as connection: + row = connection.execute("SELECT encrypted_value FROM secret_values WHERE key=?", (key,)).fetchone() + return bytes(row["encrypted_value"]) if row else None + + def list_secret_blobs(self) -> dict[str, bytes]: + with self.connect() as connection: + rows = connection.execute("SELECT key,encrypted_value FROM secret_values ORDER BY key").fetchall() + return {str(row["key"]): bytes(row["encrypted_value"]) for row in rows} + + def delete_secret(self, key: str) -> None: + with self.transaction(immediate=True) as connection: + connection.execute("DELETE FROM secret_values WHERE key=?", (key,)) + + def create_paired_device(self, device_id: str, name: str, token_hash: str, payload: dict[str, Any]) -> None: + now = int(time.time() * 1000) + with self.transaction(immediate=True) as connection: + connection.execute( + """INSERT INTO paired_devices(id,name,token_hash,created_at,last_seen_at,revoked_at,payload_json) + VALUES(?,?,?,?,?,0,?)""", + (device_id, name, token_hash, now, now, _json(payload or {})), + ) + + def paired_device_by_hash(self, token_hash: str) -> Optional[dict[str, Any]]: + with self.connect() as connection: + row = connection.execute( + "SELECT * FROM paired_devices WHERE token_hash=? AND revoked_at=0", (token_hash,) + ).fetchone() + return self._paired_device_record(row) if row else None + + def paired_device(self, device_id: str) -> Optional[dict[str, Any]]: + with self.connect() as connection: + row = connection.execute("SELECT * FROM paired_devices WHERE id=?", (device_id,)).fetchone() + return self._paired_device_record(row) if row else None + + def list_paired_devices(self, include_revoked: bool = False) -> list[dict[str, Any]]: + query = "SELECT * FROM paired_devices" if include_revoked else "SELECT * FROM paired_devices WHERE revoked_at=0" + with self.connect() as connection: + rows = connection.execute(query + " ORDER BY created_at DESC").fetchall() + return [self._paired_device_record(row) for row in rows] + + def touch_paired_device(self, device_id: str, timestamp: Optional[int] = None) -> None: + with self.transaction(immediate=True) as connection: + connection.execute( + "UPDATE paired_devices SET last_seen_at=? WHERE id=? AND revoked_at=0", + (int(timestamp or time.time() * 1000), device_id), + ) + + def revoke_paired_device(self, device_id: str) -> bool: + with self.transaction(immediate=True) as connection: + cursor = connection.execute( + "UPDATE paired_devices SET revoked_at=? WHERE id=? AND revoked_at=0", + (int(time.time() * 1000), device_id), + ) + return cursor.rowcount > 0 + + @staticmethod + def _paired_device_record(row: sqlite3.Row) -> dict[str, Any]: + payload = _payload(row["payload_json"], {}) + return { + "id": str(row["id"]), + "name": str(row["name"]), + "created_at": int(row["created_at"]), + "last_seen_at": int(row["last_seen_at"]), + "revoked_at": int(row["revoked_at"]), + "client_type": str((payload or {}).get("client_type") or "browser"), + } + + def load_providers(self) -> list[dict[str, Any]]: + with self.connect() as connection: + rows = connection.execute("SELECT payload_json FROM providers ORDER BY sort_order,id").fetchall() + return [_payload(row["payload_json"], {}) for row in rows] + + def save_providers(self, providers: Iterable[dict[str, Any]]) -> None: + now = int(time.time() * 1000) + values = list(providers) + with self.transaction(immediate=True) as connection: + connection.execute("DELETE FROM providers") + for index, provider in enumerate(values): + connection.execute( + "INSERT INTO providers(id,sort_order,enabled,payload_json,updated_at) VALUES(?,?,?,?,?)", + (str(provider.get("id") or f"provider-{index}"), index, int(provider.get("enabled", True)), _json(provider), now), + ) + + def load_projects(self) -> list[dict[str, Any]]: + with self.connect() as connection: + rows = connection.execute("SELECT payload_json FROM projects ORDER BY sort_order,updated_at").fetchall() + return [_payload(row["payload_json"], {}) for row in rows] + + def save_projects(self, projects: Iterable[dict[str, Any]]) -> None: + now = int(time.time() * 1000) + values = list(projects) + with self.transaction(immediate=True) as connection: + connection.execute("DELETE FROM projects") + for index, project in enumerate(values): + connection.execute( + "INSERT INTO projects(id,sort_order,payload_json,updated_at) VALUES(?,?,?,?)", + (str(project.get("id") or f"project-{index}"), int(project.get("order") or index), _json(project), now), + ) + + def save_canvas(self, canvas: dict[str, Any], touch: bool = True) -> dict[str, Any]: + value = dict(canvas) + now = int(time.time() * 1000) + if touch: + value["updated_at"] = now + else: + value["updated_at"] = int(value.get("updated_at") or now) + with self.transaction(immediate=True) as connection: + row = connection.execute("SELECT revision FROM canvases WHERE id=?", (value["id"],)).fetchone() + revision = int(row["revision"] if row else 0) + 1 + value["revision"] = revision + connection.execute( + """INSERT INTO canvases(id,project_id,kind,title,updated_at,deleted_at,revision,payload_json) + VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET + project_id=excluded.project_id,kind=excluded.kind,title=excluded.title, + updated_at=excluded.updated_at,deleted_at=excluded.deleted_at, + revision=excluded.revision,payload_json=excluded.payload_json""", + ( + value["id"], str(value.get("project") or "default"), str(value.get("kind") or "classic"), + str(value.get("title") or ""), int(value["updated_at"]), int(value.get("deleted_at") or 0), + revision, _json(value), + ), + ) + canvas.clear() + canvas.update(value) + return canvas + + def get_canvas(self, canvas_id: str) -> Optional[dict[str, Any]]: + with self.connect() as connection: + row = connection.execute("SELECT payload_json FROM canvases WHERE id=?", (canvas_id,)).fetchone() + return _payload(row["payload_json"], None) if row else None + + def list_canvases(self, include_deleted: Optional[bool] = None) -> list[dict[str, Any]]: + query = "SELECT payload_json FROM canvases" + params: tuple[Any, ...] = () + if include_deleted is True: + query += " WHERE deleted_at>0" + elif include_deleted is False: + query += " WHERE deleted_at=0" + query += " ORDER BY updated_at DESC" + with self.connect() as connection: + rows = connection.execute(query, params).fetchall() + return [_payload(row["payload_json"], {}) for row in rows] + + def purge_canvas(self, canvas_id: str) -> None: + with self.transaction(immediate=True) as connection: + connection.execute("DELETE FROM canvases WHERE id=?", (canvas_id,)) + + def reassign_project(self, project_id: str, target_project_id: str) -> int: + moved = 0 + for canvas in self.list_canvases(include_deleted=None): + if str(canvas.get("project") or "") == project_id: + canvas["project"] = target_project_id + self.save_canvas(canvas, touch=False) + moved += 1 + return moved + + def save_conversation(self, user_id: str, conversation: dict[str, Any]) -> None: + now = int(conversation.get("updated_at") or time.time() * 1000) + with self.transaction(immediate=True) as connection: + row = connection.execute( + "SELECT revision FROM conversations WHERE user_id=? AND id=?", (user_id, conversation["id"]) + ).fetchone() + revision = int(row["revision"] if row else 0) + 1 + connection.execute( + """INSERT INTO conversations(id,user_id,title,updated_at,revision,payload_json) VALUES(?,?,?,?,?,?) + ON CONFLICT(user_id,id) DO UPDATE SET title=excluded.title,updated_at=excluded.updated_at, + revision=excluded.revision,payload_json=excluded.payload_json""", + (conversation["id"], user_id, str(conversation.get("title") or ""), now, revision, _json(conversation)), + ) + + def get_conversation(self, user_id: str, conversation_id: str) -> Optional[dict[str, Any]]: + with self.connect() as connection: + row = connection.execute( + "SELECT payload_json FROM conversations WHERE user_id=? AND id=?", (user_id, conversation_id) + ).fetchone() + return _payload(row["payload_json"], None) if row else None + + def list_conversations(self, user_id: str) -> list[dict[str, Any]]: + with self.connect() as connection: + rows = connection.execute( + "SELECT payload_json FROM conversations WHERE user_id=? ORDER BY updated_at DESC", (user_id,) + ).fetchall() + return [_payload(row["payload_json"], {}) for row in rows] + + def delete_conversation(self, user_id: str, conversation_id: str) -> None: + with self.transaction(immediate=True) as connection: + connection.execute("DELETE FROM conversations WHERE user_id=? AND id=?", (user_id, conversation_id)) + + def prepend_history(self, record: dict[str, Any], limit: int = 5000) -> None: + timestamp = float(record.get("timestamp") or time.time()) + record["timestamp"] = timestamp + history_id = str(record.get("id") or f"{timestamp:.6f}-{abs(hash(_json(record))) & 0xFFFFFFFF:08x}") + with self.transaction(immediate=True) as connection: + connection.execute( + "INSERT OR REPLACE INTO generation_history(id,kind,created_at,payload_json) VALUES(?,?,?,?)", + (history_id, str(record.get("type") or "zimage"), timestamp, _json(record)), + ) + connection.execute( + "DELETE FROM generation_history WHERE id IN (SELECT id FROM generation_history ORDER BY created_at DESC LIMIT -1 OFFSET ?)", + (limit,), + ) + + def list_history(self, kind: str = "") -> list[dict[str, Any]]: + query = "SELECT id,payload_json FROM generation_history" + params: tuple[Any, ...] = () + if kind: + query += " WHERE kind=?" + params = (kind,) + query += " ORDER BY created_at DESC" + with self.connect() as connection: + rows = connection.execute(query, params).fetchall() + records = [] + for row in rows: + payload = _payload(row["payload_json"], {}) + if not isinstance(payload, dict): + payload = {} + payload.setdefault("_history_id", str(row["id"])) + records.append(payload) + return records + + def delete_history_timestamp(self, timestamp: float) -> Optional[dict[str, Any]]: + with self.transaction(immediate=True) as connection: + row = connection.execute( + "SELECT id,payload_json FROM generation_history WHERE ABS(created_at-?)<0.001 ORDER BY created_at DESC LIMIT 1", + (float(timestamp),), + ).fetchone() + if row: + item = _payload(row["payload_json"], {}) + connection.execute("DELETE FROM generation_history WHERE id=?", (row["id"],)) + return item + return None + + def get_library(self, kind: str, default: Any = None) -> Any: + with self.connect() as connection: + row = connection.execute("SELECT payload_json FROM library_documents WHERE kind=?", (kind,)).fetchone() + return _payload(row["payload_json"], default) if row else default + + def save_library(self, kind: str, value: Any) -> int: + now = int(time.time() * 1000) + with self.transaction(immediate=True) as connection: + row = connection.execute("SELECT revision FROM library_documents WHERE kind=?", (kind,)).fetchone() + revision = int(row["revision"] if row else 0) + 1 + connection.execute( + """INSERT INTO library_documents(kind,payload_json,revision,updated_at) VALUES(?,?,?,?) + ON CONFLICT(kind) DO UPDATE SET payload_json=excluded.payload_json, + revision=excluded.revision,updated_at=excluded.updated_at""", + (kind, _json(value), revision, now), + ) + return revision + + def save_tasks(self, kind: str, tasks: Iterable[dict[str, Any]]) -> None: + values = list(tasks) + with self.transaction(immediate=True) as connection: + connection.execute("DELETE FROM tasks WHERE kind=?", (kind,)) + for task in values: + task_id = str(task.get("id") or task.get("task_id") or "") + if not task_id: + continue + connection.execute( + "INSERT INTO tasks(id,kind,status,updated_at,payload_json) VALUES(?,?,?,?,?)", + (task_id, kind, str(task.get("status") or ""), float(task.get("updated_at") or time.time()), _json(task)), + ) + + def upsert_task(self, kind: str, task: dict[str, Any]) -> None: + task_id = str(task.get("id") or task.get("task_id") or "") + if not task_id: + return + updated_at = task.get("updated_at") + if updated_at is None: + updated_at = time.time() + with self.transaction(immediate=True) as connection: + connection.execute( + """INSERT INTO tasks(id,kind,status,updated_at,payload_json) VALUES(?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET kind=excluded.kind,status=excluded.status, + updated_at=excluded.updated_at,payload_json=excluded.payload_json""", + (task_id, kind, str(task.get("status") or ""), float(updated_at), _json(task)), + ) + + def prune_tasks(self, kind: str, keep: int = 5000) -> int: + safe_keep = max(1, int(keep or 1)) + with self.transaction(immediate=True) as connection: + before = int(connection.execute("SELECT COUNT(*) FROM tasks WHERE kind=?", (kind,)).fetchone()[0]) + connection.execute( + """DELETE FROM tasks WHERE kind=? AND id NOT IN ( + SELECT id FROM tasks WHERE kind=? ORDER BY updated_at DESC LIMIT ? + )""", + (kind, kind, safe_keep), + ) + return max(0, before - safe_keep) + + def load_tasks(self, kind: str) -> list[dict[str, Any]]: + with self.connect() as connection: + rows = connection.execute("SELECT payload_json FROM tasks WHERE kind=? ORDER BY updated_at DESC", (kind,)).fetchall() + return [_payload(row["payload_json"], {}) for row in rows] + + def counts(self) -> dict[str, int]: + tables = ("providers", "projects", "canvases", "conversations", "generation_history", "tasks", "library_documents") + with self.connect() as connection: + return {table: int(connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]) for table in tables} + + def import_legacy(self, snapshot: dict[str, Any]) -> None: + with self.transaction(immediate=True) as connection: + now = int(time.time() * 1000) + if not connection.execute("SELECT 1 FROM providers LIMIT 1").fetchone(): + for index, provider in enumerate(snapshot.get("providers") or []): + if isinstance(provider, dict) and provider.get("id"): + connection.execute( + "INSERT OR IGNORE INTO providers(id,sort_order,enabled,payload_json,updated_at) VALUES(?,?,?,?,?)", + (provider["id"], index, int(provider.get("enabled", True)), _json(provider), now), + ) + if not connection.execute("SELECT 1 FROM projects LIMIT 1").fetchone(): + for index, project in enumerate(snapshot.get("projects") or []): + if isinstance(project, dict) and project.get("id"): + connection.execute( + "INSERT OR IGNORE INTO projects(id,sort_order,payload_json,updated_at) VALUES(?,?,?,?)", + (project["id"], int(project.get("order") or index), _json(project), now), + ) + if not connection.execute("SELECT 1 FROM canvases LIMIT 1").fetchone(): + for canvas in snapshot.get("canvases") or []: + if not isinstance(canvas, dict) or not canvas.get("id"): + continue + canvas.setdefault("revision", 1) + connection.execute( + "INSERT OR IGNORE INTO canvases(id,project_id,kind,title,updated_at,deleted_at,revision,payload_json) VALUES(?,?,?,?,?,?,?,?)", + (canvas["id"], str(canvas.get("project") or "default"), str(canvas.get("kind") or "classic"), + str(canvas.get("title") or ""), int(canvas.get("updated_at") or now), int(canvas.get("deleted_at") or 0), + int(canvas.get("revision") or 1), _json(canvas)), + ) + if not connection.execute("SELECT 1 FROM conversations LIMIT 1").fetchone(): + for user_id, conversation in snapshot.get("conversations") or []: + if isinstance(conversation, dict) and conversation.get("id"): + connection.execute( + "INSERT OR IGNORE INTO conversations(id,user_id,title,updated_at,payload_json) VALUES(?,?,?,?,?)", + (conversation["id"], user_id, str(conversation.get("title") or ""), int(conversation.get("updated_at") or now), _json(conversation)), + ) + if not connection.execute("SELECT 1 FROM generation_history LIMIT 1").fetchone(): + for index, record in enumerate(snapshot.get("history") or []): + if not isinstance(record, dict): + continue + created = float(record.get("timestamp") or (time.time() - index / 1000)) + history_id = f"legacy-{index}-{created:.6f}" + connection.execute( + "INSERT OR IGNORE INTO generation_history(id,kind,created_at,payload_json) VALUES(?,?,?,?)", + (history_id, str(record.get("type") or "zimage"), created, _json(record)), + ) + libraries = snapshot.get("libraries") or {} + for kind, value in libraries.items(): + if value is not None: + connection.execute( + "INSERT OR IGNORE INTO library_documents(kind,payload_json,updated_at) VALUES(?,?,?)", + (kind, _json(value), now), + ) + if not connection.execute("SELECT 1 FROM tasks LIMIT 1").fetchone(): + for task in snapshot.get("online_image_tasks") or []: + if not isinstance(task, dict): + continue + task_id = str(task.get("id") or task.get("task_id") or "") + if task_id: + connection.execute( + "INSERT OR IGNORE INTO tasks(id,kind,status,updated_at,payload_json) VALUES(?,?,?,?,?)", + (task_id, "online_image", str(task.get("status") or ""), float(task.get("updated_at") or time.time()), _json(task)), + ) diff --git a/canvas_core/ecommerce.py b/canvas_core/ecommerce.py new file mode 100644 index 000000000..936e4f9f1 --- /dev/null +++ b/canvas_core/ecommerce.py @@ -0,0 +1,780 @@ +"""Pure contracts, routing and prompts for the e-commerce image workspace.""" + +from __future__ import annotations + +import json +import re +from typing import Any, Iterable + + +OPERATIONS = ( + "try_on", + "pose_transfer", + "prop_replace", + "angle_change", + "background_change", + "universal", +) +MODES = ("standard",) +LEGACY_MODES = {"preview", "publish"} +ASPECT_RATIOS = ("source", "1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "9:16", "16:9") +RESOLUTIONS = ("auto", "1k", "2k", "4k") +QUALITIES = ("auto", "low", "medium", "high") + +SIZE_PRESETS: dict[str, dict[str, str]] = { + "1:1": {"1k": "1024x1024", "2k": "2048x2048", "4k": "2880x2880"}, + "2:3": {"1k": "1024x1536", "2k": "1360x2040", "4k": "2304x3456"}, + "3:2": {"1k": "1536x1024", "2k": "2040x1360", "4k": "3456x2304"}, + "3:4": {"1k": "1008x1344", "2k": "1536x2048", "4k": "2400x3200"}, + "4:3": {"1k": "1344x1008", "2k": "2048x1536", "4k": "3200x2400"}, + "4:5": {"1k": "1024x1280", "2k": "1632x2040", "4k": "2560x3200"}, + "9:16": {"1k": "720x1280", "2k": "1152x2048", "4k": "2160x3840"}, + "16:9": {"1k": "1280x720", "2k": "2048x1152", "4k": "3840x2160"}, +} + +OPERATION_INPUTS: dict[str, tuple[str, ...]] = { + "try_on": ("source", "garment"), + "pose_transfer": ("source",), + "prop_replace": ("source", "prop"), + "angle_change": ("source",), + "background_change": ("source",), + "universal": (), +} +UNIVERSAL_REFERENCE_LIMIT = 14 +UNIVERSAL_REFERENCE_ROLES = [ + {"id": "subject", "label": {"zh": "主体/模特", "en": "Subject / model"}}, + {"id": "upper_garment", "label": {"zh": "上装", "en": "Upper garment"}}, + {"id": "lower_garment", "label": {"zh": "下装", "en": "Lower garment"}}, + {"id": "full_garment", "label": {"zh": "连衣裙/套装", "en": "Dress / full outfit"}}, + {"id": "shoes", "label": {"zh": "鞋靴", "en": "Shoes"}}, + {"id": "accessory", "label": {"zh": "首饰/配饰", "en": "Accessory"}}, + {"id": "prop", "label": {"zh": "道具/商品", "en": "Prop / product"}}, + {"id": "pose", "label": {"zh": "动作参考", "en": "Pose reference"}}, + {"id": "scene", "label": {"zh": "场景/背景", "en": "Scene / background"}}, + {"id": "style", "label": {"zh": "风格/光影", "en": "Style / lighting"}}, +] +UNIVERSAL_REFERENCE_ROLE_IDS = {item["id"] for item in UNIVERSAL_REFERENCE_ROLES} +ALLOWED_INPUT_ROLES = {"source", "garment", "pose", "prop", "background", "mask", *UNIVERSAL_REFERENCE_ROLE_IDS} +UNIVERSAL_INTERACTIONS = {"wear", "put_on", "hold", "carry", "place", "use", "pose", "scene", "style", "identity"} +ACCESSORY_WEAR_KEYWORDS = ( + "necklace", "项链", "earring", "耳环", "bracelet", "手链", "bangle", "手镯", "ring", "戒指", + "watch", "手表", "hat", "帽", "cap", "glasses", "眼镜", "sunglasses", "墨镜", "belt", "腰带", + "scarf", "围巾", "brooch", "胸针", "tie", "领带", +) +HANDHELD_KEYWORDS = ( + "phone", "手机", "smartphone", "camera", "相机", "cup", "杯", "bottle", "瓶", "book", "书", + "umbrella", "伞", "flower", "花", "wallet", "钱包", "card", "卡", "cosmetic", "口红", "lipstick", +) +BAG_KEYWORDS = ("bag", "包", "handbag", "tote", "clutch", "purse", "satchel", "backpack", "shoulder bag", "手提包", "托特包", "双肩包", "挎包") +PLACED_PROP_KEYWORDS = ("chair", "椅", "sofa", "沙发", "table", "桌", "vase", "花瓶", "lamp", "灯", "plant", "植物", "furniture", "家具", "decor", "摆件") + +POSE_PRESETS = [ + {"id": "standing_front", "label": {"zh": "正面站立", "en": "Front standing"}, "prompt": "standing upright, front view, arms relaxed naturally"}, + {"id": "standing_three_quarter", "label": {"zh": "四分之三站姿", "en": "Three-quarter"}, "prompt": "three-quarter standing pose with a natural weight shift"}, + {"id": "side_profile", "label": {"zh": "侧身站立", "en": "Side profile"}, "prompt": "clean side-profile standing pose"}, + {"id": "walking", "label": {"zh": "自然行走", "en": "Walking"}, "prompt": "natural mid-step walking pose with realistic balance"}, + {"id": "sitting", "label": {"zh": "自然坐姿", "en": "Sitting"}, "prompt": "natural seated pose with anatomically correct limbs"}, + {"id": "arms_crossed", "label": {"zh": "双臂交叉", "en": "Arms crossed"}, "prompt": "standing with arms crossed naturally"}, + {"id": "hand_on_hip", "label": {"zh": "单手叉腰", "en": "Hand on hip"}, "prompt": "standing with one hand on the hip, confident catalog pose"}, + {"id": "product_hold", "label": {"zh": "手持商品", "en": "Holding product"}, "prompt": "balanced standing pose holding a product naturally at chest level"}, +] + +BACKGROUND_PRESETS = [ + {"id": "studio_white", "label": {"zh": "纯白棚拍", "en": "White studio"}, "prompt": "seamless pure white e-commerce studio background, soft grounded shadow"}, + {"id": "studio_gray", "label": {"zh": "中性灰棚拍", "en": "Gray studio"}, "prompt": "neutral light-gray studio cyclorama, soft commercial lighting"}, + {"id": "warm_minimal", "label": {"zh": "暖色极简", "en": "Warm minimal"}, "prompt": "warm minimal beige set, refined natural materials, soft daylight"}, + {"id": "luxury_dark", "label": {"zh": "深色奢华", "en": "Luxury dark"}, "prompt": "premium dark studio set with controlled highlights and elegant reflections"}, + {"id": "home_lifestyle", "label": {"zh": "居家生活", "en": "Home lifestyle"}, "prompt": "tasteful modern home lifestyle scene, natural window light"}, + {"id": "outdoor_daylight", "label": {"zh": "户外日光", "en": "Outdoor daylight"}, "prompt": "clean outdoor lifestyle scene in soft natural daylight"}, + {"id": "festival_red", "label": {"zh": "节庆红金", "en": "Festive red"}, "prompt": "refined festive red and gold commercial set, tasteful and uncluttered"}, + {"id": "transparent_style", "label": {"zh": "透明底观感", "en": "Cutout style"}, "prompt": "isolated clean catalog presentation with no visible environment and a subtle contact shadow"}, +] + +QUALITY_CHECKS: dict[str, list[dict[str, Any]]] = { + "try_on": [ + {"id": "identity", "label": {"zh": "人物脸部、发型、体型和肤色与原图一致", "en": "Face, hair, body shape, and skin tone match the source"}}, + {"id": "garment", "label": {"zh": "服装版型、颜色、面料、图案、Logo 和文字准确", "en": "Garment cut, color, fabric, pattern, logo, and text are accurate"}}, + {"id": "anatomy", "label": {"zh": "四肢、手指、衣褶和遮挡关系自然", "en": "Limbs, fingers, folds, and occlusions look natural"}}, + {"id": "background", "label": {"zh": "姿态、镜头、光线和背景未被意外修改", "en": "Pose, camera, lighting, and background were not changed unexpectedly"}}, + {"id": "artifacts", "label": {"zh": "放大检查后无破损、重影、水印或额外物体", "en": "No damage, ghosting, watermark, or extra objects at full size"}}, + ], + "pose_transfer": [ + {"id": "identity", "label": {"zh": "人物身份、脸部和体型保持一致", "en": "Identity, face, and body shape are preserved"}}, + {"id": "outfit", "label": {"zh": "原服装、配饰、图案和文字保持一致", "en": "Original outfit, accessories, patterns, and text are preserved"}}, + {"id": "pose", "label": {"zh": "目标动作迁移正确且重心合理", "en": "Target pose is transferred with believable balance"}}, + {"id": "anatomy", "label": {"zh": "关节、手脚和遮挡关系符合人体结构", "en": "Joints, hands, feet, and occlusions are anatomically valid"}}, + {"id": "scene", "label": {"zh": "背景、镜头与光线没有非预期变化", "en": "Background, camera, and lighting have no unintended changes"}}, + ], + "prop_replace": [ + {"id": "prop", "label": {"zh": "新道具的造型、材质、颜色、Logo 和文字准确", "en": "New prop shape, material, color, logo, and text are accurate"}}, + {"id": "placement", "label": {"zh": "尺寸、透视、握持或接触关系合理", "en": "Scale, perspective, grip, and contact are believable"}}, + {"id": "lighting", "label": {"zh": "道具光线、阴影和反射与场景匹配", "en": "Lighting, shadow, and reflections match the scene"}}, + {"id": "preservation", "label": {"zh": "替换区域以外的人物、商品和背景保持不变", "en": "People, products, and background outside the target are preserved"}}, + {"id": "artifacts", "label": {"zh": "边缘自然,无残留旧道具、重影或水印", "en": "Edges are clean with no old prop remnants, ghosting, or watermark"}}, + ], + "angle_change": [ + {"id": "identity", "label": {"zh": "主体身份、商品结构和比例保持一致", "en": "Subject identity, product structure, and proportions are preserved"}}, + {"id": "details", "label": {"zh": "颜色、材质、Logo、文字与关键细节准确", "en": "Color, material, logo, text, and key details are accurate"}}, + {"id": "view", "label": {"zh": "水平角、俯仰角和景别符合选择", "en": "Azimuth, elevation, and distance match the controls"}}, + {"id": "geometry", "label": {"zh": "新露出的表面合理,无镜像、复制或结构畸变", "en": "Newly revealed surfaces are plausible with no mirroring or deformation"}}, + {"id": "scene", "label": {"zh": "非目标场景、光线和背景保持一致", "en": "Non-target scene, lighting, and background remain consistent"}}, + ], + "background_change": [ + {"id": "foreground", "label": {"zh": "人物或商品主体、Logo、文字和颜色保持准确", "en": "Foreground subject, logo, text, and color remain accurate"}}, + {"id": "edges", "label": {"zh": "头发、透明材质和商品边缘无白边或缺损", "en": "Hair, transparent materials, and edges have no halos or damage"}}, + {"id": "scene", "label": {"zh": "背景内容符合所选模板、描述或参考图", "en": "Background matches the selected preset, prompt, or reference"}}, + {"id": "lighting", "label": {"zh": "接触阴影、反射、景深和光线方向自然", "en": "Contact shadow, reflections, depth, and light direction are natural"}}, + {"id": "artifacts", "label": {"zh": "无额外主体、文字、水印或明显生成瑕疵", "en": "No extra subjects, text, watermark, or visible generation defects"}}, + ], + "universal": [ + {"id": "identity", "label": {"zh": "主体身份、脸部、发型、体型和肤色只来自主体参考图", "en": "Identity, face, hair, body shape, and skin tone come only from subject references"}}, + {"id": "products", "label": {"zh": "服装、鞋、配饰和道具的版型、材质、颜色、Logo 与文字准确", "en": "Garments, shoes, accessories, and props preserve shape, material, color, logos, and text"}}, + {"id": "pose", "label": {"zh": "动作只迁移姿态与关节关系,人体结构和重心自然", "en": "Pose transfers only posture and joints with natural anatomy and balance"}}, + {"id": "scene", "label": {"zh": "场景构图、光线、透视和接触阴影协调", "en": "Scene composition, lighting, perspective, and contact shadows are coherent"}}, + {"id": "ownership", "label": {"zh": "各参考图没有串脸、串服装、串背景或复制无关物体", "en": "References do not leak identity, clothing, backgrounds, or unrelated objects"}}, + {"id": "artifacts", "label": {"zh": "放大检查后无重影、畸形肢体、镜像文字、水印或多余物体", "en": "No ghosting, malformed limbs, mirrored text, watermark, or extra objects at full size"}}, + ], +} + +EDIT_MODEL_HINTS = ( + "qwen-image-edit", + "flux.2-klein", + "flux2-klein", + "nano-banana", + "gpt-image", + "gemini-3-pro-image", + "gemini-3.1-flash-image", +) +STANDARD_PRIORITIES = ("gemini-3-pro-image-preview", "gpt-image-2-vip", "nano-banana-pro-4k-vip", "qwen-image-edit-2511") + +GARMENT_CATEGORY_ALIASES = { + "upper": "upper", + "upper_body": "upper", + "upper-body": "upper", + "top": "upper", + "tops": "upper", + "上装": "upper", + "上衣": "upper", + "lower": "lower", + "lower_body": "lower", + "lower-body": "lower", + "bottom": "lower", + "bottoms": "lower", + "下装": "lower", + "裤装": "lower", + "裙装": "lower", + "dress": "dress", + "one-piece": "dress", + "one_piece": "dress", + "连衣裙": "dress", + "连体衣": "dress", +} + + +def validate_operation(value: str) -> str: + operation = str(value or "").strip().lower() + if operation not in OPERATIONS: + raise ValueError("不支持的电商功能") + return operation + + +def validate_mode(value: str) -> str: + mode = str(value or "").strip().lower() + if mode in LEGACY_MODES: + return "standard" + if mode not in MODES: + raise ValueError("生成模式只能是 standard") + return "standard" + + +def normalize_garment_analysis(value: dict[str, Any] | None) -> dict[str, Any]: + data = value if isinstance(value, dict) else {} + raw_category = str(data.get("category") or data.get("garment_category") or "").strip().lower() + category = GARMENT_CATEGORY_ALIASES.get(raw_category, "auto") + garment_type = re.sub(r"\s+", " ", str(data.get("garment_type") or data.get("type") or "").strip())[:120] + reason = re.sub(r"\s+", " ", str(data.get("reason") or "").strip())[:240] + try: + confidence = max(0.0, min(1.0, float(data.get("confidence") or 0))) + except (TypeError, ValueError): + confidence = 0.0 + return { + "category": category, + "garment_type": garment_type, + "confidence": round(confidence, 4), + "reason": reason, + } + + +def parse_garment_analysis(text: str) -> dict[str, Any]: + value = str(text or "").strip() + value = re.sub(r"^```(?:json)?\s*", "", value, flags=re.IGNORECASE).strip() + value = re.sub(r"\s*```$", "", value).strip() + try: + data = json.loads(value) + except Exception: + match = re.search(r"\{.*?\}", value, re.S) + data = json.loads(match.group(0)) if match else {} + return normalize_garment_analysis(data) + + +def normalize_universal_reference_analysis(value: dict[str, Any] | None) -> dict[str, Any]: + data = value if isinstance(value, dict) else {} + item_name = re.sub(r"\s+", " ", str(data.get("item_name") or data.get("name") or "").strip())[:120] + category = re.sub(r"\s+", " ", str(data.get("category") or data.get("type") or "").strip())[:80] + interaction = str(data.get("interaction") or "").strip().lower() + if interaction not in UNIVERSAL_INTERACTIONS: + interaction = "" + placement = re.sub(r"\s+", " ", str(data.get("placement") or "").strip())[:160] + visual_details = re.sub(r"\s+", " ", str(data.get("visual_details") or data.get("details") or "").strip())[:300] + reason = re.sub(r"\s+", " ", str(data.get("reason") or "").strip())[:240] + try: + confidence = max(0.0, min(1.0, float(data.get("confidence") or 0))) + except (TypeError, ValueError): + confidence = 0.0 + return { + "item_name": item_name, + "category": category, + "interaction": interaction, + "placement": placement, + "visual_details": visual_details, + "confidence": round(confidence, 4), + "reason": reason, + } + + +def parse_universal_reference_analysis(text: str) -> dict[str, Any]: + value = str(text or "").strip() + value = re.sub(r"^```(?:json)?\s*", "", value, flags=re.IGNORECASE).strip() + value = re.sub(r"\s*```$", "", value).strip() + try: + data = json.loads(value) + except Exception: + match = re.search(r"\{.*?\}", value, re.S) + data = json.loads(match.group(0)) if match else {} + return normalize_universal_reference_analysis(data) + + +def is_compatible_edit_model(model: str) -> bool: + value = str(model or "").strip().lower() + return bool(value and any(hint in value for hint in EDIT_MODEL_HINTS)) + + +def build_model_catalog(providers: Iterable[dict[str, Any]]) -> list[dict[str, Any]]: + catalog: list[dict[str, Any]] = [] + for provider_index, provider in enumerate(providers or []): + if not isinstance(provider, dict) or not provider.get("enabled", True): + continue + provider_id = str(provider.get("id") or "").strip().lower() + if not provider_id: + continue + for model_index, model in enumerate(provider.get("image_models") or []): + model_name = str(model or "").strip() + if not is_compatible_edit_model(model_name): + continue + low = model_name.lower() + if "gemini-3" in low or "nano-banana-pro" in low or "nano-banana-2" in low: + max_reference_images = 14 + elif "gemini-2.5" in low or low in {"nano-banana", "nano-banana-fast"}: + max_reference_images = 3 + else: + max_reference_images = 10 + catalog.append({ + "provider_id": provider_id, + "provider_name": str(provider.get("name") or provider_id), + "model": model_name, + "primary": bool(provider.get("primary")), + "provider_order": provider_index, + "model_order": model_index, + "supports_multi_reference": True, + "supports_mask": "gpt-image" in low or "qwen-image-edit" in low or "flux.2" in low, + "max_reference_images": max_reference_images, + }) + return catalog + + +def _priority_index(model: str, mode: str) -> int: + validate_mode(mode) + low = str(model or "").lower() + for index, hint in enumerate(STANDARD_PRIORITIES): + if hint in low: + return index + return len(STANDARD_PRIORITIES) + 1 + + +def route_candidates( + catalog: Iterable[dict[str, Any]], + mode: str, + provider_id: str = "", + model: str = "", +) -> list[dict[str, Any]]: + mode = validate_mode(mode) + provider_id = str(provider_id or "").strip().lower() + model = str(model or "").strip() + items = [dict(item) for item in catalog or [] if isinstance(item, dict)] + if provider_id: + items = [item for item in items if str(item.get("provider_id") or "").lower() == provider_id] + if model: + exact = [item for item in items if str(item.get("model") or "") == model] + if not exact: + raise ValueError("所选平台没有该兼容图片编辑模型") + exact.sort(key=lambda item: ( + 0 if item.get("primary") else 1, + int(item.get("provider_order") or 0), + int(item.get("model_order") or 0), + )) + return exact + items.sort(key=lambda item: ( + _priority_index(item.get("model", ""), mode), + 0 if item.get("primary") else 1, + int(item.get("provider_order") or 0), + int(item.get("model_order") or 0), + )) + return items + + +def select_route(catalog: Iterable[dict[str, Any]], mode: str, provider_id: str = "", model: str = "") -> dict[str, Any]: + candidates = route_candidates(catalog, mode, provider_id, model) + if not candidates: + raise ValueError("没有找到兼容的图片编辑模型,请检查 API 设置") + return candidates[0] + + +def normalize_inputs(inputs: Iterable[dict[str, Any]]) -> list[dict[str, Any]]: + normalized: list[dict[str, Any]] = [] + seen: set[str] = set() + for value in inputs or []: + if not isinstance(value, dict): + continue + role = str(value.get("role") or "").strip().lower() + url = str(value.get("url") or "").strip() + if role not in ALLOWED_INPUT_ROLES or not url or role in seen: + continue + seen.add(role) + normalized.append({ + "role": role, + "url": url, + "name": str(value.get("name") or role)[:240], + "kind": "image", + "mime": str(value.get("mime") or "")[:120], + }) + return normalized + + +def normalize_universal_inputs(inputs: Iterable[dict[str, Any]]) -> list[dict[str, Any]]: + normalized: list[dict[str, Any]] = [] + seen_ids: set[str] = set() + for index, value in enumerate(inputs or []): + if not isinstance(value, dict): + continue + reference_type = str(value.get("reference_type") or value.get("role") or "").strip().lower() + url = str(value.get("url") or "").strip() + if reference_type not in UNIVERSAL_REFERENCE_ROLE_IDS or not url: + continue + raw_id = re.sub(r"[^a-zA-Z0-9_-]", "", str(value.get("reference_id") or f"reference_{index + 1}"))[:80] + reference_id = raw_id or f"reference_{index + 1}" + if reference_id in seen_ids: + reference_id = f"{reference_id}_{index + 1}" + seen_ids.add(reference_id) + normalized.append({ + "role": reference_type, + "reference_type": reference_type, + "reference_id": reference_id, + "url": url, + "name": str(value.get("name") or reference_type)[:240], + "label": re.sub(r"\s+", " ", str(value.get("label") or "").strip())[:160], + "instruction": re.sub(r"\s+", " ", str(value.get("instruction") or "").strip())[:300], + "kind": "image", + "mime": str(value.get("mime") or "")[:120], + }) + if len(normalized) >= UNIVERSAL_REFERENCE_LIMIT: + break + return normalized + + +def validate_input_roles(operation: str, inputs: Iterable[dict[str, Any]], options: dict[str, Any] | None = None) -> list[dict[str, Any]]: + operation = validate_operation(operation) + options = options if isinstance(options, dict) else {} + if operation == "universal": + values = list(inputs or []) + if len(values) > UNIVERSAL_REFERENCE_LIMIT: + raise ValueError(f"全能模式最多上传 {UNIVERSAL_REFERENCE_LIMIT} 张参考图") + normalized = normalize_universal_inputs(values) + if not any(item["reference_type"] == "subject" for item in normalized): + raise ValueError("全能模式至少需要一张主体/模特参考图") + return normalized + normalized = normalize_inputs(inputs) + roles = {item["role"] for item in normalized} + required = set(OPERATION_INPUTS[operation]) + if operation == "pose_transfer" and str(options.get("pose_source") or "preset") == "reference": + required.add("pose") + if operation == "background_change" and str(options.get("background_mode") or "preset") == "reference": + required.add("background") + missing = sorted(required - roles) + if missing: + raise ValueError("缺少必需输入:" + "、".join(missing)) + return normalized + + +def target_size(width: int, height: int, mode: str, aspect_ratio: str = "source", resolution: str = "auto") -> str: + mode = validate_mode(mode) + width = max(1, int(width or 1)) + height = max(1, int(height or 1)) + aspect_ratio = str(aspect_ratio or "source").strip().lower() + resolution = str(resolution or "auto").strip().lower() + if aspect_ratio not in ASPECT_RATIOS: + raise ValueError("不支持的生成比例") + if resolution not in RESOLUTIONS: + raise ValueError("分辨率只能是 auto、1k、2k 或 4k") + resolved_resolution = "2k" if resolution == "auto" else resolution + if aspect_ratio != "source": + return SIZE_PRESETS[aspect_ratio][resolved_resolution] + long_edge = {"1k": 1024, "2k": 2048, "4k": 3840}[resolved_resolution] + scale = long_edge / max(width, height) + out_w = max(64, int(round(width * scale / 64)) * 64) + out_h = max(64, int(round(height * scale / 64)) * 64) + return f"{out_w}x{out_h}" + + +def resolve_generation_settings( + width: int, + height: int, + mode: str, + aspect_ratio: str = "source", + resolution: str = "auto", + quality: str = "auto", + count: int = 0, +) -> dict[str, Any]: + mode = validate_mode(mode) + aspect_ratio = str(aspect_ratio or "source").strip().lower() + resolution = str(resolution or "auto").strip().lower() + quality = str(quality or "auto").strip().lower() + if aspect_ratio not in ASPECT_RATIOS: + raise ValueError("不支持的生成比例") + if resolution not in RESOLUTIONS: + raise ValueError("分辨率只能是 auto、1k、2k 或 4k") + if quality not in QUALITIES: + raise ValueError("质量只能是 auto、low、medium 或 high") + try: + selected_count = int(count or 0) + except (TypeError, ValueError) as exc: + raise ValueError("生成数量必须是 1 到 4") from exc + if selected_count < 0 or selected_count > 4: + raise ValueError("生成数量必须是 1 到 4,或使用自动") + resolved_resolution = "2k" if resolution == "auto" else resolution + resolved_quality = "high" if quality == "auto" else quality + resolved_count = 1 if selected_count == 0 else selected_count + return { + "parameters": { + "aspect_ratio": aspect_ratio, + "resolution": resolution, + "quality": quality, + "count": selected_count, + }, + "aspect_ratio": aspect_ratio, + "resolution": resolved_resolution, + "size": target_size(width, height, mode, aspect_ratio, resolved_resolution), + "quality": resolved_quality, + "count": resolved_count, + } + + +def _preset_prompt(items: list[dict[str, Any]], preset_id: str, default_id: str) -> str: + selected = next((item for item in items if item.get("id") == preset_id), None) + if not selected: + selected = next((item for item in items if item.get("id") == default_id), items[0]) + return str(selected.get("prompt") or "") + + +def _global_preservation() -> str: + return ( + "Change only the requested dimension. Preserve identity, silhouette, proportions, colors, materials, " + "patterns, logos, readable product text, approved accessories, lighting, camera, and every non-target region. " + "Do not add people, products, text, watermarks, duplicated limbs, mirrored logos, or unrelated objects." + ) + + +def _clean_prompt_text(*values: Any) -> str: + return re.sub(r"\s+", " ", " ".join(str(value or "") for value in values).strip()).lower() + + +def _analysis_lookup(options: dict[str, Any] | None) -> dict[str, dict[str, Any]]: + raw = (options or {}).get("reference_analysis") + if isinstance(raw, dict): + items = raw.items() + elif isinstance(raw, list): + items = ((str(item.get("reference_id") or item.get("id") or index), item) for index, item in enumerate(raw) if isinstance(item, dict)) + else: + items = [] + normalized: dict[str, dict[str, Any]] = {} + for key, value in items: + if isinstance(value, dict): + normalized[str(key)] = normalize_universal_reference_analysis(value) + return normalized + + +def _reference_analysis(item: dict[str, Any], options: dict[str, Any] | None) -> dict[str, Any]: + analysis = _analysis_lookup(options) + return analysis.get(str(item.get("reference_id") or "")) or analysis.get(str(item.get("name") or "")) or {} + + +def _reference_detail(item: dict[str, Any], analysis: dict[str, Any] | None = None) -> str: + analysis = analysis or {} + detail = ( + analysis.get("item_name") + or item.get("label") + or analysis.get("category") + or item.get("name") + or item.get("reference_type") + or item.get("role") + or "reference" + ) + visual = analysis.get("visual_details") or "" + return re.sub(r"\s+", " ", f"{detail}; {visual}" if visual else str(detail)).strip()[:360] + + +def infer_universal_interaction(item: dict[str, Any], analysis: dict[str, Any] | None = None) -> str: + analysis = analysis or {} + role = str(item.get("reference_type") or item.get("role") or "").strip().lower() + suggested = str(analysis.get("interaction") or "").strip().lower() + text = _clean_prompt_text( + role, + item.get("label"), + item.get("instruction"), + item.get("name"), + analysis.get("item_name"), + analysis.get("category"), + analysis.get("visual_details"), + ) + if role == "subject": + return "identity" + if role in {"upper_garment", "lower_garment", "full_garment"}: + return "wear" + if role == "shoes": + return "put_on" + if role == "pose": + return "pose" + if role == "scene": + return "scene" + if role == "style": + return "style" + if any(keyword in text for keyword in BAG_KEYWORDS): + return "carry" + if any(keyword in text for keyword in PLACED_PROP_KEYWORDS): + return "place" + if any(keyword in text for keyword in HANDHELD_KEYWORDS): + return "hold" + if any(keyword in text for keyword in ACCESSORY_WEAR_KEYWORDS): + return "wear" + if suggested in {"wear", "put_on", "hold", "carry", "place", "use"}: + return suggested + return "wear" if role == "accessory" else "hold" + + +def _interaction_phrase(index: int, item: dict[str, Any], analysis: dict[str, Any] | None = None) -> str: + role = str(item.get("reference_type") or item.get("role") or "").strip().lower() + detail = _reference_detail(item, analysis) + interaction = infer_universal_interaction(item, analysis) + if role == "subject": + return f"Use Image {index} as the exact same primary model, preserving identity, face, hair, body proportions, and skin tone." + if role == "upper_garment": + return f"Dress the model in the exact upper garment from Image {index} ({detail})." + if role == "lower_garment": + return f"Dress the model in the exact lower garment from Image {index} ({detail})." + if role == "full_garment": + return f"Dress the model in the exact full outfit or dress from Image {index} ({detail})." + if role == "shoes": + return f"Put the exact shoes from Image {index} ({detail}) on the model's feet." + if role in {"accessory", "prop"}: + if interaction == "wear": + verb = "Have the model wear" + elif interaction == "put_on": + verb = "Put" + elif interaction == "carry": + verb = "Have the model naturally carry" + elif interaction == "place": + verb = "Place" + elif interaction == "use": + verb = "Have the model naturally use" + else: + verb = "Have the model naturally hold" + placement = f" {analysis.get('placement')}." if analysis and analysis.get("placement") else "" + return f"{verb} the exact item from Image {index} ({detail}).{placement}" + if role == "pose": + return f"Make the model follow only the body pose/action from Image {index}; do not copy that person's identity, clothing, accessories, or background." + if role == "scene": + return f"Place the model and products inside the scene from Image {index}, matching environment layout, perspective, and natural lighting." + if role == "style": + return f"Apply only the color, lighting, contrast, and finish style from Image {index}; do not copy its subjects or layout." + return f"Use Image {index} as a reference for {detail}." + + +def build_universal_auto_instruction(inputs: Iterable[dict[str, Any]], options: dict[str, Any] | None = None) -> str: + normalized = list(inputs or []) + lines = [] + for index, item in enumerate(normalized, 1): + lines.append(_interaction_phrase(index, item, _reference_analysis(item, options))) + if not lines: + return "" + return ( + "AUTO FINAL COMPOSITION: " + + " ".join(lines) + + " Produce one coherent, high-end e-commerce product image with a polished catalog/lifestyle look, clean composition, believable fit, contact, scale, shadows, and product fidelity." + ) + + +def build_prompt(operation: str, inputs: Iterable[dict[str, Any]], options: dict[str, Any] | None = None) -> str: + operation = validate_operation(operation) + normalized = validate_input_roles(operation, inputs, options) + options = options if isinstance(options, dict) else {} + ordered_roles = [item["role"] for item in normalized if item["role"] != "mask"] + role_lines = "; ".join(f"Image {index + 1} is {role}" for index, role in enumerate(ordered_roles)) + mask_note = " A final mask reference marks red pixels to replace and green pixels to preserve." if any(item["role"] == "mask" for item in normalized) else "" + instruction = str(options.get("instruction") or "").strip() + + if operation == "universal": + role_names = { + "subject": "PRIMARY SUBJECT / MODEL IDENTITY", + "upper_garment": "UPPER GARMENT", + "lower_garment": "LOWER GARMENT", + "full_garment": "DRESS OR FULL OUTFIT", + "shoes": "SHOES", + "accessory": "JEWELRY OR ACCESSORY", + "prop": "PROP OR PRODUCT", + "pose": "POSE ONLY", + "scene": "SCENE / BACKGROUND ONLY", + "style": "STYLE / LIGHTING ONLY", + } + reference_map = [] + for index, item in enumerate(normalized): + analysis = _reference_analysis(item, options) + detail = item.get("label") or item.get("name") or item["reference_type"] + if analysis.get("item_name"): + detail = analysis["item_name"] + if analysis.get("visual_details"): + detail = f"{detail}; {analysis['visual_details']}" + note = f"; specific instruction: {item['instruction']}" if item.get("instruction") else "" + reference_map.append(f"Image {index + 1} = [{role_names[item['reference_type']]}] {detail}{note}") + auto_instruction = build_universal_auto_instruction(normalized, options) + user_instruction = instruction + final_instruction = auto_instruction + if user_instruction: + final_instruction = f"{auto_instruction}\nUSER SUPPLEMENT: {user_instruction}" if auto_instruction else user_instruction + task = ( + "Create one coherent, photorealistic e-commerce image by following this exact ordered reference map:\n" + + "\n".join(reference_map) + + "\nFINAL COMPOSITION: " + final_instruction + + "\nREFERENCE OWNERSHIP RULES: Subject references own identity, face, hair, skin tone, and body proportions only. " + "Garment, shoe, accessory, and prop references own their exact product geometry, construction, material, color, pattern, logo, and readable text only. " + "Pose references own only body posture, joint arrangement, balance, and gesture; never copy their identity, clothing, accessories, camera, or background. " + "Scene references own only environment, layout, camera perspective, and environmental lighting; never copy foreground people or products. " + "Style references own only palette, finish, contrast, and lighting treatment; never copy subjects or layout. " + "CONFLICT PRIORITY: (1) subject identity, (2) exact product fidelity, (3) requested pose and contact, (4) scene composition and lighting, (5) style. " + "Resolve occlusion, fit, scale, perspective, grip, contact shadows, reflections, fabric folds, and anatomy physically. " + "Do not blend identities or leak clothing, people, props, or backgrounds between references." + ) + elif operation == "try_on": + category = {"upper": "upper-body garment", "lower": "lower-body garment", "dress": "dress or one-piece", "auto": "garment"}.get(str(options.get("garment_category") or "auto"), "garment") + garment_type = re.sub(r"\s+", " ", str(options.get("garment_type") or "").strip())[:120] + detected_note = f" The garment was visually identified as {garment_type}." if garment_type else "" + task = ( + f"Put the exact {category} from the garment reference onto the person in the source image. " + f"{detected_note} " + "Preserve the source person's face, hair, body shape, pose, hands, framing, lighting, and background. " + "Preserve the garment neckline, sleeve and hem geometry, fit, fabric texture, colors, pattern, logo, and text. " + "Create physically natural folds, seams, coverage, and occlusions." + ) + elif operation == "pose_transfer": + if str(options.get("pose_source") or "preset") == "reference": + target = "Use only the body posture and joint arrangement from the pose reference image. Do not copy that person's identity, clothes, or background." + else: + target = "Apply this target pose: " + _preset_prompt(POSE_PRESETS, str(options.get("pose_preset") or "standing_front"), "standing_front") + "." + task = ( + target + " Preserve the source person's identity, facial expression, body proportions, outfit, accessories, product details, camera framing, lighting, and background. " + "Keep anatomy, balance, hands, feet, folds, and occlusions realistic." + ) + elif operation == "prop_replace": + target_description = str(options.get("target_description") or "the matching existing prop").strip() + task = ( + f"Replace only {target_description} in the source image with the exact prop from the prop reference. " + "Match believable scale, perspective, grip or contact, lighting, shadow, and reflections. Preserve the new prop's shape, material, colors, logo, and text. " + "Remove every remnant of the old prop while leaving all pixels outside the target region semantically unchanged." + ) + elif operation == "angle_change": + azimuth = max(-180, min(180, int(options.get("azimuth") or 0))) + elevation = max(-30, min(30, int(options.get("elevation") or 0))) + distance = {"close": "close shot", "medium": "medium shot", "wide": "wide full-subject shot"}.get(str(options.get("distance") or "medium"), "medium shot") + task = ( + f"Move the camera to azimuth {azimuth} degrees and elevation {elevation} degrees, using a {distance}. " + "Rotate the viewpoint around the subject; do not rotate, redesign, mirror, or replace the subject. " + "Infer newly visible surfaces consistently with the same structure, materials, colors, logos, and text." + ) + else: + background_mode = str(options.get("background_mode") or "preset") + if background_mode == "reference": + target = "Use the background reference for environment, composition, palette, and lighting, without copying any foreground subject from it." + elif background_mode == "prompt": + target = str(options.get("background_prompt") or "clean professional e-commerce studio background").strip() + else: + target = _preset_prompt(BACKGROUND_PRESETS, str(options.get("background_preset") or "studio_white"), "studio_white") + task = ( + f"Replace only the background with: {target} Preserve the foreground person or product exactly, including silhouette, hair, transparent materials, colors, logos, and text. " + "Create natural contact shadows, reflections, depth of field, and coherent light direction without halos or clipped edges." + ) + + preservation = ( + "Preserve every reference-owned attribute unless the final composition explicitly changes it. " + "Add only the mapped subjects and products. Do not add unrelated people, products, text, watermarks, duplicate objects, or extra limbs." + if operation == "universal" else _global_preservation() + mask_note + ) + parts = ([] if operation == "universal" else [role_lines + "."]) + [task, preservation] + if instruction and operation != "universal": + parts.append("Additional user instruction: " + instruction) + return " ".join(part for part in parts if part).strip() + + +def safe_fallback_error(status_code: int, detail: str) -> bool: + status_code = int(status_code or 0) + if status_code == 405: + return True + if status_code not in {400, 404, 422}: + return False + text = str(detail or "").lower() + markers = ( + "not support", "unsupported", "does not support", "model not found", "model does not exist", + "no such model", "images api is not supported", "不支持", "未找到模型", "模型不存在", + ) + return any(marker in text for marker in markers) + + +def public_capabilities(providers: Iterable[dict[str, Any]]) -> dict[str, Any]: + catalog = build_model_catalog(providers) + routes: dict[str, Any] = {} + candidates = route_candidates(catalog, "standard") + routes["standard"] = candidates[0] if candidates else None + provider_items: list[dict[str, str]] = [] + seen: set[str] = set() + for item in catalog: + if item["provider_id"] in seen: + continue + seen.add(item["provider_id"]) + provider_items.append({"id": item["provider_id"], "name": item["provider_name"]}) + public_models = [{key: value for key, value in item.items() if key not in {"provider_order", "model_order", "primary"}} for item in catalog] + public_routes = { + mode: ({key: value for key, value in route.items() if key not in {"provider_order", "model_order", "primary"}} if route else None) + for mode, route in routes.items() + } + return { + "operations": list(OPERATIONS), + "modes": list(MODES), + "providers": provider_items, + "models": public_models, + "routes": public_routes, + "pose_presets": POSE_PRESETS, + "background_presets": BACKGROUND_PRESETS, + "quality_checks": QUALITY_CHECKS, + "universal_reference_roles": UNIVERSAL_REFERENCE_ROLES, + "universal_reference_limit": UNIVERSAL_REFERENCE_LIMIT, + "defaults": { + "standard": {"count": 1, "resolution": "2k", "quality": "high", "aspect_ratio": "source"}, + }, + } diff --git a/canvas_core/events.py b/canvas_core/events.py new file mode 100644 index 000000000..b2eb26d4f --- /dev/null +++ b/canvas_core/events.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import time +from dataclasses import asdict, dataclass + + +EVENT_TOPICS = { + "canvas", + "project", + "asset", + "prompt", + "platform", + "workflow", + "preference", + "history", + "session", + "task", +} + + +@dataclass(frozen=True) +class ChangeEvent: + type: str + topic: str + entity_id: str + revision: int + actor_id: str + updated_at: int + + def public(self) -> dict[str, object]: + return asdict(self) + + +def entity_changed( + topic: str, + entity_id: str = "global", + revision: int = 0, + actor_id: str = "", + updated_at: int = 0, +) -> ChangeEvent: + normalized = str(topic or "").strip().lower() + if normalized not in EVENT_TOPICS: + raise ValueError(f"不支持的事件主题:{normalized or '(empty)'}") + return ChangeEvent( + type="entity.changed", + topic=normalized, + entity_id=str(entity_id or "global"), + revision=max(0, int(revision or 0)), + actor_id=str(actor_id or ""), + updated_at=int(updated_at or time.time() * 1000), + ) diff --git a/canvas_core/maintenance.py b/canvas_core/maintenance.py new file mode 100644 index 000000000..091ba6b41 --- /dev/null +++ b/canvas_core/maintenance.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import json +import os +import threading +import time +from pathlib import Path +from typing import Any + +from .data_layout import DataLayout + + +GIB = 1024 * 1024 * 1024 +DEFAULT_CACHE_LIMIT = 10 * GIB +HARD_CACHE_LIMIT = 20 * GIB +DEFAULT_TEMP_MAX_AGE = 24 * 60 * 60 +DEFAULT_LOG_MAX_BYTES = 10 * 1024 * 1024 + + +class MaintenanceManager: + """Keep disposable data bounded without touching user media or database files.""" + + def __init__(self, layout: DataLayout, interval_seconds: int = 60 * 60): + self.layout = layout + self.interval_seconds = max(60, int(interval_seconds)) + self._started = False + self._lock = threading.Lock() + + def _cache_limit(self) -> int: + value = DEFAULT_CACHE_LIMIT + try: + payload = json.loads(self.layout.app_config.read_text(encoding="utf-8")) + value = int(payload.get("cache_max_bytes", value)) + except (OSError, ValueError, TypeError, json.JSONDecodeError): + pass + return max(0, min(value, HARD_CACHE_LIMIT)) + + @staticmethod + def _files(root: Path) -> list[Path]: + if not root.exists(): + return [] + return [path for path in root.rglob("*") if path.is_file() and not path.is_symlink()] + + def _trim_cache(self) -> dict[str, int]: + entries: list[tuple[float, int, Path]] = [] + for path in self._files(self.layout.cache): + try: + stat = path.stat() + entries.append((stat.st_atime or stat.st_mtime, stat.st_size, path)) + except OSError: + continue + total = sum(item[1] for item in entries) + removed = 0 + removed_bytes = 0 + limit = self._cache_limit() + for _accessed_at, size, path in sorted(entries, key=lambda item: item[0]): + if total <= limit: + break + try: + path.unlink() + total -= size + removed += 1 + removed_bytes += size + except OSError: + continue + return {"limit": limit, "remaining_bytes": total, "removed": removed, "removed_bytes": removed_bytes} + + def _clean_temp(self, max_age_seconds: int = DEFAULT_TEMP_MAX_AGE) -> int: + cutoff = time.time() - max_age_seconds + removed = 0 + for path in self._files(self.layout.temp): + try: + if path.stat().st_mtime < cutoff: + path.unlink() + removed += 1 + except OSError: + continue + for directory in sorted((path for path in self.layout.temp.rglob("*") if path.is_dir()), reverse=True): + try: + directory.rmdir() + except OSError: + pass + return removed + + def _rotate_logs(self, max_bytes: int = DEFAULT_LOG_MAX_BYTES, backups: int = 5) -> int: + rotated = 0 + for path in self.layout.logs.glob("*.log"): + try: + if path.stat().st_size <= max_bytes: + continue + oldest = path.with_name(f"{path.name}.{backups}") + if oldest.exists(): + oldest.unlink() + for number in range(backups - 1, 0, -1): + source = path.with_name(f"{path.name}.{number}") + if source.exists(): + os.replace(source, path.with_name(f"{path.name}.{number + 1}")) + os.replace(path, path.with_name(f"{path.name}.1")) + rotated += 1 + except OSError: + continue + return rotated + + def run_once(self) -> dict[str, Any]: + with self._lock: + return { + "cache": self._trim_cache(), + "temp_removed": self._clean_temp(), + "logs_rotated": self._rotate_logs(), + "completed_at": int(time.time() * 1000), + } + + def start(self) -> None: + if self._started: + return + self._started = True + + def loop() -> None: + while True: + time.sleep(self.interval_seconds) + self.run_once() + + threading.Thread(target=loop, name="canvas-data-maintenance", daemon=True).start() diff --git a/canvas_core/migration.py b/canvas_core/migration.py new file mode 100644 index 000000000..04ffcfd5a --- /dev/null +++ b/canvas_core/migration.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import time +from pathlib import Path +from typing import Any, Iterable + +from .data_layout import DataLayout, atomic_write_json +from .database import CanvasDatabase +from .paths import AppPaths + + +def _read_json(path: Path, default: Any) -> Any: + if not path.is_file(): + return default + try: + with path.open("r", encoding="utf-8-sig") as handle: + return json.load(handle) + except (OSError, json.JSONDecodeError): + return default + + +def _file_hash(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +class LegacyMigrator: + VERSION = 1 + + def __init__(self, paths: AppPaths, layout: DataLayout, database: CanvasDatabase): + self.paths = paths + self.layout = layout + self.database = database + self.manifest: dict[str, Any] = {} + + def run(self) -> dict[str, Any]: + self.layout.ensure() + existing = self.layout.manifest_payload() + migration = existing.get("migration") if isinstance(existing.get("migration"), dict) else {} + if int(existing.get("schema_version") or 0) >= self.VERSION and migration.get("status") == "complete": + return migration + if migration.get("status") == "running": + self._rollback(migration.get("moves") or []) + + stamp = time.strftime("%Y%m%d-%H%M%S") + backup_dir = self.layout.backups / f"migration-v{self.VERSION}-{stamp}" + backup_dir.mkdir(parents=True, exist_ok=False) + self.manifest = { + "schema_version": 0, + "migration": { + "version": self.VERSION, + "status": "running", + "started_at": int(time.time() * 1000), + "backup_dir": str(backup_dir), + "moves": [], + }, + } + self._save_manifest() + + try: + snapshot, structured_sources = self._snapshot_legacy() + self.database.import_legacy(snapshot) + self._migrate_media() + self._migrate_custom_workflows() + self._migrate_secret_env(backup_dir) + self._archive_structured(structured_sources, backup_dir) + self._migrate_logs() + counts = self.database.counts() + self.manifest["schema_version"] = self.VERSION + self.manifest["migration"].update( + { + "status": "complete", + "completed_at": int(time.time() * 1000), + "database_counts": counts, + "media_files": self._count_files(self.layout.media), + } + ) + self._save_manifest() + atomic_write_json(backup_dir / "migration-report.json", self.manifest["migration"]) + return self.manifest["migration"] + except Exception as exc: + self.manifest["migration"]["status"] = "failed" + self.manifest["migration"]["error"] = str(exc) + self._save_manifest() + self._rollback(self.manifest["migration"].get("moves") or []) + raise + + def _save_manifest(self) -> None: + atomic_write_json(self.layout.manifest, self.manifest) + + def _record_move(self, source: Path, destination: Path) -> None: + operation = {"source": str(source), "destination": str(destination)} + self.manifest["migration"].setdefault("moves", []).append(operation) + self._save_manifest() + + def _move_file(self, source: Path, destination: Path) -> Path: + if not source.is_file(): + return destination + destination.parent.mkdir(parents=True, exist_ok=True) + selected = destination + if selected.exists(): + if selected.is_file() and source.stat().st_size == selected.stat().st_size and _file_hash(source) == _file_hash(selected): + selected = destination.with_name(f"{destination.stem}.legacy-duplicate{destination.suffix}") + else: + counter = 1 + while selected.exists(): + selected = destination.with_name(f"{destination.stem}.legacy-{counter}{destination.suffix}") + counter += 1 + os.replace(source, selected) + self._record_move(source, selected) + return selected + + def _snapshot_legacy(self) -> tuple[dict[str, Any], list[Path]]: + root = self.paths.portable_root + data = self.layout.root + structured: list[Path] = [] + + def tracked_json(path: Path, default: Any) -> Any: + if path.is_file(): + structured.append(path) + return _read_json(path, default) + + providers = tracked_json(data / "api_providers.json", []) + projects_raw = tracked_json(data / "projects.json", {}) + projects = projects_raw.get("projects") if isinstance(projects_raw, dict) else projects_raw + history = tracked_json(root / "history.json", []) + if not history: + history = tracked_json(data / "history.json", []) + + canvases = [] + canvas_dir = data / "canvases" + if canvas_dir.is_dir(): + for path in sorted(canvas_dir.glob("*.json")): + value = _read_json(path, None) + if isinstance(value, dict): + canvases.append(value) + structured.append(path) + + conversations: list[tuple[str, dict[str, Any]]] = [] + conversation_dir = data / "conversations" + if conversation_dir.is_dir(): + for path in sorted(conversation_dir.glob("*/*.json")): + value = _read_json(path, None) + if isinstance(value, dict): + conversations.append((path.parent.name, value)) + structured.append(path) + + tasks_raw = tracked_json(data / "online_image_tasks.json", {}) + tasks = tasks_raw.get("tasks") if isinstance(tasks_raw, dict) else tasks_raw + libraries = { + "asset_library": tracked_json(data / "asset_library.json", None), + "prompt_libraries": tracked_json(data / "prompt_libraries.json", None), + "shared_folders": tracked_json(data / "shared_folders.json", None), + "runninghub_workflows": tracked_json(data / "runninghub_workflows.json", None), + "legacy_global_config": tracked_json(root / "global_config.json", None), + } + return ( + { + "providers": providers if isinstance(providers, list) else [], + "projects": projects if isinstance(projects, list) else [], + "canvases": canvases, + "conversations": conversations, + "history": history if isinstance(history, list) else [], + "online_image_tasks": tasks if isinstance(tasks, list) else [], + "libraries": libraries, + }, + list(dict.fromkeys(structured)), + ) + + def _migrate_tree(self, source_root: Path, destination_root: Path) -> None: + if not source_root.is_dir() or source_root.resolve() == destination_root.resolve(): + return + for source in sorted(source_root.rglob("*")): + if source.is_file(): + self._move_file(source, destination_root / source.relative_to(source_root)) + + def _migrate_media(self) -> None: + root = self.paths.portable_root + legacy_assets = root / "assets" + self._migrate_tree(legacy_assets / "input", self.layout.media_input) + self._migrate_tree(legacy_assets / "output", self.layout.media_generated) + self._migrate_tree(legacy_assets / "library", self.layout.media_library) + self._migrate_tree(legacy_assets / "uploads", self.layout.media_uploads) + self._migrate_tree(root / "output", self.layout.exports) + + def _migrate_custom_workflows(self) -> None: + workflow_root = self.paths.app_root / "workflows" + self._migrate_tree(workflow_root / "custom", self.layout.workflow_custom) + self._migrate_tree(workflow_root / "自定义", self.layout.workflow_custom) + + def _migrate_secret_env(self, backup_dir: Path) -> None: + legacy_env = self.paths.portable_root / "API" / ".env" + if not legacy_env.is_file(): + return + backup_env = backup_dir / "legacy-root" / "API" / ".env" + backup_env.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(legacy_env, backup_env) + self._move_file(legacy_env, self.layout.secret_env) + + def _archive_structured(self, paths: Iterable[Path], backup_dir: Path) -> None: + root = self.paths.portable_root.resolve() + data_root = self.layout.root.resolve() + for source in paths: + if not source.is_file(): + continue + resolved = source.resolve() + try: + relative = resolved.relative_to(data_root) + destination = backup_dir / "legacy-data" / relative + except ValueError: + try: + relative = resolved.relative_to(root) + except ValueError: + relative = Path(source.name) + destination = backup_dir / "legacy-root" / relative + self._move_file(source, destination) + + def _migrate_logs(self) -> None: + for source in sorted(self.layout.root.glob("*.log")): + self._move_file(source, self.layout.logs / source.name) + for source in sorted(self.layout.root.glob("*.pid")): + self._move_file(source, self.layout.run / source.name) + + def _rollback(self, moves: Iterable[dict[str, str]]) -> None: + for operation in reversed(list(moves)): + source = Path(str(operation.get("source") or "")) + destination = Path(str(operation.get("destination") or "")) + if not destination.is_file() or source.exists(): + continue + source.parent.mkdir(parents=True, exist_ok=True) + try: + os.replace(destination, source) + except OSError: + pass + + @staticmethod + def _count_files(root: Path) -> int: + return sum(1 for path in root.rglob("*") if path.is_file()) if root.is_dir() else 0 + diff --git a/canvas_core/paths.py b/canvas_core/paths.py new file mode 100644 index 000000000..aec9dc008 --- /dev/null +++ b/canvas_core/paths.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import os +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping, Optional + + +def _resolved(value: str | os.PathLike[str]) -> Path: + return Path(value).expanduser().resolve() + + +@dataclass(frozen=True) +class AppPaths: + """Resolved read-only application roots and the single writable data root.""" + + portable_root: Path + app_root: Path + data_root: Path + web_root: Path + builtin_workflow_root: Path + + @classmethod + def discover( + cls, + environ: Optional[Mapping[str, str]] = None, + module_file: Optional[str | os.PathLike[str]] = None, + executable: Optional[str | os.PathLike[str]] = None, + frozen: Optional[bool] = None, + ) -> "AppPaths": + env = os.environ if environ is None else environ + module_path = _resolved(module_file or __file__) + source_root = module_path.parent.parent + is_frozen = bool(getattr(sys, "frozen", False)) if frozen is None else frozen + executable_path = _resolved(executable or sys.executable) + + configured_app_root = str(env.get("CANVAS_APP_ROOT") or "").strip() + if configured_app_root: + app_root = _resolved(configured_app_root) + elif is_frozen: + executable_dir = executable_path.parent + app_root = executable_dir.parent if executable_dir.name.lower() == "backend" else executable_dir + else: + app_root = source_root + + configured_portable_root = str(env.get("CANVAS_PORTABLE_ROOT") or "").strip() + if configured_portable_root: + portable_root = _resolved(configured_portable_root) + elif app_root.name.lower() == "app": + portable_root = app_root.parent + else: + portable_root = app_root + + configured_data_root = str(env.get("CANVAS_DATA_DIR") or "").strip() + data_root = _resolved(configured_data_root) if configured_data_root else portable_root / "data" + + packaged_web_root = app_root / "web" + web_root = packaged_web_root if packaged_web_root.is_dir() else app_root / "static" + builtin_workflow_root = app_root / "workflows" + + return cls( + portable_root=portable_root, + app_root=app_root, + data_root=data_root, + web_root=web_root, + builtin_workflow_root=builtin_workflow_root, + ) + + @property + def version_file(self) -> Path: + portable_version = self.portable_root / "VERSION" + return portable_version if portable_version.is_file() else self.app_root / "VERSION" + + def public_summary(self) -> dict[str, str]: + return { + "portable_root": str(self.portable_root), + "app_root": str(self.app_root), + "data_root": str(self.data_root), + "web_root": str(self.web_root), + "workflow_root": str(self.builtin_workflow_root), + } + + +APP_PATHS = AppPaths.discover() + diff --git a/canvas_core/runtime.py b/canvas_core/runtime.py new file mode 100644 index 000000000..fa06ccddc --- /dev/null +++ b/canvas_core/runtime.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import argparse +import os +import sys +import json +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Optional + + +@dataclass(frozen=True) +class RuntimeOptions: + host: str + port: int + data_dir: str + app_root: str + portable_root: str + desktop_token: str + parent_pid: int + mode: str + + +def bootstrap_runtime(argv: Optional[Iterable[str]] = None) -> RuntimeOptions: + """Read backend flags before main.py resolves any filesystem paths.""" + + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--host", default=os.getenv("CANVAS_HOST", "0.0.0.0")) + parser.add_argument("--port", type=int, default=int(os.getenv("CANVAS_PORT", "3000"))) + parser.add_argument("--data-dir", default=os.getenv("CANVAS_DATA_DIR", "")) + parser.add_argument("--app-root", default=os.getenv("CANVAS_APP_ROOT", "")) + parser.add_argument("--portable-root", default=os.getenv("CANVAS_PORTABLE_ROOT", "")) + parser.add_argument("--desktop-token", default=os.getenv("CANVAS_DESKTOP_TOKEN", "")) + parser.add_argument("--parent-pid", type=int, default=int(os.getenv("CANVAS_PARENT_PID", "0") or 0)) + parser.add_argument("--runtime-mode", default=os.getenv("CANVAS_RUNTIME_MODE", "source")) + parsed, _unknown = parser.parse_known_args(list(argv) if argv is not None else sys.argv[1:]) + + if not (1 <= parsed.port <= 65535): + raise ValueError(f"端口必须位于 1-65535:{parsed.port}") + + env_updates = { + "CANVAS_HOST": parsed.host, + "CANVAS_PORT": str(parsed.port), + "CANVAS_DATA_DIR": parsed.data_dir, + "CANVAS_APP_ROOT": parsed.app_root, + "CANVAS_PORTABLE_ROOT": parsed.portable_root, + "CANVAS_DESKTOP_TOKEN": parsed.desktop_token, + "CANVAS_PARENT_PID": str(parsed.parent_pid), + "CANVAS_RUNTIME_MODE": parsed.runtime_mode, + } + for key, value in env_updates.items(): + if value != "": + os.environ[key] = value + + return RuntimeOptions( + host=str(parsed.host), + port=int(parsed.port), + data_dir=str(parsed.data_dir), + app_root=str(parsed.app_root), + portable_root=str(parsed.portable_root), + desktop_token=str(parsed.desktop_token), + parent_pid=max(0, int(parsed.parent_pid)), + mode=str(parsed.runtime_mode or "source"), + ) + + +RUNTIME_OPTIONS = bootstrap_runtime() + +_ACTIVE_SERVER = None + + +def request_shutdown() -> bool: + server = _ACTIVE_SERVER + if server is None: + return False + server.should_exit = True + return True + + +def _write_runtime_state(status: str) -> None: + root = Path(RUNTIME_OPTIONS.data_dir or os.getenv("CANVAS_DATA_DIR", "data")) / "run" + root.mkdir(parents=True, exist_ok=True) + payload = { + "pid": os.getpid(), + "parent_pid": RUNTIME_OPTIONS.parent_pid, + "host": RUNTIME_OPTIONS.host, + "port": RUNTIME_OPTIONS.port, + "mode": RUNTIME_OPTIONS.mode, + "status": status, + "updated_at": int(time.time() * 1000), + } + temp = root / ".backend.json.tmp" + temp.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + os.replace(temp, root / "backend.json") + (root / "backend.pid").write_text(str(os.getpid()) + "\n", encoding="ascii") + + +def _parent_alive(parent_pid: int) -> bool: + if parent_pid <= 0: + return True + if os.name != "nt": + try: + os.kill(parent_pid, 0) + return True + except OSError: + return False + import ctypes + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + handle = kernel32.OpenProcess(0x00100000, False, parent_pid) + if not handle: + return False + try: + return kernel32.WaitForSingleObject(handle, 0) == 0x00000102 + finally: + kernel32.CloseHandle(handle) + + +def _watch_parent() -> None: + while _parent_alive(RUNTIME_OPTIONS.parent_pid): + time.sleep(1) + request_shutdown() + + +def run_uvicorn(app) -> None: + import uvicorn + global _ACTIVE_SERVER + config = uvicorn.Config( + app, + host=RUNTIME_OPTIONS.host, + port=RUNTIME_OPTIONS.port, + ws_ping_interval=None, + ws_ping_timeout=None, + workers=1, + ) + server = uvicorn.Server(config) + _ACTIVE_SERVER = server + _write_runtime_state("starting") + if RUNTIME_OPTIONS.parent_pid: + threading.Thread(target=_watch_parent, name="canvas-parent-watch", daemon=True).start() + try: + _write_runtime_state("running") + server.run() + finally: + _write_runtime_state("stopped") + pid_file = Path(RUNTIME_OPTIONS.data_dir or os.getenv("CANVAS_DATA_DIR", "data")) / "run" / "backend.pid" + try: + pid_file.unlink() + except FileNotFoundError: + pass + _ACTIVE_SERVER = None diff --git a/canvas_core/secrets.py b/canvas_core/secrets.py new file mode 100644 index 000000000..926afc3e1 --- /dev/null +++ b/canvas_core/secrets.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +import ctypes +import os +import re +import time +from ctypes import wintypes +from pathlib import Path +from typing import Callable, Iterable, Optional + +from .database import CanvasDatabase + + +class SecretProtectionError(RuntimeError): + pass + + +class _DataBlob(ctypes.Structure): + _fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_ubyte))] + + +def _blob(value: bytes) -> tuple[_DataBlob, ctypes.Array]: + buffer = ctypes.create_string_buffer(value) + return _DataBlob(len(value), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte))), buffer + + +class DpapiProtector: + PREFIX = b"CANVAS-DPAPI-1\0" + ENTROPY = b"Canvas Windows portable secrets v1" + + def __init__(self) -> None: + if os.name != "nt": + raise SecretProtectionError("Windows DPAPI 仅能在 Windows 上使用") + self.crypt32 = ctypes.WinDLL("crypt32", use_last_error=True) + self.kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + self.crypt32.CryptProtectData.argtypes = [ + ctypes.POINTER(_DataBlob), + wintypes.LPCWSTR, + ctypes.POINTER(_DataBlob), + ctypes.c_void_p, + ctypes.c_void_p, + wintypes.DWORD, + ctypes.POINTER(_DataBlob), + ] + self.crypt32.CryptProtectData.restype = wintypes.BOOL + self.crypt32.CryptUnprotectData.argtypes = [ + ctypes.POINTER(_DataBlob), + ctypes.POINTER(wintypes.LPWSTR), + ctypes.POINTER(_DataBlob), + ctypes.c_void_p, + ctypes.c_void_p, + wintypes.DWORD, + ctypes.POINTER(_DataBlob), + ] + self.crypt32.CryptUnprotectData.restype = wintypes.BOOL + self.kernel32.LocalFree.argtypes = [ctypes.c_void_p] + self.kernel32.LocalFree.restype = ctypes.c_void_p + + def protect(self, value: str) -> bytes: + raw = str(value).encode("utf-8") + source, source_buffer = _blob(raw) + entropy, entropy_buffer = _blob(self.ENTROPY) + output = _DataBlob() + if not self.crypt32.CryptProtectData( + ctypes.byref(source), "Canvas secret", ctypes.byref(entropy), None, None, 0x1, ctypes.byref(output) + ): + raise SecretProtectionError(f"DPAPI 加密失败,Windows 错误码 {ctypes.get_last_error()}") + try: + return self.PREFIX + ctypes.string_at(output.pbData, output.cbData) + finally: + self.kernel32.LocalFree(output.pbData) + + def unprotect(self, value: bytes) -> str: + payload = bytes(value) + if not payload.startswith(self.PREFIX): + raise SecretProtectionError("密钥数据格式不受支持") + source, source_buffer = _blob(payload[len(self.PREFIX) :]) + entropy, entropy_buffer = _blob(self.ENTROPY) + output = _DataBlob() + description = wintypes.LPWSTR() + if not self.crypt32.CryptUnprotectData( + ctypes.byref(source), ctypes.byref(description), ctypes.byref(entropy), None, None, 0x1, ctypes.byref(output) + ): + raise SecretProtectionError(f"DPAPI 解密失败,密钥可能来自其他 Windows 用户,错误码 {ctypes.get_last_error()}") + try: + return ctypes.string_at(output.pbData, output.cbData).decode("utf-8") + finally: + self.kernel32.LocalFree(output.pbData) + if description: + self.kernel32.LocalFree(description) + + +def parse_env_text(raw: str) -> dict[str, str]: + values: dict[str, str] = {} + for raw_line in str(raw or "").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key): + continue + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + value = value[1:-1] + values[key] = value + return values + + +class SecretStore: + def __init__( + self, + database: CanvasDatabase, + protect: Optional[Callable[[str], bytes]] = None, + unprotect: Optional[Callable[[bytes], str]] = None, + ) -> None: + self.database = database + if protect is None or unprotect is None: + protector = DpapiProtector() + protect = protector.protect + unprotect = protector.unprotect + self._protect = protect + self._unprotect = unprotect + + def get(self, key: str, default: str = "") -> str: + blob = self.database.load_secret_blob(str(key or "").strip()) + if blob is None: + return default + try: + return self._unprotect(blob) + except SecretProtectionError: + return default + + def set(self, key: str, value: str) -> None: + name = str(key or "").strip() + if not name: + raise ValueError("密钥名称不能为空") + text = str(value or "") + if not text: + self.database.delete_secret(name) + return + self.database.save_secret_blob(name, self._protect(text)) + + def update(self, values: dict[str, str]) -> None: + for key, value in values.items(): + self.set(key, str(value or "")) + + def all(self) -> dict[str, str]: + result: dict[str, str] = {} + for key, blob in self.database.list_secret_blobs().items(): + try: + result[key] = self._unprotect(blob) + except SecretProtectionError: + continue + return result + + def load_into_environ(self, overwrite: bool = False) -> None: + for key, value in self.all().items(): + if overwrite or key not in os.environ: + os.environ[key] = value + + def import_env_files(self, paths: Iterable[Path]) -> dict[str, int]: + candidates = [Path(path) for path in paths] + imported: dict[str, str] = {} + readable: list[Path] = [] + for path in candidates: + if not path.is_file(): + continue + values = parse_env_text(path.read_text(encoding="utf-8-sig")) + imported.update(values) + readable.append(path) + if not readable: + return {"files": 0, "values": 0, "removed": 0} + self.update(imported) + for key, expected in imported.items(): + if self.get(key) != expected: + raise SecretProtectionError(f"DPAPI 导入校验失败:{key}") + removed = 0 + for path in readable: + path.unlink() + removed += 1 + self.database.put_document( + "security", + "secret_migration", + { + "status": "complete", + "files_removed": removed, + "values_imported": len(imported), + "completed_at": int(time.time() * 1000), + }, + ) + return {"files": len(readable), "values": len(imported), "removed": removed} diff --git a/canvas_core/storage_bootstrap.py b/canvas_core/storage_bootstrap.py new file mode 100644 index 000000000..b969a42a7 --- /dev/null +++ b/canvas_core/storage_bootstrap.py @@ -0,0 +1,25 @@ +from .data_layout import DataLayout +from .database import CanvasDatabase +from .migration import LegacyMigrator +from .maintenance import MaintenanceManager +from .paths import APP_PATHS +from .secrets import SecretStore + + +DATA_LAYOUT = DataLayout.from_app_paths(APP_PATHS) +DATA_LAYOUT.ensure() + +MAINTENANCE = MaintenanceManager(DATA_LAYOUT) +MAINTENANCE_REPORT = MAINTENANCE.run_once() +MAINTENANCE.start() + +DATABASE = CanvasDatabase(DATA_LAYOUT.database_file) +DATABASE.initialize() + +MIGRATION_REPORT = LegacyMigrator(APP_PATHS, DATA_LAYOUT, DATABASE).run() + +SECRET_STORE = SecretStore(DATABASE) +_legacy_secret_files = sorted(DATA_LAYOUT.backups.glob("migration-*/legacy-root/API/.env")) +_legacy_secret_files.append(DATA_LAYOUT.secret_env) +SECRET_MIGRATION_REPORT = SECRET_STORE.import_env_files(_legacy_secret_files) +SECRET_STORE.load_into_environ() diff --git a/desktop-placeholder/index.html b/desktop-placeholder/index.html new file mode 100644 index 000000000..74a08d915 --- /dev/null +++ b/desktop-placeholder/index.html @@ -0,0 +1 @@ +CanvasCanvas 服务正在启动… diff --git a/main.py b/main.py index 74321e993..89886ca27 100644 --- a/main.py +++ b/main.py @@ -25,6 +25,7 @@ import shlex import functools import html +import ipaddress import xml.etree.ElementTree as ET from typing import List, Dict, Any, Optional, Tuple from threading import Lock, Thread @@ -34,14 +35,49 @@ from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File, Form, Header, Request from fastapi.exceptions import RequestValidationError from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse, Response, StreamingResponse, JSONResponse +from fastapi.responses import FileResponse, Response, StreamingResponse, JSONResponse, RedirectResponse from pydantic import BaseModel, Field from fastapi.middleware.cors import CORSMiddleware +PROJECT_MODULE_DIR = os.path.dirname(os.path.abspath(__file__)) +if PROJECT_MODULE_DIR not in sys.path: + sys.path.insert(0, PROJECT_MODULE_DIR) + +from canvas_core.paths import APP_PATHS +from canvas_core.runtime import RUNTIME_OPTIONS, request_shutdown, run_uvicorn +from canvas_core.storage_bootstrap import ( + DATA_LAYOUT, + DATABASE, + MAINTENANCE_REPORT, + MIGRATION_REPORT, + SECRET_MIGRATION_REPORT, + SECRET_STORE, +) +from canvas_core.auth import AuthIdentity, AuthManager, SESSION_COOKIE +from canvas_core.database import RevisionConflict +from canvas_core.events import entity_changed +from canvas_core.ecommerce import ( + QUALITY_CHECKS as ECOMMERCE_QUALITY_CHECKS, + build_model_catalog as build_ecommerce_model_catalog, + build_prompt as build_ecommerce_prompt, + parse_garment_analysis as parse_ecommerce_garment_analysis, + parse_universal_reference_analysis as parse_ecommerce_universal_reference_analysis, + public_capabilities as ecommerce_public_capabilities, + resolve_generation_settings as resolve_ecommerce_generation_settings, + route_candidates as ecommerce_route_candidates, + safe_fallback_error as ecommerce_safe_fallback_error, + validate_input_roles as validate_ecommerce_input_roles, + validate_mode as validate_ecommerce_mode, + validate_operation as validate_ecommerce_operation, +) + +AUTH_MANAGER = AuthManager(DATABASE, RUNTIME_OPTIONS.desktop_token) + QUIET_ACCESS_PATHS = { - "/api/queue_status", "/api/canvases", "/api/canvases/trash", + "/api/auth/bootstrap", + "/api/runtime/shutdown", } QUIET_ACCESS_PREFIXES = ( "/api/canvases/", @@ -67,11 +103,43 @@ def filter(self, record): app = FastAPI() +PUBLIC_HTTP_PATHS = { + "/pair", + "/favicon.ico", + "/api/health", + "/api/auth/status", + "/api/auth/pair", + "/api/auth/bootstrap", + "/api/runtime/shutdown", +} + + +def request_access_token(request: Request) -> str: + bearer = AUTH_MANAGER.bearer_token(request.headers.get("authorization", "")) + return bearer or request.cookies.get(SESSION_COOKIE, "") + + +@app.middleware("http") +async def authentication_middleware(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + if request.method == "OPTIONS" or path in PUBLIC_HTTP_PATHS: + return await call_next(request) + identity = AUTH_MANAGER.authenticate(request_access_token(request)) + if identity: + request.state.auth_identity = identity + return await call_next(request) + if path == "/" or not path.startswith("/api/") and "text/html" in request.headers.get("accept", ""): + return RedirectResponse("/pair", status_code=307) + return JSONResponse({"detail": "设备尚未配对或会话已失效"}, status_code=401) + + app.add_middleware( CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], - allow_headers=["*"], + allow_origins=["null", "http://127.0.0.1:3000", "http://localhost:3000"], + allow_origin_regex=r"(?:chrome-extension://[a-z]{32}|uxp://[^/]+|https?://(?:127\.0\.0\.1|localhost)(?::\d+)?)", + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["Authorization", "Content-Type", "X-Canvas-Client", "X-User-Id"], ) # --- WebSocket 状态管理器 --- @@ -80,11 +148,15 @@ def __init__(self): self.active_connections: List[WebSocket] = [] self.user_connections: Dict[str, WebSocket] = {} self.connection_clients: Dict[WebSocket, str] = {} + self.connection_devices: Dict[WebSocket, str] = {} - async def connect(self, websocket: WebSocket, client_id: str = None): - await websocket.accept() + async def connect(self, websocket: WebSocket, client_id: str = None, identity: AuthIdentity = None, accept: bool = True): + if accept: + await websocket.accept() self.active_connections.append(websocket) self.connection_clients[websocket] = client_id or f"anon-{id(websocket)}" + if identity: + self.connection_devices[websocket] = identity.device_id if client_id: self.user_connections[client_id] = websocket print(f"WS Connected. Total: {len(self.active_connections)}, Online: {self.online_count()}") @@ -94,11 +166,21 @@ async def disconnect(self, websocket: WebSocket, client_id: str = None): if websocket in self.active_connections: self.active_connections.remove(websocket) self.connection_clients.pop(websocket, None) + self.connection_devices.pop(websocket, None) if client_id and self.user_connections.get(client_id) is websocket: del self.user_connections[client_id] print(f"WS Disconnected. Total: {len(self.active_connections)}, Online: {self.online_count()}") await self.broadcast_count() + async def disconnect_device(self, device_id: str): + targets = [ws for ws, current in self.connection_devices.items() if current == device_id] + for websocket in targets: + try: + await websocket.close(code=4403, reason="设备授权已撤销") + except Exception: + pass + await self.disconnect(websocket) + def online_count(self): visible_clients = { client_id for client_id in self.connection_clients.values() @@ -117,38 +199,23 @@ async def broadcast_count(self): self.active_connections.remove(connection) async def broadcast_new_image(self, image_data: dict): - data = json.dumps({"type": "new_image", "data": image_data}) - for connection in self.active_connections[:]: - try: - await connection.send_text(data) - except Exception as e: - print(f"Broadcast image error: {e}") - self.active_connections.remove(connection) + return None async def broadcast_canvas_updated(self, canvas_id: str, updated_at: int, client_id: str = ""): - data = json.dumps({ - "type": "canvas_updated", - "canvas_id": canvas_id, - "updated_at": updated_at, - "client_id": client_id or "", - }) - for connection in self.active_connections[:]: - try: - await connection.send_text(data) - except Exception as e: - print(f"Broadcast canvas error: {e}") - self.active_connections.remove(connection) + canvas = DATABASE.get_canvas(canvas_id) or {} + await self.broadcast_entity_changed("canvas", canvas_id, int(canvas.get("revision") or 0), client_id, updated_at) async def broadcast_asset_library_updated(self, updated_at: int = 0): - data = json.dumps({ - "type": "asset_library_updated", - "updated_at": updated_at or now_ms(), - }) + revision = DATABASE.next_revision("asset", "global") + await self.broadcast_entity_changed("asset", "global", revision, updated_at=updated_at) + + async def broadcast_entity_changed(self, topic: str, entity_id: str, revision: int, actor_id: str = "", updated_at: int = 0): + data = json.dumps(entity_changed(topic, entity_id, revision, actor_id, updated_at).public(), ensure_ascii=False) for connection in self.active_connections[:]: try: await connection.send_text(data) except Exception as e: - print(f"Broadcast asset library error: {e}") + print(f"Broadcast entity event error: {e}") self.active_connections.remove(connection) async def send_personal_message(self, message: dict, client_id: str): @@ -161,7 +228,7 @@ async def send_personal_message(self, message: dict, client_id: str): manager = ConnectionManager() GLOBAL_LOOP = None -APP_VERSION = "2026.06.03" +APP_VERSION = "1.0.16" GITHUB_REPO_URL = "https://github.com/hero8152/Infinite-Canvas" GITHUB_VERSION_URL = "https://raw.githubusercontent.com/hero8152/Infinite-Canvas/main/VERSION" GITHUB_TREE_URL = "https://api.github.com/repos/hero8152/Infinite-Canvas/git/trees/main?recursive=1" @@ -180,7 +247,7 @@ async def send_personal_message(self, message: dict, client_id: str): async def startup_event(): global GLOBAL_LOOP GLOBAL_LOOP = asyncio.get_running_loop() - sync_static_html_versions() + # 程序资源在桌面包内按只读处理;静态资源版本号只在构建阶段写入暂存副本。 # 启动时整理资产库:给所有图片分组(含默认角色/场景)建好文件夹,并把根目录里的旧素材归整进去。 try: await asyncio.to_thread(migrate_asset_library_into_dirs) @@ -196,64 +263,105 @@ async def startup_event(): await asyncio.to_thread(migrate_mislabeled_image_extensions) except Exception as exc: print(f"纠正图片扩展名失败: {exc}") + try: + report = await asyncio.to_thread(prune_removed_provider_presets_once) + if report.get("removed"): + print(f"已移除未使用的预置 API 平台:{', '.join(report['removed'])}") + except Exception as exc: + print(f"清理未使用预置 API 平台失败: {exc}") + try: + await asyncio.to_thread(seed_builtin_local_vision_secret_once) + except Exception as exc: + print(f"初始化内置视觉模型失败: {exc}") + try: + await asyncio.to_thread(load_online_image_tasks_from_disk) + except Exception as exc: + print(f"在线生图任务恢复失败: {exc}") + try: + await asyncio.to_thread(load_ecommerce_tasks_from_disk) + except Exception as exc: + print(f"电商专用任务恢复失败: {exc}") +@app.websocket("/ws/events") @app.websocket("/ws/stats") async def websocket_endpoint(websocket: WebSocket, client_id: str = None): - await manager.connect(websocket, client_id) + await websocket.accept() + connected = False try: + raw_auth = await asyncio.wait_for(websocket.receive_text(), timeout=5.0) + try: + auth_message = json.loads(raw_auth) + except json.JSONDecodeError: + auth_message = {} + if auth_message.get("type") != "auth": + await websocket.send_text(json.dumps({"type": "auth.failed", "message": "首条消息必须完成鉴权"})) + await websocket.close(code=4401) + return + token = str(auth_message.get("token") or "").strip() + token = token or websocket.cookies.get(SESSION_COOKIE, "") + token = token or AUTH_MANAGER.bearer_token(websocket.headers.get("authorization", "")) + identity = AUTH_MANAGER.authenticate(token) + if not identity: + await websocket.send_text(json.dumps({"type": "auth.failed", "message": "设备尚未配对或授权已失效"})) + await websocket.close(code=4401) + return + client_id = str(auth_message.get("client_id") or client_id or "").strip() + await manager.connect(websocket, client_id, identity, accept=False) + connected = True + await websocket.send_text(json.dumps({"type": "auth.ok", "device": identity.public()})) while True: data = await websocket.receive_text() - if data == "ping": + if data == "ping" or data == '{"type":"ping"}': await websocket.send_text(json.dumps({"type": "pong"})) except WebSocketDisconnect: - await manager.disconnect(websocket, client_id) + if connected: + await manager.disconnect(websocket, client_id) + except asyncio.TimeoutError: + await websocket.close(code=4401, reason="鉴权超时") except Exception as e: print(f"WS Error: {e}") - await manager.disconnect(websocket, client_id) + if connected: + await manager.disconnect(websocket, client_id) # --- 配置区域 --- -CLIENT_ID = str(uuid.uuid4()) -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -WORKFLOW_DIR = os.path.join(BASE_DIR, "workflows") -WORKFLOW_PATH = os.path.join(WORKFLOW_DIR, "Z-Image.json") -STATIC_DIR = os.path.join(BASE_DIR, "static") +BASE_DIR = str(APP_PATHS.app_root) +STATIC_DIR = str(APP_PATHS.web_root) STATIC_RUNNINGHUB_DIR = os.path.join(STATIC_DIR, "runninghub") STATIC_RUNNINGHUB_THUMBNAIL_DIR = os.path.join(STATIC_RUNNINGHUB_DIR, "thumbnails") STATIC_RUNNINGHUB_API_PROVIDERS_FILE = os.path.join(STATIC_RUNNINGHUB_DIR, "api_providers.json") STATIC_RUNNINGHUB_MODEL_REGISTRY_FILE = os.path.join(STATIC_RUNNINGHUB_DIR, "models_registry.json") -OUTPUT_DIR = os.path.join(BASE_DIR, "output") -ASSETS_DIR = os.path.join(BASE_DIR, "assets") -OUTPUT_INPUT_DIR = os.path.join(ASSETS_DIR, "input") -OUTPUT_OUTPUT_DIR = os.path.join(ASSETS_DIR, "output") -ASSET_LIBRARY_DIR = os.path.join(ASSETS_DIR, "library") -LOCAL_UPLOAD_DIR = os.path.join(ASSETS_DIR, "uploads") -HISTORY_FILE = os.path.join(BASE_DIR, "history.json") -API_ENV_FILE = os.path.join(BASE_DIR, "API", ".env") -DATA_DIR = os.path.join(BASE_DIR, "data") -CONVERSATION_DIR = os.path.join(DATA_DIR, "conversations") -CANVAS_DIR = os.path.join(DATA_DIR, "canvases") -MEDIA_PREVIEW_DIR = os.path.join(DATA_DIR, "media_previews") -ASSET_LIBRARY_PATH = os.path.join(DATA_DIR, "asset_library.json") -PROMPT_LIBRARY_PATH = os.path.join(DATA_DIR, "prompt_libraries.json") -API_PROVIDERS_FILE = os.path.join(DATA_DIR, "api_providers.json") -RUNNINGHUB_WORKFLOW_STORE_FILE = os.path.join(DATA_DIR, "runninghub_workflows.json") -SHARED_FOLDERS_FILE = os.path.join(DATA_DIR, "shared_folders.json") -GLOBAL_CONFIG_FILE = os.path.join(BASE_DIR, "global_config.json") +OUTPUT_DIR = str(DATA_LAYOUT.exports) +ASSETS_DIR = str(DATA_LAYOUT.media) +OUTPUT_INPUT_DIR = str(DATA_LAYOUT.media_input) +OUTPUT_OUTPUT_DIR = str(DATA_LAYOUT.media_generated) +ASSET_LIBRARY_DIR = str(DATA_LAYOUT.media_library) +LOCAL_UPLOAD_DIR = str(DATA_LAYOUT.media_uploads) +HISTORY_FILE = "" +API_ENV_FILE = str(DATA_LAYOUT.secret_env) +DATA_DIR = str(APP_PATHS.data_root) +CONVERSATION_DIR = "" +CANVAS_DIR = "" +MEDIA_PREVIEW_DIR = str(DATA_LAYOUT.cache_previews) +ASSET_LIBRARY_PATH = "" +PROMPT_LIBRARY_PATH = "" +API_PROVIDERS_FILE = "" +RUNNINGHUB_WORKFLOW_STORE_FILE = "" +SHARED_FOLDERS_FILE = "" +ONLINE_IMAGE_TASKS_FILE = "" +GLOBAL_CONFIG_FILE = "" CANVAS_TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000 LOCAL_IMAGE_IMPORT_MAX_BYTES = int(os.getenv("LOCAL_IMAGE_IMPORT_MAX_BYTES", str(50 * 1024 * 1024))) LOCAL_IMAGE_IMPORT_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif"} RUNNINGHUB_THUMBNAIL_EXTS = (".jpg",) -QUEUE = [] -QUEUE_LOCK = Lock() HISTORY_LOCK = Lock() GLOBAL_CONFIG_LOCK = Lock() CONVERSATION_LOCK = Lock() CANVAS_LOCK = Lock() -LOAD_LOCK = Lock() RUNNINGHUB_WORKFLOW_LOCK = Lock() -NEXT_TASK_ID = 1 +ONLINE_IMAGE_TASK_LOCK = Lock() +ECOMMERCE_TASK_LOCK = Lock() UPDATE_LOCK = Lock() JIMENG_LOGIN_SESSION = { "proc": None, @@ -263,12 +371,34 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str = None): } PROVIDER_ID_RE = re.compile(r"^[a-zA-Z0-9_-]{2,40}$") -SUPPORTED_PROVIDER_PROTOCOLS = {"openai", "apimart", "gemini", "volcengine", "runninghub", "jimeng"} +SUPPORTED_PROVIDER_PROTOCOLS = {"openai", "apimart", "gemini", "volcengine", "runninghub", "jimeng", "codex"} SUPPORTED_IMAGE_REQUEST_MODES = {"openai", "openai-json"} RUNNINGHUB_DEFAULT_BASE_URL = "https://www.runninghub.cn" RUNNINGHUB_OPENAPI_BASE_URL = "https://www.runninghub.cn/openapi/v2" RUNNINGHUB_MODEL_REGISTRY_URL = "https://raw.githubusercontent.com/HM-RunningHub/ComfyUI_RH_OpenAPI/main/models_registry.json" RUNNINGHUB_LLM_BASE_URL = "https://llm.runninghub.cn/v1" +GRSAI_DEFAULT_BASE_URL = "https://grsaiapi.com" +GRSAI_DEFAULT_IMAGE_MODELS = [ + "nano-banana", + "nano-banana-fast", + "nano-banana-2", + "nano-banana-2-cl", + "nano-banana-2-2k-cl", + "nano-banana-2-4k-cl", + "nano-banana-pro", + "nano-banana-pro-vt", + "nano-banana-pro-cl", + "nano-banana-pro-vip", + "nano-banana-pro-4k-vip", + "gpt-image-2", + "gpt-image-2-vip", +] +SHIYING_DEFAULT_BASE_URL = "https://www.shiying-api.com" +SHIYING_DEFAULT_IMAGE_MODELS = ["gemini-3-pro-image-preview"] +LOCAL_VISION_DEFAULT_BASE_URL = "http://115.231.35.105:12345/v1" +LOCAL_VISION_DEFAULT_MODEL = "qwen3.5-9b-vlm" +LOCAL_VISION_BUILTIN_API_KEY = "sk-lm-VF0plfgx:ZdOB4jyCcB63K1N1tIQg" +LOCAL_VISION_SECRET_SEED_SETTING = "local_vision_builtin_secret_v1" LINGJING_DEFAULT_BASE_URL = "https://apistudio.vip" RUNNINGHUB_LLM_MODELS_URLS = [ "https://llm.runninghub.cn/v1/models", @@ -299,6 +429,12 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str = None): "3.0", "3.0fast", ] +CODEX_DEFAULT_IMAGE_MODELS = ["$imagegen"] +CODEX_DEFAULT_CHAT_MODELS = ["gpt-5.5"] +try: + CODEX_DEFAULT_TIMEOUT = max(30, min(3600, int(os.getenv("CODEX_CLI_TIMEOUT", "900")))) +except Exception: + CODEX_DEFAULT_TIMEOUT = 900 AGNES_DEFAULT_VIDEO_MODELS = ["agnes-video-v2.0"] JIMENG_LEGACY_IMAGE_MODELS = { "jimeng-image-2k", @@ -426,37 +562,14 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str = None): ] def ensure_runtime_config_files(): - """首次运行时提前创建配置目录,避免第一次保存 API Key 时才创建目录/文件。""" - try: - os.makedirs(os.path.dirname(API_ENV_FILE), exist_ok=True) - os.makedirs(DATA_DIR, exist_ok=True) - if not os.path.exists(API_ENV_FILE): - with open(API_ENV_FILE, "a", encoding="utf-8"): - pass - except Exception as e: - print(f"初始化 API 配置目录失败: {e}") + """配置目录由 data 布局创建;密钥只写 DPAPI 存储,不再创建明文 env。""" + os.makedirs(DATA_DIR, exist_ok=True) def load_env_file(): - if not os.path.exists(API_ENV_FILE): - return - try: - with open(API_ENV_FILE, 'r', encoding='utf-8-sig') as f: - for raw_line in f.read().splitlines(): - line = raw_line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, value = line.split("=", 1) - key = key.strip() - value = value.strip().strip('"').strip("'") - os.environ.setdefault(key, value) - except Exception as e: - print(f"加载 API/.env 失败: {e}") + SECRET_STORE.load_into_environ() ensure_runtime_config_files() load_env_file() -COMFYUI_INSTANCES = [s.strip() for s in os.getenv("COMFYUI_INSTANCES", "127.0.0.1:8188").split(",") if s.strip()] -COMFYUI_ADDRESS = COMFYUI_INSTANCES[0] - AI_BASE_URL = os.getenv("COMFLY_BASE_URL", "https://ai.comfly.chat").rstrip("/") AI_API_KEY = os.getenv("COMFLY_API_KEY", "") PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "").strip().rstrip("/") @@ -512,10 +625,6 @@ def load_env_file(): AI_REQUEST_TIMEOUT = float(os.getenv("REQUEST_TIMEOUT", "1800")) IMAGE_POLL_INTERVAL = float(os.getenv("IMAGE_POLL_INTERVAL", "2")) IMAGE_TASK_TIMEOUT = float(os.getenv("IMAGE_TASK_TIMEOUT", str(AI_REQUEST_TIMEOUT))) -COMFYUI_HISTORY_TIMEOUT = int(float(os.getenv("COMFYUI_HISTORY_TIMEOUT", "1800"))) -# 下载 ComfyUI 产物的 socket 超时(秒,作用于连接和每次 read)。没有它时一次网络卡顿会让 urlopen 永久挂起, -# 导致 generate() 不返回、画布卡片一直转圈拿不到结果。给得足够大以容纳大视频/大图的正常下载。 -COMFYUI_DOWNLOAD_TIMEOUT = float(os.getenv("COMFYUI_DOWNLOAD_TIMEOUT", "120")) APIMART_IMAGE_TASK_TIMEOUT = float(os.getenv("APIMART_IMAGE_TASK_TIMEOUT", "1800")) APIMART_IMAGE_POLL_INTERVAL = float(os.getenv("APIMART_IMAGE_POLL_INTERVAL", "5")) APIMART_IMAGE_INITIAL_POLL_DELAY = float(os.getenv("APIMART_IMAGE_INITIAL_POLL_DELAY", "10")) @@ -620,6 +729,8 @@ def reload_env_globals(): def provider_key_env(provider_id): if provider_id == "comfly": return "COMFLY_API_KEY" + if provider_id == "grsai": + return "GRSAI_API_KEY" if provider_id == "modelscope": return "MODELSCOPE_API_KEY" if provider_id == "runninghub": @@ -639,20 +750,7 @@ def volcengine_secret_key_env(): def read_api_env_value(key: str) -> str: key = str(key or "").strip() - if not key or not os.path.exists(API_ENV_FILE): - return "" - try: - with open(API_ENV_FILE, "r", encoding="utf-8-sig") as f: - for raw_line in f.read().splitlines(): - line = raw_line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - env_key, value = line.split("=", 1) - if env_key.strip() == key: - return value.strip().strip('"').strip("'") - except Exception: - return "" - return "" + return SECRET_STORE.get(key, "") if key else "" def provider_env_key_value(provider_id: str) -> str: provider_id = str(provider_id or "").strip().lower() @@ -699,8 +797,12 @@ def bearer_auth_value(value): token = strip_auth_scheme(value, "Bearer") return f"Bearer {token}" if token else "" -def default_api_providers(): - # 独立入口平台强制保留,其他平台均可自定义增删 +REMOVED_PROVIDER_PRESET_IDS = {"modelscope", "runninghub", "volcengine", "lingjing", "codex"} +PROVIDER_PRESET_CLEANUP_SETTING = "provider_preset_cleanup_grsai_v1" + + +def api_provider_templates(): + # 保留完整模板供协议兼容代码参考;默认平台列表只启用当前实际使用的 Grsai。 return [ { "id": "modelscope", @@ -754,6 +856,57 @@ def default_api_providers(): "volcengine_project_name": VOLCENGINE_DEFAULT_PROJECT_NAME, "volcengine_region": VOLCENGINE_DEFAULT_REGION, }, + { + "id": "grsai", + "name": "Grsai", + "base_url": GRSAI_DEFAULT_BASE_URL, + "protocol": "openai", + "image_request_mode": "openai", + "image_generation_endpoint": "", + "image_edit_endpoint": "", + "enabled": True, + "primary": False, + "image_models": GRSAI_DEFAULT_IMAGE_MODELS, + "chat_models": [], + "video_models": [], + "model_protocols": {}, + "ms_loras": [], + "ms_defaults_version": 0, + }, + { + "id": "shiying", + "name": "shiying", + "base_url": SHIYING_DEFAULT_BASE_URL, + "protocol": "openai", + "image_request_mode": "openai", + "image_generation_endpoint": "", + "image_edit_endpoint": "", + "enabled": True, + "primary": False, + "image_models": SHIYING_DEFAULT_IMAGE_MODELS, + "chat_models": [], + "video_models": [], + "model_protocols": {"gemini-3-pro-image-preview": "gemini"}, + "ms_loras": [], + "ms_defaults_version": 0, + }, + { + "id": "local-vision", + "name": "本地视觉模型", + "base_url": LOCAL_VISION_DEFAULT_BASE_URL, + "protocol": "openai", + "image_request_mode": "openai", + "image_generation_endpoint": "", + "image_edit_endpoint": "", + "enabled": True, + "primary": False, + "image_models": [], + "chat_models": [LOCAL_VISION_DEFAULT_MODEL], + "video_models": [], + "model_protocols": {}, + "ms_loras": [], + "ms_defaults_version": 0, + }, { "id": "lingjing", "name": "灵境API", @@ -771,8 +924,28 @@ def default_api_providers(): "ms_loras": [], "ms_defaults_version": 0, }, + { + "id": "codex", + "name": "OpenAI CLI", + "base_url": "", + "protocol": "codex", + "image_request_mode": "openai", + "image_generation_endpoint": "", + "image_edit_endpoint": "", + "enabled": True, + "primary": False, + "image_models": CODEX_DEFAULT_IMAGE_MODELS, + "chat_models": CODEX_DEFAULT_CHAT_MODELS, + "video_models": [], + "ms_loras": [], + "ms_defaults_version": 0, + }, ] + +def default_api_providers(): + return [dict(item) for item in api_provider_templates() if item.get("id") in {"grsai", "shiying", "local-vision"}] + def merge_default_api_providers(providers): merged = [dict(item) for item in providers] # 强制保留独立入口平台(不再强制 comfly) @@ -832,6 +1005,44 @@ def merge_default_api_providers(providers): current["protocol"] = "volcengine" current["volcengine_project_name"] = str(current.get("volcengine_project_name") or VOLCENGINE_DEFAULT_PROJECT_NAME).strip() or VOLCENGINE_DEFAULT_PROJECT_NAME current["volcengine_region"] = str(current.get("volcengine_region") or VOLCENGINE_DEFAULT_REGION).strip() or VOLCENGINE_DEFAULT_REGION + grsai_default = next((d for d in default_api_providers() if d["id"] == "grsai"), None) + if grsai_default: + current = next((item for item in merged if item.get("id") == "grsai"), None) + if not current: + merged.append(grsai_default) + else: + if not current.get("base_url"): + current["base_url"] = grsai_default["base_url"] + if not current.get("protocol"): + current["protocol"] = "openai" + current["image_request_mode"] = normalize_image_request_mode(current.get("image_request_mode")) + current["image_models"] = model_list_from_values([*(current.get("image_models") or []), *(grsai_default.get("image_models") or [])]) + current["chat_models"] = model_list_from_values(current.get("chat_models") or []) + current["video_models"] = model_list_from_values(current.get("video_models") or []) + shiying_default = next((d for d in default_api_providers() if d["id"] == "shiying"), None) + if shiying_default: + current = next((item for item in merged if item.get("id") == "shiying"), None) + if not current: + merged.append(shiying_default) + else: + if not current.get("base_url"): + current["base_url"] = shiying_default["base_url"] + current["image_models"] = model_list_from_values([*(current.get("image_models") or []), *SHIYING_DEFAULT_IMAGE_MODELS]) + protocols = normalize_model_protocols(current.get("model_protocols")) + protocols.update(shiying_default["model_protocols"]) + current["model_protocols"] = protocols + local_vision_default = next((d for d in default_api_providers() if d["id"] == "local-vision"), None) + if local_vision_default: + current = next((item for item in merged if item.get("id") == "local-vision"), None) + if not current: + merged.append(local_vision_default) + else: + current["name"] = str(current.get("name") or local_vision_default["name"]) + current["base_url"] = str(current.get("base_url") or local_vision_default["base_url"]) + current["protocol"] = "openai" + current["image_models"] = [] + current["chat_models"] = model_list_from_values(current.get("chat_models") or local_vision_default["chat_models"]) + current["video_models"] = [] lingjing_default = next((d for d in default_api_providers() if d["id"] == "lingjing"), None) if lingjing_default: current = next((item for item in merged if item.get("id") == "lingjing"), None) @@ -849,6 +1060,18 @@ def merge_default_api_providers(providers): protocols = normalize_model_protocols(current.get("model_protocols")) protocols.update(normalize_model_protocols(lingjing_default.get("model_protocols"))) current["model_protocols"] = protocols + codex_default = next((d for d in default_api_providers() if d["id"] == "codex"), None) + if codex_default: + current = next((item for item in merged if item.get("id") == "codex"), None) + if not current: + merged.append(codex_default) + else: + current["protocol"] = "codex" + current["base_url"] = "" + current["image_request_mode"] = "openai" + current["image_models"] = model_list_from_values([*(current.get("image_models") or []), *CODEX_DEFAULT_IMAGE_MODELS]) + current["chat_models"] = model_list_from_values([*(current.get("chat_models") or []), *CODEX_DEFAULT_CHAT_MODELS]) + current["video_models"] = [] # 即梦 CLI 不再是强制保留的默认平台:仅在用户已添加了即梦协议的平台时,规范化其默认模型/地址。 for current in merged: if not is_jimeng_provider(current): @@ -863,7 +1086,10 @@ def merge_default_api_providers(providers): *[item for item in (current.get("video_models") or []) if str(item or "").strip() not in JIMENG_LEGACY_VIDEO_MODELS], *JIMENG_DEFAULT_VIDEO_MODELS, ]) - return merged + return [ + item for item in merged + if str(item.get("id") or "").strip().lower() not in REMOVED_PROVIDER_PRESET_IDS + ] def normalize_model_list(values): return model_list_from_values(values) @@ -1143,12 +1369,48 @@ def runninghub_openapi_url(provider, path=""): base = runninghub_openapi_base_url(provider) return f"{base}/{path}" if path else base +def normalize_openai_compatible_base_url(value: str) -> str: + text = str(value or "").strip().replace(":", ":") + if not text: + return "" + if len(text) > 300 or re.search(r"\s", text): + raise HTTPException(status_code=400, detail="视觉模型请求地址不合法") + if not re.match(r"^https?://", text, re.I): + host_hint = text.split("/", 1)[0] + bare_host = host_hint.rsplit("@", 1)[-1] + if bare_host.startswith("["): + bare_host = bare_host[1:bare_host.find("]")] if "]" in bare_host else bare_host + elif bare_host.count(":") == 1: + bare_host = bare_host.split(":", 1)[0] + use_http = bare_host.lower() == "localhost" + try: + ipaddress.ip_address(bare_host) + use_http = True + except ValueError: + pass + text = f"{'http' if use_http else 'https'}://{text}" + parsed = urllib.parse.urlsplit(text) + if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc or parsed.username or parsed.password: + raise HTTPException(status_code=400, detail="视觉模型请求地址不合法") + try: + parsed.port + except ValueError as exc: + raise HTTPException(status_code=400, detail="视觉模型端口不合法") from exc + if parsed.query or parsed.fragment: + raise HTTPException(status_code=400, detail="视觉模型请求地址不能包含查询参数或片段") + path = parsed.path.rstrip("/") + if not path: + path = "/v1" + return urllib.parse.urlunsplit((parsed.scheme.lower(), parsed.netloc, path, "", "")) + def normalize_provider(item): provider_id = str(item.get("id") or "").strip().lower() if not PROVIDER_ID_RE.fullmatch(provider_id): raise HTTPException(status_code=400, detail=f"API 平台 ID 不合法:{provider_id or '(empty)'}") name = re.sub(r"\s+", " ", str(item.get("name") or provider_id).strip())[:60] or provider_id base_url = str(item.get("base_url") or "").strip().rstrip("/") + if provider_id == "local-vision": + base_url = normalize_openai_compatible_base_url(base_url or LOCAL_VISION_DEFAULT_BASE_URL) if base_url and not re.match(r"^https?://", base_url): raise HTTPException(status_code=400, detail=f"{name} 的 Base URL 需要以 http:// 或 https:// 开头") protocol = str(item.get("protocol") or "openai").strip().lower() @@ -1167,9 +1429,15 @@ def normalize_provider(item): if provider_id == "jimeng": protocol = "jimeng" base_url = "" + if provider_id == "codex": + protocol = "codex" + base_url = "" if provider_id == "runninghub": protocol = "runninghub" base_url = base_url or RUNNINGHUB_DEFAULT_BASE_URL + if provider_id == "local-vision": + protocol = "openai" + image_request_mode = "openai" return { "id": provider_id, "name": name, @@ -1180,9 +1448,9 @@ def normalize_provider(item): "image_edit_endpoint": image_edit_endpoint, "enabled": bool(item.get("enabled", True)), "primary": bool(item.get("primary", False)), - "image_models": model_list_from_values(item.get("image_models") or []), + "image_models": [] if provider_id == "local-vision" else model_list_from_values(item.get("image_models") or []), "chat_models": model_list_from_values(item.get("chat_models") or []), - "video_models": model_list_from_values(item.get("video_models") or []), + "video_models": [] if provider_id == "local-vision" else model_list_from_values(item.get("video_models") or []), "model_protocols": normalize_model_protocols(item.get("model_protocols")), "ms_loras": normalize_ms_loras(item.get("ms_loras") or []), "ms_defaults_version": int(item.get("ms_defaults_version") or 0), @@ -1194,22 +1462,50 @@ def normalize_provider(item): def load_api_providers(): defaults = default_api_providers() - if not os.path.exists(API_PROVIDERS_FILE): + raw = DATABASE.load_providers() + if not raw: return merge_default_api_providers(defaults) try: - with open(API_PROVIDERS_FILE, "r", encoding="utf-8") as f: - raw = json.load(f) - providers = [normalize_provider(item) for item in raw if isinstance(item, dict)] + providers = [ + normalize_provider(item) + for item in raw + if isinstance(item, dict) + and str(item.get("id") or "").strip().lower() not in REMOVED_PROVIDER_PRESET_IDS + ] return merge_default_api_providers(providers or defaults) except Exception as e: print(f"加载 API 平台配置失败: {e}") return defaults +def prune_removed_provider_presets_once() -> Dict[str, Any]: + marker = DATABASE.get_setting(PROVIDER_PRESET_CLEANUP_SETTING, {}) + value = marker.get("value") if isinstance(marker, dict) else {} + if isinstance(value, dict) and value.get("done"): + return {"removed": [], "skipped": True} + raw = DATABASE.load_providers() + rows = [item for item in raw if isinstance(item, dict)] + removed = [ + str(item.get("id") or "").strip().lower() + for item in rows + if str(item.get("id") or "").strip().lower() in REMOVED_PROVIDER_PRESET_IDS + ] + kept = [ + item for item in rows + if str(item.get("id") or "").strip().lower() not in REMOVED_PROVIDER_PRESET_IDS + ] + if removed: + DATABASE.save_providers(kept) + DATABASE.save_setting( + PROVIDER_PRESET_CLEANUP_SETTING, + {"done": True, "removed": removed, "completed_at": int(time.time() * 1000)}, + only_if_empty=True, + ) + return {"removed": removed, "kept": [str(item.get("id") or "") for item in kept], "skipped": False} + def save_api_providers(providers): - os.makedirs(DATA_DIR, exist_ok=True) with GLOBAL_CONFIG_LOCK: - with open(API_PROVIDERS_FILE, "w", encoding="utf-8") as f: - json.dump(providers, f, ensure_ascii=False, indent=2) + DATABASE.save_providers(providers) + publish_entity_changed("platform", "global") def public_provider(provider): if provider.get("id") == "runninghub": @@ -1310,33 +1606,29 @@ def env_quote(value): return text def update_env_values(updates): - os.makedirs(os.path.dirname(API_ENV_FILE), exist_ok=True) - lines = [] - if os.path.exists(API_ENV_FILE): - with open(API_ENV_FILE, "r", encoding="utf-8-sig") as f: - lines = f.read().splitlines() - seen = set() - next_lines = [] - for line in lines: - stripped = line.strip() - if not stripped or stripped.startswith("#") or "=" not in line: - next_lines.append(line) - continue - key = line.split("=", 1)[0].strip() - if key in updates: - next_lines.append(f"{key}={env_quote(updates[key])}") - os.environ[key] = str(updates[key] or "") - seen.add(key) - else: - next_lines.append(line) + SECRET_STORE.update({str(key): str(value or "") for key, value in updates.items()}) for key, value in updates.items(): - if key not in seen: - next_lines.append(f"{key}={env_quote(value)}") - os.environ[key] = str(value or "") - with open(API_ENV_FILE, "w", encoding="utf-8") as f: - f.write("\n".join(next_lines).rstrip() + "\n") - -BACKEND_LOCAL_LOAD = {addr: 0 for addr in COMFYUI_INSTANCES} + if value: + os.environ[str(key)] = str(value) + else: + os.environ.pop(str(key), None) + +def seed_builtin_local_vision_secret_once() -> Dict[str, Any]: + marker = DATABASE.get_setting(LOCAL_VISION_SECRET_SEED_SETTING, {}) + marker_value = marker.get("value") if isinstance(marker, dict) else {} + if isinstance(marker_value, dict) and marker_value.get("done"): + return {"seeded": False, "skipped": True} + key_env = provider_key_env("local-vision") + seeded = False + if not provider_env_key_value("local-vision"): + update_env_values({key_env: LOCAL_VISION_BUILTIN_API_KEY}) + seeded = True + DATABASE.save_setting( + LOCAL_VISION_SECRET_SEED_SETTING, + {"done": True, "seeded": seeded, "completed_at": int(time.time() * 1000)}, + only_if_empty=True, + ) + return {"seeded": seeded, "skipped": False} os.makedirs(OUTPUT_DIR, exist_ok=True) os.makedirs(ASSETS_DIR, exist_ok=True) @@ -1344,19 +1636,20 @@ def update_env_values(updates): os.makedirs(OUTPUT_OUTPUT_DIR, exist_ok=True) os.makedirs(ASSET_LIBRARY_DIR, exist_ok=True) os.makedirs(LOCAL_UPLOAD_DIR, exist_ok=True) -os.makedirs(STATIC_DIR, exist_ok=True) -os.makedirs(WORKFLOW_DIR, exist_ok=True) -os.makedirs(CONVERSATION_DIR, exist_ok=True) -os.makedirs(CANVAS_DIR, exist_ok=True) +# static 和内置 workflows 属于只读程序资源,不在运行时创建或改写。 app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") app.mount("/output", StaticFiles(directory=OUTPUT_DIR), name="output") +app.mount("/assets/input", StaticFiles(directory=OUTPUT_INPUT_DIR), name="assets-input") +app.mount("/assets/output", StaticFiles(directory=OUTPUT_OUTPUT_DIR), name="assets-output") +app.mount("/assets/library", StaticFiles(directory=ASSET_LIBRARY_DIR), name="assets-library") +app.mount("/assets/uploads", StaticFiles(directory=LOCAL_UPLOAD_DIR), name="assets-uploads") app.mount("/assets", StaticFiles(directory=ASSETS_DIR), name="assets") # --- Pydantic 模型 --- def current_app_version(): - version_file = os.path.join(BASE_DIR, "VERSION") + version_file = str(APP_PATHS.version_file) try: if os.path.exists(version_file): with open(version_file, "r", encoding="utf-8") as f: @@ -1641,6 +1934,8 @@ def app_info(): version = current_app_version() return { "version": version, + "update_enabled": RUNTIME_OPTIONS.mode != "desktop", + "update_strategy": "replace-portable-package" if RUNTIME_OPTIONS.mode == "desktop" else "source-files", "repo_url": GITHUB_REPO_URL, "version_url": GITHUB_VERSION_URL, "tree_url": GITHUB_TREE_URL, @@ -1663,6 +1958,172 @@ def app_info(): "update_notes": read_local_update_notes(version), } + +@app.get("/api/health") +def health_check(): + return { + "status": "ok", + "version": current_app_version(), + "pid": os.getpid(), + "runtime_mode": RUNTIME_OPTIONS.mode, + "database": "ok", + "schema_version": DATABASE.SCHEMA_VERSION, + } + + +class PairDeviceRequest(BaseModel): + code: str + name: str = "" + client_type: str = "browser" + + +PREFERENCE_KEYS = { + "theme", + "language", + "ui_scale", + "default_image_provider", + "default_image_model", + "default_video_provider", + "default_video_model", + "default_chat_provider", + "default_chat_model", + "ecommerce_settings", +} + + +class PreferencesUpdateRequest(BaseModel): + values: Dict[str, Any] = Field(default_factory=dict) + base_revision: int = 0 + actor_id: str = "" + import_if_empty: bool = False + + +@app.get("/pair") +def pair_page(): + return FileResponse(os.path.join(STATIC_DIR, "pair.html"), media_type="text/html") + + +@app.get("/devices") +def devices_page(): + return FileResponse(os.path.join(STATIC_DIR, "devices.html"), media_type="text/html") + + +@app.get("/api/auth/status") +def auth_status(request: Request): + identity = AUTH_MANAGER.authenticate(request_access_token(request)) + return {"authenticated": bool(identity), "device": identity.public() if identity else None} + + +@app.get("/api/auth/bootstrap") +def desktop_bootstrap(token: str = ""): + try: + session_token, _identity = AUTH_MANAGER.consume_desktop_token(token) + except PermissionError as exc: + raise HTTPException(status_code=401, detail=str(exc)) from exc + response = RedirectResponse("/", status_code=303) + response.set_cookie(SESSION_COOKIE, session_token, httponly=True, samesite="strict", path="/") + return response + + +@app.post("/api/auth/pair") +def pair_device(payload: PairDeviceRequest): + try: + access_token, identity = AUTH_MANAGER.pair(payload.code, payload.name, payload.client_type) + except PermissionError as exc: + raise HTTPException(status_code=401, detail=str(exc)) from exc + response = JSONResponse({"ok": True, "access_token": access_token, "device": identity.public()}) + response.set_cookie( + SESSION_COOKIE, + access_token, + max_age=365 * 24 * 60 * 60, + httponly=True, + samesite="strict", + path="/", + ) + return response + + +@app.post("/api/auth/pair-code") +def create_pair_code(): + code, expires_at = AUTH_MANAGER.create_pair_code() + return {"code": code, "expires_at": expires_at, "ttl_seconds": 300} + + +@app.get("/api/auth/devices") +def paired_devices(): + return {"devices": AUTH_MANAGER.list_devices()} + + +@app.delete("/api/auth/devices/{device_id}") +async def revoke_paired_device(device_id: str): + if not AUTH_MANAGER.revoke(device_id): + raise HTTPException(status_code=404, detail="设备不存在或已撤销") + await manager.disconnect_device(device_id) + return {"ok": True} + + +@app.get("/api/preferences") +def get_preferences(): + record = DATABASE.get_setting("global_preferences", {}) + values = record.get("value") if isinstance(record.get("value"), dict) else {} + return {"values": values, "revision": record["revision"], "updated_at": record["updated_at"]} + + +@app.put("/api/preferences") +def save_preferences(payload: PreferencesUpdateRequest): + clean = {key: value for key, value in payload.values.items() if key in PREFERENCE_KEYS and value not in (None, "")} + before = DATABASE.get_setting("global_preferences", {}) + try: + record = DATABASE.save_setting( + "global_preferences", + clean, + base_revision=payload.base_revision, + only_if_empty=payload.import_if_empty, + ) + except RevisionConflict as exc: + raise HTTPException(status_code=409, detail={ + "message": "全局偏好已被其他设备修改", + "values": exc.value if isinstance(exc.value, dict) else {}, + "revision": exc.revision, + }) from exc + if int(record["revision"]) != int(before["revision"]): + publish_entity_changed("preference", "global", int(record["revision"]), payload.actor_id, int(record["updated_at"])) + return {"values": record["value"], "revision": record["revision"], "updated_at": record["updated_at"]} + + +@app.get("/api/runtime/info") +def runtime_info(): + data_writable = os.access(DATA_DIR, os.W_OK) if os.path.exists(DATA_DIR) else os.access(os.path.dirname(DATA_DIR), os.W_OK) + return { + "version": current_app_version(), + "pid": os.getpid(), + "host": RUNTIME_OPTIONS.host, + "port": RUNTIME_OPTIONS.port, + "runtime_mode": RUNTIME_OPTIONS.mode, + "parent_pid": RUNTIME_OPTIONS.parent_pid, + "data_writable": bool(data_writable), + "paths": APP_PATHS.public_summary(), + "data_layout": { + "database": str(DATA_LAYOUT.database_file), + "media": str(DATA_LAYOUT.media), + "logs": str(DATA_LAYOUT.logs), + "cache": str(DATA_LAYOUT.cache), + }, + "database": DATABASE.pragma_summary(), + "migration": {key: value for key, value in MIGRATION_REPORT.items() if key != "moves"}, + "secret_migration": SECRET_MIGRATION_REPORT, + "maintenance": MAINTENANCE_REPORT, + } + + +@app.post("/api/runtime/shutdown") +def runtime_shutdown(request: Request): + supplied = request.headers.get("x-desktop-token", "") + if not RUNTIME_OPTIONS.desktop_token or not hmac.compare_digest(supplied, RUNTIME_OPTIONS.desktop_token): + raise HTTPException(status_code=401, detail="桌面控制令牌无效") + request_shutdown() + return {"ok": True} + def connectivity_probe(name: str, url: str, timeout: float = 5.0) -> Dict[str, Any]: started = time.time() item = { @@ -1785,6 +2246,15 @@ def version_gt(a: str, b: str) -> bool: def check_update(): """服务端检测 GitHub 与 ModelScope 两个源的远端版本(走系统代理,避免浏览器跨域/被墙)。""" current = current_app_version() + if RUNTIME_OPTIONS.mode == "desktop": + return { + "current": current, + "latest": {}, + "update_available": False, + "reachable": False, + "disabled": True, + "message": "便携版首版不提供自动更新;请退出软件后替换 Canvas.exe 与 app 目录。", + } # 并发检测两个源,避免串行 8s+8s 拖慢首屏更新提示 holder: Dict[str, Dict[str, Any]] = {} def _probe(key: str, url: str): @@ -1978,13 +2448,13 @@ def schedule_self_restart(delay_seconds: int = 3) -> bool: "cd /d \"%APP_DIR%\"\r\n" "if exist \"%LAUNCHER%\" (\r\n" " echo [%date% %time%] starting launcher: %LAUNCHER% >> \"%LOG_FILE%\"\r\n" - " start \"ComfyUI-API-Modelscope\" /D \"%APP_DIR%\" cmd /k call \"%LAUNCHER%\"\r\n" + " start \"Canvas\" /D \"%APP_DIR%\" cmd /k call \"%LAUNCHER%\"\r\n" ") else (\r\n" " echo [%date% %time%] launcher missing, fallback to python main.py >> \"%LOG_FILE%\"\r\n" " if exist \"%APP_DIR%\\python\\python.exe\" (\r\n" - " start \"ComfyUI-API-Modelscope\" /D \"%APP_DIR%\" cmd /k \"\"%APP_DIR%\\python\\python.exe\" main.py\"\r\n" + " start \"Canvas\" /D \"%APP_DIR%\" cmd /k \"\"%APP_DIR%\\python\\python.exe\" main.py\"\r\n" " ) else (\r\n" - " start \"ComfyUI-API-Modelscope\" /D \"%APP_DIR%\" cmd /k python main.py\r\n" + " start \"Canvas\" /D \"%APP_DIR%\" cmd /k python main.py\r\n" " )\r\n" ")\r\n" "del \"%~f0\"\r\n" @@ -2097,6 +2567,8 @@ def stage_update_from_source(source: str, staging_root: str) -> Tuple[List[str], @app.post("/api/update-from-github") def update_from_github(req: UpdateRequest = UpdateRequest()): + if RUNTIME_OPTIONS.mode == "desktop": + raise HTTPException(status_code=409, detail="便携版禁止覆盖程序资源;请退出后替换 Canvas.exe 与 app 目录") if not UPDATE_LOCK.acquire(blocking=False): raise HTTPException(status_code=409, detail="正在更新中,请稍后再试") staging_root = "" @@ -2269,6 +2741,8 @@ class RollbackRequest(BaseModel): @app.post("/api/update-rollback") def rollback_update(req: RollbackRequest): + if RUNTIME_OPTIONS.mode == "desktop": + raise HTTPException(status_code=409, detail="便携版不使用源码更新回滚") if not req.name: raise HTTPException(status_code=400, detail="缺少备份名称") if not UPDATE_LOCK.acquire(blocking=False): @@ -2335,41 +2809,21 @@ def rollback_update(req: RollbackRequest): finally: UPDATE_LOCK.release() -class GenerateRequest(BaseModel): - prompt: str = "" - width: int = 1024 - height: int = 1024 - workflow_json: str = "Z-Image.json" - params: Dict[str, Any] = {} - type: str = "zimage" - client_id: str = "" - convert_to_jpg: bool = False - class DeleteHistoryRequest(BaseModel): timestamp: float class TokenRequest(BaseModel): token: str -class CloudGenRequest(BaseModel): - prompt: str - api_key: str = "" - model: str = "" - resolution: str = "1024x1024" - type: str = "zimage" - image_urls: List[str] = [] - loras: Optional[Any] = None - client_id: Optional[str] = None - -class CloudPollRequest(BaseModel): - task_id: str - api_key: str = "" - client_id: Optional[str] = None class AIReference(BaseModel): url: str = "" name: str = "" role: str = "" + reference_id: str = "" + reference_type: str = "" + label: str = "" + instruction: str = "" kind: str = "" mime: str = "" @@ -2382,12 +2836,50 @@ class OnlineImageRequest(BaseModel): n: int = 1 reference_images: List[AIReference] = [] +class EcommerceTaskRequest(BaseModel): + operation: str + mode: str = "standard" + inputs: List[AIReference] = Field(default_factory=list) + options: Dict[str, Any] = Field(default_factory=dict) + provider_id: str = "" + model: str = "" + aspect_ratio: str = "source" + resolution: str = "auto" + quality: str = "auto" + count: int = Field(default=0, ge=0, le=4) + parent_task_id: str = "" + +class EcommerceTaskStatusRequest(BaseModel): + ids: List[str] = Field(default_factory=list, max_length=2000) + +class EcommerceApprovalRequest(BaseModel): + output_index: int = 0 + checks: Dict[str, bool] = Field(default_factory=dict) + note: str = Field(default="", max_length=1000) + +class WorkFavoriteRequest(BaseModel): + favorite: bool + +class WorkMetadataRequest(BaseModel): + name: Optional[str] = Field(default=None, max_length=160) + favorite: Optional[bool] = None + trashed: Optional[bool] = None + class ImageTaskQueryRequest(BaseModel): provider_id: str = "comfly" task_id: str = Field(min_length=1, max_length=240) CANVAS_TASKS: Dict[str, Dict[str, Any]] = {} CANVAS_TASK_LOCK = Lock() +ONLINE_IMAGE_TASKS: Dict[str, Dict[str, Any]] = {} +ECOMMERCE_TASKS: Dict[str, Dict[str, Any]] = {} +ECOMMERCE_MAX_CONCURRENCY = max(1, min(2000, int(os.getenv("ECOMMERCE_MAX_CONCURRENCY", "2000") or 2000))) +ECOMMERCE_VISION_MAX_CONCURRENCY = max(1, min(256, int(os.getenv("ECOMMERCE_VISION_MAX_CONCURRENCY", "32") or 32))) +ECOMMERCE_TASK_SEMAPHORE = asyncio.Semaphore(ECOMMERCE_MAX_CONCURRENCY) +ECOMMERCE_VISION_SEMAPHORE = asyncio.Semaphore(ECOMMERCE_VISION_MAX_CONCURRENCY) +ECOMMERCE_VISION_CACHE: Dict[str, Dict[str, Any]] = {} +ECOMMERCE_VISION_CACHE_LOCK = Lock() +ECOMMERCE_VISION_CACHE_LIMIT = 5000 class CanvasVideoRequest(BaseModel): prompt: str = Field(min_length=1, max_length=VIDEO_PROMPT_MAX_LENGTH) @@ -2436,6 +2928,9 @@ class RunningHubUploadAssetRequest(BaseModel): class JimengHelpRequest(BaseModel): command: str = "" +class CodexHelpRequest(BaseModel): + command: str = "" + class JimengQueryMediaRequest(BaseModel): submit_id: str = "" kind: str = "image" @@ -2515,17 +3010,6 @@ def chat_system_prompt(payload): prompt = str(getattr(payload, "system_prompt", "") or "").strip() return prompt or SYSTEM_PROMPT -class MsGenerateRequest(BaseModel): - prompt: str - api_key: str = "" - model: str = "black-forest-labs/FLUX.2-klein-9B" - image_urls: List[str] = [] - width: int = 0 - height: int = 0 - size: str = "" - loras: Optional[Any] = None - client_id: Optional[str] = None - class CanvasLLMRequest(BaseModel): message: str = Field(min_length=1, max_length=LLM_MESSAGE_MAX_LENGTH) system_prompt: str = "" @@ -2574,6 +3058,7 @@ class CanvasSaveRequest(BaseModel): settings: Dict[str, Any] = {} client_id: str = "" base_updated_at: int = 0 + base_revision: int = 0 class CanvasAssetCheckRequest(BaseModel): urls: List[str] = [] @@ -2734,289 +3219,47 @@ class PromptLibraryCategoryRequest(BaseModel): # --- 负载均衡 --- -def check_images_exist(backend_addr, images): - if not images: return True - for img in images: - try: - url = f"http://{backend_addr}/view?filename={urllib.parse.quote(img)}&type=input" - r = requests.get(url, stream=True, timeout=0.5) - r.close() - if r.status_code != 200: return False - except: return False - return True - -MEDIA_INPUT_KEYS = ("image", "video", "audio", "mask", "filename", "file") -MEDIA_INPUT_EXT_RE = re.compile(r"\.(png|jpe?g|webp|gif|bmp|tiff?|mp4|webm|mov|m4v|avi|mkv|mp3|wav|m4a|aac|ogg|flac)(?:\?|$)", re.I) - -def is_comfy_input_media_value(input_name: str, value: Any) -> bool: - if not isinstance(value, str) or not value.strip(): - return False - key = str(input_name or "").lower() - if any(token in key for token in MEDIA_INPUT_KEYS): - return True - return bool(MEDIA_INPUT_EXT_RE.search(value)) +def save_to_history(record): + with HISTORY_LOCK: + if "timestamp" not in record: + record["timestamp"] = time.time() + DATABASE.prepend_history(record, limit=5000) + publish_entity_changed("history", "global") -def collect_required_comfy_media(params: Dict[str, Any]) -> List[str]: - required = [] - for node_inputs in (params or {}).values(): - if not isinstance(node_inputs, dict): - continue - for input_name, value in node_inputs.items(): - if is_comfy_input_media_value(input_name, value): - required.append(value) - return list(dict.fromkeys(required)) +def safe_user_id(user_id, request: Request): + candidate = (user_id or "").strip() + if not candidate and request.client: + candidate = f"ip-{request.client.host}" + if not candidate: + candidate = "anonymous" + candidate = re.sub(r"[^a-zA-Z0-9_.-]", "-", candidate)[:80].strip(".-") + return candidate or "anonymous" -def get_best_backend(required_images: List[str] = None): - best_backend = COMFYUI_INSTANCES[0] - min_queue_size = float('inf') - backend_stats = {} +def user_dir(user_id): + return str(user_id or "anonymous") - for addr in COMFYUI_INSTANCES: - try: - with urllib.request.urlopen(f"http://{addr}/queue", timeout=1) as response: - data = json.loads(response.read()) - remote_load = len(data.get('queue_running', [])) + len(data.get('queue_pending', [])) - with LOAD_LOCK: - local_load = BACKEND_LOCAL_LOAD.get(addr, 0) - effective_load = max(remote_load, local_load) - has_images = check_images_exist(addr, required_images) - backend_stats[addr] = {"load": effective_load, "has_images": has_images} - except Exception as e: - print(f"Backend {addr} unreachable: {e}") - continue +def conversation_path(user_id, conversation_id): + cleaned = re.sub(r"[^a-zA-Z0-9_-]", "", conversation_id or "") + if not cleaned: + raise HTTPException(status_code=400, detail="无效的对话 ID") + return cleaned - if not backend_stats: - return COMFYUI_INSTANCES[0] +def now_ms(): + return int(time.time() * 1000) - for addr, stats in backend_stats.items(): - load = stats["load"] - if load < min_queue_size or (load == min_queue_size and stats.get("has_images") and not backend_stats.get(best_backend, {}).get("has_images")): - min_queue_size = load - best_backend = addr +def publish_entity_changed(topic: str, entity_id: str = "global", revision: int = 0, actor_id: str = "", updated_at: int = 0): + event_revision = int(revision or DATABASE.next_revision(topic, entity_id)) + if GLOBAL_LOOP and not GLOBAL_LOOP.is_closed(): + asyncio.run_coroutine_threadsafe( + manager.broadcast_entity_changed(topic, entity_id, event_revision, actor_id, updated_at or now_ms()), + GLOBAL_LOOP, + ) + return event_revision - return best_backend - -def reserve_best_backend(required_images: List[str] = None): - backend_stats = {} - for addr in COMFYUI_INSTANCES: - try: - with urllib.request.urlopen(f"http://{addr}/queue", timeout=1) as response: - data = json.loads(response.read()) - remote_load = len(data.get('queue_running', [])) + len(data.get('queue_pending', [])) - has_images = check_images_exist(addr, required_images) - backend_stats[addr] = {"remote_load": remote_load, "has_images": has_images} - except Exception as e: - print(f"Backend {addr} unreachable: {e}") - continue - with LOAD_LOCK: - best_backend = COMFYUI_INSTANCES[0] - min_load = float('inf') - if backend_stats: - for addr, stats in backend_stats.items(): - load = max(stats["remote_load"], BACKEND_LOCAL_LOAD.get(addr, 0)) - if load < min_load or (load == min_load and stats.get("has_images") and not backend_stats.get(best_backend, {}).get("has_images")): - min_load = load - best_backend = addr - BACKEND_LOCAL_LOAD[best_backend] = BACKEND_LOCAL_LOAD.get(best_backend, 0) + 1 - return best_backend - -# --- 辅助工具 --- - -def download_image(comfy_address, comfy_url_path, prefix="studio_"): - filename = f"{prefix}{uuid.uuid4().hex[:10]}.png" - local_path = output_path_for(filename, "output") - full_url = f"http://{comfy_address}{comfy_url_path}" - try: - with urllib.request.urlopen(full_url, timeout=COMFYUI_DOWNLOAD_TIMEOUT) as response, open(local_path, 'wb') as out_file: - shutil.copyfileobj(response, out_file) - return output_url_for(filename, "output") - except Exception as e: - print(f"下载图片失败: {e}") - if comfy_url_path.startswith("/view"): - return comfy_url_path.replace("/view", "/api/view", 1) - return full_url - -def comfy_output_extension(item): - filename = str((item or {}).get("filename") or "") - ext = os.path.splitext(filename)[1].lower() - if ext in { - ".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff", - ".mp4", ".webm", ".mov", ".m4v", ".avi", ".mkv", - ".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac", - ".txt", ".json", ".csv", ".srt", ".vtt", ".md", - }: - return ext - fmt = str((item or {}).get("format") or "").lower() - if "mpeg" in fmt or "mp3" in fmt: - return ".mp3" - if "wav" in fmt or "wave" in fmt: - return ".wav" - if "ogg" in fmt: - return ".ogg" - if "flac" in fmt: - return ".flac" - if "text" in fmt or "plain" in fmt: - return ".txt" - if "json" in fmt: - return ".json" - if "webm" in fmt: - return ".webm" - if "quicktime" in fmt or "mov" in fmt: - return ".mov" - if "mp4" in fmt or "h264" in fmt or "video" in fmt: - return ".mp4" - return ext or ".bin" - -def is_video_output_item(item): - ext = comfy_output_extension(item) - fmt = str((item or {}).get("format") or "").lower() - return ext in {".mp4", ".webm", ".mov", ".m4v", ".avi", ".mkv"} or "video" in fmt - -def comfy_output_kind(item): - ext = comfy_output_extension(item) - fmt = str((item or {}).get("format") or "").lower() - if ext in {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff"} or "image" in fmt: - return "image" - if ext in {".mp4", ".webm", ".mov", ".m4v", ".avi", ".mkv"} or "video" in fmt: - return "video" - if ext in {".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac"} or "audio" in fmt or "sound" in fmt: - return "audio" - if ext in {".txt", ".json", ".csv", ".srt", ".vtt", ".md"} or "text" in fmt or "json" in fmt: - return "text" - return "file" - -def download_comfy_output(comfy_address, item, prefix="studio_"): - ext = comfy_output_extension(item) - filename = f"{prefix}{uuid.uuid4().hex[:10]}{ext}" - local_path = output_path_for(filename, "output") - subfolder = urllib.parse.quote(str(item.get("subfolder") or "")) - file_type = urllib.parse.quote(str(item.get("type") or "output")) - comfy_url_path = f"/view?filename={urllib.parse.quote(str(item['filename']))}&subfolder={subfolder}&type={file_type}" - full_url = f"http://{comfy_address}{comfy_url_path}" - try: - with urllib.request.urlopen(full_url, timeout=COMFYUI_DOWNLOAD_TIMEOUT) as response, open(local_path, 'wb') as out_file: - shutil.copyfileobj(response, out_file) - return output_url_for(filename, "output") - except Exception as e: - print(f"下载 ComfyUI 输出失败: {e}") - if comfy_url_path.startswith("/view"): - return comfy_url_path.replace("/view", "/api/view", 1) - return full_url - -def save_comfy_text_output(value, prefix="studio_", name=""): - text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, indent=2) - stem = sanitize_export_filename(name or "comfy_text.txt", "comfy_text.txt") - _, ext = os.path.splitext(stem) - if ext.lower() not in {".txt", ".json", ".csv", ".srt", ".vtt", ".md"}: - stem += ".txt" - filename = f"{prefix}{uuid.uuid4().hex[:10]}_{stem}" - path = output_path_for(filename, "output") - with open(path, "w", encoding="utf-8") as f: - f.write(text) - return output_url_for(filename, "output") - -def comfy_text_values_from_output(node_output): - values = [] - text_keys = ("text", "texts", "prompt", "prompts", "string", "strings", "caption", "captions") - for key in text_keys: - if key not in node_output: - continue - value = node_output.get(key) - items = value if isinstance(value, list) else [value] - for item in items: - if isinstance(item, dict): - text = item.get("text") or item.get("prompt") or item.get("caption") or item.get("value") - name = item.get("filename") or item.get("name") or f"{key}.txt" - else: - text = item - name = f"{key}.txt" - if text is None: - continue - text = str(text) - if text.strip(): - values.append((text, name)) - return values - -def collect_comfy_file_items(node_output): - items = [] - for key, value in (node_output or {}).items(): - if key in {"text", "texts", "prompt", "prompts", "string", "strings", "caption", "captions"}: - continue - candidates = value if isinstance(value, list) else [value] - for item in candidates: - if isinstance(item, dict) and item.get("filename"): - items.append((key, item)) - return items - -# 纯预览/对比类节点:其输出只用于界面展示(PreviewImage、rgthree 的 Image Comparer 等), -# 工作流里通常还有 SaveImage 产出真正结果,故有正式产出时应丢弃这些冗余预览/对比图。 -COMFY_PREVIEW_CLASS_HINTS = ("previewimage", "comparer", "imagecompare", "image compare") -# show/utility 类调试文本节点:ShowText、各种 *Anything、CR Text、MathExpression、note 等, -# 它们的 ui 文本基本是调试信息,不应混进最终结果。 -COMFY_DEBUG_TEXT_CLASS_HINTS = ( - "showtext", "show text", "showanything", "show any", "preview any", "previewany", - "displaytext", "display text", "display any", "anything everywhere", "convertanything", - "easy show", "note", "mathexpression", "cr text", "text multiline", "string function", - "debug", -) - -def comfy_class_is_preview(class_type): - ct = str(class_type or "").lower() - return bool(ct) and any(h in ct for h in COMFY_PREVIEW_CLASS_HINTS) - -def comfy_class_is_debug_text(class_type): - ct = str(class_type or "").lower() - return bool(ct) and any(h in ct for h in COMFY_DEBUG_TEXT_CLASS_HINTS) - -def save_to_history(record): - with HISTORY_LOCK: - history = [] - if os.path.exists(HISTORY_FILE): - try: - with open(HISTORY_FILE, 'r', encoding='utf-8') as f: - history = json.load(f) - except: pass - if "timestamp" not in record: - record["timestamp"] = time.time() - history.insert(0, record) - with open(HISTORY_FILE, 'w', encoding='utf-8') as f: - json.dump(history[:5000], f, ensure_ascii=False, indent=4) - -def get_comfy_history(comfy_address, prompt_id): - try: - with urllib.request.urlopen(f"http://{comfy_address}/history/{prompt_id}") as response: - return json.loads(response.read()) - except Exception as e: - return {} - -def safe_user_id(user_id, request: Request): - candidate = (user_id or "").strip() - if not candidate and request.client: - candidate = f"ip-{request.client.host}" - if not candidate: - candidate = "anonymous" - candidate = re.sub(r"[^a-zA-Z0-9_.-]", "-", candidate)[:80].strip(".-") - return candidate or "anonymous" - -def user_dir(user_id): - path = os.path.join(CONVERSATION_DIR, user_id) - os.makedirs(path, exist_ok=True) - return path - -def conversation_path(user_id, conversation_id): - cleaned = re.sub(r"[^a-zA-Z0-9_-]", "", conversation_id or "") - if not cleaned: - raise HTTPException(status_code=400, detail="无效的对话 ID") - return os.path.join(user_dir(user_id), f"{cleaned}.json") - -def now_ms(): - return int(time.time() * 1000) - -def save_conversation(user_id, conversation): - with CONVERSATION_LOCK: - path = conversation_path(user_id, conversation["id"]) - with open(path, 'w', encoding='utf-8') as f: - json.dump(conversation, f, ensure_ascii=False, indent=2) +def save_conversation(user_id, conversation, actor_id=""): + with CONVERSATION_LOCK: + DATABASE.save_conversation(user_id, conversation) + publish_entity_changed("session", conversation.get("id") or user_id, actor_id=actor_id) def new_conversation(user_id, title="新对话"): timestamp = now_ms() @@ -3031,23 +3274,15 @@ def new_conversation(user_id, title="新对话"): return conversation def load_conversation(user_id, conversation_id): - path = conversation_path(user_id, conversation_id) - if not os.path.exists(path): + cleaned = conversation_path(user_id, conversation_id) + conversation = DATABASE.get_conversation(user_id, cleaned) + if not conversation: raise HTTPException(status_code=404, detail="对话不存在") - with open(path, 'r', encoding='utf-8') as f: - return json.load(f) + return conversation def list_conversations(user_id): records = [] - for filename in os.listdir(user_dir(user_id)): - if not filename.endswith(".json"): - continue - path = os.path.join(user_dir(user_id), filename) - try: - with open(path, 'r', encoding='utf-8') as f: - data = json.load(f) - except Exception: - continue + for data in DATABASE.list_conversations(user_id): messages = data.get("messages", []) last_message = next((m for m in reversed(messages) if m.get("role") != "system"), None) records.append({ @@ -3063,36 +3298,28 @@ def canvas_path(canvas_id): cleaned = re.sub(r"[^a-zA-Z0-9_-]", "", canvas_id or "") if not cleaned: raise HTTPException(status_code=400, detail="无效的画布 ID") - return os.path.join(CANVAS_DIR, f"{cleaned}.json") + return cleaned -def save_canvas(canvas): +def save_canvas(canvas, actor_id=""): canvas["updated_at"] = now_ms() with CANVAS_LOCK: - with open(canvas_path(canvas["id"]), 'w', encoding='utf-8') as f: - json.dump(canvas, f, ensure_ascii=False, indent=2) + canvas_path(canvas["id"]) + DATABASE.save_canvas(canvas, touch=False) + publish_entity_changed("canvas", canvas["id"], int(canvas.get("revision") or 0), actor_id, int(canvas.get("updated_at") or 0)) def normalize_canvas_kind(kind="classic"): return "smart" if str(kind or "").strip().lower() == "smart" else "classic" # ===== 项目(按项目分类管理画布)===== -PROJECTS_PATH = os.path.join(DATA_DIR, "projects.json") DEFAULT_PROJECT_ID = "default" def load_projects(): - try: - with open(PROJECTS_PATH, 'r', encoding='utf-8') as f: - data = json.load(f) - projects = data.get("projects") if isinstance(data, dict) else data - if isinstance(projects, list): - return [p for p in projects if isinstance(p, dict) and p.get("id")] - except Exception: - pass - return [] + return [p for p in DATABASE.load_projects() if isinstance(p, dict) and p.get("id")] def save_projects(projects): with CANVAS_LOCK: - with open(PROJECTS_PATH, 'w', encoding='utf-8') as f: - json.dump({"projects": projects}, f, ensure_ascii=False, indent=2) + DATABASE.save_projects(projects) + publish_entity_changed("project", "global") def project_record(p): return { @@ -3164,21 +3391,20 @@ def new_canvas(title="未命名画布", icon="layers", kind="classic", project=N return canvas def load_canvas(canvas_id): - path = canvas_path(canvas_id) - if not os.path.exists(path): + cleaned = canvas_path(canvas_id) + canvas = DATABASE.get_canvas(cleaned) + if not canvas: raise HTTPException(status_code=404, detail="画布不存在") - with open(path, 'r', encoding='utf-8') as f: - canvas = json.load(f) if canvas.get("deleted_at"): raise HTTPException(status_code=404, detail="画布已在回收站") return canvas def load_canvas_any(canvas_id): - path = canvas_path(canvas_id) - if not os.path.exists(path): + cleaned = canvas_path(canvas_id) + canvas = DATABASE.get_canvas(cleaned) + if not canvas: raise HTTPException(status_code=404, detail="画布不存在") - with open(path, 'r', encoding='utf-8') as f: - return json.load(f) + return canvas CANVAS_COLORS = {"", "red", "orange", "amber", "green", "teal", "blue", "violet", "pink", "slate"} @@ -3207,33 +3433,15 @@ def canvas_record(data): def cleanup_expired_canvas_trash(): cutoff = now_ms() - CANVAS_TRASH_RETENTION_MS with CANVAS_LOCK: - for filename in os.listdir(CANVAS_DIR): - if not filename.endswith(".json"): - continue - path = os.path.join(CANVAS_DIR, filename) - try: - with open(path, 'r', encoding='utf-8') as f: - data = json.load(f) - deleted_at = int(data.get("deleted_at") or 0) - if deleted_at and deleted_at < cutoff: - os.remove(path) - except Exception: - continue + for data in DATABASE.list_canvases(include_deleted=True): + deleted_at = int(data.get("deleted_at") or 0) + if deleted_at and deleted_at < cutoff: + DATABASE.purge_canvas(str(data.get("id") or "")) def iter_canvas_records(include_deleted=False): cleanup_expired_canvas_trash() records = [] - for filename in os.listdir(CANVAS_DIR): - if not filename.endswith(".json"): - continue - try: - with open(os.path.join(CANVAS_DIR, filename), 'r', encoding='utf-8') as f: - data = json.load(f) - except Exception: - continue - is_deleted = bool(data.get("deleted_at")) - if include_deleted != is_deleted: - continue + for data in DATABASE.list_canvases(include_deleted=bool(include_deleted)): records.append(canvas_record(data)) return records @@ -3361,16 +3569,7 @@ def canvas_assets_index(): canvas_counts = {"all": 0, "smart": 0, "classic": 0} item_counts = {"all": 0, "smart": 0, "classic": 0} cleanup_expired_canvas_trash() - for filename in os.listdir(CANVAS_DIR): - if not filename.endswith(".json"): - continue - try: - with open(os.path.join(CANVAS_DIR, filename), "r", encoding="utf-8") as f: - canvas = json.load(f) - except Exception: - continue - if canvas.get("deleted_at"): - continue + for canvas in DATABASE.list_canvases(include_deleted=False): record = canvas_record(canvas) canvas_items = extract_canvas_assets(canvas) record["asset_count"] = len(canvas_items) @@ -3484,7 +3683,7 @@ def looks_like_vision_chat_model(model): if not lc: return False vision_keys = [ - "vision", "vl-", "-vl-", "internvl", "qvq", "qwen-vl", + "vision", "vl-", "-vl-", "vlm", "internvl", "qvq", "qwen-vl", "doubao-vision", "glm-4v", "minicpm-v", ] return any(key in lc for key in vision_keys) @@ -3779,7 +3978,7 @@ def provider_protocol(provider): # 单模型可覆盖的协议(仅 OpenAI / Gemini,二者可共用同一站点的 Base URL + Key) PER_MODEL_PROTOCOL_OPTIONS = {"openai", "gemini"} # 协议固定、不支持单模型覆盖的内置平台 -FIXED_PROTOCOL_PROVIDER_IDS = {"modelscope", "volcengine", "jimeng", "runninghub"} +FIXED_PROTOCOL_PROVIDER_IDS = {"modelscope", "volcengine", "jimeng", "runninghub", "grsai", "codex", "local-vision"} def normalize_model_protocols(value): """规整 {模型名: 协议} 覆盖表,仅保留 openai/gemini。""" @@ -3833,9 +4032,291 @@ def is_volcengine_provider(provider): def is_runninghub_provider(provider): return provider_protocol(provider) == "runninghub" or str((provider or {}).get("id") or "").strip().lower() == "runninghub" +def is_grsai_provider(provider): + provider_id = str((provider or {}).get("id") or "").strip().lower() + base_url = str((provider or {}).get("base_url") or "").strip().lower() + return provider_id == "grsai" or "grsaiapi.com" in base_url or "grsai.dakka.com.cn" in base_url + +def is_grsai_nano_model(model): + return str(model or "").strip().lower().startswith("nano-banana") + def is_jimeng_provider(provider): return provider_protocol(provider) == "jimeng" or str((provider or {}).get("id") or "").strip().lower() == "jimeng" +def is_codex_provider(provider): + return provider_protocol(provider) == "codex" or str((provider or {}).get("id") or "").strip().lower() == "codex" + +def codex_env_value(key): + return os.getenv(key, "") or read_api_env_value(key) + +def codex_cli_executable(): + configured = str(codex_env_value("CODEX_BIN") or "").strip() + if configured: + return configured + return shutil.which("codex") or shutil.which("codex.exe") or shutil.which("codex.cmd") or "" + +def codex_timeout(default=CODEX_DEFAULT_TIMEOUT): + try: + return max(30, min(3600, int(os.getenv("CODEX_CLI_TIMEOUT", str(default)) or default))) + except Exception: + return default + +def codex_model_for_exec(model="", fallback=""): + value = str(model or fallback or "").strip() + low = value.lower() + if not value or low.startswith("$imagegen") or low.startswith("gpt-image"): + return "" + return value + +def codex_decode_output(stdout, stderr): + out_text = (stdout or b"").decode("utf-8", errors="replace").strip() + err_text = (stderr or b"").decode("utf-8", errors="replace").strip() + return out_text, err_text + +async def run_codex_cli(prompt, model="", image_paths=None, timeout=None, output_last_message=True): + exe = codex_cli_executable() + if not exe: + raise HTTPException(status_code=400, detail="未找到 OpenAI Codex CLI。请先运行 CLI/windows/openai/1-install_openai_codex_cli.bat,并完成 codex 登录。") + image_paths = [str(path) for path in (image_paths or []) if path and os.path.isfile(str(path))] + last_path = "" + args = [ + exe, + "exec", + "--cd", + BASE_DIR, + "--sandbox", + "workspace-write", + "--skip-git-repo-check", + ] + exec_model = codex_model_for_exec(model) + if exec_model: + args.extend(["--model", exec_model]) + for path in image_paths: + args.extend(["--image", path]) + if output_last_message: + fd, last_path = tempfile.mkstemp(prefix="codex_last_", suffix=".txt", dir=OUTPUT_OUTPUT_DIR) + os.close(fd) + args.extend(["--output-last-message", last_path]) + args.append("-") + prompt_bytes = str(prompt or "").encode("utf-8") + try: + proc = await asyncio.create_subprocess_exec( + *args, + cwd=BASE_DIR, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for(proc.communicate(input=prompt_bytes), timeout=timeout or codex_timeout()) + except asyncio.TimeoutError as exc: + raise HTTPException(status_code=504, detail="OpenAI Codex CLI 执行超时。可设置 CODEX_CLI_TIMEOUT 增大等待时间。") from exc + except FileNotFoundError as exc: + raise HTTPException(status_code=400, detail=f"未找到 OpenAI Codex CLI:{exe}") from exc + out_text, err_text = codex_decode_output(stdout, stderr) + last_text = "" + if last_path and os.path.exists(last_path): + try: + with open(last_path, "r", encoding="utf-8-sig") as f: + last_text = f.read().strip() + except Exception: + last_text = "" + try: + os.remove(last_path) + except Exception: + pass + if proc.returncode != 0: + message = err_text or out_text or last_text or f"exit={proc.returncode}" + raise HTTPException(status_code=502, detail=f"OpenAI Codex CLI 调用失败:{message[:1200]}") + return {"text": last_text or out_text, "_stdout": out_text, "_stderr": err_text} + +def codex_output_image_files(since_time=0): + exts = {".png", ".jpg", ".jpeg", ".webp", ".gif"} + root = os.path.abspath(OUTPUT_OUTPUT_DIR) + files = [] + try: + for name in os.listdir(root): + path = os.path.join(root, name) + if not os.path.isfile(path): + continue + ext = os.path.splitext(name)[1].lower() + if ext not in exts: + continue + mtime = os.path.getmtime(path) + if mtime + 1 < float(since_time or 0): + continue + files.append((mtime, path)) + except Exception: + return [] + return [path for _mtime, path in sorted(files, reverse=True)] + +def codex_output_url_from_path(path): + path = os.path.abspath(str(path or "")) + root = os.path.abspath(OUTPUT_OUTPUT_DIR) + try: + if os.path.commonpath([root, path]) == root: + return output_url_for(os.path.basename(path), "output") + except Exception: + return "" + return "" + +async def codex_prepare_local_media(ref_url): + text = str(ref_url or "").strip() + if not text: + return "", [] + if text.startswith(("/output/", "/assets/")): + path = output_file_from_url(text) + if path: + return path, [] + raise HTTPException(status_code=404, detail=f"OpenAI CLI 参考素材不存在:{text}") + if text.startswith("file://"): + path = urllib.parse.unquote(urllib.parse.urlparse(text).path) + if os.name == "nt" and re.match(r"^/[A-Za-z]:/", path): + path = path[1:] + if os.path.isfile(path): + return path, [] + if os.path.isfile(text): + return text, [] + temp_paths = [] + suffix = ".png" + if text.startswith("data:"): + if ";base64," not in text: + raise HTTPException(status_code=400, detail="OpenAI CLI 参考素材 data URL 缺少 base64 数据") + header, encoded = text.split(";base64,", 1) + mime = header.split(":", 1)[1].split(";", 1)[0] if ":" in header else "" + suffix = mimetypes.guess_extension(mime) or suffix + fd, path = tempfile.mkstemp(prefix="codex_ref_", suffix=suffix) + with os.fdopen(fd, "wb") as f: + f.write(base64.b64decode(encoded)) + temp_paths.append(path) + return path, temp_paths + if text.startswith(("http://", "https://")): + async with httpx.AsyncClient(timeout=httpx.Timeout(connect=20.0, read=300.0, write=60.0, pool=20.0), follow_redirects=True) as client: + response = await client.get(text) + response.raise_for_status() + clean_path = urllib.parse.urlparse(text).path + suffix = os.path.splitext(clean_path)[1] or mimetypes.guess_extension(response.headers.get("content-type", "")) or suffix + fd, path = tempfile.mkstemp(prefix="codex_ref_", suffix=suffix) + with os.fdopen(fd, "wb") as f: + f.write(response.content) + temp_paths.append(path) + return path, temp_paths + raise HTTPException(status_code=400, detail=f"OpenAI CLI 无法读取参考素材:{text[:120]}") + +async def codex_reference_paths(reference_images=None): + paths = [] + temp_paths = [] + try: + for ref in (reference_images or [])[:ONLINE_IMAGE_REFERENCE_MAX]: + url = ref.get("url") if isinstance(ref, dict) else getattr(ref, "url", "") + if not url: + continue + path, created = await codex_prepare_local_media(url) + if path: + paths.append(path) + temp_paths.extend(created) + return paths, temp_paths + except Exception: + for path in temp_paths: + try: + os.remove(path) + except Exception: + pass + raise + +def codex_models_payload(raw=None): + all_models = [*CODEX_DEFAULT_IMAGE_MODELS, *CODEX_DEFAULT_CHAT_MODELS] + return { + "ok": True, + "protocol": "codex", + "status": 200, + "message": "OpenAI Codex CLI 可用,模型列表来自本机 CLI 默认配置。", + "model_count": len(all_models), + "total": len(all_models), + "image_models": CODEX_DEFAULT_IMAGE_MODELS, + "chat_models": CODEX_DEFAULT_CHAT_MODELS, + "video_models": [], + "all": all_models, + "raw": raw or {}, + } + +async def generate_codex_provider_image(prompt, size, model, reference_images=None, provider=None): + ref_paths, temp_paths = await codex_reference_paths(reference_images) + since = time.time() + try: + image_prompt = ( + "$imagegen\n\n" + f"任务:{prompt}\n\n" + f"尺寸/比例参考:{size or 'auto'}。\n" + f"请生成或编辑图片,并把最终图片文件保存到这个本地目录:{OUTPUT_OUTPUT_DIR}\n" + "只需要输出最终文件路径和一句简短说明;不要修改项目代码,不要创建额外文档。" + ) + raw = await run_codex_cli(image_prompt, model="", image_paths=ref_paths, timeout=codex_timeout(), output_last_message=True) + files = codex_output_image_files(since) + urls = [] + for path in files: + url = codex_output_url_from_path(path) + if url and url not in urls: + urls.append(url) + if not urls: + text = f"{raw.get('text') or raw.get('_stdout') or ''}\n{raw.get('_stderr') or ''}" + pattern = r"([A-Za-z]:\\[^\r\n\"'<>]+\.(?:png|jpe?g|webp|gif)|/[^\r\n\"'<>]+\.(?:png|jpe?g|webp|gif))" + for match in re.findall(pattern, text, flags=re.I): + url = codex_output_url_from_path(match.strip()) + if url and url not in urls: + urls.append(url) + if not urls: + status_text = (raw.get("text") or raw.get("_stdout") or raw.get("_stderr") or "")[:1200] + raise HTTPException(status_code=502, detail=f"OpenAI CLI 已返回,但没有在输出目录发现图片:{status_text}") + return {"type": "url", "value": urls[0]}, {"images": urls, "text": raw.get("text"), "provider": "codex"} + finally: + for path in temp_paths: + try: + os.remove(path) + except Exception: + pass + +def codex_chat_prompt(payload, history_messages=None): + parts = [] + system_prompt = str(getattr(payload, "system_prompt", "") or "").strip() + if system_prompt: + parts.append(f"系统要求:\n{system_prompt}") + for item in (history_messages or [])[-MAX_HISTORY_MESSAGES:]: + role = str(item.get("role") or "").strip() + content = item.get("content") + if role in {"user", "assistant"} and content: + label = "用户" if role == "user" else "助手" + parts.append(f"{label}:\n{content}") + message = str(getattr(payload, "message", "") or "").strip() + parts.append(f"用户:\n{message}") + parts.append("请直接回答用户,输出纯文本,不要修改项目文件。") + return "\n\n".join(part for part in parts if part).strip() + +async def codex_chat_text(payload, history_messages=None): + image_paths = [] + temp_paths = [] + try: + image_values = [] + if hasattr(payload, "images"): + image_values.extend([{"url": item} for item in (getattr(payload, "images", None) or []) if item]) + if hasattr(payload, "reference_images"): + image_values.extend([ref.dict() for ref in (getattr(payload, "reference_images", None) or []) if getattr(ref, "url", "")]) + image_paths, temp_paths = await codex_reference_paths(image_values) + raw = await run_codex_cli( + codex_chat_prompt(payload, history_messages), + model=getattr(payload, "model", "") or CODEX_DEFAULT_CHAT_MODELS[0], + image_paths=image_paths, + timeout=codex_timeout(), + output_last_message=True, + ) + text = str(raw.get("text") or "").strip() + return text or "Codex CLI 返回了空回复。", raw + finally: + for path in temp_paths: + try: + os.remove(path) + except Exception: + pass + def is_yuli_provider(provider): # 玉玉API(yuli.host)的视频接口走自有格式(/v1/video/create + /v1/video/query), # 与通用 OpenAI /v1/videos/generations 不同,需单独识别。 @@ -4695,12 +5176,18 @@ def output_file_from_url(url): if not url or not (url.startswith("/output/") or url.startswith("/assets/")): return None clean = urllib.parse.unquote(url.split("?", 1)[0]).replace("\\", "/") - if clean.startswith("/assets/"): - root = ASSETS_DIR - rel = clean[len("/assets/"):] - else: - root = OUTPUT_DIR - rel = clean[len("/output/"):] + mappings = ( + ("/assets/input/", OUTPUT_INPUT_DIR), + ("/assets/output/", OUTPUT_OUTPUT_DIR), + ("/assets/library/", ASSET_LIBRARY_DIR), + ("/assets/uploads/", LOCAL_UPLOAD_DIR), + ("/assets/", ASSETS_DIR), + ("/output/", OUTPUT_DIR), + ) + prefix, root = next(((prefix, root) for prefix, root in mappings if clean.startswith(prefix)), ("", "")) + if not prefix: + return None + rel = clean[len(prefix):] rel = rel.lstrip("/") if not rel: return None @@ -4710,6 +5197,27 @@ def output_file_from_url(url): return None return path +def media_url_from_path(path: str): + absolute = os.path.abspath(path) + mappings = ( + (OUTPUT_INPUT_DIR, "/assets/input"), + (OUTPUT_OUTPUT_DIR, "/assets/output"), + (ASSET_LIBRARY_DIR, "/assets/library"), + (LOCAL_UPLOAD_DIR, "/assets/uploads"), + (OUTPUT_DIR, "/output"), + (ASSETS_DIR, "/assets"), + ) + for root, prefix in mappings: + root_abs = os.path.abspath(root) + try: + if os.path.commonpath([root_abs, absolute]) != root_abs: + continue + except ValueError: + continue + rel = os.path.relpath(absolute, root_abs).replace("\\", "/") + return f"{prefix}/{urllib.parse.quote(rel, safe='/')}" + return None + def image_has_alpha(img: Image.Image) -> bool: if img.mode in ("RGBA", "LA"): return True @@ -4838,9 +5346,9 @@ def local_media_file_by_basename(name: str): roots = [ OUTPUT_OUTPUT_DIR, OUTPUT_INPUT_DIR, - os.path.join(ASSETS_DIR, "output"), - os.path.join(ASSETS_DIR, "input"), - os.path.join(ASSETS_DIR, "library"), + ASSET_LIBRARY_DIR, + LOCAL_UPLOAD_DIR, + OUTPUT_DIR, ] for root in roots: path = os.path.abspath(os.path.join(root, safe)) @@ -4859,7 +5367,7 @@ def fetch_remote_media_bytes(url: str, timeout: float = 30.0, max_bytes: int = 2 parsed = urllib.parse.urlparse(text) if parsed.scheme not in ("http", "https") or not parsed.netloc: return None - with requests.get(text, stream=True, timeout=timeout, headers={"User-Agent": "ComfyUI-API-Modelscope/1.0"}) as response: + with requests.get(text, stream=True, timeout=timeout, headers={"User-Agent": "Canvas/1.0"}) as response: response.raise_for_status() content_type = response.headers.get("content-type") or "application/octet-stream" chunks = [] @@ -5013,13 +5521,13 @@ def migrate_asset_item_registrations(item): item.pop(key, None) def load_asset_library(): - if not os.path.exists(ASSET_LIBRARY_PATH): + stored = DATABASE.get_library("asset_library", None) + if stored is None: lib = default_asset_library() save_asset_library(lib) return lib try: - with open(ASSET_LIBRARY_PATH, "r", encoding="utf-8") as f: - lib = json.load(f) + lib = stored except Exception: lib = default_asset_library() return normalize_asset_library(lib) @@ -5375,11 +5883,8 @@ def save_asset_library(lib): lib = normalize_asset_library(lib) sort_asset_library_items(lib) lib["updated_at"] = now_ms() - os.makedirs(DATA_DIR, exist_ok=True) - with open(ASSET_LIBRARY_PATH, "w", encoding="utf-8") as f: - json.dump(lib, f, ensure_ascii=False, indent=2) - if GLOBAL_LOOP: - asyncio.run_coroutine_threadsafe(manager.broadcast_asset_library_updated(int(lib["updated_at"])), GLOBAL_LOOP) + revision = DATABASE.save_library("asset_library", lib) + publish_entity_changed("asset", "global", revision, updated_at=int(lib["updated_at"])) def find_asset_category(lib, category_id): for cat in lib.get("categories", []): @@ -5423,11 +5928,7 @@ def find_asset_category_with_library(lib, category_id, library_id=""): SHARED_FOLDERS_LOCK = Lock() def shared_folders_load(): - try: - with open(SHARED_FOLDERS_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - except (FileNotFoundError, json.JSONDecodeError): - data = {} + data = DATABASE.get_library("shared_folders", {}) if not isinstance(data, dict): data = {} folders = data.get("folders") @@ -5436,9 +5937,7 @@ def shared_folders_load(): return {"folders": [f for f in folders if isinstance(f, dict)]} def shared_folders_save(data): - os.makedirs(DATA_DIR, exist_ok=True) - with open(SHARED_FOLDERS_FILE, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) + DATABASE.save_library("shared_folders", data) def shared_folder_by_id(folder_id): for entry in shared_folders_load().get("folders", []): @@ -5448,16 +5947,16 @@ def shared_folder_by_id(folder_id): def shared_folder_abs(entry): rel = (entry or {}).get("rel") or "" - return os.path.normpath(os.path.join(BASE_DIR, rel)) + return os.path.normpath(os.path.join(DATA_DIR, rel)) def shared_resolve_register(path): """校验 path 必须位于项目目录内、是一个存在的子目录(非项目根)。返回 (abs, rel)。""" raw = (path or "").strip().strip('"').strip("'") if not raw: raise HTTPException(status_code=400, detail="请提供文件夹路径") - candidate = raw if os.path.isabs(raw) else os.path.join(BASE_DIR, raw) + candidate = raw if os.path.isabs(raw) else os.path.join(DATA_DIR, raw) abs_path = os.path.normpath(os.path.abspath(candidate)) - base = os.path.normpath(os.path.abspath(BASE_DIR)) + base = os.path.normpath(os.path.abspath(DATA_DIR)) try: common = os.path.commonpath([abs_path, base]) except ValueError: @@ -5688,12 +6187,12 @@ def normalize_prompt_libraries(data): return {"active_library_id": active, "libraries": libraries, "updated_at": int(data.get("updated_at") or now_ms())} def load_prompt_libraries(): - if not os.path.exists(PROMPT_LIBRARY_PATH): + stored = DATABASE.get_library("prompt_libraries", None) + if stored is None: data = default_prompt_libraries() return save_prompt_libraries(data) try: - with open(PROMPT_LIBRARY_PATH, "r", encoding="utf-8") as f: - data = json.load(f) + data = stored except Exception: data = default_prompt_libraries() if not isinstance(data, dict): @@ -5706,9 +6205,8 @@ def load_prompt_libraries(): def save_prompt_libraries(data): data = normalize_prompt_libraries(data) data["updated_at"] = now_ms() - os.makedirs(DATA_DIR, exist_ok=True) - with open(PROMPT_LIBRARY_PATH, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) + revision = DATABASE.save_library("prompt_libraries", data) + publish_entity_changed("prompt", "global", revision, updated_at=int(data["updated_at"])) return data def public_prompt_libraries(data=None): @@ -5823,13 +6321,7 @@ def convert_output_to_jpg(url, quality=88): else: img = img.convert("RGB") img.save(jpg_path, "JPEG", quality=quality, optimize=True) - try: - root = ASSETS_DIR if os.path.commonpath([os.path.abspath(ASSETS_DIR), os.path.abspath(jpg_path)]) == os.path.abspath(ASSETS_DIR) else OUTPUT_DIR - except ValueError: - root = OUTPUT_DIR - rel = os.path.relpath(jpg_path, root).replace("\\", "/") - prefix = "/assets" if root == ASSETS_DIR else "/output" - return f"{prefix}/{rel}" + return media_url_from_path(jpg_path) or url except Exception as e: print(f"转换 JPG 失败: {e}") return url @@ -7102,7 +7594,7 @@ async def save_remote_video_to_output(url, prefix="video_", category="output"): try: timeout = httpx.Timeout(connect=20.0, read=VIDEO_POLL_TIMEOUT, write=60.0, pool=20.0) headers = { - "User-Agent": "ComfyUI-API-Modelscope/1.0", + "User-Agent": "Canvas/1.0", "Accept": "video/*,application/octet-stream,*/*;q=0.8", } async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, headers=headers) as client: @@ -7276,23 +7768,109 @@ def apimart_size_resolution(size): best = min(common, key=lambda item: abs(ratio - item[0] / item[1])) return best[2], resolution -VOLCENGINE_MIN_PIXELS = 3_686_400 -VOLCENGINE_MIN_EDGE = 1536 -VOLCENGINE_MAX_EDGE = 4096 -VOLCENGINE_RATIO_CHOICES = [ - (1, 1, "1:1"), - (4, 3, "4:3"), - (3, 4, "3:4"), - (16, 9, "16:9"), - (9, 16, "9:16"), - (21, 9, "21:9"), - (9, 21, "9:21"), - (3, 2, "3:2"), - (2, 3, "2:3"), - (5, 4, "5:4"), - (4, 5, "4:5"), -] - +def grsai_aspect_ratio(size, fallback="1:1"): + width, height = parse_size_pair(size) + if width and height: + divisor = math.gcd(width, height) or 1 + return f"{width // divisor}:{height // divisor}" + raw = str(size or "").strip().lower() + if raw == "auto" or re.fullmatch(r"\d+\s*:\s*\d+", raw): + return raw.replace(" ", "") + return fallback + +def grsai_image_size(size, fallback="1K"): + width, height = parse_size_pair(size) + if width and height: + long_edge = max(width, height) + if long_edge >= 3200: + return "4K" + if long_edge >= 1400: + return "2K" + return "1K" + raw = str(size or "").strip().upper() + return raw if raw in {"1K", "2K", "4K"} else fallback + +def grsai_endpoint_url(provider, path): + return provider_endpoint_url(provider, "", path) + +def grsai_status(raw): + if not isinstance(raw, dict): + return "" + return str(raw.get("status") or "").strip().lower() + +async def wait_for_grsai_image_task(client, provider, task_id): + query_url = grsai_endpoint_url(provider, "/v1/api/result") + deadline = time.monotonic() + IMAGE_TASK_TIMEOUT + last_payload = None + while time.monotonic() < deadline: + await asyncio.sleep(IMAGE_POLL_INTERVAL) + response = await client.get( + query_url, + headers=api_headers(provider=provider), + params={"id": task_id}, + ) + response.raise_for_status() + raw = response.json() + last_payload = raw + status = grsai_status(raw) + if status == "succeeded": + return raw + if status in {"failed", "violation"}: + raise HTTPException(status_code=502, detail=f"Grsai 任务失败:{raw.get('error') or raw}") + try: + extract_image(raw) + return raw + except HTTPException: + pass + raise HTTPException(status_code=504, detail=f"Grsai 生图任务超时:{last_payload or task_id}") + +async def generate_grsai_nano_provider_image(prompt, size, model, reference_images=None, provider=None): + endpoint = grsai_endpoint_url(provider, "/v1/api/generate") + body = { + "model": model, + "prompt": prompt, + "images": [ + reference_to_data_url(ref, max_size=1536) + for ref in (reference_images or [])[:ONLINE_IMAGE_REFERENCE_MAX] + if ref.get("url") + ], + "aspectRatio": grsai_aspect_ratio(size), + "imageSize": grsai_image_size(size), + "replyType": "json", + } + async with httpx.AsyncClient(timeout=httpx.Timeout(connect=20.0, read=1800.0, write=120.0, pool=20.0)) as client: + response = await client.post(endpoint, headers=api_headers(provider=provider), json=body) + response.raise_for_status() + raw = response.json() + status = grsai_status(raw) + if status in {"failed", "violation"}: + raise HTTPException(status_code=502, detail=f"Grsai 生成失败:{raw.get('error') or raw}") + try: + return extract_image(raw), raw + except HTTPException: + task_id = extract_task_id(raw) + if not task_id: + raise + task_result = await wait_for_grsai_image_task(client, provider, task_id) + return extract_image(task_result), task_result + +VOLCENGINE_MIN_PIXELS = 3_686_400 +VOLCENGINE_MIN_EDGE = 1536 +VOLCENGINE_MAX_EDGE = 4096 +VOLCENGINE_RATIO_CHOICES = [ + (1, 1, "1:1"), + (4, 3, "4:3"), + (3, 4, "3:4"), + (16, 9, "16:9"), + (9, 16, "9:16"), + (21, 9, "21:9"), + (9, 21, "9:21"), + (3, 2, "3:2"), + (2, 3, "2:3"), + (5, 4, "5:4"), + (4, 5, "4:5"), +] + def is_volcengine_seedream_model(model): value = str(model or "").strip().lower() return "seedream" in value or "doubao-seedream" in value @@ -8625,14 +9203,27 @@ async def generate_runninghub_video(payload, provider): local_urls = [await save_remote_video_to_output(url, prefix="rh_video_") for url in urls] return {"videos": local_urls, "task_id": task_id, "raw": result} -async def generate_ai_image(prompt, size, quality, model, reference_images=None, provider_id="comfly"): +async def generate_ai_image( + prompt, + size, + quality, + model, + reference_images=None, + provider_id="comfly", + allow_edit_endpoint_fallback=True, + semantic_mask=False, +): provider = get_api_provider(provider_id) if provider["id"] == "modelscope": return await generate_modelscope_provider_image(prompt, size, model, reference_images, provider) + if is_codex_provider(provider): + return await generate_codex_provider_image(prompt, size, model, reference_images, provider) if is_jimeng_provider(provider): return await generate_jimeng_provider_image(prompt, size, model, reference_images, provider) if is_runninghub_provider(provider): return await generate_runninghub_provider_image(prompt, size, model, reference_images, provider) + if is_grsai_provider(provider) and is_grsai_nano_model(model): + return await generate_grsai_nano_provider_image(prompt, size, model, reference_images, provider) if effective_protocol(provider, model) == "gemini": return await generate_gemini_provider_image(prompt, size, model, reference_images, provider) if is_volcengine_provider(provider): @@ -8650,8 +9241,8 @@ async def generate_ai_image(prompt, size, quality, model, reference_images=None, gen_url = provider_endpoint_url(provider, "image_generation_endpoint", "/v1/images/generations") edit_url = provider_endpoint_url(provider, "image_edit_endpoint", "/v1/images/edits") refs = [ref for ref in (reference_images or []) if ref.get("url")] - mask_refs = [ref for ref in refs if str(ref.get("role") or "").strip().lower() == "mask" or str(ref.get("name") or "").lower().endswith("_mask.png")] - image_refs = [ref for ref in refs if ref not in mask_refs] + mask_refs = [] if semantic_mask else [ref for ref in refs if str(ref.get("role") or "").strip().lower() == "mask" or str(ref.get("name") or "").lower().endswith("_mask.png")] + image_refs = refs if semantic_mask else [ref for ref in refs if ref not in mask_refs] image_request_mode = effective_image_request_mode(provider, model) request_timeout = httpx.Timeout(connect=20.0, read=1800.0, write=120.0, pool=20.0) if (is_gpt2 or is_apimart or image_request_mode == "openai-json") else AI_REQUEST_TIMEOUT async with httpx.AsyncClient(timeout=request_timeout) as client: @@ -8734,10 +9325,12 @@ async def post_openai_edits(edit_files=None): fh.close() # 2) edits 失败 → 非 GPT-Image-2 可回退到 /images/generations + JSON image:[urls/base64](grsai 风格) if response is None: - if is_gpt2: + if is_gpt2 or not allow_edit_endpoint_fallback: + failure_status = edit_failed_status if isinstance(edit_failed_status, int) and edit_failed_status >= 400 else 502 + failure_prefix = "GPT-Image-2 编辑接口" if is_gpt2 else "图片编辑接口" raise HTTPException( - status_code=502, - detail=f"GPT-Image-2 编辑接口 /images/edits 调用失败:{edit_failed_text[:300] or edit_failed_status}。已停止自动重试,避免上游可能已扣费后再次请求。" + status_code=failure_status, + detail=f"{failure_prefix} /images/edits 调用失败:{edit_failed_text[:300] or edit_failed_status}。已停止自动补发,避免上游状态未知时重复扣费。" ) print(f"/images/edits failed ({edit_failed_status}): {edit_failed_text[:200]} → 回退到 /images/generations + image:[] JSON") image_payload = [reference_to_data_url(ref, max_size=1536) for ref in image_refs[:ONLINE_IMAGE_REFERENCE_MAX]] @@ -8969,8 +9562,21 @@ async def decide_chat_agent_action(payload, conversation, refs): return fallback async def build_chat_text_reply(payload, conversation): - chat_base, chat_hdrs, model = resolve_chat_provider(payload.provider, payload.model, payload.ms_model) provider_cfg = get_api_provider(payload.provider) if payload.provider not in ("modelscope",) else {} + if is_codex_provider(provider_cfg): + model = selected_model(payload.model, (provider_cfg.get("chat_models") or CODEX_DEFAULT_CHAT_MODELS)[0]) + payload.model = model + text, raw = await codex_chat_text(payload, conversation["messages"][-MAX_HISTORY_MESSAGES:]) + return { + "id": uuid.uuid4().hex, + "role": "assistant", + "content": text, + "created_at": now_ms(), + "model": model, + "raw_usage": None, + "raw": raw, + } + chat_base, chat_hdrs, model = resolve_chat_provider(payload.provider, payload.model, payload.ms_model) is_apimart = is_apimart_provider(provider_cfg) upstream_messages = [{"role": "system", "content": chat_system_prompt(payload)}] for item in conversation["messages"][-MAX_HISTORY_MESSAGES:]: @@ -9009,26 +9615,14 @@ async def index(): @app.get("/api/view") def view_image(filename: str, type: str = "input", subfolder: str = ""): - # 先按原逻辑去各 ComfyUI 后端找 - for addr in COMFYUI_INSTANCES: - try: - url = f"http://{addr}/view" - params = {"filename": filename, "type": type, "subfolder": subfolder} - r = requests.get(url, params=params, timeout=1) - if r.status_code == 200: - return Response(content=r.content, media_type=r.headers.get('Content-Type')) - except Exception: - continue - # 后端都拿不到时回退本地 assets// - # 适用场景:画布通过 /api/ai/upload 把参考图直接落到本地 assets/input/, - # 但 ComfyUI 的 input 可能因为重启/清理而丢失,导致 enhance/klein 等页面预览对比图 404 + # 兼容旧素材链接,仅从 Canvas 自身的输入/输出目录读取。 if not subfolder and type in ("input", "output"): safe_name = os.path.basename(filename or "") if safe_name: local_path = output_path_for(safe_name, "input" if type == "input" else "output") if os.path.isfile(local_path): return FileResponse(local_path, media_type=content_type_for_path(local_path)) - raise HTTPException(status_code=404, detail="Image not found on any available backend") + raise HTTPException(status_code=404, detail="Image not found") @app.get("/api/download-output") def download_output(request: Request, url: str, name: str = "", inline: bool = False): @@ -9043,7 +9637,7 @@ def download_output(request: Request, url: str, name: str = "", inline: bool = F if parsed.scheme not in ("http", "https") or not parsed.netloc: raise HTTPException(status_code=400, detail="无效的下载地址") try: - upstream_headers = {"User-Agent": "ComfyUI-API-Modelscope/1.0"} + upstream_headers = {"User-Agent": "Canvas/1.0"} range_header = request.headers.get("range") if range_header: upstream_headers["Range"] = range_header @@ -9077,33 +9671,6 @@ def stream_remote(): return StreamingResponse(stream_remote(), media_type=content_type, headers=headers, status_code=upstream.status_code) -@app.post("/api/upload") -async def upload_image(files: List[UploadFile] = File(...)): - uploaded_files = [] - files_content = [] - for file in files: - content = await file.read() - files_content.append((file, content)) - - for file, content in files_content: - success_count = 0 - last_result = None - for addr in COMFYUI_INSTANCES: - try: - files_data = {'image': (file.filename, content, file.content_type)} - response = requests.post(f"http://{addr}/upload/image", files=files_data, timeout=5) - if response.status_code == 200: - last_result = response.json() - success_count += 1 - except Exception as e: - print(f"Upload error for {addr}: {e}") - - if success_count > 0 and last_result: - uploaded_files.append({"comfy_name": last_result.get("name", file.filename)}) - else: - raise HTTPException(status_code=500, detail="Failed to upload to any backend") - - return {"files": uploaded_files} @app.post("/api/ai/upload") async def upload_ai_reference(files: List[UploadFile] = File(...)): @@ -9181,35 +9748,6 @@ async def upload_ai_base64(payload: Base64UploadRequest): f.write(content) return {"files": [{"url": output_url_for(filename, "input"), "name": payload.name or filename, "kind": kind}]} -@app.post("/api/comfyui/upload-base64") -async def upload_comfyui_base64(payload: Base64UploadRequest): - """base64 方式把图片传到 ComfyUI 各后端的 input 目录,返回 comfy 用文件名(供 UXP 做 ComfyUI 图生图)。""" - raw = (payload.data or "").strip() - ct = (payload.content_type or "").split(";", 1)[0].strip().lower() - if raw.startswith("data:"): - header, _, raw = raw.partition(",") - if not ct: - ct = header[5:].split(";", 1)[0].strip().lower() - try: - content = base64.b64decode(raw, validate=False) - except Exception: - raise HTTPException(status_code=400, detail="数据无法解码") - if not content: - raise HTTPException(status_code=400, detail="内容为空") - _, ext = _local_upload_kind_ext(payload.name or "", ct or "image/png") - filename = f"dx_{uuid.uuid4().hex[:12]}{ext or '.png'}" - comfy_name = None - for addr in COMFYUI_INSTANCES: - try: - resp = requests.post(f"http://{addr}/upload/image", - files={'image': (filename, content, ct or 'image/png')}, timeout=10) - if resp.status_code == 200: - comfy_name = resp.json().get("name", filename) - except Exception as exc: - print(f"ComfyUI base64 upload error for {addr}: {exc}") - if not comfy_name: - raise HTTPException(status_code=502, detail="上传到 ComfyUI 失败") - return {"name": comfy_name} def _local_upload_kind_ext(filename, content_type): image_exts = {".png", ".jpg", ".jpeg", ".webp", ".gif"} @@ -10155,6 +10693,67 @@ async def runninghub_upload_asset(payload: RunningHubUploadAssetRequest): return {"success": True, "data": {"fileName": raw["data"]["fileName"], "fileType": raw["data"].get("fileType") or content_type}} raise HTTPException(status_code=400, detail=(raw.get("msg") if isinstance(raw, dict) else "") or f"RunningHub 上传失败:{raw}") +@app.get("/api/codex/status") +async def codex_status(): + exe = codex_cli_executable() + if not exe: + return { + "installed": False, + "logged_in": False, + "message": "未找到 OpenAI Codex CLI,请先安装。", + } + try: + proc = await asyncio.create_subprocess_exec( + exe, + "--version", + cwd=BASE_DIR, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10) + out_text, err_text = codex_decode_output(stdout, stderr) + ok = proc.returncode == 0 + return { + "installed": ok, + "logged_in": None, + "version": out_text or err_text, + "path": exe, + "message": "OpenAI Codex CLI 已安装。登录状态会在首次执行 codex exec 时由 CLI 校验。" if ok else (err_text or out_text or "Codex CLI 检测失败"), + "raw": {"stdout": out_text, "stderr": err_text, "returncode": proc.returncode}, + } + except Exception as exc: + return { + "installed": False, + "logged_in": False, + "path": exe, + "message": f"Codex CLI 检测失败:{exc}", + } + +@app.post("/api/codex/help") +async def codex_help(payload: CodexHelpRequest): + exe = codex_cli_executable() + if not exe: + raise HTTPException(status_code=400, detail="未找到 OpenAI Codex CLI。") + allowed = {"", "exec", "login", "logout", "doctor", "mcp", "app", "update"} + command = str(payload.command or "").strip() + if command not in allowed: + raise HTTPException(status_code=400, detail="不允许的 Codex CLI 命令") + args = [exe] + if command: + args.append(command) + args.append("--help") + proc = await asyncio.create_subprocess_exec( + *args, + cwd=BASE_DIR, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=20) + out_text, err_text = codex_decode_output(stdout, stderr) + if proc.returncode != 0: + raise HTTPException(status_code=502, detail=(err_text or out_text or f"exit={proc.returncode}")[:1000]) + return {"text": out_text or err_text, "raw": {"stdout": out_text, "stderr": err_text}} + @app.get("/api/jimeng/status") async def jimeng_status(): exe = jimeng_cli_executable() @@ -10307,7 +10906,6 @@ async def ai_config(): "chat_models": CHAT_MODELS, "image_models": IMAGE_MODELS, "video_models": VIDEO_MODELS, - "comfy_instances": COMFYUI_INSTANCES, "api_providers": providers, "has_api_key": bool(AI_API_KEY), "ms_chat_models": MODELSCOPE_CHAT_MODELS, @@ -10386,18 +10984,7 @@ async def save_providers(payload: List[ApiProviderPayload]): @app.get("/api/config/token") async def get_global_token(): - # 优先读 env,回退到 global_config.json(兼容旧数据) - saved_token = modelscope_api_key() - if saved_token: - return {"token": saved_token} - if os.path.exists(GLOBAL_CONFIG_FILE): - try: - with open(GLOBAL_CONFIG_FILE, 'r', encoding='utf-8') as f: - config = json.load(f) - return {"token": config.get("modelscope_token", "")} - except: - pass - return {"token": ""} + return {"configured": bool(modelscope_api_key())} # --- 在线生图 (COMFLY) --- @@ -10416,6 +11003,8 @@ def protocol_from_payload(payload): return "runninghub" if provider_id == "jimeng": return "jimeng" + if provider_id == "codex": + return "codex" base_url = str(getattr(payload, "base_url", "") or "").strip().lower() if "runninghub.cn" in base_url or "runninghub.ai" in base_url: return "runninghub" @@ -10631,6 +11220,15 @@ def apply_agnes_model_defaults(base_url, grouped, ids): async def test_provider_connection(payload: TestConnectionPayload): """测试请求地址是否可用:调上游 /v1/models。验证通过时同时把模型清单按类别返回,避免再调一次拉取接口。""" protocol = protocol_from_payload(payload) + if protocol == "codex": + status = await codex_status() + payload_models = codex_models_payload(raw={"status": status}) + payload_models.update({ + "ok": bool(status.get("installed")), + "status": 200 if status.get("installed") else 0, + "message": status.get("message") or ("OpenAI Codex CLI 可用" if status.get("installed") else "未找到 OpenAI Codex CLI"), + }) + return payload_models if protocol == "jimeng": status = await jimeng_status() return { @@ -10726,9 +11324,18 @@ async def probe_async_endpoint(payload: TestConnectionPayload): """验证异步协议:用假 task_id 请求 GET /v1/tasks/{fake_id}。 收到 400 Invalid task ID = 端点存在且 Key 有效;401/403 = Key 无效;404/连接失败 = 不支持异步端点。""" base_url = (payload.base_url or "").strip().rstrip("/") + protocol = protocol_from_payload(payload) + if protocol == "codex": + status = await codex_status() + return { + "ok": bool(status.get("installed")), + "protocol": "codex", + "status_code": 200 if status.get("installed") else 0, + "message": status.get("message") or "OpenAI Codex CLI 本机检测完成", + "raw": status, + } if not base_url: raise HTTPException(status_code=400, detail="请先填写请求地址") - protocol = protocol_from_payload(payload) api_key = api_key_from_payload(payload, protocol) if not api_key: raise HTTPException(status_code=400, detail="请先填写或保存 API Key") @@ -10852,6 +11459,11 @@ async def probe_async_endpoint(payload: TestConnectionPayload): async def fetch_models_from_upstream(base_url: str, api_key: str, protocol: str = "openai", image_request_mode: str = "openai"): """从上游模型列表端点拉取模型,并按名称做轻量分类。""" protocol = protocol if protocol in SUPPORTED_PROVIDER_PROTOCOLS else "openai" + if protocol == "codex": + status = await codex_status() + payload = codex_models_payload(raw={"status": status}) + payload["message"] = status.get("message") or payload["message"] + return payload if protocol == "jimeng": return { "total": len(JIMENG_DEFAULT_IMAGE_MODELS) + len(JIMENG_DEFAULT_VIDEO_MODELS), @@ -10979,6 +11591,8 @@ async def fetch_upstream_models_from_payload(payload: TestConnectionPayload): async def fetch_upstream_models(provider_id: str): """从已保存的上游 OpenAI 兼容接口拉取 /v1/models 列表,按名称智能分类为 image/chat/video。""" provider = get_api_provider_exact(provider_id) + if is_codex_provider(provider): + return await fetch_models_from_upstream("", "", "codex", provider.get("image_request_mode") or "openai") api_key = os.getenv(runninghub_wallet_key_env(), "") if provider["id"] == "runninghub" else "" if not api_key: api_key = os.getenv(provider_key_env(provider["id"]), "") @@ -10986,15 +11600,37 @@ async def fetch_upstream_models(provider_id: str): raise HTTPException(status_code=400, detail=f"{provider.get('name') or provider_id} 未配置 API Key") return await fetch_models_from_upstream(provider.get("base_url") or "", api_key, provider_protocol(provider), provider.get("image_request_mode") or "openai") -async def build_online_image_result(payload: OnlineImageRequest): - provider = get_api_provider(payload.provider_id) +async def execute_ai_image_batch( + prompt: str, + provider_id: str, + model: str, + size: str, + quality: str, + references: List[Dict[str, Any]], + count: int, + prefix: str, + allow_edit_endpoint_fallback: bool = True, + semantic_mask: bool = False, +) -> Dict[str, Any]: + provider = get_api_provider(provider_id) default_model = (provider.get("image_models") or [IMAGE_MODEL])[0] - model = selected_model(payload.model, default_model) - refs = [ref.dict() for ref in payload.reference_images if ref.url] + resolved_model = selected_model(model, default_model) + refs = [dict(ref) for ref in references or [] if isinstance(ref, dict) and ref.get("url")] image_refs = image_references(refs) - count = max(1, min(8, int(payload.n or 1))) + safe_count = max(1, min(8, int(count or 1))) + generation_started_at = time.time() + async def generate_one(): - image_data, raw_item = await generate_ai_image(payload.prompt, payload.size, payload.quality, model, image_refs, provider["id"]) + image_data, raw_item = await generate_ai_image( + prompt, + size, + quality, + resolved_model, + image_refs, + provider["id"], + allow_edit_endpoint_fallback=allow_edit_endpoint_fallback, + semantic_mask=semantic_mask, + ) try: image_items = extract_images(raw_item) if isinstance(raw_item, dict) else [image_data] except HTTPException: @@ -11002,21 +11638,22 @@ async def generate_one(): local_urls = [] local_items = [] for item in image_items: - local_url = await save_ai_image_to_output(item, prefix="online_") + local_url = await save_ai_image_to_output(item, prefix=prefix) if local_url: local_urls.append(local_url) local_items.append(image_output_meta(local_url, item)) return local_urls, local_items, raw_item + try: - generated = await asyncio.gather(*(generate_one() for _ in range(count))) + generated = await asyncio.gather(*(generate_one() for _ in range(safe_count))) except httpx.HTTPStatusError as exc: - log_net_error(f"生图 HTTP状态错误 provider={provider.get('id')} model={model} size={payload.size}", exc) + log_net_error(f"生图 HTTP状态错误 provider={provider.get('id')} model={resolved_model} size={size}", exc) text = exc.response.text or '' - friendly = friendly_image_error_detail(text, payload.size, model) + friendly = friendly_image_error_detail(text, size, resolved_model) detail = friendly or f"上游生图接口错误:{text[:300]}" raise HTTPException(status_code=exc.response.status_code, detail=detail) from exc except httpx.HTTPError as exc: - log_net_error(f"生图 网络/TLS错误 provider={provider.get('id')} model={model}", exc) + log_net_error(f"生图 网络/TLS错误 provider={provider.get('id')} model={resolved_model}", exc) raise HTTPException(status_code=502, detail=f"请求上游生图接口失败:{exc}") from exc local_urls = [url for urls, _items, _raw in generated for url in (urls or []) if url] @@ -11026,18 +11663,55 @@ async def generate_one(): provider_name = provider.get("name") or provider["id"] raw_text = json.dumps(raw, ensure_ascii=False)[:800] if isinstance(raw, (dict, list)) else str(raw)[:800] raise HTTPException(status_code=502, detail=f"{provider_name} 没有返回图片:{raw_text}") - result = { - "prompt": payload.prompt, + generation_completed_at = time.time() + generation_elapsed_seconds = round(max(0, generation_completed_at - generation_started_at), 3) + return { + "provider": provider, + "model": resolved_model, + "count": safe_count, + "references": refs, "images": local_urls, "image_items": local_items, - "timestamp": time.time(), + "raw": raw, + "generation_started_at": generation_started_at, + "generation_completed_at": generation_completed_at, + "generation_elapsed_seconds": generation_elapsed_seconds, + } + +async def build_online_image_result(payload: OnlineImageRequest): + batch = await execute_ai_image_batch( + prompt=payload.prompt, + provider_id=payload.provider_id, + model=payload.model, + size=payload.size, + quality=payload.quality, + references=[ref.dict() for ref in payload.reference_images if ref.url], + count=payload.n, + prefix="online_", + ) + provider = batch["provider"] + model = batch["model"] + refs = batch["references"] + count = batch["count"] + raw = batch["raw"] + generation_started_at = batch["generation_started_at"] + generation_completed_at = batch["generation_completed_at"] + generation_elapsed_seconds = batch["generation_elapsed_seconds"] + result = { + "prompt": payload.prompt, + "images": batch["images"], + "image_items": batch["image_items"], + "timestamp": generation_completed_at, "type": "online", "model": model, "provider_id": provider["id"], "provider_name": provider.get("name") or provider["id"], "task_id": extract_task_id(raw) if isinstance(raw, dict) else None, "request_id": raw.get("id") if isinstance(raw, dict) else None, - "params": {"provider_id": provider["id"], "model": model, "size": payload.size, "quality": payload.quality, "n": count, "reference_images": refs}, + "generation_started_at": generation_started_at, + "generation_completed_at": generation_completed_at, + "generation_elapsed_seconds": generation_elapsed_seconds, + "params": {"provider_id": provider["id"], "model": model, "size": payload.size, "quality": payload.quality, "n": count, "reference_images": refs, "generation_elapsed_seconds": generation_elapsed_seconds}, "raw_usage": raw.get("usage") if isinstance(raw, dict) else None, } save_to_history(result) @@ -11049,6 +11723,796 @@ async def generate_one(): async def online_image(payload: OnlineImageRequest): return await build_online_image_result(payload) +ONLINE_IMAGE_ACTIVE_STATUSES = {"queued", "running"} +ONLINE_IMAGE_TASK_RESTART_ERROR = "服务已重启,本地未完成任务已中断" +ONLINE_IMAGE_TASK_RECOVERY_MESSAGE = "服务已重启,已保留云端任务 ID,可继续查询" + +def online_image_request_snapshot(payload: OnlineImageRequest) -> Dict[str, Any]: + refs = [ref.dict() for ref in payload.reference_images if ref.url] + return { + "prompt": payload.prompt, + "provider_id": payload.provider_id, + "model": payload.model, + "size": payload.size, + "quality": payload.quality, + "n": max(1, min(8, int(payload.n or 1))), + "reference_images": refs, + } + +def write_online_image_tasks_locked(): + tasks = sorted( + ONLINE_IMAGE_TASKS.values(), + key=lambda item: float(item.get("created_at") or 0), + reverse=True, + )[:500] + DATABASE.save_tasks("online_image", tasks) + publish_entity_changed("task", "online_image") + +def load_online_image_tasks_from_disk(): + items = DATABASE.load_tasks("online_image") + if not isinstance(items, list): + return + now = time.time() + changed = False + restored = {} + for item in items: + if not isinstance(item, dict): + continue + task_id = str(item.get("id") or item.get("task_id") or "").strip() + if not task_id: + continue + task = dict(item) + task["id"] = task_id + task["task_id"] = task_id + if task.get("status") in ONLINE_IMAGE_ACTIVE_STATUSES: + recoverable_id = next((str(task.get(key) or "").strip() for key in ( + "upstream_task_id", "submit_id", "taskId", "video_id", "asset_id" + ) if str(task.get(key) or "").strip()), "") + if recoverable_id: + task["status"] = "recovery_pending" + task["recovery_task_id"] = recoverable_id + task["message"] = ONLINE_IMAGE_TASK_RECOVERY_MESSAGE + task["error"] = "" + else: + task["status"] = "interrupted" + task["error"] = ONLINE_IMAGE_TASK_RESTART_ERROR + task["updated_at"] = now + changed = True + restored[task_id] = task + with ONLINE_IMAGE_TASK_LOCK: + ONLINE_IMAGE_TASKS.clear() + ONLINE_IMAGE_TASKS.update(restored) + if changed: + write_online_image_tasks_locked() + +def update_online_image_task(task_id: str, changes: Dict[str, Any]): + with ONLINE_IMAGE_TASK_LOCK: + task = ONLINE_IMAGE_TASKS.get(task_id) + if not task: + return + task.update(changes) + task["updated_at"] = time.time() + write_online_image_tasks_locked() + +def public_online_image_task(task: Dict[str, Any]) -> Dict[str, Any]: + data = dict(task or {}) + task_id = str(data.get("id") or data.get("task_id") or "") + data["id"] = task_id + data["task_id"] = task_id + return data + +async def run_online_image_task(task_id: str, payload: OnlineImageRequest): + update_online_image_task(task_id, {"status": "running", "error": ""}) + try: + result = await build_online_image_result(payload) + update_online_image_task(task_id, { + "status": "succeeded", + "result": result, + "error": "", + "provider_id": result.get("provider_id") or payload.provider_id, + "model": result.get("model") or payload.model, + }) + except JimengPendingError as exc: + info = jimeng_pending_payload(exc) + update_online_image_task(task_id, { + "status": "jimeng_pending", + "jimeng_pending": True, + "submit_id": exc.submit_id, + "kind": exc.kind, + "queue_info": exc.queue_info, + "message": info["message"], + "error": "", + }) + except Exception as exc: + detail = getattr(exc, "detail", None) or str(exc) + status_code = getattr(exc, "status_code", 500) + update_online_image_task(task_id, { + "status": "failed", + "error": str(detail), + "status_code": status_code, + "upstream_task_id": getattr(exc, "upstream_task_id", "") or extract_task_id_from_text(str(detail)), + }) + +@app.post("/api/online-image-tasks") +async def create_online_image_task(payload: OnlineImageRequest): + task_id = f"online_img_{uuid.uuid4().hex}" + snapshot = online_image_request_snapshot(payload) + now = time.time() + task = { + "id": task_id, + "task_id": task_id, + "type": "online-image", + "status": "queued", + "created_at": now, + "updated_at": now, + "result": None, + "error": "", + "queue_info": {}, + "submit_id": "", + "message": "", + **snapshot, + "request": snapshot, + } + with ONLINE_IMAGE_TASK_LOCK: + ONLINE_IMAGE_TASKS[task_id] = task + write_online_image_tasks_locked() + asyncio.create_task(run_online_image_task(task_id, payload)) + return public_online_image_task(task) + +@app.get("/api/online-image-tasks") +async def list_online_image_tasks(limit: int = 50): + safe_limit = max(1, min(200, int(limit or 50))) + with ONLINE_IMAGE_TASK_LOCK: + tasks = sorted( + (public_online_image_task(task) for task in ONLINE_IMAGE_TASKS.values()), + key=lambda item: float(item.get("created_at") or 0), + reverse=True, + )[:safe_limit] + return {"tasks": tasks} + +@app.get("/api/online-image-tasks/{task_id}") +async def get_online_image_task(task_id: str): + with ONLINE_IMAGE_TASK_LOCK: + task = public_online_image_task(ONLINE_IMAGE_TASKS.get(task_id) or {}) + if not task.get("id"): + raise HTTPException(status_code=404, detail="在线生图任务不存在,可能服务已重启或任务已过期") + return task + +ECOMMERCE_ACTIVE_STATUSES = {"queued", "running"} +ECOMMERCE_TASK_RESTART_ERROR = "服务已重启,未完成的电商任务已中断;为避免重复扣费,系统不会自动补发" +ECOMMERCE_INPUT_EXTS = {".png", ".jpg", ".jpeg", ".webp"} +ECOMMERCE_INPUT_MAX_BYTES = 50 * 1024 * 1024 +ECOMMERCE_INPUT_FORMAT_MIMES = { + "PNG": "image/png", + "JPEG": "image/jpeg", + "MPO": "image/jpeg", + "WEBP": "image/webp", +} +ECOMMERCE_GARMENT_ANALYSIS_PROMPT = """请只分析图片中的服装产品,输出严格 JSON,不要 Markdown、不要解释: +{"category":"upper|lower|dress","garment_type":"具体服装名称","confidence":0.0,"reason":"简短判断依据"} +category 只能是 upper(上装)、lower(下装)或 dress(连衣裙/连体衣)。如果图片中有模特,仍只判断需要试穿的主要服装。""" +ECOMMERCE_UNIVERSAL_REFERENCE_ANALYSIS_PROMPT = """请分析这张电商全能模式参考图。参考角色:{role}。用户标签:{label}。用户单图要求:{instruction}。 +输出严格 JSON,不要 Markdown、不要解释: +{{"item_name":"具体主体/商品/场景名称","category":"服装/鞋/项链/包/手机/动作/场景等","interaction":"wear|put_on|hold|carry|place|use|pose|scene|style|identity","placement":"建议佩戴、手持、背挎、放置或场景位置","visual_details":"需要保留的颜色、材质、结构、Logo、文字和关键特征","confidence":0.0,"reason":"简短依据"}} +interaction 选择规则:衣服/首饰/帽子/眼镜/腰带通常 wear;鞋子 put_on;手机、杯子、相机等小物 hold;手提包/托特包/背包 carry;家具/摆件 place;动作图 pose;场景图 scene;风格图 style;主体图 identity。""" + +def public_ecommerce_route(route: Dict[str, Any]) -> Dict[str, Any]: + return { + key: route.get(key) + for key in ("provider_id", "provider_name", "model", "supports_multi_reference", "supports_mask", "max_reference_images") + if key in route + } + +def public_ecommerce_task(task: Dict[str, Any]) -> Dict[str, Any]: + data = dict(task or {}) + task_id = str(data.get("id") or data.get("task_id") or "") + data["id"] = task_id + data["task_id"] = task_id + return data + +def write_ecommerce_task_locked(task: Dict[str, Any]): + if hasattr(DATABASE, "upsert_task"): + DATABASE.upsert_task("ecommerce", task) + else: + DATABASE.save_tasks("ecommerce", ECOMMERCE_TASKS.values()) + publish_entity_changed("task", "ecommerce") + +def load_ecommerce_tasks_from_disk(): + items = DATABASE.load_tasks("ecommerce") + if not isinstance(items, list): + return + now = time.time() + changed = False + restored = {} + for item in items: + if not isinstance(item, dict): + continue + task_id = str(item.get("id") or item.get("task_id") or "").strip() + if not task_id: + continue + task = dict(item) + task["id"] = task_id + task["task_id"] = task_id + if task.get("status") in ECOMMERCE_ACTIVE_STATUSES: + task.update({ + "status": "interrupted", + "error": ECOMMERCE_TASK_RESTART_ERROR, + "updated_at": now, + }) + changed = True + restored[task_id] = task + with ECOMMERCE_TASK_LOCK: + ECOMMERCE_TASKS.clear() + ECOMMERCE_TASKS.update(restored) + if changed: + if hasattr(DATABASE, "upsert_task"): + for task in restored.values(): + DATABASE.upsert_task("ecommerce", task) + else: + DATABASE.save_tasks("ecommerce", restored.values()) + publish_entity_changed("task", "ecommerce") + +def update_ecommerce_task(task_id: str, changes: Dict[str, Any]): + with ECOMMERCE_TASK_LOCK: + task = ECOMMERCE_TASKS.get(task_id) + if not task: + return + task.update(changes) + task["updated_at"] = time.time() + write_ecommerce_task_locked(task) + +def configured_ecommerce_vision_route(providers: Optional[List[Dict[str, Any]]] = None) -> Optional[Dict[str, str]]: + candidates = [] + preferred_provider_id = str(os.getenv("ECOMMERCE_VISION_PROVIDER_ID", "local-vision") or "").strip().lower() + for provider_index, provider in enumerate(providers if providers is not None else load_api_providers()): + if not isinstance(provider, dict) or not provider.get("enabled", True): + continue + provider_id = str(provider.get("id") or "").strip().lower() + if not provider_id or not provider_env_key_value(provider_id): + continue + provider_name = str(provider.get("name") or provider_id) + for model_index, model in enumerate(provider.get("chat_models") or []): + model_name = str(model or "").strip() + if not looks_like_vision_chat_model(model_name): + continue + name_hint = f"{provider_id} {provider_name}".lower() + candidates.append(( + 0 if provider_id == preferred_provider_id else 1, + 0 if any(hint in name_hint for hint in ("vision", "视觉", "vlm")) else 1, + provider_index, + model_index, + {"provider_id": provider_id, "provider_name": provider_name, "model": model_name}, + )) + candidates.sort(key=lambda item: item[:4]) + return candidates[0][4] if candidates else None + +async def analyze_ecommerce_garment(inputs: List[Dict[str, Any]]) -> Dict[str, Any]: + garment = next((item for item in inputs if str(item.get("role") or "").lower() == "garment"), None) + path = output_file_from_url((garment or {}).get("url") or "") + route = configured_ecommerce_vision_route() + if not path or not route: + return {"status": "skipped", "category": "auto", "garment_type": "", "confidence": 0.0, "reason": ""} + try: + text, resolved_model = await caption_image_with_provider( + path, + ECOMMERCE_GARMENT_ANALYSIS_PROMPT, + route["provider_id"], + route["model"], + ) + analysis = parse_ecommerce_garment_analysis(text) + analysis.update({ + "status": "succeeded" if analysis.get("category") != "auto" else "unrecognized", + "provider_id": route["provider_id"], + "provider_name": route["provider_name"], + "model": resolved_model, + }) + return analysis + except Exception: + return {"status": "failed", "category": "auto", "garment_type": "", "confidence": 0.0, "reason": ""} + +def ecommerce_universal_analysis_prompt(item: Dict[str, Any]) -> str: + role = str(item.get("reference_type") or item.get("role") or "").strip() + label = str(item.get("label") or item.get("name") or "").strip() + instruction = str(item.get("instruction") or "").strip() + return ECOMMERCE_UNIVERSAL_REFERENCE_ANALYSIS_PROMPT.format( + role=role or "unknown", + label=label or "无", + instruction=instruction or "无", + ) + +def ecommerce_vision_cache_key(path: str, prompt: str, route: Dict[str, str]) -> str: + stat = os.stat(path) + identity = "|".join(( + os.path.abspath(path), str(stat.st_mtime_ns), str(stat.st_size), + str(route.get("provider_id") or ""), str(route.get("model") or ""), prompt, + )) + return hashlib.sha256(identity.encode("utf-8", "ignore")).hexdigest() + +def get_cached_ecommerce_vision_analysis(cache_key: str) -> Optional[Dict[str, Any]]: + with ECOMMERCE_VISION_CACHE_LOCK: + cached = ECOMMERCE_VISION_CACHE.pop(cache_key, None) + if cached is None: + return None + ECOMMERCE_VISION_CACHE[cache_key] = cached + return dict(cached) + +def cache_ecommerce_vision_analysis(cache_key: str, analysis: Dict[str, Any]): + with ECOMMERCE_VISION_CACHE_LOCK: + ECOMMERCE_VISION_CACHE.pop(cache_key, None) + ECOMMERCE_VISION_CACHE[cache_key] = dict(analysis) + while len(ECOMMERCE_VISION_CACHE) > ECOMMERCE_VISION_CACHE_LIMIT: + ECOMMERCE_VISION_CACHE.pop(next(iter(ECOMMERCE_VISION_CACHE))) + +async def analyze_ecommerce_universal_reference(index: int, item: Dict[str, Any], route: Dict[str, str]) -> Tuple[str, Dict[str, Any]]: + reference_id = str(item.get("reference_id") or f"reference_{index + 1}") + path = output_file_from_url(item.get("url") or "") + if not path: + return reference_id, {"status": "skipped", "reason": "参考图不是本机素材"} + prompt = ecommerce_universal_analysis_prompt(item) + try: + cache_key = ecommerce_vision_cache_key(path, prompt, route) + except OSError: + return reference_id, {"status": "skipped", "reason": "参考图文件不存在"} + cached = get_cached_ecommerce_vision_analysis(cache_key) + if cached is not None: + cached["cached"] = True + return reference_id, cached + try: + async with ECOMMERCE_VISION_SEMAPHORE: + cached = get_cached_ecommerce_vision_analysis(cache_key) + if cached is not None: + cached["cached"] = True + return reference_id, cached + text, resolved_model = await caption_image_with_provider( + path, + prompt, + route["provider_id"], + route["model"], + ) + analysis = parse_ecommerce_universal_reference_analysis(text) + analysis.update({ + "status": "succeeded" if analysis.get("item_name") or analysis.get("category") else "unrecognized", + "provider_id": route["provider_id"], + "provider_name": route["provider_name"], + "model": resolved_model, + }) + cache_ecommerce_vision_analysis(cache_key, analysis) + return reference_id, analysis + except Exception as exc: + return reference_id, {"status": "failed", "error": str(exc)[:240]} + +async def analyze_ecommerce_universal_references(inputs: List[Dict[str, Any]]) -> Dict[str, Any]: + route = configured_ecommerce_vision_route() + if not route: + return {"status": "skipped", "reason": "未配置可用视觉模型", "items": {}} + analyzed = await asyncio.gather(*( + analyze_ecommerce_universal_reference(index, item, route) + for index, item in enumerate(inputs) + )) + items = dict(analyzed) + succeeded = sum(1 for analysis in items.values() if analysis.get("status") == "succeeded") + return { + "status": "succeeded" if succeeded else "failed", + "provider_id": route["provider_id"], + "provider_name": route["provider_name"], + "model": route["model"], + "succeeded": succeeded, + "total": len(inputs), + "items": items, + } + +async def enrich_ecommerce_snapshot_with_garment_analysis(snapshot: Dict[str, Any]) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]: + working = { + **snapshot, + "inputs": [dict(item) for item in (snapshot.get("inputs") or [])], + "options": dict(snapshot.get("options") or {}), + } + if working.get("operation") != "try_on" or str(working["options"].get("garment_category") or "auto") != "auto": + return working, None + analysis = await analyze_ecommerce_garment(working["inputs"]) + if analysis.get("category") in {"upper", "lower", "dress"}: + working["options"]["garment_category"] = analysis["category"] + working["options"]["garment_type"] = analysis.get("garment_type") or "" + working["prompt"] = build_ecommerce_prompt(working["operation"], working["inputs"], working["options"]) + return working, analysis + +async def enrich_ecommerce_snapshot_with_universal_analysis(snapshot: Dict[str, Any]) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]: + working = { + **snapshot, + "inputs": [dict(item) for item in (snapshot.get("inputs") or [])], + "options": dict(snapshot.get("options") or {}), + } + if working.get("operation") != "universal": + return working, None + analysis = await analyze_ecommerce_universal_references(working["inputs"]) + items = analysis.get("items") if isinstance(analysis, dict) else {} + if isinstance(items, dict) and any((value or {}).get("status") == "succeeded" for value in items.values() if isinstance(value, dict)): + working["options"]["reference_analysis"] = items + working["prompt"] = build_ecommerce_prompt(working["operation"], working["inputs"], working["options"]) + return working, analysis + +def validate_ecommerce_local_inputs(inputs: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], Tuple[int, int]]: + checked = [] + dimensions = {} + for item in inputs: + role = str(item.get("role") or "").strip().lower() + reference_type = str(item.get("reference_type") or role).strip().lower() + url = str(item.get("url") or "").strip() + path = output_file_from_url(url) + if not path: + raise HTTPException(status_code=400, detail=f"{role} 输入必须是已上传到本机素材目录的图片") + ext = os.path.splitext(path)[1].lower() + if ext not in ECOMMERCE_INPUT_EXTS: + raise HTTPException(status_code=400, detail=f"{role} 仅支持 PNG、JPEG 或 WebP") + try: + size_bytes = os.path.getsize(path) + if size_bytes > ECOMMERCE_INPUT_MAX_BYTES: + raise HTTPException(status_code=400, detail=f"{role} 文件不能超过 50MB") + with Image.open(path) as image: + width, height = image.size + image_format = str(image.format or "").upper() + if image_format not in ECOMMERCE_INPUT_FORMAT_MIMES or width < 1 or height < 1: + raise ValueError("unsupported image") + image.verify() + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=400, detail=f"{role} 不是有效的 PNG、JPEG 或 WebP 图片") from exc + dimension_key = str(item.get("reference_id") or role) + dimensions[dimension_key] = (width, height) + if reference_type in {"source", "subject"} and "source" not in dimensions: + dimensions["source"] = (width, height) + normalized = { + "role": role, + "url": url, + "name": str(item.get("name") or os.path.basename(path))[:240], + "kind": "image", + "mime": ECOMMERCE_INPUT_FORMAT_MIMES[image_format], + } + for key in ("reference_id", "reference_type", "label", "instruction"): + if item.get(key): + normalized[key] = item.get(key) + checked.append(normalized) + source_dimensions = dimensions.get("source") + if not source_dimensions: + raise HTTPException(status_code=400, detail="缺少源图尺寸信息") + if dimensions.get("mask") and dimensions["mask"] != source_dimensions: + raise HTTPException(status_code=400, detail="蒙版尺寸必须与原图完全一致") + return checked, source_dimensions + +def prepare_ecommerce_request(payload: EcommerceTaskRequest) -> Dict[str, Any]: + try: + operation = validate_ecommerce_operation(payload.operation) + mode = validate_ecommerce_mode(payload.mode) + options_json = json.dumps(payload.options or {}, ensure_ascii=False) + if len(options_json.encode("utf-8")) > 20 * 1024: + raise ValueError("功能参数过大") + options = json.loads(options_json) + normalized = validate_ecommerce_input_roles( + operation, + [item.dict() for item in payload.inputs], + options, + ) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + inputs, source_dimensions = validate_ecommerce_local_inputs(normalized) + providers = configured_ecommerce_providers() + catalog = build_ecommerce_model_catalog(providers) + try: + candidates = ecommerce_route_candidates(catalog, mode, payload.provider_id, payload.model) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + reference_count = len(inputs) + candidates = [route for route in candidates if int(route.get("max_reference_images") or 0) >= reference_count] + if not candidates: + if operation == "universal": + raise HTTPException(status_code=400, detail=f"所选模型无法同时处理 {reference_count} 张参考图,请减少图片或选择支持更多参考图的 Gemini 3 模型") + raise HTTPException(status_code=400, detail="没有找到兼容的图片编辑模型,请检查已启用平台和模型配置") + if payload.model: + candidates = candidates[:1] + parent_task_id = str(payload.parent_task_id or "").strip() + if parent_task_id: + with ECOMMERCE_TASK_LOCK: + parent = dict(ECOMMERCE_TASKS.get(parent_task_id) or {}) + if not parent: + raise HTTPException(status_code=400, detail="父任务不存在,无法创建新版本") + if parent.get("operation") != operation: + raise HTTPException(status_code=400, detail="新版本必须与父任务使用相同功能") + try: + generation = resolve_ecommerce_generation_settings( + source_dimensions[0], + source_dimensions[1], + mode, + payload.aspect_ratio, + payload.resolution, + payload.quality, + payload.count, + ) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + prompt = build_ecommerce_prompt(operation, inputs, options) + return { + "operation": operation, + "mode": mode, + "inputs": inputs, + "options": options, + "provider_id": str(payload.provider_id or "").strip().lower(), + "model": str(payload.model or "").strip(), + "parent_task_id": parent_task_id, + "source_dimensions": {"width": source_dimensions[0], "height": source_dimensions[1]}, + **generation, + "prompt": prompt, + "route_candidates": [public_ecommerce_route(route) for route in candidates], + } + +async def run_ecommerce_task(task_id: str, snapshot: Dict[str, Any]): + async with ECOMMERCE_TASK_SEMAPHORE: + await execute_ecommerce_task(task_id, snapshot) + +async def execute_ecommerce_task(task_id: str, snapshot: Dict[str, Any]): + update_ecommerce_task(task_id, {"status": "running", "error": ""}) + snapshot, garment_analysis = await enrich_ecommerce_snapshot_with_garment_analysis(snapshot) + snapshot, universal_analysis = await enrich_ecommerce_snapshot_with_universal_analysis(snapshot) + if garment_analysis is not None or universal_analysis is not None: + update_ecommerce_task(task_id, { + "options": snapshot["options"], + "prompt": snapshot["prompt"], + "garment_analysis": garment_analysis, + "universal_analysis": universal_analysis, + "request": snapshot, + }) + routes = list(snapshot.get("route_candidates") or []) + failures = [] + try: + for index, route in enumerate(routes): + try: + batch = await execute_ai_image_batch( + prompt=snapshot["prompt"], + provider_id=route["provider_id"], + model=route["model"], + size=snapshot["size"], + quality=snapshot["quality"], + references=snapshot["inputs"], + count=snapshot["count"], + prefix="ecommerce_", + allow_edit_endpoint_fallback=False, + semantic_mask=True, + ) + raw = batch["raw"] + result = { + "type": "ecommerce", + "operation": snapshot["operation"], + "mode": snapshot["mode"], + "ecommerce_task_id": task_id, + "parent_task_id": snapshot.get("parent_task_id") or "", + "prompt": snapshot["prompt"], + "inputs": snapshot["inputs"], + "images": batch["images"], + "image_items": batch["image_items"], + "timestamp": batch["generation_completed_at"], + "provider_id": batch["provider"]["id"], + "provider_name": batch["provider"].get("name") or batch["provider"]["id"], + "model": batch["model"], + "size": snapshot["size"], + "aspect_ratio": snapshot["aspect_ratio"], + "resolution": snapshot["resolution"], + "quality": snapshot["quality"], + "candidate_count": len(batch["images"]), + "garment_analysis": garment_analysis, + "universal_analysis": universal_analysis, + "upstream_task_id": extract_task_id(raw) if isinstance(raw, dict) else None, + "request_id": raw.get("id") if isinstance(raw, dict) else None, + "generation_started_at": batch["generation_started_at"], + "generation_completed_at": batch["generation_completed_at"], + "generation_elapsed_seconds": batch["generation_elapsed_seconds"], + "raw_usage": raw.get("usage") if isinstance(raw, dict) else None, + "params": { + "operation": snapshot["operation"], + "mode": snapshot["mode"], + "options": snapshot["options"], + "provider_id": batch["provider"]["id"], + "model": batch["model"], + "size": snapshot["size"], + "aspect_ratio": snapshot["aspect_ratio"], + "resolution": snapshot["resolution"], + "quality": snapshot["quality"], + "n": snapshot["count"], + "parameters": snapshot["parameters"], + "reference_images": snapshot["inputs"], + }, + } + save_to_history(result) + if GLOBAL_LOOP: + asyncio.run_coroutine_threadsafe(manager.broadcast_new_image(result), GLOBAL_LOOP) + update_ecommerce_task(task_id, { + "status": "succeeded", + "result": result, + "error": "", + "provider_id": result["provider_id"], + "provider_name": result["provider_name"], + "model": result["model"], + "route_attempts": failures + [{"route": public_ecommerce_route(route), "status": "succeeded"}], + }) + return + except Exception as exc: + detail = str(getattr(exc, "detail", None) or exc) + status_code = int(getattr(exc, "status_code", 500) or 500) + failures.append({ + "route": public_ecommerce_route(route), + "status": "failed", + "status_code": status_code, + "error": detail[:500], + }) + can_fallback = index < len(routes) - 1 and ecommerce_safe_fallback_error(status_code, detail) + if can_fallback: + continue + raise + except Exception as exc: + detail = str(getattr(exc, "detail", None) or exc) + update_ecommerce_task(task_id, { + "status": "failed", + "error": detail, + "status_code": int(getattr(exc, "status_code", 500) or 500), + "route_attempts": failures, + "upstream_task_id": getattr(exc, "upstream_task_id", "") or extract_task_id_from_text(detail), + }) + +def configured_ecommerce_providers() -> List[Dict[str, Any]]: + return [ + provider for provider in load_api_providers() + if provider.get("enabled", True) and bool(provider_env_key_value(provider.get("id") or "")) + ] + +@app.get("/api/ecommerce/capabilities") +async def get_ecommerce_capabilities(): + return ecommerce_public_capabilities(configured_ecommerce_providers()) + +@app.post("/api/ecommerce/tasks") +async def create_ecommerce_task(payload: EcommerceTaskRequest): + snapshot = prepare_ecommerce_request(payload) + task_id = f"ecommerce_{uuid.uuid4().hex}" + now = time.time() + task = { + "id": task_id, + "task_id": task_id, + "type": "ecommerce", + "status": "queued", + "created_at": now, + "updated_at": now, + "result": None, + "error": "", + "approval": {"status": "pending", "output_index": None, "checks": {}, "note": ""}, + **snapshot, + "request": snapshot, + } + with ECOMMERCE_TASK_LOCK: + ECOMMERCE_TASKS[task_id] = task + write_ecommerce_task_locked(task) + if len(ECOMMERCE_TASKS) > 5000: + removable = sorted( + (item for item in ECOMMERCE_TASKS.values() if item.get("status") not in ECOMMERCE_ACTIVE_STATUSES), + key=lambda item: float(item.get("updated_at") or 0), + )[:max(0, len(ECOMMERCE_TASKS) - 5000)] + for item in removable: + ECOMMERCE_TASKS.pop(str(item.get("id") or ""), None) + DATABASE.prune_tasks("ecommerce", 5000) + asyncio.create_task(run_ecommerce_task(task_id, snapshot)) + return public_ecommerce_task(task) + +@app.get("/api/ecommerce/tasks") +async def list_ecommerce_tasks(limit: int = 50, operation: str = ""): + safe_limit = max(1, min(2000, int(limit or 50))) + operation = str(operation or "").strip().lower() + if operation: + try: + validate_ecommerce_operation(operation) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + with ECOMMERCE_TASK_LOCK: + tasks = sorted( + ( + public_ecommerce_task(task) + for task in ECOMMERCE_TASKS.values() + if not operation or task.get("operation") == operation + ), + key=lambda item: float(item.get("created_at") or 0), + reverse=True, + )[:safe_limit] + return {"tasks": tasks} + +@app.post("/api/ecommerce/tasks/status") +async def ecommerce_task_status(payload: EcommerceTaskStatusRequest): + ids = list(dict.fromkeys(str(item or "").strip() for item in payload.ids if str(item or "").strip()))[:2000] + tasks = [] + missing = [] + with ECOMMERCE_TASK_LOCK: + for task_id in ids: + task = ECOMMERCE_TASKS.get(task_id) + if not task: + missing.append(task_id) + continue + tasks.append({ + "id": task_id, + "task_id": task_id, + "status": str(task.get("status") or "queued"), + "updated_at": float(task.get("updated_at") or 0), + "error": str(task.get("error") or "")[:500], + }) + return {"tasks": tasks, "missing": missing} + +@app.get("/api/ecommerce/tasks/{task_id}") +async def get_ecommerce_task(task_id: str): + with ECOMMERCE_TASK_LOCK: + task = public_ecommerce_task(ECOMMERCE_TASKS.get(task_id) or {}) + if not task.get("id"): + raise HTTPException(status_code=404, detail="电商任务不存在或已过期") + return task + +@app.post("/api/ecommerce/tasks/{task_id}/approve") +async def approve_ecommerce_task(task_id: str, payload: EcommerceApprovalRequest): + with ECOMMERCE_TASK_LOCK: + task = dict(ECOMMERCE_TASKS.get(task_id) or {}) + if not task: + raise HTTPException(status_code=404, detail="电商任务不存在或已过期") + if task.get("status") != "succeeded" or not isinstance(task.get("result"), dict): + raise HTTPException(status_code=409, detail="只有生成成功的任务可以审核") + images = list(task["result"].get("images") or []) + output_index = int(payload.output_index) + if output_index < 0 or output_index >= len(images): + raise HTTPException(status_code=400, detail="所选候选不存在") + required_checks = ECOMMERCE_QUALITY_CHECKS.get(task.get("operation") or "", []) + missing = [item["id"] for item in required_checks if payload.checks.get(item["id"]) is not True] + if missing: + raise HTTPException(status_code=400, detail="必须完成全部人工质量检查后才能标记为上架成片") + approval = { + "status": "approved", + "output_index": output_index, + "output_url": images[output_index], + "checks": {item["id"]: True for item in required_checks}, + "note": str(payload.note or "").strip(), + "approved_at": time.time(), + "export": None, + } + update_ecommerce_task(task_id, {"approval": approval}) + return {"task_id": task_id, "approval": approval} + +@app.post("/api/ecommerce/tasks/{task_id}/export") +async def export_ecommerce_task(task_id: str): + with ECOMMERCE_TASK_LOCK: + task = dict(ECOMMERCE_TASKS.get(task_id) or {}) + if not task: + raise HTTPException(status_code=404, detail="电商任务不存在或已过期") + approval = dict(task.get("approval") or {}) + if approval.get("status") != "approved" or not approval.get("output_url"): + raise HTTPException(status_code=409, detail="候选尚未通过完整人工审核,不能导出上架成片") + existing = dict(approval.get("export") or {}) + if existing.get("url") and output_file_from_url(existing["url"]): + return {"task_id": task_id, "export": existing} + source = output_file_from_url(approval["output_url"]) + if not source: + raise HTTPException(status_code=404, detail="已审核候选文件不存在") + extension = os.path.splitext(source)[1].lower() + if extension not in ECOMMERCE_INPUT_EXTS: + extension = ".png" + date_part = datetime.datetime.now().strftime("%Y-%m-%d") + export_dir = os.path.abspath(os.path.join(OUTPUT_DIR, "ecommerce", date_part)) + output_root = os.path.abspath(OUTPUT_DIR) + if os.path.commonpath([output_root, export_dir]) != output_root: + raise HTTPException(status_code=500, detail="导出目录校验失败") + os.makedirs(export_dir, exist_ok=True) + filename = f"ecommerce_{task.get('operation')}_{datetime.datetime.now().strftime('%H%M%S')}_{uuid.uuid4().hex[:8]}{extension}" + destination = os.path.join(export_dir, filename) + await asyncio.to_thread(shutil.copy2, source, destination) + export_info = { + "url": media_url_from_path(destination), + "name": filename, + "exported_at": time.time(), + "kind": "official", + } + approval["export"] = export_info + update_ecommerce_task(task_id, {"approval": approval}) + return {"task_id": task_id, "export": export_info} + @app.post("/api/image-task-query") async def query_image_task(payload: ImageTaskQueryRequest): provider = get_api_provider(payload.provider_id) @@ -11170,71 +12634,20 @@ async def create_canvas_image_task(payload: OnlineImageRequest): "result": None, "error": "", "provider_id": payload.provider_id, - "model": payload.model, - } - asyncio.create_task(run_canvas_image_task(task_id, payload)) - return {"task_id": task_id, "status": "queued"} - -@app.get("/api/canvas-image-tasks/{task_id}") -async def get_canvas_image_task(task_id: str): - with CANVAS_TASK_LOCK: - task = dict(CANVAS_TASKS.get(task_id) or {}) - if not task: - raise HTTPException(status_code=404, detail="画布任务不存在,可能服务已重启或任务已过期") - return task - -async def run_canvas_comfy_task(task_id: str, payload: GenerateRequest): - with CANVAS_TASK_LOCK: - if task_id in CANVAS_TASKS: - CANVAS_TASKS[task_id]["status"] = "running" - CANVAS_TASKS[task_id]["updated_at"] = time.time() - try: - result = await asyncio.to_thread(generate, payload) - if isinstance(result, dict) and result.get("error"): - raise RuntimeError(str(result.get("error") or "ComfyUI 生成失败")) - with CANVAS_TASK_LOCK: - CANVAS_TASKS[task_id].update({ - "status": "succeeded", - "result": result, - "error": "", - "updated_at": time.time(), - }) - except Exception as exc: - detail = getattr(exc, "detail", None) or str(exc) - status_code = getattr(exc, "status_code", 500) - with CANVAS_TASK_LOCK: - CANVAS_TASKS[task_id].update({ - "status": "failed", - "error": str(detail), - "status_code": status_code, - "updated_at": time.time(), - }) - -@app.post("/api/canvas-comfy-tasks") -async def create_canvas_comfy_task(payload: GenerateRequest): - task_id = f"canvas_comfy_{uuid.uuid4().hex}" - with CANVAS_TASK_LOCK: - CANVAS_TASKS[task_id] = { - "id": task_id, - "type": "comfy", - "status": "queued", - "created_at": time.time(), - "updated_at": time.time(), - "result": None, - "error": "", - "workflow_json": payload.workflow_json, + "model": payload.model, } - asyncio.create_task(run_canvas_comfy_task(task_id, payload)) + asyncio.create_task(run_canvas_image_task(task_id, payload)) return {"task_id": task_id, "status": "queued"} -@app.get("/api/canvas-comfy-tasks/{task_id}") -async def get_canvas_comfy_task(task_id: str): +@app.get("/api/canvas-image-tasks/{task_id}") +async def get_canvas_image_task(task_id: str): with CANVAS_TASK_LOCK: task = dict(CANVAS_TASKS.get(task_id) or {}) if not task: - raise HTTPException(status_code=404, detail="ComfyUI 任务不存在,可能服务已重启或任务已过期") + raise HTTPException(status_code=404, detail="画布任务不存在,可能服务已重启或任务已过期") return task + # --- 图像生成参数 schema(供客户端动态渲染参数表单,避免把参数写死在前端) --- IMAGE_PARAM_RATIOS = [ {"value": "1:1", "label": "1:1"}, @@ -12298,9 +13711,9 @@ async def get_conversation(conversation_id: str, request: Request, x_user_id: st @app.delete("/api/conversations/{conversation_id}") async def delete_conversation(conversation_id: str, request: Request, x_user_id: str = Header(default="")): user_id = safe_user_id(x_user_id, request) - path = conversation_path(user_id, conversation_id) - if os.path.exists(path): - os.remove(path) + cleaned = conversation_path(user_id, conversation_id) + DATABASE.delete_conversation(user_id, cleaned) + publish_entity_changed("session", cleaned) return {"ok": True} # --- 画布管理 --- @@ -12342,22 +13755,10 @@ async def delete_project(project_id: str): projects = [p for p in projects if p.get("id") != project_id] save_projects(projects) # 把该项目下的画布迁回默认项目 - moved = 0 with CANVAS_LOCK: - for filename in os.listdir(CANVAS_DIR): - if not filename.endswith(".json"): - continue - path = os.path.join(CANVAS_DIR, filename) - try: - with open(path, 'r', encoding='utf-8') as f: - data = json.load(f) - except Exception: - continue - if str(data.get("project") or "") == project_id: - data["project"] = DEFAULT_PROJECT_ID - with open(path, 'w', encoding='utf-8') as f: - json.dump(data, f, ensure_ascii=False, indent=2) - moved += 1 + moved = DATABASE.reassign_project(project_id, DEFAULT_PROJECT_ID) + if moved: + publish_entity_changed("canvas", "global") return {"ok": True, "moved": moved} @app.get("/api/canvases/trash") @@ -12401,8 +13802,8 @@ async def update_canvas_meta(canvas_id: str, payload: CanvasMetaUpdate): if payload.board_y is not None: canvas["board_y"] = float(payload.board_y) with CANVAS_LOCK: - with open(canvas_path(canvas["id"]), 'w', encoding='utf-8') as f: - json.dump(canvas, f, ensure_ascii=False, indent=2) + DATABASE.save_canvas(canvas, touch=False) + publish_entity_changed("canvas", canvas_id, int(canvas.get("revision") or 0), updated_at=int(canvas.get("updated_at") or 0)) return {"canvas": canvas_record(canvas)} @app.get("/api/canvases/{canvas_id}") @@ -13495,11 +14896,15 @@ async def batch_crop_asset_library_items(payload: AssetLibraryBatchCropRequest): async def update_canvas(canvas_id: str, payload: CanvasSaveRequest): canvas = load_canvas(canvas_id) current_updated_at = int(canvas.get("updated_at") or 0) - if payload.base_updated_at and current_updated_at and int(payload.base_updated_at) < current_updated_at: + current_revision = int(canvas.get("revision") or 0) + revision_conflict = payload.base_revision and current_revision and int(payload.base_revision) != current_revision + timestamp_conflict = payload.base_updated_at and current_updated_at and int(payload.base_updated_at) < current_updated_at + if revision_conflict or timestamp_conflict: raise HTTPException(status_code=409, detail={ "message": "画布已被其他页面更新,已拒绝旧版本覆盖。", "canvas": canvas, "updated_at": current_updated_at, + "revision": current_revision, }) canvas["title"] = (payload.title or canvas.get("title") or "未命名画布")[:80] canvas["icon"] = (payload.icon or canvas.get("icon") or "layers")[:32] @@ -13512,8 +14917,7 @@ async def update_canvas(canvas_id: str, payload: CanvasSaveRequest): canvas["viewport"] = canvas.get("viewport") or {"x": 0, "y": 0, "scale": 1} canvas["logs"] = payload.logs[-500:] canvas["settings"] = payload.settings or {} - save_canvas(canvas) - await manager.broadcast_canvas_updated(canvas_id, int(canvas.get("updated_at") or now_ms()), payload.client_id) + save_canvas(canvas, payload.client_id) return {"canvas": canvas} @app.delete("/api/canvases/{canvas_id}") @@ -13534,9 +14938,8 @@ async def restore_canvas(canvas_id: str): @app.delete("/api/canvases/{canvas_id}/purge") async def purge_canvas(canvas_id: str): - path = canvas_path(canvas_id) - if os.path.exists(path): - os.remove(path) + DATABASE.purge_canvas(canvas_path(canvas_id)) + publish_entity_changed("canvas", canvas_id) return {"ok": True} # --- GPT 对话 --- @@ -13594,6 +14997,24 @@ async def chat(payload: ChatRequest, request: Request, x_user_id: str = Header(d "raw_usage": raw.get("usage") if isinstance(raw, dict) else None, } else: + _codex_provider = get_api_provider(payload.provider) + if is_codex_provider(_codex_provider): + model = selected_model(payload.model, (_codex_provider.get("chat_models") or CODEX_DEFAULT_CHAT_MODELS)[0]) + payload.model = model + text, raw = await codex_chat_text(payload, conversation["messages"][-MAX_HISTORY_MESSAGES:]) + assistant_message = { + "id": uuid.uuid4().hex, + "role": "assistant", + "content": text, + "created_at": now_ms(), + "model": model, + "raw_usage": None, + "raw": raw, + } + conversation["messages"].append(assistant_message) + conversation["updated_at"] = now_ms() + save_conversation(user_id, conversation) + return {"conversation": conversation, "message": assistant_message} chat_base, chat_hdrs, model = resolve_chat_provider(payload.provider, payload.model, payload.ms_model) _conv_provider = get_api_provider(payload.provider) if payload.provider not in ("modelscope",) else {} _conv_is_apimart = is_apimart_provider(_conv_provider) @@ -13749,6 +15170,35 @@ async def chat_stream(payload: ChatRequest, request: Request, x_user_id: str = H conversation["updated_at"] = now_ms() save_conversation(user_id, conversation) + _codex_provider = get_api_provider(payload.provider) + if is_codex_provider(_codex_provider): + model = selected_model(payload.model, (_codex_provider.get("chat_models") or CODEX_DEFAULT_CHAT_MODELS)[0]) + payload.model = model + + async def codex_stream(): + yield sse_event({"type": "meta", "conversation": conversation}) + try: + text, raw = await codex_chat_text(payload, conversation["messages"][-MAX_HISTORY_MESSAGES:]) + except HTTPException as exc: + yield sse_event({"type": "error", "detail": exc.detail}) + return + assistant_message = { + "id": uuid.uuid4().hex, + "role": "assistant", + "content": text, + "created_at": now_ms(), + "model": model, + "raw_usage": None, + "raw": raw, + } + conversation["messages"].append(assistant_message) + conversation["updated_at"] = now_ms() + save_conversation(user_id, conversation) + yield sse_event({"type": "delta", "delta": text}) + yield sse_event({"type": "done", "conversation": conversation, "message": assistant_message}) + + return StreamingResponse(codex_stream(), media_type="text/event-stream") + chat_base, chat_hdrs, model = resolve_chat_provider(payload.provider, payload.model, payload.ms_model) _stream_provider = get_api_provider(payload.provider) if payload.provider not in ("modelscope",) else {} history = conversation["messages"][-MAX_HISTORY_MESSAGES:] @@ -13815,726 +15265,310 @@ async def stream(): # --- 历史记录 --- -@app.get("/api/history") -async def get_history_api(type: str = None): - if os.path.exists(HISTORY_FILE): - try: - with open(HISTORY_FILE, 'r', encoding='utf-8') as f: - data = json.load(f) - if type: - data = [item for item in data if item.get("type", "zimage") == type] - data = [item for item in data if item.get("images") and len(item["images"]) > 0] - - def sort_key(item): - ts = item.get("timestamp", 0) - if isinstance(ts, (int, float)): - return float(ts) - return 0 - - data.sort(key=sort_key, reverse=True) - return data - except Exception as e: - print(f"读取历史文件失败: {e}") - return [] - return [] - -@app.get("/api/queue_status") -async def get_queue_status(client_id: str): - with QUEUE_LOCK: - total = len(QUEUE) - positions = [i + 1 for i, t in enumerate(QUEUE) if t["client_id"] == client_id] - position = positions[0] if positions else 0 - return {"total": total, "position": position} - -@app.post("/api/history/delete") -async def delete_history(req: DeleteHistoryRequest): - if not os.path.exists(HISTORY_FILE): - return {"success": False, "message": "History file not found"} - try: - with HISTORY_LOCK: - with open(HISTORY_FILE, 'r', encoding='utf-8') as f: - history = json.load(f) - target_record = None - new_history = [] - for item in history: - is_match = False - item_ts = item.get("timestamp", 0) - if isinstance(req.timestamp, (int, float)) and isinstance(item_ts, (int, float)): - if abs(float(item_ts) - float(req.timestamp)) < 0.001: - is_match = True - elif str(item_ts) == str(req.timestamp): - is_match = True - if is_match: - target_record = item - else: - new_history.append(item) - if target_record: - with open(HISTORY_FILE, 'w', encoding='utf-8') as f: - json.dump(new_history, f, ensure_ascii=False, indent=4) - - if target_record: - for img_url in target_record.get("images", []): - file_path = output_file_from_url(img_url) - if file_path and os.path.exists(file_path): - try: - os.remove(file_path) - except Exception as e: - print(f"Failed to delete file {file_path}: {e}") - return {"success": True} - else: - return {"success": False, "message": "Record not found"} - except Exception as e: - print(f"Delete history error: {e}") - return {"success": False, "message": str(e)} - -# --- ModelScope 角度控制 --- - -@app.post("/api/angle/poll_status") -async def poll_angle_cloud(req: CloudPollRequest): - api_root = modelscope_image_api_root() - clean_token = modelscope_api_key(req.api_key) - if not clean_token: - raise HTTPException(status_code=400, detail="未提供 ModelScope API Key") - - headers = { - "Authorization": f"Bearer {clean_token}", - "Content-Type": "application/json", - "X-ModelScope-Async-Mode": "true" - } - task_id = req.task_id - print(f"Resuming polling for Angle Task: {task_id}") - - try: - async with httpx.AsyncClient(timeout=30) as client: - for i in range(300): - await asyncio.sleep(2) - result = await client.get( - f"{api_root}/tasks/{task_id}", - headers={**headers, "X-ModelScope-Task-Type": "image_generation"}, - ) - result.raise_for_status() - data = result.json() - status = str(data.get("task_status") or "").upper() - - if status == "SUCCEED": - img_url = data["output_images"][0] - local_path = "" - try: - async with httpx.AsyncClient() as dl_client: - img_res = await dl_client.get(img_url) - if img_res.status_code == 200: - filename = f"cloud_angle_{int(time.time())}.png" - file_path = output_path_for(filename, "output") - with open(file_path, "wb") as f: - f.write(img_res.content) - local_path = output_url_for(filename, "output") - else: - local_path = img_url - except Exception: - local_path = img_url - - record = {"timestamp": time.time(), "prompt": f"Resumed {task_id}", "images": [local_path], "type": "angle"} - save_to_history(record) - if req.client_id: - await manager.send_personal_message({"type": "cloud_status", "status": "SUCCEED", "task_id": task_id}, req.client_id) - return {"url": local_path} - - elif status in {"FAILED", "FAIL", "ERROR", "CANCELED", "CANCELLED", "TIMEOUT", "REVOKED"}: - if req.client_id: - await manager.send_personal_message({"type": "cloud_status", "status": "FAILED", "task_id": task_id}, req.client_id) - raise HTTPException(status_code=502, detail=f"ModelScope task failed: {data}") - - if i % 5 == 0 and req.client_id: - await manager.send_personal_message({ - "type": "cloud_status", "status": f"{status} ({i}/300)", - "task_id": task_id, "progress": i, "total": 300 - }, req.client_id) - - if req.client_id: - await manager.send_personal_message({"type": "cloud_status", "status": "TIMEOUT", "task_id": task_id}, req.client_id) - return {"status": "timeout", "task_id": task_id, "message": "Task still pending"} - - except HTTPException: - raise - except Exception as e: - print(f"Angle polling error: {e}") - raise HTTPException(status_code=400, detail=str(e)) - -@app.post("/api/angle/generate") -async def generate_angle_cloud(req: CloudGenRequest): - api_root = modelscope_image_api_root() - clean_token = modelscope_api_key(req.api_key) - if not clean_token: - raise HTTPException(status_code=400, detail="未提供 ModelScope API Key") - - headers = { - "Authorization": f"Bearer {clean_token}", - "Content-Type": "application/json", - "X-ModelScope-Async-Mode": "true" - } - model = selected_model(req.model, "Qwen/Qwen-Image-Edit-2511") - payload = { - "model": model, - "prompt": req.prompt.strip(), - "image_url": [modelscope_image_url(url, max_size=1536) for url in req.image_urls] - } - if req.resolution: - payload["size"] = modelscope_size(req.resolution) - if req.loras is not None: - payload["loras"] = req.loras - +def online_history_number(value): + if value is None or value == "": + return None try: - async with httpx.AsyncClient(timeout=30) as client: - submit_res = await client.post(f"{api_root}/images/generations", headers=headers, json=payload) - if submit_res.status_code != 200: - try: - detail = submit_res.json() - except: - detail = submit_res.text - raise HTTPException(status_code=submit_res.status_code, detail=detail) - - task_id = submit_res.json().get("task_id") - print(f"Angle Task submitted, ID: {task_id}") - - for i in range(300): - await asyncio.sleep(2) - result = await client.get( - f"{api_root}/tasks/{task_id}", - headers={**headers, "X-ModelScope-Task-Type": "image_generation"}, - ) - result.raise_for_status() - data = result.json() - status = str(data.get("task_status") or "").upper() - - if status == "SUCCEED": - img_url = data["output_images"][0] - local_path = "" - try: - async with httpx.AsyncClient() as dl_client: - img_res = await dl_client.get(img_url) - if img_res.status_code == 200: - filename = f"cloud_angle_{int(time.time())}.png" - file_path = output_path_for(filename, "output") - with open(file_path, "wb") as f: - f.write(img_res.content) - local_path = output_url_for(filename, "output") - else: - local_path = img_url - except Exception: - local_path = img_url - - record = {"timestamp": time.time(), "prompt": req.prompt, "images": [local_path], "type": "angle"} - save_to_history(record) - if req.client_id: - await manager.send_personal_message({"type": "cloud_status", "status": "SUCCEED", "task_id": task_id}, req.client_id) - if GLOBAL_LOOP: - asyncio.run_coroutine_threadsafe(manager.broadcast_new_image(record), GLOBAL_LOOP) - return {"url": local_path, "task_id": task_id} - - elif status in {"FAILED", "FAIL", "ERROR", "CANCELED", "CANCELLED", "TIMEOUT", "REVOKED"}: - if req.client_id: - await manager.send_personal_message({"type": "cloud_status", "status": "FAILED", "task_id": task_id}, req.client_id) - raise HTTPException(status_code=502, detail=f"ModelScope task failed: {data}") - - if i % 5 == 0 and req.client_id: - await manager.send_personal_message({ - "type": "cloud_status", "status": f"{status} ({i}/300)", - "task_id": task_id, "progress": i, "total": 300 - }, req.client_id) - - if req.client_id: - await manager.send_personal_message({"type": "cloud_status", "status": "TIMEOUT", "task_id": task_id}, req.client_id) - return {"status": "timeout", "task_id": task_id, "message": "Task still pending"} - - except HTTPException: - raise - except Exception as e: - print(f"Angle generation error: {e}") - raise HTTPException(status_code=400, detail=str(e)) + return float(value) + except (TypeError, ValueError): + return None -# --- ModelScope Z-Image 云端生图 --- +def online_history_first_value(*values): + for value in values: + if value is not None and value != "": + return value + return None -@app.post("/generate") -async def generate_cloud(req: CloudGenRequest): - api_root = modelscope_image_api_root() - clean_token = modelscope_api_key(req.api_key) - if not clean_token: - raise HTTPException(status_code=400, detail="未提供 ModelScope API Key") +def online_history_timestamp_key(value): + number = online_history_number(value) + if number is None: + return "" + return f"{number:.6f}" - headers = { - "Authorization": f"Bearer {clean_token}", - "Content-Type": "application/json", - } - payload = { - "model": "Tongyi-MAI/Z-Image-Turbo", - "prompt": req.prompt.strip(), - "size": modelscope_size(req.resolution), - "n": 1 +def online_history_task_meta(task: Dict[str, Any]): + result = task.get("result") if isinstance(task, dict) else {} + if not isinstance(result, dict) or result.get("type") != "online": + return None + params = result.get("params") if isinstance(result.get("params"), dict) else {} + started_at = online_history_number(online_history_first_value( + result.get("generation_started_at"), + task.get("created_at"), + )) + completed_at = online_history_number(online_history_first_value( + result.get("generation_completed_at"), + result.get("timestamp"), + task.get("updated_at"), + )) + elapsed = online_history_number(online_history_first_value( + result.get("generation_elapsed_seconds"), + params.get("generation_elapsed_seconds"), + )) + if elapsed is None and started_at is not None and completed_at is not None: + elapsed = max(0, completed_at - started_at) + if elapsed is None: + return None + return { + "task_record_id": task.get("id") or task.get("task_id"), + "generation_started_at": started_at, + "generation_completed_at": completed_at, + "generation_elapsed_seconds": round(elapsed, 3), } - if req.loras is not None: - payload["loras"] = req.loras - try: - async with httpx.AsyncClient(timeout=30) as client: - submit_res = await client.post( - f"{api_root}/images/generations", - headers={**headers, "X-ModelScope-Async-Mode": "true"}, - json=payload - ) - if submit_res.status_code != 200: - try: - detail = submit_res.json() - except: - detail = submit_res.text - raise HTTPException(status_code=submit_res.status_code, detail=detail) - - task_id = submit_res.json().get("task_id") - print(f"Z-Image Task submitted, ID: {task_id}") - - for i in range(200): - await asyncio.sleep(3) - result = await client.get( - f"{api_root}/tasks/{task_id}", - headers={**headers, "X-ModelScope-Task-Type": "image_generation"}, - ) - result.raise_for_status() - data = result.json() - status = str(data.get("task_status") or "").upper() +def online_history_task_meta_index(): + by_timestamp = {} + by_image = {} + with ONLINE_IMAGE_TASK_LOCK: + tasks = list(ONLINE_IMAGE_TASKS.values()) + for task in tasks: + meta = online_history_task_meta(task) + if not meta: + continue + result = task.get("result") if isinstance(task, dict) else {} + timestamp_key = online_history_timestamp_key(result.get("timestamp")) + if timestamp_key: + by_timestamp[timestamp_key] = meta + for url in result.get("images") or []: + if url: + by_image[str(url)] = meta + return by_timestamp, by_image + +def merge_online_history_meta(item: Dict[str, Any], meta: Dict[str, Any] = None): + if not isinstance(item, dict) or item.get("type") != "online": + return item + params = item.get("params") if isinstance(item.get("params"), dict) else {} + current_elapsed = online_history_number(online_history_first_value( + item.get("generation_elapsed_seconds"), + params.get("generation_elapsed_seconds"), + )) + if not meta and current_elapsed is None: + return item + merged = dict(item) + if meta: + for key, value in meta.items(): + if value is not None and merged.get(key) in (None, ""): + merged[key] = value + merged_params = dict(params) + elapsed_value = online_history_first_value( + merged.get("generation_elapsed_seconds"), + merged_params.get("generation_elapsed_seconds"), + ) + if elapsed_value is not None and elapsed_value != "" and merged_params.get("generation_elapsed_seconds") in (None, ""): + merged_params["generation_elapsed_seconds"] = elapsed_value + if merged_params: + merged["params"] = merged_params + return merged - if i % 5 == 0: - print(f"Task {task_id} status check {i}: {status}") +def enrich_online_history_generation_meta(items): + if not any(isinstance(item, dict) and item.get("type") == "online" for item in items): + return items + by_timestamp, by_image = online_history_task_meta_index() + enriched = [] + for item in items: + if not isinstance(item, dict) or item.get("type") != "online": + enriched.append(item) + continue + meta = by_timestamp.get(online_history_timestamp_key(item.get("timestamp"))) + if not meta: + for url in item.get("images") or []: + meta = by_image.get(str(url)) + if meta: + break + enriched.append(merge_online_history_meta(item, meta)) + return enriched - if status == "SUCCEED": - img_url = data["output_images"][0] - local_path = "" - try: - async with httpx.AsyncClient() as dl_client: - img_res = await dl_client.get(img_url) - if img_res.status_code == 200: - filename = f"cloud_{int(time.time())}.png" - file_path = output_path_for(filename, "output") - with open(file_path, "wb") as f: - f.write(img_res.content) - local_path = output_url_for(filename, "output") - else: - local_path = img_url - except Exception as dl_e: - print(f"Download error: {dl_e}") - local_path = img_url - - record = {"timestamp": time.time(), "prompt": req.prompt, "images": [local_path], "type": "cloud"} - save_to_history(record) - try: - await manager.broadcast_new_image(record) - except Exception: - pass - return {"url": local_path} +@app.get("/api/history") +async def get_history_api(type: str = None): + data = DATABASE.list_history(type or "") + data = [item for item in data if item.get("images") and len(item["images"]) > 0] + return enrich_online_history_generation_meta(data) - elif status in {"FAILED", "FAIL", "ERROR", "CANCELED", "CANCELLED", "TIMEOUT", "REVOKED"}: - raise HTTPException(status_code=502, detail=f"ModelScope task failed: {data}") - raise Exception("Cloud generation timeout") +WORK_METADATA_LOCK = Lock() - except HTTPException: - raise - except Exception as e: - print(f"Cloud generation error: {e}") - raise HTTPException(status_code=400, detail=str(e)) -# --- ModelScope 通用图片生成(支持图生图) --- +def work_metadata() -> Dict[str, Dict[str, Any]]: + value = DATABASE.get_document("works", "metadata", {}) + return value if isinstance(value, dict) else {} -@app.post("/api/ms/generate") -async def ms_generate(req: MsGenerateRequest): - api_root = modelscope_image_api_root() - clean_token = modelscope_api_key(req.api_key) - if not clean_token: - raise HTTPException(status_code=400, detail="未配置 ModelScope API Key,请在 API 设置中填写,或重新保存 ModelScope Token。") - headers = { - "Authorization": f"Bearer {clean_token}", - "Content-Type": "application/json", - "X-ModelScope-Async-Mode": "true" - } - payload = { - "model": req.model, - "prompt": req.prompt.strip(), - } - if req.width and req.height: - payload["width"] = req.width - payload["height"] = req.height - payload["size"] = modelscope_size(req.size or f"{req.width}x{req.height}") - elif req.size: - payload["size"] = modelscope_size(req.size) - if req.image_urls: - payload["image_url"] = [modelscope_image_url(url, max_size=1536) for url in req.image_urls] - if req.loras is not None: - payload["loras"] = req.loras +def work_item_id(history_id: str, index: int, url: str) -> str: + identity = f"{history_id}\0{int(index)}\0{url}".encode("utf-8") + return "work_" + hashlib.sha256(identity).hexdigest()[:24] - try: - async with httpx.AsyncClient(timeout=30) as client: - submit_res = await client.post( - f"{api_root}/images/generations", - headers=headers, - json=payload - ) - if submit_res.status_code != 200: - try: - detail = submit_res.json() - except: - detail = submit_res.text - raise HTTPException(status_code=submit_res.status_code, detail=detail) - task_id = submit_res.json().get("task_id") - print(f"MS Generate Task submitted ({req.model}), ID: {task_id}") +def history_reference_images(record: Dict[str, Any]) -> List[Dict[str, Any]]: + params = record.get("params") if isinstance(record.get("params"), dict) else {} + values = record.get("inputs") or params.get("reference_images") or record.get("reference_images") or [] + return [dict(item) for item in values if isinstance(item, dict) and str(item.get("url") or "").strip()] - TERMINAL_FAILED_STATUSES = {"FAILED", "FAIL", "ERROR", "CANCELED", "CANCELLED", "TIMEOUT", "REVOKED"} - for i in range(300): - await asyncio.sleep(2) - try: - result = await client.get( - f"{api_root}/tasks/{task_id}", - headers={**headers, "X-ModelScope-Task-Type": "image_generation"}, - ) - data = result.json() - status = data.get("task_status") - print(f"MS Task {task_id} poll {i}: status={status}") +def generated_work_items(records: List[Dict[str, Any]], metadata: Optional[Dict[str, Dict[str, Any]]] = None) -> List[Dict[str, Any]]: + metadata = metadata if isinstance(metadata, dict) else {} + works = [] + for record in records: + if not isinstance(record, dict): + continue + history_id = str(record.get("_history_id") or record.get("id") or "").strip() + if not history_id: + continue + images = [str(url).strip() for url in record.get("images") or [] if str(url).strip()] + image_items = record.get("image_items") if isinstance(record.get("image_items"), list) else [] + references = history_reference_images(record) + source = next((item for item in references if item.get("role") == "source"), references[0] if references else {}) + created_at = float(record.get("timestamp") or record.get("created_at") or 0) + params = record.get("params") if isinstance(record.get("params"), dict) else {} + for index, url in enumerate(images): + item_meta = image_items[index] if index < len(image_items) and isinstance(image_items[index], dict) else {} + item_id = work_item_id(history_id, index, url) + saved_meta = metadata.get(item_id) if isinstance(metadata.get(item_id), dict) else {} + original_name = os.path.basename(urllib.parse.unquote(urllib.parse.urlparse(url).path)) or f"作品-{index + 1}" + name = str(saved_meta.get("name") or original_name).strip()[:160] or original_name + works.append({ + "id": item_id, + "history_id": history_id, + "output_index": index, + "name": name, + "original_name": original_name, + "url": url, + "kind": str(record.get("type") or "image"), + "operation": str(record.get("operation") or params.get("operation") or ""), + "created_at": created_at, + "prompt": str(record.get("prompt") or params.get("prompt") or ""), + "provider_id": str(record.get("provider_id") or params.get("provider_id") or ""), + "provider_name": str(record.get("provider_name") or ""), + "model": str(record.get("model") or params.get("model") or ""), + "width": int(item_meta.get("width") or 0), + "height": int(item_meta.get("height") or 0), + "task_id": str(record.get("ecommerce_task_id") or record.get("task_id") or ""), + "source_url": str(source.get("url") or ""), + "references": references, + "favorite": bool(saved_meta.get("favorite")), + "favorite_updated_at": float(saved_meta.get("favorite_updated_at") or saved_meta.get("updated_at") or 0), + "trashed": bool(saved_meta.get("trashed")), + "trashed_at": float(saved_meta.get("trashed_at") or 0), + "metadata_updated_at": float(saved_meta.get("updated_at") or 0), + }) + works.sort(key=lambda item: (float(item.get("created_at") or 0), item["id"]), reverse=True) + return works + + +def all_generated_works() -> List[Dict[str, Any]]: + records = DATABASE.list_history("") + return generated_work_items(records, work_metadata()) + + +@app.get("/api/works") +async def list_generated_works(favorite: Optional[bool] = None, kind: str = "", search: str = "", limit: int = 500, include_trashed: bool = False): + works = all_generated_works() + if not include_trashed: + works = [item for item in works if not item.get("trashed")] + normalized_kind = str(kind or "").strip().lower() + normalized_search = str(search or "").strip().lower() + if favorite is not None: + works = [item for item in works if bool(item.get("favorite")) is bool(favorite)] + if normalized_kind: + works = [item for item in works if str(item.get("kind") or "").lower() == normalized_kind] + if normalized_search: + works = [item for item in works if normalized_search in " ".join(( + str(item.get("name") or ""), str(item.get("prompt") or ""), + str(item.get("model") or ""), str(item.get("operation") or ""), + )).lower()] + safe_limit = max(1, min(1000, int(limit or 500))) + return {"works": works[:safe_limit], "total": len(works)} + + +def update_work_metadata(work_id: str, *, name: Optional[str] = None, favorite: Optional[bool] = None, trashed: Optional[bool] = None) -> Tuple[Dict[str, Any], int]: + work_id = str(work_id or "").strip() + target = next((item for item in all_generated_works() if item.get("id") == work_id), None) + if not target: + raise HTTPException(status_code=404, detail="作品不存在或对应文件记录已被移除") + now = time.time() + with WORK_METADATA_LOCK: + metadata = work_metadata() + entry = dict(metadata.get(work_id) or {}) + if name is not None: + normalized_name = re.sub(r"[\r\n\t]+", " ", str(name)).strip()[:160] + if normalized_name and normalized_name != target.get("original_name"): + entry["name"] = normalized_name + else: + entry.pop("name", None) + if favorite is not None: + if favorite: + entry["favorite"] = True + entry["favorite_updated_at"] = now + else: + entry.pop("favorite", None) + entry.pop("favorite_updated_at", None) + if trashed is not None: + if trashed: + entry["trashed"] = True + entry["trashed_at"] = now + else: + entry.pop("trashed", None) + entry.pop("trashed_at", None) + entry["updated_at"] = now + meaningful_keys = {"name", "favorite", "favorite_updated_at", "trashed", "trashed_at"} + if any(key in entry for key in meaningful_keys): + metadata[work_id] = entry + else: + metadata.pop(work_id, None) + revision = DATABASE.put_document("works", "metadata", metadata) + publish_entity_changed("history", "works", revision) + refreshed = next((item for item in all_generated_works() if item.get("id") == work_id), target) + return refreshed, revision + + +@app.put("/api/works/{work_id}/metadata") +async def set_work_metadata(work_id: str, payload: WorkMetadataRequest): + if payload.name is None and payload.favorite is None and payload.trashed is None: + raise HTTPException(status_code=400, detail="没有需要修改的作品信息") + work, revision = update_work_metadata( + work_id, + name=payload.name, + favorite=payload.favorite, + trashed=payload.trashed, + ) + return {"work": work, "revision": revision} - if status == "SUCCEED": - img_url = data["output_images"][0] - local_path = "" - try: - async with httpx.AsyncClient() as dl_client: - img_res = await dl_client.get(img_url) - if img_res.status_code == 200: - filename = f"ms_{req.model.replace('/', '_').replace(':', '_')}_{int(time.time())}.png" - file_path = output_path_for(filename, "output") - with open(file_path, "wb") as f: - f.write(img_res.content) - local_path = output_url_for(filename, "output") - else: - local_path = img_url - except Exception: - local_path = img_url - - record = { - "timestamp": time.time(), - "prompt": req.prompt, - "images": [local_path], - "type": "klein", - "model": req.model, - } - save_to_history(record) - if GLOBAL_LOOP: - asyncio.run_coroutine_threadsafe(manager.broadcast_new_image(record), GLOBAL_LOOP) - return {"url": local_path, "task_id": task_id} - - elif status in TERMINAL_FAILED_STATUSES: - error_info = data.get("error_info") or data.get("message") or data.get("detail") or str(data) - raise HTTPException(status_code=502, detail=f"MS task {status}: {error_info}") - - except HTTPException: - raise - except Exception as loop_e: - print(f"MS polling error: {loop_e}") - continue - raise HTTPException(status_code=504, detail="MS 生图超时") +@app.put("/api/works/{work_id}/favorite") +async def set_work_favorite(work_id: str, payload: WorkFavoriteRequest): + work, revision = update_work_metadata(work_id, favorite=payload.favorite) + return {"work": work, "revision": revision} - except HTTPException: - raise - except Exception as e: - print(f"MS generate error: {e}") - raise HTTPException(status_code=400, detail=str(e)) - -# --- 本地 ComfyUI 生图 --- - -@app.post("/api/generate") -def generate(req: GenerateRequest): - global NEXT_TASK_ID - current_task = None - target_backend = None - with QUEUE_LOCK: - task_id = NEXT_TASK_ID - NEXT_TASK_ID += 1 - current_task = {"task_id": task_id, "client_id": req.client_id} - QUEUE.append(current_task) +@app.post("/api/history/delete") +async def delete_history(req: DeleteHistoryRequest): try: - required_images = collect_required_comfy_media(req.params) - - target_backend = reserve_best_backend(required_images) - - for image_name in required_images: - need_sync = False - try: - check_url = f"http://{target_backend}/view?filename={urllib.parse.quote(image_name)}&type=input" - resp = requests.get(check_url, stream=True, timeout=0.5) - resp.close() - if resp.status_code != 200: - need_sync = True - except: - need_sync = True - - if need_sync: - image_content = None - image_type = "image/png" - for addr in COMFYUI_INSTANCES: - if addr == target_backend: continue - try: - src_url = f"http://{addr}/view?filename={urllib.parse.quote(image_name)}&type=input" - r = requests.get(src_url, timeout=5) - if r.status_code == 200: - image_content = r.content - image_type = r.headers.get("Content-Type", "image/png") - break - except: continue + with HISTORY_LOCK: + target_record = DATABASE.delete_history_timestamp(float(req.timestamp)) - if image_content: + if target_record: + for img_url in target_record.get("images", []): + file_path = output_file_from_url(img_url) + if file_path and os.path.exists(file_path): try: - files = {'image': (image_name, image_content, image_type)} - requests.post(f"http://{target_backend}/upload/image", files=files, timeout=10) + os.remove(file_path) except Exception as e: - print(f"Sync upload failed: {e}") - - workflow_path = os.path.join(WORKFLOW_DIR, req.workflow_json) - if not os.path.exists(workflow_path) and req.workflow_json == "Z-Image.json": - workflow_path = WORKFLOW_PATH - if not os.path.exists(workflow_path): - raise Exception(f"Workflow file not found: {req.workflow_json}") - - with open(workflow_path, 'r', encoding='utf-8') as f: - workflow = json.load(f) - - seed = random.randint(1, 4294967295) - - if "23" in workflow and req.prompt: - workflow["23"]["inputs"]["text"] = req.prompt - if "144" in workflow: - workflow["144"]["inputs"]["width"] = req.width - workflow["144"]["inputs"]["height"] = req.height - if "22" in workflow: - workflow["22"]["inputs"]["seed"] = seed - if "158" in workflow: - workflow["158"]["inputs"]["noise_seed"] = seed - for node_id in ["146", "181"]: - if node_id in workflow and "inputs" in workflow[node_id] and "seed" in workflow[node_id]["inputs"]: - workflow[node_id]["inputs"]["seed"] = seed - if "184" in workflow and "inputs" in workflow["184"] and "seed" in workflow["184"]["inputs"]: - workflow["184"]["inputs"]["seed"] = seed - if "172" in workflow and "inputs" in workflow["172"] and "seed" in workflow["172"]["inputs"]: - workflow["172"]["inputs"]["seed"] = seed - if "14" in workflow and "inputs" in workflow["14"] and "seed" in workflow["14"]["inputs"]: - workflow["14"]["inputs"]["seed"] = seed - - for node_id, node_inputs in req.params.items(): - if node_id in workflow: - if "inputs" not in workflow[node_id]: - workflow[node_id]["inputs"] = {} - for input_name, value in node_inputs.items(): - workflow[node_id]["inputs"][input_name] = value - - p = {"prompt": workflow, "client_id": CLIENT_ID} - data = json.dumps(p).encode('utf-8') - try: - post_req = urllib.request.Request(f"http://{target_backend}/prompt", data=data) - prompt_id = json.loads(urllib.request.urlopen(post_req, timeout=10).read())['prompt_id'] - except urllib.error.HTTPError as e: - error_body = e.read().decode('utf-8') - raise Exception(f"HTTP Error {e.code}: {error_body}") - - history_data = None - for i in range(COMFYUI_HISTORY_TIMEOUT): - try: - res = get_comfy_history(target_backend, prompt_id) - if prompt_id in res: - history_data = res[prompt_id] - break - except Exception: - pass - time.sleep(1) - - if not history_data: - raise Exception("ComfyUI 渲染超时") - - local_images = [] - local_videos = [] - local_audios = [] - local_texts = [] - local_files = [] - local_items = [] - local_urls = [] - current_timestamp = time.time() - if 'outputs' in history_data: - # 先把所有节点的输出收集为候选(带上 class_type),再决定下载哪些, - # 避免把冗余的预览/对比图、调试文本一起下载进结果(后端层过滤,历史记录也更干净)。 - workflow_nodes = workflow if isinstance(workflow, dict) else {} - def _class_type_of(nid): - node_def = workflow_nodes.get(str(nid)) - return str(node_def.get("class_type") or "") if isinstance(node_def, dict) else "" - file_candidates = [] # (node_id, class_type, output_key, item, kind) - text_candidates = [] # (node_id, class_type, text, name) - for node_id in history_data['outputs']: - node_output = history_data['outputs'][node_id] - class_type = _class_type_of(node_id) - for output_key, item in collect_comfy_file_items(node_output): - file_candidates.append((node_id, class_type, output_key, item, comfy_output_kind(item))) - for text, name in comfy_text_values_from_output(node_output): - text_candidates.append((node_id, class_type, text, name)) - - # 只要存在“非预览节点”产出的图片,就把 PreviewImage/对比节点的图片视为冗余丢弃; - # 若整个工作流只有预览图(没有 SaveImage 等),则保留预览图作为唯一结果,避免零输出。 - has_primary_image = any( - kind == "image" and not comfy_class_is_preview(ct) - for (_nid, ct, _ok, _it, kind) in file_candidates - ) - prefix = f"{req.type}_{int(current_timestamp)}_" - for node_id, class_type, output_key, item, kind in file_candidates: - if kind == "image" and has_primary_image and comfy_class_is_preview(class_type): - continue # 跳过冗余的预览/对比图 - local_path = download_comfy_output(target_backend, item, prefix=prefix) - if kind == "image" and req.convert_to_jpg: - local_path = convert_output_to_jpg(local_path) - name = os.path.basename(str(item.get("filename") or "")) or os.path.basename(str(local_path).split("?", 1)[0]) - entry = { - "url": local_path, - "kind": kind, - "name": name, - "node_id": str(node_id), - "output_key": str(output_key), - "class_type": class_type, - } - if kind == "image": - local_images.append(local_path) - elif kind == "video": - local_videos.append(local_path) - elif kind == "audio": - local_audios.append(local_path) - elif kind == "text": - local_texts.append(local_path) - else: - local_files.append(local_path) - local_items.append(entry) - local_urls.append(local_path) - - # 默认抑制 show/utility 类节点的调试文本,避免 .txt 噪声混入结果。 - for node_id, class_type, text, name in text_candidates: - if comfy_class_is_debug_text(class_type): - continue - local_path = save_comfy_text_output(text, prefix=prefix, name=name) - entry = { - "url": local_path, - "kind": "text", - "name": os.path.basename(str(local_path).split("?", 1)[0]), - "node_id": str(node_id), - "output_key": "text", - "class_type": class_type, - } - local_texts.append(local_path) - local_items.append(entry) - local_urls.append(local_path) - - result = { - "prompt": req.prompt if req.prompt else "Detail Enhance", - "images": local_images, - "videos": local_videos, - "audios": local_audios, - "texts": local_texts, - "files": local_files, - "items": local_items, - "outputs": local_urls, - "seed": seed, - "timestamp": current_timestamp, - "type": req.type, - "workflow_json": req.workflow_json, - "task_id": task_id, - "prompt_id": prompt_id, - "backend": target_backend, - "params": req.params - } - save_to_history(result) - if GLOBAL_LOOP: - asyncio.run_coroutine_threadsafe(manager.broadcast_new_image(result), GLOBAL_LOOP) - return result - + print(f"Failed to delete file {file_path}: {e}") + publish_entity_changed("history", "global") + return {"success": True} + else: + return {"success": False, "message": "Record not found"} except Exception as e: - return {"images": [], "error": str(e)} - finally: - if target_backend: - with LOAD_LOCK: - if BACKEND_LOCAL_LOAD.get(target_backend, 0) > 0: - BACKEND_LOCAL_LOAD[target_backend] -= 1 - if current_task: - with QUEUE_LOCK: - if current_task in QUEUE: - QUEUE.remove(current_task) - -# --- ComfyUI 工作流管理 --- - -BUILTIN_WORKFLOWS = {"Z-Image.json", "Z-Image-Enhance.json", "2511.json", "klein-enhance.json", "Flux2-Klein.json", "upscale.json"} -CUSTOM_WORKFLOW_FOLDER = "custom" -LEGACY_CUSTOM_WORKFLOW_FOLDER = "自定义" -WORKFLOW_NAME_RE = re.compile(rf"^(?:(?:{CUSTOM_WORKFLOW_FOLDER}|{LEGACY_CUSTOM_WORKFLOW_FOLDER})/)?[a-zA-Z0-9_一-龥\.\-]+\.json$") - -class WorkflowField(BaseModel): - id: str - node: str = "" - input: str = "" - name: str = "" - type: str = "text" - default: Any = None - min: Optional[float] = None - max: Optional[float] = None - step: Optional[float] = None - options: List[str] = [] - random_enabled: bool = False - -class WorkflowConfig(BaseModel): - title: str = "" - fields: List[WorkflowField] = [] - mini_cards: Dict[str, Any] = {} - -class WorkflowUploadRequest(BaseModel): - name: str - workflow: Dict[str, Any] - -class WorkflowRunRequest(BaseModel): - fields: Dict[str, Any] = {} - config: WorkflowConfig - client_id: str = "" - -def workflow_path_from_name(name: str) -> str: - if not WORKFLOW_NAME_RE.match(name): - raise HTTPException(status_code=400, detail="Invalid workflow name") - path = os.path.abspath(os.path.join(WORKFLOW_DIR, *name.split("/"))) - workflow_root = os.path.abspath(WORKFLOW_DIR) - if os.path.commonpath([workflow_root, path]) != workflow_root: - raise HTTPException(status_code=400, detail="Invalid workflow name") - return path - -def workflow_config_path(name: str) -> str: - return workflow_path_from_name(name).replace(".json", ".config.json") - -def is_builtin_workflow(name: str) -> bool: - return "/" not in name and os.path.basename(name) in BUILTIN_WORKFLOWS + print(f"Delete history error: {e}") + return {"success": False, "message": str(e)} def runninghub_workflow_store_path() -> str: - return RUNNINGHUB_WORKFLOW_STORE_FILE + return str(DATA_LAYOUT.database_file) def load_runninghub_workflow_store(): - if not os.path.exists(RUNNINGHUB_WORKFLOW_STORE_FILE): - return {} - try: - with open(RUNNINGHUB_WORKFLOW_STORE_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - return data if isinstance(data, dict) else {} - except Exception: - return {} + data = DATABASE.get_library("runninghub_workflows", {}) + return data if isinstance(data, dict) else {} def save_runninghub_workflow_store(store): - os.makedirs(DATA_DIR, exist_ok=True) - with open(RUNNINGHUB_WORKFLOW_STORE_FILE, "w", encoding="utf-8") as f: - json.dump(store, f, ensure_ascii=False, indent=2) + revision = DATABASE.save_library("runninghub_workflows", store) + publish_entity_changed("workflow", "runninghub", revision) def runninghub_workflow_config_has_payload(cfg): if not isinstance(cfg, dict): @@ -14881,193 +15915,8 @@ def runninghub_collect_workflow_fields(workflow_json): }) return fields -class ComfyInstancesPayload(BaseModel): - instances: List[str] = [] - -@app.get("/api/comfyui/instances") -def get_comfyui_instances(): - return {"instances": COMFYUI_INSTANCES} - -@app.put("/api/comfyui/instances") -def save_comfyui_instances(payload: ComfyInstancesPayload): - # 宽容校验:去前后空白、去 http(s):// 前缀、去尾部斜杠;要求形如 host:port - cleaned = [] - for item in payload.instances: - s = str(item or "").strip() - if not s: - continue - s = re.sub(r"^https?://", "", s) - s = s.rstrip("/") - if ":" not in s: - raise HTTPException(status_code=400, detail=f"地址缺少端口号:{item}(应为 host:port,例如 127.0.0.1:8188)") - host, _, port = s.rpartition(":") - if not host or not port.isdigit(): - raise HTTPException(status_code=400, detail=f"地址不合法:{item}(应为 host:port,例如 127.0.0.1:8188)") - if s in cleaned: - continue - cleaned.append(s) - if not cleaned: - raise HTTPException(status_code=400, detail="至少保留一个 ComfyUI 后端地址") - # 写入 env 文件 - try: - update_env_values({"COMFYUI_INSTANCES": ",".join(cleaned)}) - except Exception as e: - raise HTTPException(status_code=500, detail=f"写入 env 失败:{e}") - # 更新进程中的全局变量 - global COMFYUI_INSTANCES, COMFYUI_ADDRESS, BACKEND_LOCAL_LOAD - COMFYUI_INSTANCES = cleaned - COMFYUI_ADDRESS = cleaned[0] - new_load = {addr: 0 for addr in cleaned} - for addr, n in (BACKEND_LOCAL_LOAD or {}).items(): - if addr in new_load: - new_load[addr] = n - BACKEND_LOCAL_LOAD = new_load - return {"instances": COMFYUI_INSTANCES} - -@app.get("/api/workflows") -def list_workflows(): - if not os.path.isdir(WORKFLOW_DIR): - return {"workflows": []} - items = [] - for root, dirs, files in os.walk(WORKFLOW_DIR): - if os.path.abspath(root) == os.path.abspath(WORKFLOW_DIR): - dirs[:] = [d for d in dirs if d in {CUSTOM_WORKFLOW_FOLDER, LEGACY_CUSTOM_WORKFLOW_FOLDER}] - for fn in sorted(files): - if not fn.endswith(".json") or fn.endswith(".config.json"): - continue - rel = os.path.relpath(os.path.join(root, fn), WORKFLOW_DIR).replace("\\", "/") - if is_builtin_workflow(rel): - continue - cfg = {} - cfg_path = workflow_config_path(rel) - if os.path.exists(cfg_path): - try: - with open(cfg_path, "r", encoding="utf-8") as f: - cfg = json.load(f) or {} - except Exception: - cfg = {} - items.append({ - "name": rel, - "title": cfg.get("title") or fn.replace(".json", ""), - "builtin": False, - "field_count": len(cfg.get("fields") or []), - }) - items.sort(key=lambda item: (0 if item["name"].startswith(f"{CUSTOM_WORKFLOW_FOLDER}/") else 1, item["title"])) - return {"workflows": items} - -@app.get("/api/workflows/{name:path}") -def get_workflow(name: str): - if not WORKFLOW_NAME_RE.match(name): - raise HTTPException(status_code=400, detail="Invalid workflow name") - workflow_path = workflow_path_from_name(name) - if not os.path.exists(workflow_path): - raise HTTPException(status_code=404, detail="Workflow not found") - with open(workflow_path, "r", encoding="utf-8") as f: - workflow = json.load(f) - cfg = {"title": name.replace(".json", ""), "fields": []} - cfg_path = workflow_config_path(name) - if os.path.exists(cfg_path): - try: - with open(cfg_path, "r", encoding="utf-8") as f: - cfg = json.load(f) or cfg - except Exception: - pass - return {"name": name, "workflow": workflow, "config": cfg, "builtin": is_builtin_workflow(name)} - -@app.post("/api/workflows") -def upload_workflow(payload: WorkflowUploadRequest): - name = os.path.basename(payload.name.strip()) - if not name.endswith(".json"): - name = name + ".json" - if not WORKFLOW_NAME_RE.match(name): - raise HTTPException(status_code=400, detail="工作流名称不合法,请使用中文/英文/数字/_-.") - if not isinstance(payload.workflow, dict) or not payload.workflow: - raise HTTPException(status_code=400, detail="工作流 JSON 为空") - # 简单校验:是 API 格式(节点 id 为 key,含 class_type) - sample = next(iter(payload.workflow.values()), None) - if not isinstance(sample, dict) or "class_type" not in sample: - raise HTTPException(status_code=400, detail="不是有效的 ComfyUI API 工作流 JSON(需包含 class_type)") - custom_dir = os.path.join(WORKFLOW_DIR, CUSTOM_WORKFLOW_FOLDER) - os.makedirs(custom_dir, exist_ok=True) - stored_name = f"{CUSTOM_WORKFLOW_FOLDER}/{name}" - path = workflow_path_from_name(stored_name) - with open(path, "w", encoding="utf-8") as f: - json.dump(payload.workflow, f, ensure_ascii=False, indent=2) - return {"name": stored_name} - -@app.put("/api/workflows/{name:path}/config") -def save_workflow_config(name: str, payload: WorkflowConfig): - if not WORKFLOW_NAME_RE.match(name): - raise HTTPException(status_code=400, detail="Invalid workflow name") - workflow_path = workflow_path_from_name(name) - if not os.path.exists(workflow_path): - raise HTTPException(status_code=404, detail="Workflow not found") - cfg_path = workflow_config_path(name) - with open(cfg_path, "w", encoding="utf-8") as f: - json.dump(payload.dict(), f, ensure_ascii=False, indent=2) - return {"config": payload.dict()} - -@app.delete("/api/workflows/{name:path}") -def delete_workflow(name: str): - if not WORKFLOW_NAME_RE.match(name): - raise HTTPException(status_code=400, detail="Invalid workflow name") - if is_builtin_workflow(name): - raise HTTPException(status_code=400, detail="内置工作流不可删除") - workflow_path = workflow_path_from_name(name) - cfg_path = workflow_config_path(name) - if not os.path.exists(workflow_path): - raise HTTPException(status_code=404, detail="Workflow not found") - os.remove(workflow_path) - if os.path.exists(cfg_path): - os.remove(cfg_path) - return {"ok": True} - -@app.post("/api/workflows/{name:path}/run") -def run_workflow(name: str, payload: WorkflowRunRequest): - if not WORKFLOW_NAME_RE.match(name): - raise HTTPException(status_code=400, detail="Invalid workflow name") - if not os.path.exists(workflow_path_from_name(name)): - raise HTTPException(status_code=404, detail="Workflow not found") - # 根据 config 的字段把值映射成 params 节点覆盖 - params: Dict[str, Dict[str, Any]] = {} - for field in payload.config.fields: - if not field.node or not field.input: - continue - if field.id in payload.fields: - value = payload.fields[field.id] - # 类型转换 - if field.type in ("number", "slider"): - try: - value = float(value) if (field.step and field.step < 1) else int(float(value)) - except Exception: - pass - elif field.type == "boolean": - value = bool(value) - elif field.type == "dropdown": - # 下拉值如果看起来是数字(如 "1024" / "2048" / "0.8"),自动转成 int/float - if isinstance(value, str): - s = value.strip() - try: - if s and ('.' in s or 'e' in s.lower()): - value = float(s) - elif s and (s.lstrip('-').isdigit()): - value = int(s) - except (ValueError, TypeError): - pass - params.setdefault(field.node, {})[field.input] = value - req = GenerateRequest( - prompt="", - workflow_json=name, - params=params, - type="workflow-test", - client_id=payload.client_id or str(uuid.uuid4()), - ) - return generate(req) - if __name__ == "__main__": - import uvicorn # 关闭服务端协议级 WebSocket ping:部分客户端(如 PS UXP 面板)不会自动回 pong, # 默认 20s ping/20s 超时会把这些连接每隔一会儿就踢掉造成"频繁断连"。 # 客户端有自己的应用层心跳 + 断线重连兜底,这里禁用协议 ping 更稳。 - uvicorn.run(app, host="0.0.0.0", port=3000, - ws_ping_interval=None, ws_ping_timeout=None) + run_uvicorn(app) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..a76eb9451 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,247 @@ +{ + "name": "canvas-windows-desktop", + "version": "1.0.16", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "canvas-windows-desktop", + "version": "1.0.16", + "devDependencies": { + "@tauri-apps/cli": "2.11.4" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz", + "integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.4", + "@tauri-apps/cli-darwin-x64": "2.11.4", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", + "@tauri-apps/cli-linux-arm64-musl": "2.11.4", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-musl": "2.11.4", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", + "@tauri-apps/cli-win32-x64-msvc": "2.11.4" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", + "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", + "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", + "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", + "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", + "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", + "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", + "integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", + "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", + "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", + "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", + "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..7bc0c7473 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "canvas-windows-desktop", + "private": true, + "version": "1.0.16", + "scripts": { + "desktop:dev": "tauri dev", + "desktop:build": "tauri build --no-bundle", + "portable:build": "powershell -ExecutionPolicy Bypass -File tools/build-portable.ps1", + "portable:release": "powershell -ExecutionPolicy Bypass -File tools/build-portable.ps1 -IncrementVersion", + "version:patch": "powershell -ExecutionPolicy Bypass -File tools/increment-version.ps1" + }, + "devDependencies": { + "@tauri-apps/cli": "2.11.4" + } +} diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..565391d5e --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +norecursedirs = .build backup data dist node_modules python diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock new file mode 100644 index 000000000..2c265bcba --- /dev/null +++ b/src-tauri/Cargo.lock @@ -0,0 +1,5515 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +dependencies = [ + "serde_core", +] + +[[package]] +name = "canvas-desktop" +version = "1.0.16" +dependencies = [ + "arboard", + "open", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-single-instance", + "ureq", + "uuid", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.19", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading 0.8.9", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.3+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", + "tiff", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading 0.7.4", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.188" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22053b6a34f84abc97f9129e61334f40174659a1b9bd18c970b83db6a9a6348b" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.19", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml 0.41.0", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.19", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2", + "dispatch2", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.1", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.19", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.19", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.19", + "tokio", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.19", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.19", + "toml 1.1.3+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.3+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.19", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.13.1", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml 0.39.4", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.19", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.19", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow 1.0.4", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.119", + "winnow 1.0.4", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 000000000..0aa786b92 --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "canvas-desktop" +version = "1.0.16" +description = "Canvas Windows portable desktop host" +authors = ["Canvas contributors"] +edition = "2021" +license-file = "../LICENSE" + +[lib] +name = "canvas_desktop_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[[bin]] +name = "Canvas" +path = "src/main.rs" + +[build-dependencies] +tauri-build = { version = "2.5.3", features = [] } + +[dependencies] +arboard = "3.6.1" +open = "5.3.2" +rfd = "0.15.4" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tauri = { version = "2.11.5", features = ["tray-icon"] } +tauri-plugin-single-instance = "2.3.6" +ureq = { version = "3.1.4", features = ["json"] } +uuid = { version = "1.18.1", features = ["v4"] } + +[profile.release] +codegen-units = 1 +lto = "thin" +opt-level = "s" +panic = "abort" +strip = true diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 000000000..d860e1e6a --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png new file mode 100644 index 000000000..279020cb6 Binary files /dev/null and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png new file mode 100644 index 000000000..28e564c59 Binary files /dev/null and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png new file mode 100644 index 000000000..94dbae26e Binary files /dev/null and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png new file mode 100644 index 000000000..9193e431d Binary files /dev/null and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 000000000..9f14d1a65 Binary files /dev/null and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 000000000..2b2ce70bf Binary files /dev/null and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 000000000..6c2abd8ab Binary files /dev/null and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 000000000..90b367682 Binary files /dev/null and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 000000000..308a2e645 Binary files /dev/null and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 000000000..4222e7adf Binary files /dev/null and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 000000000..4e7b93c36 Binary files /dev/null and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 000000000..caf261655 Binary files /dev/null and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 000000000..9d87b2d64 Binary files /dev/null and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png new file mode 100644 index 000000000..feddfa11b Binary files /dev/null and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..2ffbf24b6 --- /dev/null +++ b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..55ca6aed8 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..fc96b76d8 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 000000000..56ae257ac Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..ba704514d Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..173e90102 Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 000000000..e3d559916 Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..0139e5872 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..8a0df6b2d Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 000000000..fb0b7cc26 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..09f3587cc Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..dc07e7142 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..7ce1ac61f Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..5f2d0c94a Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..1eba8142d Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..1278dd87b Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/values/ic_launcher_background.xml b/src-tauri/icons/android/values/ic_launcher_background.xml new file mode 100644 index 000000000..ea9c223a6 --- /dev/null +++ b/src-tauri/icons/android/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns new file mode 100644 index 000000000..bd41ff903 Binary files /dev/null and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico new file mode 100644 index 000000000..81c77c565 Binary files /dev/null and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png new file mode 100644 index 000000000..062b02363 Binary files /dev/null and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 000000000..060657eb1 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 000000000..999dd9700 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png new file mode 100644 index 000000000..999dd9700 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png new file mode 100644 index 000000000..b04d49f5b Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png new file mode 100644 index 000000000..a319d366a Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 000000000..9af30f5bc Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png new file mode 100644 index 000000000..9af30f5bc Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png new file mode 100644 index 000000000..60f9208e5 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png new file mode 100644 index 000000000..999dd9700 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 000000000..0a8a63928 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png new file mode 100644 index 000000000..0a8a63928 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png new file mode 100644 index 000000000..4b6e4d0cc Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png new file mode 100644 index 000000000..f14cb2bfb Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png new file mode 100644 index 000000000..4b6e4d0cc Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png new file mode 100644 index 000000000..35445a4f8 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png new file mode 100644 index 000000000..112238ef3 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png new file mode 100644 index 000000000..0a2721fdc Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 000000000..c3d395e0a Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 000000000..5ff0ad594 --- /dev/null +++ b/src-tauri/src/lib.rs @@ -0,0 +1,500 @@ +use arboard::Clipboard; +use rfd::{MessageButtons, MessageDialog, MessageLevel}; +use serde::{Deserialize, Serialize}; +use std::{ + fs::{self, OpenOptions}, + io::Read, + net::{TcpListener, UdpSocket}, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + sync::{ + atomic::{AtomicBool, Ordering}, + Mutex, + }, + thread, + time::{Duration, Instant}, +}; +use tauri::{ + menu::{Menu, MenuItem}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + AppHandle, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent, +}; +use uuid::Uuid; + +fn boxed_error(message: impl Into) -> Box { + Box::new(std::io::Error::other(message.into())) +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct AppConfig { + #[serde(default = "default_host")] + host: String, + #[serde(default = "default_port")] + port: u16, + #[serde(default = "default_true")] + lan_enabled: bool, + #[serde(default = "default_cache")] + cache_max_bytes: u64, +} + +fn default_host() -> String { + "0.0.0.0".to_string() +} +fn default_port() -> u16 { + 3000 +} +fn default_true() -> bool { + true +} +fn default_cache() -> u64 { + 10 * 1024 * 1024 * 1024 +} + +impl Default for AppConfig { + fn default() -> Self { + Self { + host: default_host(), + port: default_port(), + lan_enabled: true, + cache_max_bytes: default_cache(), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +struct WindowPlacement { + x: i32, + y: i32, + width: u32, + height: u32, + maximized: bool, +} + +struct DesktopState { + backend: Mutex>, + quitting: AtomicBool, + data_root: PathBuf, + desktop_token: String, + port: u16, +} + +fn discover_portable_root() -> Result { + if let Ok(root) = std::env::var("CANVAS_DEV_ROOT") { + return Ok(PathBuf::from(root) + .canonicalize() + .unwrap_or_else(|_| PathBuf::from("."))); + } + if let Ok(cwd) = std::env::current_dir() { + if cwd.join("backend_entry.py").is_file() { + return Ok(cwd); + } + } + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + exe.parent() + .map(Path::to_path_buf) + .ok_or_else(|| "无法定位 Canvas.exe 所在目录".to_string()) +} + +fn read_config(data_root: &Path) -> Result { + let config_dir = data_root.join("config"); + fs::create_dir_all(&config_dir).map_err(|e| format!("无法创建 data/config:{e}"))?; + let path = config_dir.join("app.json"); + if !path.exists() { + let value = + serde_json::to_string_pretty(&AppConfig::default()).map_err(|e| e.to_string())? + "\n"; + fs::write(&path, value).map_err(|e| format!("无法创建 app.json:{e}"))?; + } + let raw = fs::read_to_string(&path).map_err(|e| format!("无法读取 app.json:{e}"))?; + serde_json::from_str(&raw).map_err(|e| format!("app.json 格式错误:{e}")) +} + +fn port_owner(port: u16) -> String { + let Some(text) = command_stdout("netstat", &["-ano", "-p", "tcp"], Duration::from_secs(2)) + else { + return "未知进程".to_string(); + }; + let needle = format!(":{port}"); + for line in text + .lines() + .filter(|line| line.contains(&needle) && line.contains("LISTENING")) + { + if let Some(pid) = line.split_whitespace().last() { + let filter = format!("PID eq {pid}"); + let name = command_stdout( + "tasklist", + &["/FI", &filter, "/FO", "CSV", "/NH"], + Duration::from_secs(2), + ) + .unwrap_or_default() + .trim() + .to_string(); + return format!("PID {pid} {name}"); + } + } + "未知进程".to_string() +} + +fn command_stdout(program: &str, args: &[&str], timeout: Duration) -> Option { + let mut child = Command::new(program) + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + let deadline = Instant::now() + timeout; + loop { + if child.try_wait().ok().flatten().is_some() { + let mut bytes = Vec::new(); + child.stdout.take()?.read_to_end(&mut bytes).ok()?; + return Some(String::from_utf8_lossy(&bytes).into_owned()); + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + thread::sleep(Duration::from_millis(25)); + } +} + +fn rotate_log(path: &Path, max_bytes: u64, backups: u8) { + if path + .metadata() + .map(|metadata| metadata.len() <= max_bytes) + .unwrap_or(true) + { + return; + } + let oldest = path.with_extension(format!("log.{backups}")); + let _ = fs::remove_file(oldest); + for number in (1..backups).rev() { + let source = path.with_extension(format!("log.{number}")); + let target = path.with_extension(format!("log.{}", number + 1)); + if source.exists() { + let _ = fs::rename(source, target); + } + } + let _ = fs::rename(path, path.with_extension("log.1")); +} + +fn ensure_port_available(port: u16, config_path: &Path) -> Result<(), String> { + match TcpListener::bind(("0.0.0.0", port)) { + Ok(listener) => { + drop(listener); + Ok(()) + } + Err(_) => { + let message = format!( + "端口 {port} 已被占用({})。\n\n请在以下文件修改端口后重新启动:\n{}", + port_owner(port), + config_path.display() + ); + MessageDialog::new() + .set_level(MessageLevel::Error) + .set_title("Canvas 无法启动") + .set_description(&message) + .set_buttons(MessageButtons::Ok) + .show(); + let _ = Command::new("notepad.exe").arg(config_path).spawn(); + Err(message) + } + } +} + +fn spawn_backend( + root: &Path, + data_root: &Path, + config: &AppConfig, + token: &str, +) -> Result { + let app_root = root.join("app"); + let packaged = app_root + .join("backend") + .join("canvas-backend") + .join("canvas-backend.exe"); + let parent_pid = std::process::id().to_string(); + let common = [ + "--data-dir".to_string(), + data_root.display().to_string(), + "--app-root".to_string(), + app_root.display().to_string(), + "--portable-root".to_string(), + root.display().to_string(), + "--host".to_string(), + if config.lan_enabled { + config.host.clone() + } else { + "127.0.0.1".to_string() + }, + "--port".to_string(), + config.port.to_string(), + "--desktop-token".to_string(), + token.to_string(), + "--parent-pid".to_string(), + parent_pid, + "--runtime-mode".to_string(), + "desktop".to_string(), + ]; + let logs = data_root.join("logs"); + fs::create_dir_all(&logs).map_err(|e| e.to_string())?; + let stdout_path = logs.join("backend.stdout.log"); + let stderr_path = logs.join("backend.stderr.log"); + rotate_log(&stdout_path, 10 * 1024 * 1024, 5); + rotate_log(&stderr_path, 10 * 1024 * 1024, 5); + let stdout = OpenOptions::new() + .create(true) + .append(true) + .open(stdout_path) + .map_err(|e| e.to_string())?; + let stderr = OpenOptions::new() + .create(true) + .append(true) + .open(stderr_path) + .map_err(|e| e.to_string())?; + let mut command; + if packaged.is_file() { + command = Command::new(packaged); + command.args(&common); + } else { + let python = root.join("python").join("python.exe"); + let entry = root.join("backend_entry.py"); + if !python.is_file() || !entry.is_file() { + return Err("未找到 app/backend Sidecar,也未找到源码开发运行时".to_string()); + } + command = Command::new(python); + command + .arg(entry) + .args(&common) + .env("PYTHONPATH", root) + .current_dir(root); + } + command + .stdout(Stdio::from(stdout)) + .stderr(Stdio::from(stderr)) + .stdin(Stdio::null()) + .spawn() + .map_err(|e| format!("启动后端失败:{e}")) +} + +fn wait_for_health(port: u16, child: &mut Child) -> Result<(), String> { + let deadline = Instant::now() + Duration::from_secs(30); + let url = format!("http://127.0.0.1:{port}/api/health"); + while Instant::now() < deadline { + if let Some(status) = child.try_wait().map_err(|e| e.to_string())? { + return Err(format!("后端提前退出:{status}")); + } + if ureq::get(&url) + .config() + .timeout_global(Some(Duration::from_millis(500))) + .build() + .call() + .map(|r| r.status().as_u16() == 200) + .unwrap_or(false) + { + return Ok(()); + } + thread::sleep(Duration::from_millis(100)); + } + Err("后端未在 30 秒内完成启动,请检查 data/logs/backend.stderr.log".to_string()) +} + +fn local_ip() -> String { + UdpSocket::bind("0.0.0.0:0") + .and_then(|socket| { + socket.connect("8.8.8.8:80")?; + socket.local_addr().map(|addr| addr.ip().to_string()) + }) + .unwrap_or_else(|_| "127.0.0.1".to_string()) +} + +fn show_main(app: &AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } +} + +fn save_window_placement(app: &AppHandle) { + let Some(window) = app.get_webview_window("main") else { + return; + }; + let Ok(position) = window.outer_position() else { + return; + }; + let Ok(size) = window.outer_size() else { + return; + }; + let placement = WindowPlacement { + x: position.x, + y: position.y, + width: size.width, + height: size.height, + maximized: window.is_maximized().unwrap_or(false), + }; + let state = app.state::(); + let path = state.data_root.join("config").join("window.json"); + if let Ok(raw) = serde_json::to_string_pretty(&placement) { + let _ = fs::write(path, raw + "\n"); + } +} + +fn stop_backend(app: &AppHandle) { + let state = app.state::(); + if state.quitting.swap(true, Ordering::SeqCst) { + return; + } + save_window_placement(app); + let url = format!("http://127.0.0.1:{}/api/runtime/shutdown", state.port); + let _ = ureq::post(&url) + .header("X-Desktop-Token", &state.desktop_token) + .config() + .timeout_global(Some(Duration::from_secs(2))) + .build() + .send_empty(); + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + let exited = state + .backend + .lock() + .ok() + .and_then(|mut guard| { + guard + .as_mut() + .and_then(|child| child.try_wait().ok().flatten()) + }) + .is_some(); + if exited { + return; + } + thread::sleep(Duration::from_millis(100)); + } + if let Ok(mut guard) = state.backend.lock() { + if let Some(child) = guard.as_mut() { + let _ = child.kill(); + let _ = child.wait(); + } + }; +} + +fn setup_tray(app: &tauri::App) -> tauri::Result<()> { + let open_item = MenuItem::with_id(app, "open", "打开软件", true, None::<&str>)?; + let browser_item = MenuItem::with_id(app, "browser", "浏览器打开", true, None::<&str>)?; + let copy_item = MenuItem::with_id(app, "copy", "复制局域网地址", true, None::<&str>)?; + let devices_item = MenuItem::with_id(app, "devices", "配对设备", true, None::<&str>)?; + let data_item = MenuItem::with_id(app, "data", "打开 data 目录", true, None::<&str>)?; + let quit_item = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; + let menu = Menu::with_items( + app, + &[ + &open_item, + &browser_item, + ©_item, + &devices_item, + &data_item, + &quit_item, + ], + )?; + let mut tray = TrayIconBuilder::new() + .menu(&menu) + .show_menu_on_left_click(false) + .tooltip("Canvas"); + if let Some(icon) = app.default_window_icon() { + tray = tray.icon(icon.clone()); + } + tray.on_menu_event(|app, event| match event.id.as_ref() { + "open" => show_main(app), + "browser" => { + let state = app.state::(); + let _ = open::that(format!("http://127.0.0.1:{}", state.port)); + } + "copy" => { + let state = app.state::(); + let address = format!("http://{}:{}", local_ip(), state.port); + let _ = Clipboard::new().and_then(|mut value| value.set_text(address)); + } + "devices" => { + show_main(app); + if let Some(window) = app.get_webview_window("main") { + let _ = window.eval("location.href='/devices'"); + } + } + "data" => { + let state = app.state::(); + let _ = open::that(&state.data_root); + } + "quit" => { + stop_backend(app); + app.exit(0); + } + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if matches!( + event, + TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } + ) { + show_main(tray.app_handle()); + } + }) + .build(app)?; + Ok(()) +} + +pub fn run() { + let builder = tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| show_main(app))) + .setup(|app| { + let root = discover_portable_root().map_err(boxed_error)?; + let data_root = root.join("data"); + let config = read_config(&data_root).map_err(boxed_error)?; + ensure_port_available(config.port, &data_root.join("config").join("app.json")).map_err(boxed_error)?; + let token = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let mut child = spawn_backend(&root, &data_root, &config, &token).map_err(boxed_error)?; + if let Err(error) = wait_for_health(config.port, &mut child) { + let _ = child.kill(); + MessageDialog::new().set_level(MessageLevel::Error).set_title("Canvas 后端启动失败").set_description(&error).show(); + return Err(boxed_error(error)); + } + app.manage(DesktopState { backend: Mutex::new(Some(child)), quitting: AtomicBool::new(false), data_root: data_root.clone(), desktop_token: token.clone(), port: config.port }); + let url: tauri::Url = format!("http://127.0.0.1:{}/api/auth/bootstrap?token={token}", config.port).parse().map_err(|e| boxed_error(format!("URL 错误:{e}")))?; + let placement_path = data_root.join("config").join("window.json"); + let placement = fs::read_to_string(placement_path).ok().and_then(|raw| serde_json::from_str::(&raw).ok()).unwrap_or_default(); + let mut window = WebviewWindowBuilder::new(app, "main", WebviewUrl::External(url)).title("Canvas").min_inner_size(960.0, 640.0).inner_size(1440.0, 900.0).data_directory(data_root.join("cache").join("webview2")); + if placement.width >= 960 && placement.height >= 640 { window = window.inner_size(placement.width as f64, placement.height as f64).position(placement.x as f64, placement.y as f64); } + match window.build() { + Ok(view) => { if placement.maximized { let _ = view.maximize(); } } + Err(error) => { + stop_backend(app.handle()); + MessageDialog::new().set_level(MessageLevel::Error).set_title("缺少 Microsoft Edge WebView2").set_description(format!("Canvas 无法创建窗口:{error}\n\n请安装 Microsoft Edge WebView2 Evergreen Runtime 后重试。\nhttps://developer.microsoft.com/microsoft-edge/webview2/")).show(); + return Err(boxed_error(error.to_string())); + } + } + setup_tray(app)?; + let monitor = app.handle().clone(); + thread::spawn(move || loop { + thread::sleep(Duration::from_millis(500)); + let state = monitor.state::(); + if state.quitting.load(Ordering::SeqCst) { break; } + let exited = state.backend.lock().ok().and_then(|mut guard| guard.as_mut().and_then(|child| child.try_wait().ok().flatten())); + if let Some(status) = exited { + MessageDialog::new().set_level(MessageLevel::Error).set_title("Canvas 后端已停止").set_description(format!("Sidecar 异常退出:{status}\n请查看 data/logs/backend.stderr.log 后重新启动。")).show(); + monitor.exit(1); break; + } + }); + Ok(()) + }) + .on_window_event(|window, event| { + if let WindowEvent::CloseRequested { api, .. } = event { + let state = window.app_handle().state::(); + if !state.quitting.load(Ordering::SeqCst) { api.prevent_close(); save_window_placement(window.app_handle()); let _ = window.hide(); } + } + }); + builder + .run(tauri::generate_context!()) + .expect("Canvas desktop runtime failed"); +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 000000000..cb6c704e4 --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,5 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + canvas_desktop_lib::run(); +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 000000000..37bac2630 --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,20 @@ +{ + "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", + "productName": "Canvas", + "version": "1.0.16", + "identifier": "com.hero8152.canvas.desktop", + "build": { + "frontendDist": "../desktop-placeholder" + }, + "app": { + "windows": [], + "security": { + "csp": null + } + }, + "bundle": { + "active": false, + "targets": [], + "icon": ["icons/icon.png"] + } +} diff --git a/static/angle.html b/static/angle.html deleted file mode 100644 index 9afdb35ea..000000000 --- a/static/angle.html +++ /dev/null @@ -1,1361 +0,0 @@ - - - - - - - - Angle Control | 视角重塑 - - - - - - - - - - - - - - -
-
-
-

- ANGLE CONTROL® -

-

Camera & Perspective Control -

-
- -
- -
- -
-
-

01. Input Source -

-
- - -
-
- -
-

Drop image here

-
- - - - -
-
- -
-

02. Camera Control

-
- -
- - -
- -
-
-
- - Rotation -
-
- -
- - ° -
-
-
- -
- - -
-
-
- - Pitch -
-
- -
- - ° -
-
-
- -
- - -
-
-
- - Distance -
-
- -
- -
-
-
- -
-
-
-
-
- - -
-
-

03. Parameters

- -
-
- - Prompt -
- -
- - -
-
- - Local -
-
- - ModelScope -
-
-
- - -
- -
-

04. Result Preview

-
-
- -

Canvas Ready

-
- - - - - - - - -
-
-
-
- -
-
-

Archive

-
-
-
-
- End of Archive -
-
-
- - - - - - - - - diff --git a/static/api-settings.html b/static/api-settings.html index 10c960be1..cf41e5109 100644 --- a/static/api-settings.html +++ b/static/api-settings.html @@ -15,12 +15,12 @@ } catch(e) {} })(); - - - - - - + + + + + +
@@ -215,6 +215,20 @@
+
@@ -275,8 +290,8 @@
-
模型列表
-
从上游 API 自动拉取所有可用模型并按类型分类(image / chat / video)
+
模型列表
+
从上游 API 自动拉取所有可用模型并按类型分类(image / chat / video)
-
+
生图模型
@@ -302,18 +317,18 @@
-
+
-
聊天模型
-
GPT 对话和 LLM 节点使用
+
聊天模型
+
GPT 对话和 LLM 节点使用
-
+
视频模型
@@ -433,6 +448,32 @@
+
+
+
+
+
OpenAI Codex CLI 帮助
+
查看 codex 官方命令帮助输出
+
+
+
+
+ + +
+

+        
+
+
@@ -474,6 +515,6 @@
- + diff --git a/static/asset-manager.html b/static/asset-manager.html index 1ca48b4ba..a7a6a15cc 100644 --- a/static/asset-manager.html +++ b/static/asset-manager.html @@ -16,10 +16,10 @@ } catch(e) {} })(); - - - - + + + +
@@ -46,6 +46,6 @@
- + diff --git a/static/canvas-list.html b/static/canvas-list.html index cb7ec0a9c..41b90063c 100644 --- a/static/canvas-list.html +++ b/static/canvas-list.html @@ -16,12 +16,12 @@ } catch(e) {} })(); - - - - - - + + + + + +
@@ -89,6 +89,6 @@
- + diff --git a/static/canvas.html b/static/canvas.html index d8ff0d837..09e21b229 100644 --- a/static/canvas.html +++ b/static/canvas.html @@ -16,12 +16,12 @@ } catch(e) {} })(); - - - - - - + + + + + +
@@ -42,11 +42,8 @@ - - -
@@ -70,11 +67,8 @@ - - -
@@ -231,6 +225,7 @@ +
@@ -268,6 +263,11 @@
+
+ + + - +
@@ -316,6 +316,7 @@
+
@@ -342,7 +343,6 @@ - - + diff --git a/static/comfyui-settings.html b/static/comfyui-settings.html deleted file mode 100644 index 0fc1b396e..000000000 --- a/static/comfyui-settings.html +++ /dev/null @@ -1,127 +0,0 @@ - - - - - - 工作流设置 - - - - - - - - - -
-
-
-
-
工作流设置
-
选择本地 ComfyUI 工作流,配置可暴露到画布的输入参数。
-
-
-
- -
- - -
- - - -
- -
-
-
-
- preview -
- - - - diff --git a/static/css/api-settings.css b/static/css/api-settings.css index 71b503389..d411fe69d 100644 --- a/static/css/api-settings.css +++ b/static/css/api-settings.css @@ -324,6 +324,13 @@ body.show-volcengine .api-key-label { display:inline-flex !important; } .runninghub-config-block[hidden] { display:none !important; } .runninghub-config-block { display:none; gap:14px; } body.show-runninghub .runninghub-config-block { display:flex; } +body.show-local-vision .api-image-model-block, +body.show-local-vision .api-video-model-block, +body.show-local-vision .image-request-mode-wrap, +body.show-local-vision .image-edit-route-wrap, +body.show-local-vision #probeAsyncBtn { display:none !important; } +body.show-local-vision .model-grid { grid-template-columns:minmax(0,760px); } +body.show-local-vision .api-chat-model-block { border-color:color-mix(in srgb,var(--accent) 28%,var(--line)); box-shadow:0 14px 34px rgba(15,23,42,.06); } .rh-paste-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; } .rh-paste-input { width:100%; height:40px; border:1px solid var(--line); border-radius:11px; background:var(--panel); color:var(--text); outline:none; padding:0 12px; font-size:13px; font-weight:700; } .rh-paste-input:focus { border-color:var(--text); } diff --git a/static/css/canvas.css b/static/css/canvas.css index 085567ebc..c9493f21f 100644 --- a/static/css/canvas.css +++ b/static/css/canvas.css @@ -188,8 +188,7 @@ body.canvas-board-pan * { cursor:grabbing !important; user-select:none !importan .world { position:absolute; left:0; top:0; width:6000px; height:4000px; transform-origin:0 0; } .links { position:absolute; inset:0; width:6000px; height:4000px; overflow:visible; pointer-events:none; z-index:1; } .link { stroke:var(--faint); stroke-width:2.5; fill:none; opacity:.82; pointer-events:none; } -.link.link-active { stroke:var(--strong); stroke-width:3.4; opacity:1; filter:drop-shadow(0 0 7px rgba(17,24,39,.22)); } -.link.link-dim { opacity:.24; } +.link.link-active { stroke:var(--strong); opacity:1; } .link.temp { stroke:var(--strong); stroke-dasharray:6 6; } .link.knife-trail { stroke:#ef4444; stroke-width:2.4; stroke-dasharray:7 6; opacity:.88; filter:drop-shadow(0 0 6px rgba(239,68,68,.35)); } .link-hit { stroke:transparent; stroke-width:18; fill:none; pointer-events:stroke; } @@ -714,9 +713,6 @@ input[type=range].canvas-range::-webkit-slider-runnable-track { height:3px; bord .ltx-params-row .field { display:flex; flex-direction:column; gap:3px; min-width:0; } .ltx-params-row .setting-title { font-size:9px; font-weight:800; color:var(--muted); text-transform:uppercase; letter-spacing:.08em; } .ltx-params-row .setting-input { width:100%; height:30px; padding:0 8px; border-radius:10px; border:1px solid var(--line); background:var(--soft); font-size:11px; font-weight:700; color:var(--text); } -.ltx-director-timeline-host { flex:1; min-height:0; min-width:0; overflow:hidden; } -.ltx-director-timeline-host .pr-wrapper { height:100%; } -.ltx-director-timeline-host .pr-viewport { max-height:100%; } .node.sized.ltxDirector-node .gen-run-row, .node.sized.ltx-director-node .gen-run-row { margin-top:auto; flex-shrink:0; } .hint { position:absolute; left:50%; bottom:24px; transform:translateX(-50%); z-index:25; color:#94a3b8; font-size:11px; font-weight:700; pointer-events:none; } @@ -960,6 +956,9 @@ body.theme-dark .error-title { color:#fca5a5; } .grid-gap-control { display:flex; align-items:center; gap:8px; color:#64748b; font-size:11px; font-weight:800; min-width:250px; } .grid-gap-control input[type="range"] { width:160px; } .grid-gap-value { min-width:42px; height:24px; padding:0 8px; border-radius:999px; display:inline-flex; align-items:center; justify-content:center; background:#111827; color:#fff; font-size:11px; font-weight:900; font-variant-numeric:tabular-nums; } +.image-resize-tools input[type="range"] { width:180px; } +.image-resize-tools input[type="number"] { width:74px; text-align:center; font-variant-numeric:tabular-nums; } +.image-resize-resolution { margin:0; min-width:88px; color:#64748b; font-weight:900; font-variant-numeric:tabular-nums; } .grid-preset-row { display:flex; align-items:center; gap:6px; flex-wrap:wrap; } .grid-preset-btn { height:28px; padding:0 9px; border-radius:999px; border:1px solid rgba(203,213,225,.9); background:#fff; color:#64748b; font-size:11px; font-weight:850; } .grid-preset-btn:hover { background:#111827; border-color:#111827; color:#fff; } @@ -981,7 +980,7 @@ body.theme-dark .error-title { color:#fca5a5; } .crop-canvas.text-mode { cursor:text; } .crop-canvas.text-mode .edit-draw-canvas { pointer-events:none; } .crop-canvas.text-mode .edit-text-canvas { pointer-events:auto; } -.crop-canvas.preview-mode .crop-box,.crop-canvas.mask-mode .crop-box,.crop-canvas.brush-mode .crop-box,.crop-canvas.grid-mode .crop-box,.crop-canvas.outpaint-mode .crop-box { display:none; } +.crop-canvas.preview-mode .crop-box,.crop-canvas.mask-mode .crop-box,.crop-canvas.brush-mode .crop-box,.crop-canvas.resize-mode .crop-box,.crop-canvas.grid-mode .crop-box,.crop-canvas.outpaint-mode .crop-box { display:none; } .crop-canvas.preview-mode { cursor:default; } .crop-canvas.grid-custom-h .edit-draw-canvas { pointer-events:auto; cursor:row-resize; } .crop-canvas.grid-custom-v .edit-draw-canvas { pointer-events:auto; cursor:col-resize; } @@ -1007,6 +1006,8 @@ body.theme-dark .error-title { color:#fca5a5; } .outpaint-resolution { position:absolute; left:10px; top:10px; z-index:5; height:26px; padding:0 9px; border-radius:999px; display:none; align-items:center; background:rgba(15,23,42,.74); color:#fff; font-size:11px; font-weight:900; line-height:26px; box-shadow:0 8px 22px rgba(15,23,42,.22); pointer-events:none; } .crop-canvas.outpaint-mode .outpaint-resolution { display:inline-flex; } .crop-canvas.outpaint-mode.outpaint-warning .outpaint-resolution { background:rgba(239,68,68,.94); } +.resize-resolution-overlay { position:absolute; left:10px; top:10px; z-index:5; height:26px; padding:0 10px; border-radius:999px; display:none; align-items:center; background:rgba(15,23,42,.76); color:#fff; font-size:11px; font-weight:900; line-height:26px; font-variant-numeric:tabular-nums; box-shadow:0 8px 22px rgba(15,23,42,.22); pointer-events:none; } +.crop-canvas.resize-mode .resize-resolution-overlay { display:inline-flex; } .outpaint-handle { position:absolute; z-index:6; display:none; background:#fff; border:2px solid #f8fafc; box-shadow:0 3px 12px rgba(15,23,42,.24); } .crop-canvas.outpaint-mode.outpaint-warning .outpaint-handle { border-color:#ef4444; } .crop-canvas.outpaint-mode .outpaint-frame .outpaint-handle { display:block; pointer-events:auto; } diff --git a/static/css/compare-viewer.css b/static/css/compare-viewer.css new file mode 100644 index 000000000..fa8561fdd --- /dev/null +++ b/static/css/compare-viewer.css @@ -0,0 +1,23 @@ +.compare-viewer-stage { --compare-divider:50%; --compare-scale:1; --compare-pan-x:0px; --compare-pan-y:0px; position:relative; overflow:hidden; user-select:none; touch-action:none; } +.compare-viewer-stage .compare-viewer-media { transform:translate3d(var(--compare-pan-x),var(--compare-pan-y),0) scale(var(--compare-scale)); transform-origin:50% 50%; transition:transform .14s ease; will-change:transform; } +.compare-viewer-stage.is-panning .compare-viewer-media { transition:none; cursor:grabbing; } +.compare-viewer-stage.is-zoomed { cursor:grab; } +.compare-viewer-stage .compare-viewer-after-clip { width:var(--compare-divider); } +.compare-viewer-stage .compare-viewer-handle { left:var(--compare-divider); } +.compare-viewer-stage:fullscreen, +.compare-viewer-stage.compare-viewer-fallback-fullscreen { width:100vw !important; height:100vh !important; max-width:none !important; max-height:none !important; border:0 !important; border-radius:0 !important; background:#090a0d; } +.compare-viewer-stage.compare-viewer-fallback-fullscreen { position:fixed !important; inset:0 !important; z-index:1000 !important; } +.compare-viewer-stage:fullscreen .compare-viewer-tools, +.compare-viewer-stage.compare-viewer-fallback-fullscreen .compare-viewer-tools { right:22px; bottom:22px; transform:scale(1.08); transform-origin:right bottom; } +.compare-viewer-stage:fullscreen .compare-viewer-label, +.compare-viewer-stage.compare-viewer-fallback-fullscreen .compare-viewer-label { top:22px; } +.compare-viewer-stage:fullscreen .compare-viewer-label.before, +.compare-viewer-stage.compare-viewer-fallback-fullscreen .compare-viewer-label.before { left:22px; } +.compare-viewer-stage:fullscreen .compare-viewer-label.after, +.compare-viewer-stage.compare-viewer-fallback-fullscreen .compare-viewer-label.after { right:22px; } +.compare-viewer-fullscreen-exit { display:none; } +.compare-viewer-stage:fullscreen .compare-viewer-fullscreen-enter, +.compare-viewer-stage.compare-viewer-fallback-fullscreen .compare-viewer-fullscreen-enter { display:none; } +.compare-viewer-stage:fullscreen .compare-viewer-fullscreen-exit, +.compare-viewer-stage.compare-viewer-fallback-fullscreen .compare-viewer-fullscreen-exit { display:inline; } + diff --git a/static/css/ecommerce.css b/static/css/ecommerce.css new file mode 100644 index 000000000..bd9e604ea --- /dev/null +++ b/static/css/ecommerce.css @@ -0,0 +1,401 @@ +:root { + --ec-bg:#f5f6f3; + --ec-panel:#fff; + --ec-panel-soft:#f8f8f5; + --ec-text:#141512; + --ec-text-soft:#50574e; + --ec-muted:#777b72; + --ec-border:#e3e5de; + --ec-border-strong:#b9beb3; + --ec-accent:#1b241b; + --ec-accent-contrast:#fff; + --ec-accent-soft:#e6ece3; + --ec-control:#fbfbf9; + --ec-control-hover:#f2f3ef; + --ec-overlay:rgba(247,248,245,.84); + --ec-checker-a:#eceee9; + --ec-checker-b:#f7f8f5; + --ec-danger:#b42318; + --ec-danger-soft:#fceae8; + --ec-success:#26734d; + --ec-success-soft:#e5f3eb; + --ec-shadow:0 18px 48px rgba(26,32,25,.08); + --ec-radius:24px; +} + +* { box-sizing:border-box; } +html, body { width:100%; height:100%; min-height:0; margin:0; overflow:hidden; } +html { color-scheme:light; } +body { background:var(--ec-bg); color:var(--ec-text); font-family:Inter,"Microsoft YaHei",system-ui,-apple-system,sans-serif; } +button, select, input, textarea { font:inherit; } +button { color:inherit; } +svg { width:20px; height:20px; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; } +.hidden { display:none !important; } + +.ec-page { width:min(1640px,100%); height:100%; min-height:0; display:grid; grid-template-rows:auto minmax(0,1fr); gap:14px; margin:0 auto; padding:18px 22px 22px; } +.ec-topbar { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:14px; } +.ec-header { display:flex; align-items:flex-end; justify-content:space-between; gap:24px; margin-bottom:26px; } +.ec-eyebrow { margin-bottom:8px; color:#788073; font-size:10px; font-weight:800; letter-spacing:.28em; } +.ec-header h1 { margin:0; font-family:"Space Grotesk",Inter,sans-serif; font-size:clamp(32px,4vw,54px); line-height:.95; letter-spacing:-.055em; } +.ec-header p { margin:12px 0 0; color:var(--ec-muted); font-size:13px; } +.ec-header-actions { min-width:0; display:flex; align-items:center; gap:10px; } +.ec-status { display:inline-flex; align-items:center; min-height:38px; padding:0 14px; border:1px solid var(--ec-border); border-radius:999px; background:color-mix(in srgb,var(--ec-panel) 82%,transparent); color:var(--ec-muted); font-size:11px; font-weight:700; } +.ec-status.ready::before { content:""; width:7px; height:7px; margin-right:8px; border-radius:50%; background:var(--ec-success); box-shadow:0 0 0 4px rgba(38,115,77,.12); } +.ec-status.error { color:var(--ec-danger); } +.ec-icon-button { width:40px; height:40px; display:inline-flex; align-items:center; justify-content:center; border:1px solid var(--ec-border); border-radius:13px; background:var(--ec-panel); cursor:pointer; transition:.2s ease; } +.ec-icon-button:hover { transform:translateY(-1px); border-color:var(--ec-border-strong); } + +.ec-operation-tabs { min-width:0; display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:7px; padding:6px; border:1px solid var(--ec-border); border-radius:18px; background:color-mix(in srgb,var(--ec-panel) 72%,transparent); } +.ec-operation-tabs button { min-width:0; height:48px; display:flex; align-items:center; gap:10px; padding:0 13px; border:0; border-radius:13px; background:transparent; color:var(--ec-muted); cursor:pointer; text-align:left; transition:.22s ease; } +.ec-operation-tabs button span { flex:0 0 auto; font-family:"JetBrains Mono",monospace; font-size:9px; opacity:.55; } +.ec-operation-tabs button b { overflow:hidden; font-size:13px; white-space:nowrap; text-overflow:ellipsis; } +.ec-operation-tabs button:hover { background:var(--ec-control-hover); color:var(--ec-text); } +.ec-operation-tabs button.active { background:var(--ec-accent); color:var(--ec-accent-contrast); box-shadow:0 12px 24px rgba(27,36,27,.16); } + +.ec-modebar { display:flex; align-items:center; justify-content:space-between; gap:20px; margin:18px 0; } +.ec-mode-toggle { display:flex; gap:5px; padding:5px; border:1px solid var(--ec-border); border-radius:16px; background:var(--ec-panel); } +.ec-mode-toggle button { min-width:150px; padding:9px 16px; border:0; border-radius:11px; background:transparent; color:var(--ec-muted); cursor:pointer; text-align:left; } +.ec-mode-toggle button b, .ec-mode-toggle button small { display:block; } +.ec-mode-toggle button b { font-size:12px; } +.ec-mode-toggle button small { margin-top:2px; font-size:9px; opacity:.7; } +.ec-mode-toggle button.active { background:var(--ec-accent-soft); color:var(--ec-accent); } +.ec-route-summary { min-width:120px; max-width:280px; color:var(--ec-muted); font-size:11px; text-align:right; } +.ec-route-summary span { display:block; margin-bottom:3px; font-size:9px; font-weight:800; letter-spacing:.14em; text-transform:uppercase; } +.ec-route-summary strong { display:block; max-width:480px; overflow:hidden; color:var(--ec-text); white-space:nowrap; text-overflow:ellipsis; } + +.ec-workspace { min-height:0; display:grid; grid-template-columns:minmax(320px,390px) minmax(0,1fr); gap:14px; align-items:stretch; } +.ec-control-panel, .ec-result-panel { min-height:0; height:100%; overflow:auto; overscroll-behavior:contain; scrollbar-gutter:stable; border:1px solid var(--ec-border); border-radius:var(--ec-radius); background:var(--ec-panel); box-shadow:var(--ec-shadow); } +.ec-control-panel { padding:22px 22px 104px; } +.ec-result-panel { padding:22px; } +.ec-result-body { min-height:0; } +.ec-section-head { display:flex; align-items:center; justify-content:space-between; gap:16px; margin-bottom:18px; } +.ec-section-head > div > span { display:block; margin-bottom:4px; color:#9a9f96; font-family:"JetBrains Mono",monospace; font-size:8px; font-weight:800; letter-spacing:.2em; } +.ec-section-head h2 { margin:0; font-size:16px; letter-spacing:-.02em; } +.ec-section-head > span { color:var(--ec-muted); font-family:"JetBrains Mono",monospace; font-size:10px; } +.ec-text-button { border:0; background:none; color:var(--ec-muted); font-size:10px; font-weight:700; cursor:pointer; } +.ec-text-button:hover { color:var(--ec-text); } + +.ec-input-slots { display:grid; gap:10px; } +.ec-input-module { min-width:0; } +.ec-upload-slot { position:relative; min-height:126px; overflow:hidden; border:1px dashed var(--ec-border-strong); border-radius:18px; background:var(--ec-panel-soft); transition:.2s ease; } +.ec-upload-slot:hover, .ec-upload-slot.dragover { border-color:var(--ec-accent); background:var(--ec-control-hover); } +.ec-upload-slot.dragover { box-shadow:inset 0 0 0 2px color-mix(in srgb,var(--ec-accent) 32%,transparent); } +.ec-upload-slot.required::after { content:"*"; position:absolute; top:12px; right:13px; color:var(--ec-danger); font-weight:800; } +.ec-upload-empty { width:100%; min-height:124px; display:flex; align-items:center; justify-content:center; flex-direction:column; gap:6px; padding:20px; border:0; background:transparent; cursor:pointer; } +.ec-upload-empty:focus-visible { outline:2px solid var(--ec-accent); outline-offset:-4px; } +.ec-upload-empty svg { width:25px; height:25px; color:var(--ec-muted); } +.ec-upload-empty b { font-size:12px; } +.ec-upload-empty small { color:var(--ec-muted); font-size:9px; } +.ec-upload-actions { display:flex; gap:8px; margin-top:6px; } +.ec-upload-actions button { padding:4px 7px; border:1px solid var(--ec-border); border-radius:8px; background:var(--ec-control-hover); color:var(--ec-text-soft); font-size:9px; font-weight:700; cursor:pointer; } +.ec-upload-preview { position:absolute; inset:0; display:grid; grid-template-columns:112px minmax(0,1fr); align-items:center; gap:14px; padding:10px; background:var(--ec-panel); } +.ec-upload-preview img { width:112px; height:104px; border-radius:12px; object-fit:cover; background:var(--ec-panel-soft); } +.ec-upload-info { min-width:0; } +.ec-upload-info b, .ec-upload-info span { display:block; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; } +.ec-upload-info b { margin-bottom:5px; font-size:11px; } +.ec-upload-info span { color:var(--ec-muted); font-size:9px; } +.ec-upload-info .ec-upload-actions { margin-top:13px; } +.ec-universal-guide { padding:12px 13px; border:1px solid var(--ec-border); border-radius:14px; background:var(--ec-accent-soft); } +.ec-universal-guide strong { font-size:11px; } +.ec-universal-guide p { margin:5px 0 0; color:var(--ec-muted); font-size:9px; line-height:1.55; } +.ec-universal-reference { display:grid; gap:8px; padding:9px; border:1px solid var(--ec-border); border-radius:17px; background:var(--ec-panel-soft); transition:.18s ease; } +.ec-universal-reference.dragging { opacity:.45; } +.ec-universal-reference.drag-target { border-color:var(--ec-accent); box-shadow:inset 0 0 0 2px color-mix(in srgb,var(--ec-accent) 24%,transparent); } +.ec-universal-reference > header { display:grid; grid-template-columns:auto auto minmax(0,1fr) auto; align-items:center; gap:7px; } +.ec-universal-reference > header b { font-size:9px; white-space:nowrap; } +.ec-universal-reference > header select { min-width:0; height:34px; padding:0 9px; border:1px solid var(--ec-border); border-radius:9px; outline:0; background:var(--ec-control); color:var(--ec-text); font-size:9px; } +.ec-universal-reference > header button { width:30px; height:30px; border:0; border-radius:8px; background:var(--ec-control-hover); color:var(--ec-muted); } +.ec-drag-handle { color:var(--ec-muted); cursor:grab; font-size:13px; letter-spacing:-.18em; } +.ec-universal-reference .ec-upload-slot { min-height:112px; } +.ec-universal-reference .ec-upload-empty { min-height:110px; padding:13px; } +.ec-universal-reference .ec-upload-preview { grid-template-columns:90px minmax(0,1fr); } +.ec-universal-reference .ec-upload-preview img { width:90px; height:90px; } +.ec-reference-fields { display:grid; grid-template-columns:1fr 1fr; gap:7px; } +.ec-reference-fields label { min-width:0; display:grid; gap:4px; color:var(--ec-muted); font-size:8px; font-weight:800; } +.ec-reference-fields input { width:100%; min-width:0; height:34px; padding:0 9px; border:1px solid var(--ec-border); border-radius:9px; outline:0; background:var(--ec-control); color:var(--ec-text); font-size:9px; } +.ec-add-reference { min-height:42px; display:flex; align-items:center; justify-content:center; gap:8px; border:1px dashed var(--ec-border-strong); border-radius:13px; background:transparent; color:var(--ec-text-soft); font-size:10px; font-weight:800; } +.ec-add-reference span { color:var(--ec-muted); font:8px "JetBrains Mono",monospace; } +.ec-add-reference:disabled { opacity:.42; cursor:not-allowed; } + +.ec-operation-controls { display:grid; gap:14px; margin-top:18px; padding-top:18px; border-top:1px solid var(--ec-border); } +.ec-universal-prompt-help { padding:12px; border:1px solid var(--ec-border); border-radius:13px; background:var(--ec-panel-soft); } +.ec-universal-prompt-help b { font-size:10px; } +.ec-universal-prompt-help p { margin:5px 0; color:var(--ec-muted); font-size:9px; line-height:1.5; } +.ec-universal-prompt-help code { display:block; padding:7px 8px; border-radius:8px; background:var(--ec-control); color:var(--ec-text-soft); font:8px/1.5 "JetBrains Mono",monospace; white-space:normal; } +.ec-field textarea.ec-universal-instruction { min-height:110px; } +.ec-field, .ec-field-grid label, .ec-review-note { display:grid; gap:7px; color:var(--ec-text-soft); font-size:10px; font-weight:700; } +.ec-field select, .ec-field input, .ec-field textarea, .ec-field-grid select, .ec-review-note textarea { width:100%; min-height:42px; padding:10px 12px; border:1px solid var(--ec-border); border-radius:12px; outline:0; background:var(--ec-control); color:var(--ec-text); font-size:11px; } +.ec-field textarea { min-height:76px; resize:vertical; line-height:1.5; } +.ec-field select:focus, .ec-field input:focus, .ec-field textarea:focus, .ec-field-grid select:focus, .ec-review-note textarea:focus { border-color:#71796c; box-shadow:0 0 0 3px rgba(80,92,77,.08); } +.ec-chip-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; } +.ec-chip-grid button { min-height:38px; padding:8px 9px; border:1px solid var(--ec-border); border-radius:11px; background:var(--ec-control); color:var(--ec-muted); font-size:10px; font-weight:700; cursor:pointer; } +.ec-chip-grid button.active { border-color:#869080; background:var(--ec-accent-soft); color:var(--ec-accent); } +.ec-range-row { display:grid; grid-template-columns:minmax(0,1fr) 56px; gap:10px; align-items:center; } +.ec-range-row input[type="range"] { min-height:0; padding:0; accent-color:var(--ec-accent); } +.ec-range-value { min-height:36px; display:flex; align-items:center; justify-content:center; border:1px solid var(--ec-border); border-radius:10px; background:var(--ec-control); font-family:"JetBrains Mono",monospace; font-size:10px; } + +.ec-primary-button, .ec-secondary-button { min-height:44px; display:inline-flex; align-items:center; justify-content:center; gap:9px; padding:0 15px; border-radius:13px; font-size:11px; font-weight:800; cursor:pointer; transition:.2s ease; } +.ec-primary-button { width:100%; margin-top:16px; border:1px solid var(--ec-accent); background:var(--ec-accent); color:var(--ec-accent-contrast); } +.ec-primary-button:hover:not(:disabled) { transform:translateY(-1px); box-shadow:0 13px 24px rgba(27,36,27,.17); } +.ec-primary-button.compact { width:auto; margin-top:0; } +.ec-primary-button:disabled { opacity:.38; cursor:not-allowed; } +.ec-secondary-button { border:1px solid var(--ec-border); background:var(--ec-control); color:var(--ec-text-soft); } +.ec-secondary-button:hover { border-color:#aeb4aa; } +.ec-secondary-button svg { width:17px; height:17px; } +.ec-model-panel { margin-top:16px; overflow:hidden; border:1px solid var(--ec-border); border-radius:18px; background:var(--ec-control); } +.ec-model-panel-toggle { width:100%; min-height:56px; display:flex; align-items:center; justify-content:space-between; gap:12px; padding:11px 13px; border:0; background:transparent; color:var(--ec-text); cursor:pointer; } +.ec-model-panel-heading, .ec-model-panel-actions { display:flex; align-items:center; gap:9px; min-width:0; } +.ec-model-panel-heading > svg { width:18px; height:18px; flex:0 0 auto; color:#8d9aab; } +.ec-model-panel-heading > span { min-width:0; display:grid; gap:2px; text-align:left; } +.ec-model-panel-heading b { font-size:11px; } +.ec-model-panel-heading small { overflow:hidden; max-width:230px; color:var(--ec-muted); font-size:8px; font-weight:600; white-space:nowrap; text-overflow:ellipsis; } +.ec-model-panel-actions em { padding:5px 9px; border:1px solid var(--ec-border); border-radius:999px; background:var(--ec-accent-soft); color:var(--ec-text); font-size:8px; font-style:normal; font-weight:800; white-space:nowrap; } +.ec-model-chevron { width:16px; height:16px; transition:transform .2s ease; } +.ec-model-panel.collapsed .ec-model-chevron { transform:rotate(-90deg); } +.ec-model-panel-body { padding:0 13px 13px; } +.ec-model-panel.collapsed .ec-model-panel-body { display:none; } +.ec-field-grid { display:grid; grid-template-columns:1fr 1fr; gap:9px; } +.ec-model-panel .ec-model-route-grid { grid-template-columns:minmax(96px,.62fr) minmax(0,1.38fr); } +.ec-parameter-grid { margin-top:10px; } +.ec-model-panel .ec-parameter-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } +.ec-model-panel-body p { margin:10px 0 0; color:var(--ec-muted); font-size:9px; line-height:1.45; } +.ec-inline-error { margin-top:12px; padding:10px 12px; border:1px solid rgba(180,35,24,.2); border-radius:12px; background:rgba(180,35,24,.06); color:var(--ec-danger); font-size:10px; line-height:1.5; } +.ec-generate-actions { position:fixed; left:max(44px,calc((100vw - 1640px)/2 + 44px)); bottom:22px; z-index:20; width:346px; padding:11px; border:1px solid color-mix(in srgb,var(--ec-border) 82%,transparent); border-radius:17px; background:color-mix(in srgb,var(--ec-panel) 92%,transparent); box-shadow:0 18px 45px rgba(22,27,21,.14); backdrop-filter:blur(18px); } +.ec-generate-actions .ec-inline-error { margin:0 0 9px; } +.ec-generate-actions .ec-primary-button { min-height:50px; margin-top:0; border-radius:13px; } + +.ec-page.is-universal { padding-bottom:248px; } +.ec-page.is-universal.has-many-universal-references { padding-bottom:374px; } +.ec-page.is-universal .ec-workspace { grid-template-rows:minmax(0,1fr); } +.ec-page.is-universal .ec-control-panel { padding-bottom:22px; } +.ec-universal-dock { position:fixed; left:50%; bottom:12px; z-index:45; min-width:0; width:min(1600px,calc(100vw - 24px)); height:224px; display:flex; align-items:stretch; gap:12px; padding:12px; overflow:hidden; border:1px solid var(--ec-border); border-radius:22px; background:color-mix(in srgb,var(--ec-panel) 94%,transparent); box-shadow:0 22px 70px rgba(26,32,25,.16); backdrop-filter:blur(18px); transform:translateX(-50%); } +.ec-universal-dock.has-many-references { height:350px; } +.ec-universal-dock-inputs { min-width:0; flex:1 1 auto; } +.ec-universal-dock-actions { flex:0 0 176px; display:flex; align-items:stretch; } +.ec-universal-dock .ec-input-module { height:100%; display:grid; grid-template-rows:auto minmax(0,1fr); } +.ec-universal-dock .ec-section-head { margin:0 2px 8px; } +.ec-universal-dock .ec-section-head h2 { font-size:14px; } +.ec-universal-dock .ec-input-slots { min-width:0; display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); grid-auto-rows:152px; align-items:stretch; gap:8px; overflow:hidden; padding:0 2px 2px; scrollbar-width:thin; } +.ec-universal-dock.has-many-references .ec-input-slots { grid-auto-rows:144px; overflow-y:auto; } +.ec-universal-dock .ec-universal-guide { display:none; } +.ec-universal-dock .ec-universal-reference { min-width:0; min-height:0; height:auto; grid-template-rows:28px 74px 28px; gap:5px; padding:6px; border-radius:14px; } +.ec-universal-dock.has-many-references .ec-universal-reference { grid-template-rows:26px 66px 28px; } +.ec-universal-dock .ec-universal-reference > header { grid-template-columns:auto auto minmax(0,1fr) auto; gap:5px; } +.ec-universal-dock .ec-universal-reference > header select { height:28px; padding:0 7px; } +.ec-universal-dock .ec-universal-reference > header button { width:26px; height:26px; } +.ec-universal-dock .ec-universal-reference .ec-upload-slot { min-height:72px; border-radius:12px; } +.ec-universal-dock.has-many-references .ec-universal-reference .ec-upload-slot { min-height:64px; } +.ec-universal-dock .ec-universal-reference .ec-upload-empty { min-height:70px; gap:2px; padding:6px; } +.ec-universal-dock.has-many-references .ec-universal-reference .ec-upload-empty { min-height:62px; } +.ec-universal-dock .ec-universal-reference .ec-upload-empty svg { width:18px; height:18px; } +.ec-universal-dock .ec-universal-reference .ec-upload-empty b { font-size:10px; } +.ec-universal-dock .ec-universal-reference .ec-upload-empty small { font-size:8px; } +.ec-universal-dock .ec-universal-reference .ec-upload-preview { grid-template-columns:58px minmax(0,1fr); gap:7px; padding:6px; } +.ec-universal-dock .ec-universal-reference .ec-upload-preview img { width:58px; height:58px; border-radius:9px; } +.ec-universal-dock .ec-upload-info b { margin-bottom:3px; font-size:10px; } +.ec-universal-dock .ec-upload-info .ec-upload-actions { flex-wrap:wrap; gap:4px; margin-top:7px; } +.ec-universal-dock .ec-upload-actions button { padding:3px 5px; font-size:8px; } +.ec-universal-dock .ec-reference-fields { gap:5px; } +.ec-universal-dock .ec-reference-fields label > span { display:none; } +.ec-universal-dock .ec-reference-fields input { height:28px; padding:0 7px; } +.ec-universal-dock .ec-generate-actions { position:static; width:100%; display:flex; flex-direction:column; justify-content:flex-end; gap:10px; padding:0; border:0; border-radius:0; background:transparent; box-shadow:none; backdrop-filter:none; } +.ec-universal-dock .ec-generate-actions::before { content:"GENERATE"; display:block; margin:auto 0 0; color:var(--ec-muted); font:800 8px "JetBrains Mono",monospace; letter-spacing:.18em; text-align:center; } +.ec-universal-dock .ec-add-reference-action { width:100%; min-height:46px; justify-content:space-between; padding:0 12px; border-style:solid; background:var(--ec-control); } +.ec-universal-dock .ec-add-reference-action span, .ec-universal-dock .ec-add-reference-action small { color:inherit; font:inherit; } +.ec-universal-dock .ec-add-reference-action small { color:var(--ec-muted); font-size:8px; } +.ec-universal-dock .ec-generate-actions .ec-primary-button { min-height:56px; } +.ec-universal-reference input, .ec-universal-reference textarea, .ec-universal-reference select { user-select:text; -webkit-user-select:text; } + +.ec-mask-editor { margin-top:10px; padding:10px; border:1px solid var(--ec-border); border-radius:16px; background:var(--ec-panel-soft); } +.ec-mask-toolbar { display:flex; align-items:center; gap:6px; overflow-x:auto; padding-bottom:8px; } +.ec-mask-toolbar button { flex:0 0 auto; padding:6px 8px; border:1px solid var(--ec-border); border-radius:9px; background:var(--ec-control); font-size:9px; cursor:pointer; } +.ec-mask-toolbar button.active { border-color:#687162; background:var(--ec-accent-soft); } +.ec-mask-toolbar label { min-width:120px; display:flex; align-items:center; gap:6px; color:var(--ec-muted); font-size:9px; } +.ec-mask-toolbar input { width:78px; accent-color:var(--ec-accent); } +.ec-mask-canvas-wrap { position:relative; min-height:190px; overflow:hidden; border-radius:12px; background:var(--ec-panel-soft); touch-action:none; } +.ec-mask-canvas-wrap img, .ec-mask-canvas-wrap canvas { position:absolute; inset:0; width:100%; height:100%; object-fit:contain; } +.ec-mask-canvas-wrap canvas { cursor:crosshair; opacity:.58; } +.ec-mask-canvas-wrap p { position:absolute; left:8px; bottom:8px; margin:0; padding:5px 7px; border-radius:8px; background:rgba(20,21,18,.72); color:#fff; font-size:8px; pointer-events:none; } + +.ec-empty-result { min-height:100%; display:flex; align-items:center; justify-content:center; flex-direction:column; text-align:center; color:var(--ec-muted); } +.ec-empty-symbol { position:relative; width:80px; height:80px; margin-bottom:22px; } +.ec-empty-symbol span { position:absolute; width:52px; height:68px; border:1px solid var(--ec-border-strong); border-radius:18px; } +.ec-empty-symbol span:first-child { left:5px; top:0; transform:rotate(-8deg); } +.ec-empty-symbol span:last-child { right:5px; bottom:0; transform:rotate(8deg); background:rgba(255,255,255,.6); } +.ec-empty-result h3 { margin:0 0 8px; color:var(--ec-text-soft); font-size:15px; } +.ec-empty-result p { margin:0; font-size:10px; } +.ec-result-frame { min-width:0; overflow:hidden; border:1px solid var(--ec-border); border-radius:20px; background:var(--ec-panel); } +.ec-compare-stage { position:relative; height:clamp(360px,56vh,650px); overflow:hidden; border-top:1px solid var(--ec-border); border-bottom:1px solid var(--ec-border); background:repeating-conic-gradient(var(--ec-checker-a) 0 25%,var(--ec-checker-b) 0 50%) 50% / 22px 22px; user-select:none; touch-action:none; } +.ec-before-image, .ec-after-clip, .ec-after-clip img { position:absolute; inset:0; width:100%; height:100%; object-fit:contain; object-position:center; } +.ec-after-clip { right:auto; width:50%; overflow:hidden; } +.ec-after-clip img { width:var(--compare-stage-width,100%); max-width:none; } +.ec-before-image, .ec-after-clip img { transform:scale(var(--ec-zoom,1)); transition:transform .16s ease; } +.ec-compare-handle { position:absolute; top:0; bottom:0; left:50%; width:2px; padding:0; border:0; background:#fff; box-shadow:0 0 0 1px rgba(0,0,0,.15),0 0 18px rgba(0,0,0,.16); cursor:ew-resize; transform:translateX(-1px); } +.ec-compare-handle::before { content:""; position:absolute; top:50%; left:50%; width:42px; height:42px; border:1px solid rgba(0,0,0,.14); border-radius:50%; background:#fff; box-shadow:0 8px 22px rgba(0,0,0,.17); transform:translate(-50%,-50%); } +.ec-compare-handle span { position:relative; top:0; z-index:1; margin:0 4px; color:#333; font-size:17px; } +.ec-compare-label { position:absolute; top:14px; padding:6px 9px; border-radius:999px; background:rgba(18,20,17,.72); color:#fff; font-size:8px; font-weight:800; letter-spacing:.12em; backdrop-filter:blur(8px); } +.ec-compare-label.before { left:14px; } +.ec-compare-label.after { right:14px; } +.ec-compare-tools { position:absolute; right:14px; bottom:14px; z-index:4; display:flex; gap:5px; padding:5px; border:1px solid rgba(255,255,255,.45); border-radius:11px; background:rgba(18,20,17,.72); backdrop-filter:blur(8px); } +.ec-compare-tools button { min-width:30px; height:28px; padding:0 7px; border:0; border-radius:7px; background:rgba(255,255,255,.14); color:#fff; font-size:12px; cursor:pointer; } +.ec-generation-overlay { position:absolute; inset:0; z-index:5; display:flex; align-items:center; justify-content:center; flex-direction:column; gap:8px; background:var(--ec-overlay); backdrop-filter:blur(10px); } +.ec-generation-overlay strong { font-size:12px; } +.ec-generation-overlay span { font-family:"JetBrains Mono",monospace; font-size:20px; font-weight:700; } +.ec-generation-overlay small { max-width:360px; padding:0 20px; color:var(--ec-muted); text-align:center; } +.ec-spinner { width:34px; height:34px; border:2px solid rgba(27,36,27,.15); border-top-color:var(--ec-accent); border-radius:50%; animation:ec-spin .8s linear infinite; } +@keyframes ec-spin { to { transform:rotate(360deg); } } +.ec-candidate-list { min-height:96px; display:flex; align-items:center; gap:9px; overflow-x:auto; padding:7px 10px; scrollbar-width:thin; } +.ec-candidate { position:relative; height:82px; overflow:hidden; padding:0; border:1px solid var(--ec-border); border-radius:13px; background:var(--ec-panel-soft); cursor:pointer; } +.ec-candidate { flex:0 0 96px; width:96px; } +.ec-candidate img { width:100%; height:100%; object-fit:cover; } +.ec-candidate span { position:absolute; left:6px; bottom:6px; width:20px; height:20px; display:flex; align-items:center; justify-content:center; border-radius:7px; background:rgba(20,22,18,.78); color:#fff; font-size:8px; } +.ec-candidate.active { border-color:#354031; box-shadow:0 0 0 2px rgba(53,64,49,.16); } +.ec-candidate .ec-candidate-state { position:absolute; inset:0; width:auto; height:auto; display:flex; align-items:center; justify-content:center; flex-direction:column; gap:7px; padding:8px; border-radius:0; background:var(--ec-panel-soft); color:var(--ec-muted); } +.ec-candidate-state i { width:18px; height:18px; border:2px solid color-mix(in srgb,var(--ec-muted) 25%,transparent); border-top-color:var(--ec-accent); border-radius:50%; animation:ec-spin .8s linear infinite; } +.ec-candidate-state b { max-width:100%; overflow:hidden; font-size:8px; white-space:nowrap; text-overflow:ellipsis; } +.ec-candidate.status-failed .ec-candidate-state, .ec-candidate.status-interrupted .ec-candidate-state { background:var(--ec-danger-soft); color:var(--ec-danger); } +.ec-candidate.status-failed .ec-candidate-state i, .ec-candidate.status-interrupted .ec-candidate-state i { border:0; animation:none; } +.ec-candidate.status-failed .ec-candidate-state i::before, .ec-candidate.status-interrupted .ec-candidate-state i::before { content:"×"; display:block; font-size:18px; font-style:normal; line-height:18px; } +.ec-result-meta { min-height:52px; display:flex; align-items:center; gap:7px; overflow-x:auto; padding:8px 10px; scrollbar-width:none; } +.ec-result-meta::-webkit-scrollbar { display:none; } +.ec-result-meta span { max-width:100%; overflow:hidden; padding:6px 9px; border:1px solid var(--ec-border); border-radius:999px; background:var(--ec-control); color:var(--ec-muted); font-size:9px; white-space:nowrap; text-overflow:ellipsis; } +.ec-result-meta span strong { color:var(--ec-text); } +.ec-result-actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:14px; } + +.ec-page.is-universal .ec-result-panel { display:grid; grid-template-rows:auto minmax(0,1fr); overflow:hidden; } +.ec-page.is-universal .ec-result-body { container-type:size; display:grid; min-height:0; place-items:center; } +.ec-page.is-universal .ec-empty-result { width:min(100%,560px); aspect-ratio:1; min-height:0; border:1px solid var(--ec-border); border-radius:20px; background:var(--ec-panel-soft); } +.ec-page.is-universal #resultWorkspace { width:100%; height:100%; min-height:0; display:grid; grid-template-rows:minmax(0,1fr) auto; gap:12px; } +.ec-page.is-universal #resultWorkspace.hidden { display:none; } +.ec-page.is-universal .ec-result-frame-wrap { min-height:0; container-type:size; display:grid; place-items:center; } +.ec-page.is-universal .ec-result-frame { width:min(100cqw,100cqh); max-width:100%; aspect-ratio:1; display:grid; grid-template-rows:52px minmax(0,1fr) 96px; } +.ec-page.is-universal .ec-compare-stage { width:100%; height:100%; min-height:0; border-radius:0; } +.ec-page.is-universal .ec-result-actions { flex:0 0 auto; margin-top:0; } +.ec-primary-button.submitting { box-shadow:0 0 0 4px color-mix(in srgb,var(--ec-accent) 18%,transparent),0 12px 24px rgba(27,36,27,.18); } + +.ec-drawer { position:fixed; top:0; right:0; z-index:70; width:min(430px,92vw); height:100vh; padding:24px; overflow:auto; border-left:1px solid var(--ec-border); background:var(--ec-panel); box-shadow:-28px 0 60px rgba(20,25,19,.16); transform:translateX(102%); transition:transform .28s cubic-bezier(.2,.75,.25,1); } +.ec-drawer.open { transform:translateX(0); } +.ec-drawer header, .ec-dialog header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding-bottom:18px; border-bottom:1px solid var(--ec-border); } +.ec-drawer header small, .ec-dialog header small { color:#9ca198; font-size:8px; font-weight:800; letter-spacing:.2em; } +.ec-drawer header h2, .ec-dialog header h2 { margin:3px 0 0; font-size:18px; } +.ec-backdrop { position:fixed; inset:0; z-index:65; background:rgba(20,25,19,.24); backdrop-filter:blur(2px); } +.ec-task-list { display:grid; gap:10px; margin-top:18px; } +.ec-task-item { display:grid; grid-template-columns:64px minmax(0,1fr); gap:12px; padding:10px; border:1px solid var(--ec-border); border-radius:15px; background:var(--ec-control); cursor:pointer; } +.ec-task-item img, .ec-task-placeholder { width:64px; height:64px; border-radius:11px; object-fit:cover; background:var(--ec-panel-soft); } +.ec-task-info { min-width:0; } +.ec-task-info b, .ec-task-info span { display:block; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; } +.ec-task-info b { font-size:11px; } +.ec-task-info span { margin-top:5px; color:var(--ec-muted); font-size:9px; } +.ec-task-status { display:inline-flex !important; width:auto; margin-top:7px !important; padding:4px 7px; border-radius:999px; background:var(--ec-panel-soft); color:var(--ec-text-soft) !important; font-weight:800; } +.ec-task-status.failed, .ec-task-status.interrupted { background:var(--ec-danger-soft); color:var(--ec-danger) !important; } +.ec-task-status.succeeded { background:var(--ec-success-soft); color:var(--ec-success) !important; } +.ec-task-actions { display:flex; gap:6px; margin-top:7px; } +.ec-task-actions button { padding:4px 7px; border:1px solid var(--ec-border); border-radius:7px; background:var(--ec-panel); color:var(--ec-text); font-size:8px; font-weight:700; cursor:pointer; } +.ec-task-empty { padding:60px 20px; color:var(--ec-muted); font-size:11px; text-align:center; } + +.ec-dialog { width:min(860px,calc(100vw - 30px)); max-height:min(780px,calc(100vh - 30px)); padding:0; overflow:hidden; border:1px solid var(--ec-border); border-radius:22px; background:var(--ec-panel); color:var(--ec-text); box-shadow:0 32px 90px rgba(20,25,19,.26); } +.ec-dialog::backdrop { background:rgba(20,25,19,.32); backdrop-filter:blur(4px); } +.ec-dialog-shell { max-height:min(780px,calc(100vh - 30px)); display:flex; flex-direction:column; padding:22px; } +.ec-dialog-toolbar { display:grid; grid-template-columns:1fr 1fr; gap:9px; margin:16px 0; } +.ec-dialog-toolbar select { min-height:42px; padding:0 12px; border:1px solid var(--ec-border); border-radius:12px; background:var(--ec-control); color:var(--ec-text); } +.ec-asset-grid { min-height:260px; display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; overflow:auto; } +.ec-asset-item { position:relative; height:150px; overflow:hidden; padding:0; border:1px solid var(--ec-border); border-radius:14px; background:var(--ec-panel-soft); cursor:pointer; } +.ec-asset-item img { width:100%; height:100%; object-fit:cover; transition:.2s ease; } +.ec-asset-item:hover img { transform:scale(1.03); } +.ec-asset-item span { position:absolute; left:7px; right:7px; bottom:7px; overflow:hidden; padding:6px 8px; border-radius:9px; background:rgba(20,22,18,.75); color:#fff; font-size:9px; white-space:nowrap; text-overflow:ellipsis; } +.ec-quality-dialog { width:min(600px,calc(100vw - 30px)); } +.ec-quality-intro { margin:18px 0 12px; color:var(--ec-muted); font-size:11px; line-height:1.55; } +.ec-quality-checks { display:grid; gap:8px; max-height:360px; overflow:auto; } +.ec-quality-check { display:flex; align-items:flex-start; gap:10px; padding:11px; border:1px solid var(--ec-border); border-radius:12px; background:var(--ec-control); color:var(--ec-text-soft); font-size:11px; line-height:1.45; cursor:pointer; } +.ec-quality-check input { flex:0 0 auto; width:16px; height:16px; accent-color:var(--ec-accent); } +.ec-review-note { margin-top:14px; } +.ec-review-note textarea { min-height:76px; resize:vertical; } +.ec-dialog-actions { display:flex; justify-content:flex-end; gap:9px; margin-top:16px; } +.ec-toast { position:fixed; left:50%; bottom:25px; z-index:100; max-width:min(460px,calc(100vw - 30px)); padding:11px 16px; border-radius:13px; background:#1d211c; color:#fff; box-shadow:0 16px 42px rgba(0,0,0,.2); font-size:10px; font-weight:700; opacity:0; pointer-events:none; transform:translate(-50%,12px); transition:.22s ease; } +.ec-toast.show { opacity:1; transform:translate(-50%,0); } +.ec-toast.error { background:#851d16; } + +html.studio-theme-dark { + color-scheme:dark; + --ec-bg:#08090c; + --ec-panel:#111216; + --ec-panel-soft:#17191e; + --ec-text:#e8e8ea; + --ec-text-soft:#c5c9d0; + --ec-muted:#929aaa; + --ec-border:#25262b; + --ec-border-strong:#3a3d45; + --ec-accent:#d8dee9; + --ec-accent-contrast:#10141d; + --ec-accent-soft:#272b33; + --ec-control:#17191e; + --ec-control-hover:#1d2026; + --ec-overlay:rgba(8,9,12,.86); + --ec-checker-a:#101217; + --ec-checker-b:#17191e; + --ec-danger:#ff8d86; + --ec-danger-soft:#3a1c1d; + --ec-success:#72d6a0; + --ec-success-soft:#173224; + --ec-shadow:0 18px 48px rgba(0,0,0,.22); +} +html.studio-theme-dark .ec-before-image, html.studio-theme-dark .ec-after-clip { background:var(--ec-bg); } + +@media (max-width:1100px) { + .ec-page { padding:14px 16px 16px; } + .ec-operation-tabs button { justify-content:center; padding:0 8px; } + .ec-operation-tabs button span { display:none; } + .ec-workspace { grid-template-columns:330px minmax(0,1fr); } + .ec-generate-actions { left:38px; bottom:16px; width:286px; } +} +@media (max-width:860px) { + .ec-page { padding:12px; } + .ec-topbar { grid-template-columns:minmax(0,1fr) auto; } + .ec-status { display:none; } + .ec-operation-tabs { grid-template-columns:repeat(6,1fr); overflow-x:auto; } + .ec-operation-tabs button { min-width:106px; } + .ec-route-summary { display:none; } + .ec-workspace { grid-template-columns:1fr; grid-template-rows:minmax(420px,1fr) minmax(420px,1fr); overflow:auto; scroll-snap-type:y proximity; } + .ec-control-panel { padding-bottom:96px; } + .ec-generate-actions { left:34px; right:34px; bottom:16px; width:auto; } + .ec-page.is-universal, .ec-page.is-universal.has-many-universal-references { padding-bottom:312px; } + .ec-page.is-universal .ec-workspace { grid-template-columns:1fr; grid-template-rows:minmax(380px,auto) minmax(420px,1fr); padding-bottom:0; } + .ec-page.is-universal .ec-control-panel { grid-column:1; grid-row:1; height:auto; min-height:380px; overflow:visible; } + .ec-page.is-universal .ec-universal-dock { left:12px; right:12px; bottom:16px; width:auto; height:280px; flex-direction:column; gap:10px; padding:13px; transform:none; } + .ec-page.is-universal .ec-universal-dock.has-many-references { height:280px; } + .ec-page.is-universal .ec-result-panel { grid-column:1; grid-row:2; min-height:620px; } + .ec-universal-dock-inputs { min-height:0; } + .ec-universal-dock-actions { flex:0 0 auto; } + .ec-page.is-universal .ec-generate-actions { position:static; left:auto; right:auto; bottom:auto; width:100%; display:block; padding:0; border:0; border-radius:0; background:transparent; box-shadow:none; backdrop-filter:none; } + .ec-page.is-universal .ec-generate-actions::before { display:none; } + .ec-universal-dock .ec-input-slots, .ec-universal-dock.has-many-references .ec-input-slots { grid-template-columns:none; grid-auto-flow:column; grid-auto-columns:218px; grid-auto-rows:auto; overflow-x:auto; overflow-y:hidden; padding-bottom:2px; scroll-snap-type:x proximity; } + .ec-universal-dock .ec-universal-reference, .ec-universal-dock .ec-add-reference { height:154px; min-height:154px; } + .ec-universal-dock .ec-universal-reference, .ec-universal-dock.has-many-references .ec-universal-reference { grid-template-rows:28px 72px 28px; } + .ec-universal-dock .ec-universal-reference .ec-upload-slot, .ec-universal-dock .ec-universal-reference .ec-upload-empty { min-height:70px; } + .ec-universal-dock.has-many-references .ec-universal-reference .ec-upload-slot, .ec-universal-dock.has-many-references .ec-universal-reference .ec-upload-empty { min-height:70px; } + .ec-universal-dock .ec-universal-reference .ec-upload-preview img { width:64px; height:58px; } + .ec-universal-dock .ec-reference-fields input { height:28px; } + .ec-universal-dock .ec-add-reference-action { height:auto; min-height:44px; } + .ec-control-panel, .ec-result-panel { scroll-snap-align:start; } + .ec-empty-result { min-height:100%; } + .ec-compare-stage { height:min(70vh,560px); } + .ec-page.is-universal .ec-result-body { container-type:normal; display:block; } + .ec-page.is-universal .ec-empty-result { width:100%; min-height:0; } + .ec-page.is-universal #resultWorkspace { height:auto; display:block; } + .ec-page.is-universal .ec-result-frame-wrap { display:block; } + .ec-page.is-universal .ec-result-frame { width:100%; aspect-ratio:1; } + .ec-page.is-universal .ec-result-actions { margin-top:12px; } +} +@media (max-width:560px) { + .ec-page { padding:8px; } + .ec-topbar { gap:8px; } + .ec-control-panel, .ec-result-panel { padding:17px; border-radius:19px; } + .ec-operation-tabs { justify-content:start; grid-template-columns:none; grid-auto-flow:column; grid-auto-columns:112px; } + .ec-candidate-list { grid-template-columns:repeat(2,1fr); } + .ec-result-actions > * { flex:1 1 calc(50% - 8px); } + .ec-field-grid, .ec-dialog-toolbar { grid-template-columns:1fr; } + .ec-reference-fields { grid-template-columns:1fr; } + .ec-universal-dock .ec-reference-fields { grid-template-columns:1fr 1fr; } + .ec-model-panel .ec-model-route-grid, .ec-model-panel .ec-parameter-grid { grid-template-columns:1fr; } + .ec-asset-grid { grid-template-columns:repeat(2,1fr); } +} diff --git a/static/css/smart-canvas.css b/static/css/smart-canvas.css index 66699882e..0ad2681c1 100644 --- a/static/css/smart-canvas.css +++ b/static/css/smart-canvas.css @@ -344,6 +344,15 @@ body.smart-node-resize .smart-node-floating-menu, .theme-dark .image-wrap .mini-x:hover, .theme-dark .thumb-item .mini-x:hover { background:rgba(248,113,113,.22); color:#fecaca; border-color:rgba(248,113,113,.36); } .thumb-item:hover .mini-x, .image-wrap:hover .mini-x { opacity:1; pointer-events:auto; } .image-node.dragging .mini-x { opacity:0 !important; pointer-events:none; } +.image-name-badge { position:absolute; left:6px; top:5px; z-index:7; max-width:calc(100% - 12px); height:14px; display:inline-flex; align-items:center; color:rgba(100,116,139,.86); font-size:9.5px; font-weight:400; line-height:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; cursor:text; user-select:none; pointer-events:auto; } +.thumb-item .image-name-badge { left:6px; top:5px; max-width:calc(100% - 12px); } +.thumb-item.has-outside-image-name { overflow:visible; } +.image-name-badge.image-name-badge-outside { left:0; top:-16px; max-width:100%; color:rgba(100,116,139,.78); } +.image-name-badge:hover { color:rgba(51,65,85,.95); } +.theme-dark .image-name-badge { color:rgba(203,213,225,.74); } +.theme-dark .image-name-badge.image-name-badge-outside { color:rgba(203,213,225,.68); } +.theme-dark .image-name-badge:hover { color:rgba(241,245,249,.9); } +.image-node.dragging .image-name-badge { opacity:0 !important; pointer-events:none !important; } .node-img { display:block; width:var(--node-img-w, 260px); height:var(--node-img-h, 180px); object-fit:cover; border-radius:12px; background:transparent; } video.node-img { object-fit:cover; background:#0f172a; } .media-video-card { position:relative; align-items:stretch; justify-content:stretch; overflow:hidden; border:0; border-radius:12px; background:transparent; box-sizing:border-box; } @@ -419,8 +428,7 @@ video.node-img { object-fit:cover; background:#0f172a; } @keyframes shine { from { transform:translateX(-100%); } to { transform:translateX(100%); } } .connection-layer { position:absolute; left:0; top:0; width:6000px; height:4000px; pointer-events:none; z-index:0; overflow:visible; } .connection-layer path { transition:stroke-opacity .14s ease; } -.connection-layer .conn-selected { stroke:var(--strong); stroke-width:3; opacity:1; filter:drop-shadow(0 0 7px rgba(15,23,42,.2)); } -.connection-layer .conn-dim { opacity:.22; } +.connection-layer .conn-selected { stroke:var(--strong); opacity:1; } .connection-layer .conn-hit { pointer-events:stroke; cursor:pointer; } .connection-layer .conn-cut { pointer-events:auto; cursor:pointer; opacity:.55; transition:opacity .14s ease; } .connection-layer .conn-cut:hover { opacity:1; } @@ -1052,7 +1060,12 @@ input[type=range].smart-range::-webkit-slider-thumb { -webkit-appearance:none; w .shortcut-head { height:54px; padding:0 14px 0 16px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); } .shortcut-title { display:flex; align-items:center; gap:8px; color:var(--text); font-size:14px; font-weight:900; } .shortcut-title i,.shortcut-title svg { width:16px; height:16px; } -.shortcut-list { padding:12px; overflow:auto; display:flex; flex-direction:column; gap:8px; } +.shortcut-list { padding:12px; overflow:auto; display:flex; flex-direction:column; gap:8px; scrollbar-gutter:stable; scrollbar-width:thin; scrollbar-color:rgba(148,163,184,.72) transparent; } +.shortcut-list::-webkit-scrollbar { width:10px; height:10px; background:transparent; } +.shortcut-list::-webkit-scrollbar-track { background:transparent; } +.shortcut-list::-webkit-scrollbar-thumb { min-height:36px; background-color:rgba(148,163,184,.72); border:3px solid transparent; background-clip:padding-box; border-radius:999px; } +.shortcut-list::-webkit-scrollbar-thumb:hover { background-color:rgba(100,116,139,.86); } +.shortcut-list::-webkit-scrollbar-corner { background:transparent; } .shortcut-item { min-height:38px; display:grid; grid-template-columns:126px minmax(0, 1fr); align-items:center; gap:10px; padding:8px 10px; border-radius:13px; background:var(--soft); border:1px solid var(--line); color:var(--muted); font-size:11.5px; font-weight:720; } .shortcut-keys { min-width:0; display:flex; align-items:center; gap:5px; white-space:nowrap; } .shortcut-item kbd { min-width:34px; height:24px; padding:0 7px; border-radius:8px; display:inline-flex; align-items:center; justify-content:center; background:var(--card); border:1px solid rgba(148,163,184,.38); color:var(--text); box-shadow:inset 0 -1px 0 rgba(15,23,42,.08); font-size:10px; font-weight:900; font-family:'JetBrains Mono',ui-monospace,monospace; } @@ -1198,6 +1211,9 @@ input[type=range].smart-range::-webkit-slider-thumb { -webkit-appearance:none; w .grid-gap-control { display:flex; align-items:center; gap:8px; color:var(--muted); font-size:11px; font-weight:800; min-width:250px; } .grid-gap-control input[type="range"] { width:160px; } .grid-gap-value { min-width:42px; height:24px; padding:0 8px; border-radius:999px; display:inline-flex; align-items:center; justify-content:center; background:var(--strong); color:var(--strong-text); font-size:11px; font-weight:900; font-variant-numeric:tabular-nums; } +.image-resize-tools input[type="range"] { width:180px; } +.image-resize-tools input[type="number"] { width:74px; text-align:center; font-variant-numeric:tabular-nums; } +.image-resize-resolution { margin:0; min-width:88px; color:var(--muted); font-weight:900; font-variant-numeric:tabular-nums; } .grid-operation-toggle { display:flex; align-items:center; gap:4px; padding:3px; border-radius:12px; background:var(--card); border:1px solid var(--line); } .grid-preset-row { display:flex; align-items:center; gap:6px; flex-wrap:wrap; } .grid-preset-btn { height:28px; padding:0 9px; border-radius:999px; border:1px solid var(--line); background:var(--card); color:var(--muted); font-size:11px; font-weight:850; } @@ -1230,7 +1246,7 @@ input[type=range].smart-range::-webkit-slider-thumb { -webkit-appearance:none; w .crop-canvas.text-mode { cursor:text; } .crop-canvas.text-mode .edit-draw-canvas { pointer-events:none; } .crop-canvas.text-mode .edit-text-canvas { pointer-events:auto; } -.crop-canvas.mask-mode .crop-box,.crop-canvas.brush-mode .crop-box,.crop-canvas.grid-mode .crop-box,.crop-canvas.outpaint-mode .crop-box { display:none; } +.crop-canvas.mask-mode .crop-box,.crop-canvas.brush-mode .crop-box,.crop-canvas.resize-mode .crop-box,.crop-canvas.grid-mode .crop-box,.crop-canvas.outpaint-mode .crop-box { display:none; } .crop-canvas.grid-custom-h .edit-draw-canvas { pointer-events:auto; cursor:row-resize; } .crop-canvas.grid-custom-v .edit-draw-canvas { pointer-events:auto; cursor:col-resize; } .crop-box { position:absolute; left:10%; top:10%; width:80%; height:80%; border:2px solid #f8fafc; box-shadow:0 0 0 9999px rgba(15,23,42,.48), 0 12px 30px rgba(15,23,42,.2); border-radius:10px; cursor:move; pointer-events:auto; } @@ -1255,6 +1271,8 @@ input[type=range].smart-range::-webkit-slider-thumb { -webkit-appearance:none; w .outpaint-resolution { position:absolute; left:10px; top:10px; z-index:5; height:26px; padding:0 9px; border-radius:999px; display:none; align-items:center; background:rgba(15,23,42,.74); color:#fff; font-size:11px; font-weight:900; line-height:26px; box-shadow:0 8px 22px rgba(15,23,42,.22); pointer-events:none; } .crop-canvas.outpaint-mode .outpaint-resolution { display:inline-flex; } .crop-canvas.outpaint-mode.outpaint-warning .outpaint-resolution { background:rgba(239,68,68,.94); } +.resize-resolution-overlay { position:absolute; left:10px; top:10px; z-index:5; height:26px; padding:0 10px; border-radius:999px; display:none; align-items:center; background:rgba(15,23,42,.76); color:#fff; font-size:11px; font-weight:900; line-height:26px; font-variant-numeric:tabular-nums; box-shadow:0 8px 22px rgba(15,23,42,.22); pointer-events:none; } +.crop-canvas.resize-mode .resize-resolution-overlay { display:inline-flex; } .outpaint-handle { position:absolute; z-index:6; display:none; background:#fff; border:2px solid #f8fafc; box-shadow:0 3px 12px rgba(15,23,42,.24); } .crop-canvas.outpaint-mode.outpaint-warning .outpaint-handle { border-color:#ef4444; } .crop-canvas.outpaint-mode .outpaint-frame .outpaint-handle { display:block; pointer-events:auto; } diff --git a/static/css/works.css b/static/css/works.css new file mode 100644 index 000000000..999cdf974 --- /dev/null +++ b/static/css/works.css @@ -0,0 +1,86 @@ +:root { --wk-bg:#f5f6f3; --wk-panel:#fff; --wk-soft:#f8f8f5; --wk-control:#fbfbf9; --wk-text:#151713; --wk-text-soft:#4d534a; --wk-muted:#777e72; --wk-border:#e1e4dc; --wk-border-strong:#b7bdb1; --wk-accent:#1b241b; --wk-accent-contrast:#fff; --wk-favorite:#a86705; --wk-shadow:0 16px 44px rgba(25,31,24,.09); } +html.studio-theme-dark { color-scheme:dark; --wk-bg:#08090c; --wk-panel:#111216; --wk-soft:#17191e; --wk-control:#17191e; --wk-text:#e8e8ea; --wk-text-soft:#c5c9d0; --wk-muted:#929aaa; --wk-border:#25262b; --wk-border-strong:#3a3d45; --wk-accent:#d8dee9; --wk-accent-contrast:#10141d; --wk-favorite:#ffc66d; --wk-shadow:0 18px 48px rgba(0,0,0,.25); } +* { box-sizing:border-box; } +html,body { width:100%; height:100%; margin:0; overflow:hidden; } +body { background:var(--wk-bg); color:var(--wk-text); font-family:Inter,"Microsoft YaHei",system-ui,-apple-system,sans-serif; } +button,input,select { font:inherit; color:inherit; } +button { cursor:pointer; } +svg { width:17px; height:17px; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; } +.hidden { display:none !important; } +.works-page { width:min(1680px,100%); height:100%; min-height:0; display:grid; grid-template-rows:auto minmax(0,1fr); gap:14px; margin:0 auto; padding:18px 22px 22px; } +.works-toolbar { min-width:0; display:flex; align-items:center; justify-content:space-between; gap:18px; } +.works-heading { min-width:0; display:flex; align-items:center; gap:18px; } +.works-title { display:flex; align-items:baseline; gap:9px; white-space:nowrap; } +.works-title > span { color:var(--wk-muted); font:800 8px/1 "JetBrains Mono",monospace; letter-spacing:.18em; } +.works-title h1 { margin:0; font-size:18px; letter-spacing:-.025em; } +.works-title em { min-width:24px; padding:3px 7px; border-radius:999px; background:var(--wk-soft); color:var(--wk-muted); font-size:9px; font-style:normal; text-align:center; } +.works-tabs { display:flex; gap:4px; padding:4px; border:1px solid var(--wk-border); border-radius:13px; background:var(--wk-panel); } +.works-tabs button { min-height:34px; padding:0 13px; border:0; border-radius:9px; background:transparent; color:var(--wk-muted); font-size:10px; font-weight:800; } +.works-tabs button.active { background:var(--wk-accent); color:var(--wk-accent-contrast); } +.works-filters { min-width:0; display:flex; align-items:center; gap:8px; } +.works-search { width:min(310px,28vw); min-height:40px; display:flex; align-items:center; gap:8px; padding:0 12px; border:1px solid var(--wk-border); border-radius:12px; background:var(--wk-panel); color:var(--wk-muted); } +.works-search input { min-width:0; width:100%; border:0; outline:0; background:transparent; color:var(--wk-text); font-size:10px; } +.works-filters select { min-height:40px; padding:0 32px 0 12px; border:1px solid var(--wk-border); border-radius:12px; outline:0; background:var(--wk-panel); font-size:10px; } +.works-compare-now { min-height:40px; padding:0 14px; border:1px solid var(--wk-accent); border-radius:12px; background:var(--wk-accent); color:var(--wk-accent-contrast); font-size:10px; font-weight:800; white-space:nowrap; } +.works-icon-button,.works-dialog-actions button { width:40px; height:40px; border:1px solid var(--wk-border); border-radius:12px; background:var(--wk-panel); font-size:18px; } +.works-grid { min-height:0; display:grid; grid-template-columns:repeat(auto-fill,minmax(210px,1fr)); align-content:start; gap:13px; overflow:auto; overscroll-behavior:contain; scrollbar-gutter:stable; padding:1px 4px 16px 1px; } +.works-card { position:relative; min-width:0; overflow:hidden; border:1px solid var(--wk-border); border-radius:18px; background:var(--wk-panel); box-shadow:var(--wk-shadow); } +.works-card-media { position:relative; width:100%; aspect-ratio:4/3; overflow:hidden; border:0; background:var(--wk-soft); } +.works-card-media img { width:100%; height:100%; object-fit:cover; transition:transform .24s ease; } +.works-card:hover .works-card-media img { transform:scale(1.025); } +.works-favorite { position:absolute; top:9px; right:9px; width:34px; height:34px; display:grid; place-items:center; border:1px solid rgba(255,255,255,.45); border-radius:11px; background:rgba(16,18,15,.66); color:#fff; backdrop-filter:blur(9px); font-size:19px; } +.works-favorite.active { color:#ffd37c; } +.works-kind { position:absolute; left:9px; bottom:9px; max-width:calc(100% - 18px); overflow:hidden; padding:5px 8px; border-radius:8px; background:rgba(16,18,15,.66); color:#fff; font-size:8px; font-weight:800; white-space:nowrap; text-overflow:ellipsis; backdrop-filter:blur(8px); } +.works-card-body { padding:12px; } +.works-card-body h2 { overflow:hidden; margin:0; font-size:11px; white-space:nowrap; text-overflow:ellipsis; } +.works-card-body p { height:30px; display:-webkit-box; overflow:hidden; margin:7px 0; color:var(--wk-muted); font-size:9px; line-height:1.5; -webkit-box-orient:vertical; -webkit-line-clamp:2; } +.works-card-meta { display:flex; justify-content:space-between; gap:8px; color:var(--wk-muted); font-size:8px; } +.works-card-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; margin-top:11px; } +.works-card-actions button,.works-card-actions a { min-height:34px; display:flex; align-items:center; justify-content:center; border:1px solid var(--wk-border); border-radius:10px; background:var(--wk-control); color:var(--wk-text-soft); font-size:9px; font-weight:800; text-decoration:none; } +.works-card-actions .primary { border-color:var(--wk-accent); background:var(--wk-accent); color:var(--wk-accent-contrast); } +.works-empty { min-height:0; display:flex; align-items:center; justify-content:center; flex-direction:column; color:var(--wk-muted); text-align:center; } +.works-empty div { font-size:52px; } +.works-empty h2 { margin:12px 0 7px; color:var(--wk-text-soft); font-size:15px; } +.works-empty p { margin:0; font-size:10px; } +.works-compare-dialog { width:min(1260px,calc(100vw - 24px)); height:min(900px,calc(100vh - 24px)); max-width:none; max-height:none; padding:0; overflow:hidden; border:1px solid var(--wk-border); border-radius:20px; background:var(--wk-panel); color:var(--wk-text); box-shadow:0 32px 90px rgba(0,0,0,.28); } +.works-compare-dialog::backdrop { background:rgba(6,8,10,.54); backdrop-filter:blur(4px); } +.works-compare-shell { height:100%; min-height:0; display:grid; grid-template-rows:auto auto minmax(0,1fr) auto; gap:12px; padding:17px; } +.works-compare-shell > header,.works-compare-shell > footer { min-width:0; display:flex; align-items:center; justify-content:space-between; gap:14px; } +.works-compare-shell header small { color:var(--wk-muted); font-size:8px; font-weight:800; letter-spacing:.16em; } +.works-compare-shell header h2 { max-width:70vw; overflow:hidden; margin:3px 0 0; font-size:15px; white-space:nowrap; text-overflow:ellipsis; } +.works-dialog-actions { display:flex; gap:7px; } +.works-dialog-actions button:first-child { color:var(--wk-favorite); } +.works-compare-controls { display:grid; grid-template-columns:minmax(170px,1fr) auto minmax(170px,1fr) auto minmax(160px,1fr); align-items:end; gap:9px; } +.works-compare-controls label { min-width:220px; display:grid; gap:5px; color:var(--wk-muted); font-size:8px; font-weight:800; } +.works-compare-controls select,.works-compare-controls button,.works-compare-shell footer button { min-height:38px; padding:0 12px; border:1px solid var(--wk-border); border-radius:10px; background:var(--wk-control); font-size:9px; font-weight:800; } +.works-compare-controls > span { align-self:center; color:var(--wk-muted); font-size:9px; } +.works-compare-stage { position:relative; min-height:0; overflow:hidden; border:1px solid var(--wk-border); border-radius:16px; background:repeating-conic-gradient(var(--wk-soft) 0 25%,var(--wk-panel) 0 50%) 50% / 24px 24px; } +.works-compare-stage > img,.works-after-clip,.works-after-clip img { position:absolute; inset:0; width:100%; height:100%; object-fit:contain; } +.works-after-clip { right:auto; overflow:hidden; } +.works-after-clip img { max-width:none; } +.works-compare-handle { position:absolute; top:0; bottom:0; width:2px; padding:0; border:0; background:#fff; box-shadow:0 0 0 1px rgba(0,0,0,.16); transform:translateX(-1px); } +.works-compare-handle::before { content:""; position:absolute; top:50%; left:50%; width:42px; height:42px; border-radius:50%; background:#fff; box-shadow:0 7px 22px rgba(0,0,0,.22); transform:translate(-50%,-50%); } +.works-compare-handle span { position:relative; color:#25272b; font-size:17px; } +.works-compare-label { position:absolute; top:14px; z-index:3; padding:6px 9px; border-radius:999px; background:rgba(16,18,21,.72); color:#fff; font-size:8px; font-weight:800; } +.works-compare-label.before { left:14px; }.works-compare-label.after { right:14px; } +.works-compare-tools { position:absolute; right:14px; bottom:14px; z-index:4; display:flex; gap:5px; padding:5px; border-radius:11px; background:rgba(16,18,21,.76); } +.works-compare-tools button { min-width:32px; height:30px; padding:0 7px; border:0; border-radius:7px; background:rgba(255,255,255,.14); color:#fff; } +.works-compare-meta { min-width:0; display:flex; flex-wrap:wrap; gap:6px; } +.works-compare-meta span { padding:5px 8px; border:1px solid var(--wk-border); border-radius:999px; color:var(--wk-muted); font-size:8px; } +.works-toast { position:fixed; left:50%; bottom:24px; z-index:1200; padding:10px 14px; border-radius:12px; background:#181b18; color:#fff; font-size:9px; font-weight:800; opacity:0; pointer-events:none; transform:translate(-50%,10px); transition:.2s; }.works-toast.show { opacity:1; transform:translate(-50%,0); } +.works-rename-dialog { width:min(440px,calc(100vw - 24px)); padding:0; border:1px solid var(--wk-border); border-radius:18px; background:var(--wk-panel); color:var(--wk-text); box-shadow:0 24px 70px rgba(0,0,0,.28); } +.works-rename-dialog::backdrop { background:rgba(6,8,10,.5); backdrop-filter:blur(3px); } +.works-rename-dialog form { display:grid; gap:16px; padding:18px; } +.works-rename-dialog header,.works-rename-dialog footer { display:flex; align-items:center; justify-content:space-between; gap:12px; } +.works-rename-dialog header small { color:var(--wk-muted); font-size:8px; font-weight:800; letter-spacing:.16em; } +.works-rename-dialog header h2 { margin:3px 0 0; font-size:15px; } +.works-rename-dialog header button { width:36px; height:36px; border:1px solid var(--wk-border); border-radius:10px; background:var(--wk-control); } +.works-rename-dialog label { display:grid; gap:7px; color:var(--wk-muted); font-size:9px; font-weight:800; } +.works-rename-dialog input { width:100%; height:44px; padding:0 12px; border:1px solid var(--wk-border); border-radius:11px; outline:0; background:var(--wk-control); color:var(--wk-text); } +.works-rename-dialog input:focus { border-color:var(--wk-border-strong); box-shadow:0 0 0 3px color-mix(in srgb,var(--wk-accent) 12%,transparent); } +.works-rename-dialog footer { justify-content:flex-end; } +.works-rename-dialog footer button { min-height:38px; padding:0 14px; border:1px solid var(--wk-border); border-radius:10px; background:var(--wk-control); font-size:9px; font-weight:800; } +.works-rename-dialog footer button[type="submit"] { border-color:var(--wk-accent); background:var(--wk-accent); color:var(--wk-accent-contrast); } +@media (max-width:1120px) { .works-compare-controls { grid-template-columns:minmax(150px,1fr) auto minmax(150px,1fr) auto; }.works-compare-controls > span { display:none; } } +@media (max-width:900px) { .works-toolbar { align-items:stretch; flex-direction:column; }.works-heading { justify-content:space-between; }.works-filters { width:100%; }.works-search { width:auto; flex:1; }.works-grid { grid-template-columns:repeat(auto-fill,minmax(170px,1fr)); }.works-compare-controls { grid-template-columns:1fr 1fr; } } +@media (max-width:560px) { .works-page { padding:10px; }.works-heading { align-items:flex-start; flex-direction:column; gap:9px; }.works-filters { flex-wrap:wrap; }.works-search { flex-basis:100%; }.works-filters select { max-width:120px; }.works-compare-now { flex:1; }.works-grid { grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }.works-card-body { padding:9px; }.works-compare-controls { grid-template-columns:1fr; align-items:stretch; }.works-compare-controls label { width:100%; }.works-compare-dialog { width:100vw; height:100vh; border-radius:0; }.works-compare-shell { padding:10px; } } diff --git a/static/devices.html b/static/devices.html new file mode 100644 index 000000000..4e672ce7f --- /dev/null +++ b/static/devices.html @@ -0,0 +1,103 @@ + + + + + + Canvas 配对设备 + + + +
+

配对设备

配对码仅可使用一次,并在 5 分钟后失效。

返回 Canvas
+
+ +
------
+
尚未生成
+
+
+

已授权设备

+
+
正在加载…
+
+
+ + + diff --git a/static/ecommerce.html b/static/ecommerce.html new file mode 100644 index 000000000..82aafe684 --- /dev/null +++ b/static/ecommerce.html @@ -0,0 +1,226 @@ + + + + + + 电商专用 + + + + + + + + +
+
+ +
+
+ 自动路由 + +
+ 正在检查可用模型 + +
+
+ +
+ + +
+
+
OUTPUT

结果与对比

+ +
+ +
+
+
+

准备好素材后开始生成

+

结果会保留为独立版本,原图不会被覆盖

+
+ + +
+
+ + +
+
+ + + + + +
+
ASSETS

从素材库选择

+
+
+
+
+
+ + +
+
QUALITY GATE

上架前人工验收

+

生成图只有在所有检查项确认后,才会标记为上架成片。

+
+ +
+
+
+ +
+ + + + + diff --git a/static/enhance.html b/static/enhance.html deleted file mode 100644 index 678429934..000000000 --- a/static/enhance.html +++ /dev/null @@ -1,961 +0,0 @@ - - - - - - - - Z-IMAGE | 极简影像重塑 - - - - - - - - - - - - - -
-
-
-

- Z IMAGE® -

-

Computational Photography - Archive

-
- -
- -
-
-
-

01. Input Source -

-
- - -
-
- -
-

Drop image here

-
- - - - -
-
- -
-

02. Parameters

- - -
-
- - Engine -
-
- - -
-
- - -
-
-
- - 0.50 -
- -
- -
-
-
-
-
-

Super Resolution

-

Double pixels (4K)

-
-
- -
- -
-
- - - - - -
-
- -
-
-
- -

Canvas Ready

-
- - - - - - -
-
-
- -
-
-

Archive

-
-
-
-
- End of Archive -
-
-
- - - - - - - diff --git a/static/gpt-chat.html b/static/gpt-chat.html index c68f6f5ca..975559ff3 100644 --- a/static/gpt-chat.html +++ b/static/gpt-chat.html @@ -16,13 +16,13 @@ } catch(e) {} })(); - - - - - + + + + + @@ -1446,57 +1345,26 @@
-
- D - X - -
-
wuli大雄
- -
-
- - - - - - - - - - + + + + + + +
@@ -1745,24 +1564,12 @@

一键更新

').join(''); if(document.readyState === 'loading' && document.currentScript){ diff --git a/static/js/i18n/comfyui-settings.js b/static/js/i18n/comfyui-settings.js deleted file mode 100644 index 15f949e66..000000000 --- a/static/js/i18n/comfyui-settings.js +++ /dev/null @@ -1,72 +0,0 @@ -(function(){ - if(!window.StudioI18n) return; - window.StudioI18n.register({ - "comfy.title": { zh: "工作流设置", en: "Workflow Settings" }, - "comfy.subtitle": { zh: "选择本地 ComfyUI 工作流,配置可暴露到画布的输入参数。", en: "Choose local ComfyUI workflows, then configure the inputs exposed to Canvas." }, - "comfy.localWorkflowMode": { zh: "本地 ComfyUI 工作流", en: "Local ComfyUI Workflow" }, - "comfy.workflowList": { zh: "工作流列表", en: "Workflows" }, - "comfy.uploadWorkflow": { zh: "上传工作流", en: "Upload Workflow" }, - "comfy.nodePreview": { zh: "画布节点预览", en: "Canvas Node Preview" }, - "comfy.live": { zh: "实时", en: "Live" }, - "comfy.previewDesc": { zh: "这个工作流在画布 Comfy 节点上将显示出的控件,可直接填写并点击「运行测试」。", en: "Controls shown by this workflow in the Canvas Comfy node. Fill them here and run a test directly." }, - "comfy.previewEmpty": { zh: "勾选节点输入字段后
预览将出现在这里", en: "Expose node input fields
to preview them here" }, - "comfy.workflowName": { zh: "工作流名称", en: "Workflow name" }, - "comfy.editorSubDefault": { zh: "从左侧列表选择,或上传新的 API 工作流", en: "Select a workflow on the left, or upload a new API workflow" }, - "comfy.saveConfig": { zh: "保存配置", en: "Save Config" }, - "comfy.workflow": { zh: "工作流", en: "Workflow" }, - "comfy.testCanvas": { zh: "测试画布", en: "Test Canvas" }, - "comfy.showNodeList": { zh: "显示完整节点列表", en: "Show full node list" }, - "comfy.hideNodeList": { zh: "收起节点列表", en: "Collapse node list" }, - "comfy.runTest": { zh: "运行测试", en: "Run Test" }, - "comfy.runningTest": { zh: "运行中...", en: "Running..." }, - "comfy.addPrompt": { zh: "提示词", en: "Prompt" }, - "comfy.addImage": { zh: "图片", en: "Image" }, - "comfy.addVideo": { zh: "视频", en: "Video" }, - "comfy.addAudio": { zh: "音频", en: "Audio" }, - "comfy.promptNode": { zh: "提示词", en: "Prompt" }, - "comfy.imageNode": { zh: "图片", en: "Image" }, - "comfy.videoNode": { zh: "视频", en: "Video" }, - "comfy.audioNode": { zh: "音频", en: "Audio" }, - "comfy.output": { zh: "输出", en: "Output" }, - "comfy.promptPlaceholder": { zh: "输入提示词", en: "Enter prompt" }, - "comfy.clickUploadImage": { zh: "点击上传图片", en: "Click to upload image" }, - "comfy.clickUploadVideo": { zh: "点击上传视频", en: "Click to upload video" }, - "comfy.clickUploadAudio": { zh: "点击上传音频", en: "Click to upload audio" }, - "comfy.resultHere": { zh: "运行后显示结果", en: "Result appears here after running" }, - "comfy.runSuccess": { zh: "运行成功", en: "Run succeeded" }, - "comfy.inputs": { zh: "Inputs", en: "Inputs" }, - "comfy.needsImages": { zh: "需要 {count} 张图片", en: "Needs {count} image(s)" }, - "comfy.acceptsPrompt": { zh: "接收提示词", en: "accepts prompt" }, - "comfy.noPromptField": { zh: "无提示词字段", en: "no prompt field" }, - "comfy.otherParamsHere": { zh: "其他暴露字段会在这里调整", en: "Other exposed fields appear here" }, - "comfy.noConfigFields": { zh: "此节点没有可配置字段(所有输入都来自连线)", en: "This node has no configurable fields; all inputs come from links." }, - "comfy.noConfigInputs": { zh: "此节点没有可配置字段(输入都来自连线)", en: "This node has no configurable fields; inputs come from links." }, - "comfy.fieldCount": { zh: "{count} 个字段", en: "{count} field(s)" }, - "comfy.nodeStats": { zh: "{nodes} 个节点 · 已暴露 {fields} 个字段", en: "{nodes} nodes · {fields} exposed field(s)" }, - "comfy.builtin": { zh: "内置", en: "Built-in" }, - "comfy.configurableCount": { zh: "{count} 个可配字段", en: "{count} configurable field(s)" }, - "comfy.exposedCount": { zh: "{count} 已暴露", en: "{count} exposed" }, - "comfy.usedCount": { zh: "{count} 已用", en: "{count} used" }, - "comfy.defaultValue": { zh: "默认值:", en: "Default: " }, - "comfy.displayName": { zh: "显示名称", en: "Display name" }, - "comfy.dropdownOptions": { zh: "每行一个选项", en: "One option per line" }, - "comfy.noOptions": { zh: "(无选项)", en: "(No options)" }, - "comfy.uploadFailed": { zh: "上传失败", en: "Upload failed" }, - "comfy.imageUploadFailed": { zh: "图片上传失败", en: "Image upload failed" }, - "comfy.videoUploadFailed": { zh: "视频上传失败", en: "Video upload failed" }, - "comfy.audioUploadFailed": { zh: "音频上传失败", en: "Audio upload failed" }, - "comfy.loadFailed": { zh: "加载失败", en: "Load failed" }, - "comfy.loading": { zh: "加载中...", en: "Loading..." }, - "comfy.openFailed": { zh: "打开失败", en: "Open failed" }, - "comfy.saveMissingName": { zh: "字段 \"{field}\" 缺少显示名称", en: "Field \"{field}\" is missing a display name" }, - "comfy.saving": { zh: "保存中...", en: "Saving..." }, - "comfy.saved": { zh: "已保存", en: "Saved" }, - "comfy.saveFailed": { zh: "保存失败", en: "Save failed" }, - "comfy.deleteConfirm": { zh: "确认删除工作流「{name}」?此操作不可恢复。", en: "Delete workflow \"{name}\"? This cannot be undone." }, - "comfy.deleteFailed": { zh: "删除失败", en: "Delete failed" }, - "comfy.runFailed": { zh: "运行失败", en: "Run failed" }, - "comfy.invalidJson": { zh: "不是有效的 JSON 文件", en: "Not a valid JSON file" }, - "comfy.namePrompt": { zh: "给这个工作流起个名字(中文/英文/数字/_-.):", en: "Name this workflow (letters/numbers/_-.):" }, - "comfy.uploaded": { zh: "已上传:", en: "Uploaded: " } - }); -})(); diff --git a/static/js/i18n/common.js b/static/js/i18n/common.js index 608daa64c..1153d943b 100644 --- a/static/js/i18n/common.js +++ b/static/js/i18n/common.js @@ -2,7 +2,6 @@ if(!window.StudioI18n) return; window.StudioI18n.register({ "common.apiSettings": { zh: "API 设置", en: "API Settings" }, - "common.comfyuiSettings": { zh: "工作流设置", en: "Workflow Settings" }, "common.darkMode": { zh: "黑夜模式", en: "Dark Mode" }, "common.lightMode": { zh: "白天模式", en: "Light Mode" }, "common.language": { zh: "中文", en: "English" }, @@ -23,14 +22,15 @@ "update.oneClick": { zh: "一键更新", en: "Update" }, "update.versionLine": { zh: "本地 {local} · 最新 {remote}", en: "Local {local} · Latest {remote}" }, "nav.textToImage": { zh: "文生图", en: "Text to Image" }, - "nav.localTools": { zh: "本地功能", en: "Local Tools" }, "nav.enhance": { zh: "细节增强", en: "Enhance" }, "nav.imageEdit": { zh: "图片编辑", en: "Image Edit" }, "nav.angle": { zh: "角度控制", en: "Angle Control" }, "nav.online": { zh: "在线生图", en: "Online Image" }, + "nav.ecommerce": { zh: "电商专用", en: "E-commerce Studio" }, "nav.gpt": { zh: "GPT 对话", en: "GPT Chat" }, "nav.canvas": { zh: "无限画布", en: "Infinite Canvas" }, "nav.assetManager": { zh: "素材库", en: "Assets" }, + "nav.works": { zh: "作品管理", en: "Works" }, "bulk.manage": { zh: "管理", en: "Manage" }, "bulk.selectAll": { zh: "全选", en: "Select All" }, "bulk.deselectAll": { zh: "取消全选", en: "Deselect All" }, diff --git a/static/js/i18n/ecommerce.js b/static/js/i18n/ecommerce.js new file mode 100644 index 000000000..ae3c479c9 --- /dev/null +++ b/static/js/i18n/ecommerce.js @@ -0,0 +1,188 @@ +(function(){ + if(!window.StudioI18n) return; + window.StudioI18n.register({ + "ecommerce.eyebrow": { zh:"E-COMMERCE CREATIVE SUITE", en:"E-COMMERCE CREATIVE SUITE" }, + "ecommerce.title": { zh:"电商专用", en:"E-commerce Studio" }, + "ecommerce.subtitle": { zh:"保留人物与商品特征,只修改你指定的内容", en:"Preserve people and products. Change only what you choose." }, + "ecommerce.loadingEngines": { zh:"正在检查可用模型", en:"Checking available models" }, + "ecommerce.enginesReady": { zh:"可用模型 {count} 个", en:"{count} models available" }, + "ecommerce.noEngine": { zh:"没有可用的图片编辑模型", en:"No compatible image editor" }, + "ecommerce.history": { zh:"任务历史", en:"Task history" }, + "ecommerce.tryOn": { zh:"一键换衣", en:"Virtual Try-on" }, + "ecommerce.poseTransfer": { zh:"动作迁移", en:"Pose Transfer" }, + "ecommerce.propReplace": { zh:"道具替换", en:"Prop Replace" }, + "ecommerce.angleChange": { zh:"角度修改", en:"Angle Change" }, + "ecommerce.backgroundChange": { zh:"背景修改", en:"Background Change" }, + "ecommerce.universal": { zh:"全能模式", en:"Universal" }, + "ecommerce.standard": { zh:"标准生成", en:"Standard" }, + "ecommerce.route": { zh:"自动路由", en:"Auto route" }, + "ecommerce.inputs": { zh:"输入素材", en:"Input assets" }, + "ecommerce.referenceAssets": { zh:"参考素材", en:"Reference assets" }, + "ecommerce.result": { zh:"结果与对比", en:"Result & compare" }, + "ecommerce.refineMask": { zh:"画笔修正区域", en:"Refine region with brush" }, + "ecommerce.markReplace": { zh:"替换区域", en:"Replace" }, + "ecommerce.markKeep": { zh:"保留区域", en:"Keep" }, + "ecommerce.brushSize": { zh:"画笔", en:"Brush" }, + "ecommerce.clear": { zh:"清空", en:"Clear" }, + "ecommerce.maskHint": { zh:"红色表示替换,绿色表示保留", en:"Red means replace; green means preserve" }, + "ecommerce.advanced": { zh:"高级模型设置", en:"Advanced model settings" }, + "ecommerce.modelPanel": { zh:"模型", en:"Model" }, + "ecommerce.apiImage": { zh:"API 生图", en:"API Image" }, + "ecommerce.provider": { zh:"平台", en:"Provider" }, + "ecommerce.model": { zh:"模型", en:"Model" }, + "ecommerce.aspectRatio": { zh:"比例", en:"Aspect ratio" }, + "ecommerce.ratioSource": { zh:"跟随原图", en:"Match source" }, + "ecommerce.resolution": { zh:"分辨率", en:"Resolution" }, + "ecommerce.quality": { zh:"质量", en:"Quality" }, + "ecommerce.outputCount": { zh:"生成数量", en:"Output count" }, + "ecommerce.followMode": { zh:"跟随生成模式", en:"Follow generation mode" }, + "ecommerce.autoRecommendedSimple": { zh:"智能推荐", en:"Recommended" }, + "ecommerce.qualityLow": { zh:"低", en:"Low" }, + "ecommerce.qualityMedium": { zh:"中", en:"Medium" }, + "ecommerce.qualityHigh": { zh:"高", en:"High" }, + "ecommerce.auto": { zh:"自动", en:"Auto" }, + "ecommerce.autoRecommended": { zh:"自动 · {model}", en:"Auto · {model}" }, + "ecommerce.noConfiguredProvider": { zh:"没有已配置的平台", en:"No configured provider" }, + "ecommerce.noConfiguredModel": { zh:"没有可用模型", en:"No available model" }, + "ecommerce.advancedHint": { zh:"只显示已启用且配置了 API Key 的图像 API;模型与生成参数都会记录在任务中。", en:"Only enabled image APIs with an API key are shown. The model and generation parameters are saved with the task." }, + "ecommerce.generatePreview": { zh:"生成预览", en:"Generate Preview" }, + "ecommerce.generatePublish": { zh:"生成上架候选", en:"Generate Listing Candidates" }, + "ecommerce.generate": { zh:"开始生成", en:"Generate" }, + "ecommerce.resetCompare": { zh:"对比复位", en:"Reset Compare" }, + "ecommerce.emptyTitle": { zh:"准备好素材后开始生成", en:"Add assets to start" }, + "ecommerce.emptyHint": { zh:"结果会保留为独立版本,原图不会被覆盖", en:"Every result is versioned; originals are never overwritten" }, + "ecommerce.before": { zh:"原图", en:"Before" }, + "ecommerce.after": { zh:"生成图", en:"After" }, + "ecommerce.zoomOut": { zh:"缩小", en:"Zoom out" }, + "ecommerce.zoomReset": { zh:"适合窗口", en:"Fit view" }, + "ecommerce.zoomIn": { zh:"放大", en:"Zoom in" }, + "ecommerce.fullscreenCompare": { zh:"全屏对比(滚轮缩放,中键拖拽)", en:"Fullscreen compare (wheel to zoom, middle-drag to pan)" }, + "ecommerce.generating": { zh:"正在生成", en:"Generating" }, + "ecommerce.uploading": { zh:"正在上传图片", en:"Uploading image" }, + "ecommerce.invalidImage": { zh:"只支持 PNG、JPEG、WebP 图片", en:"Only PNG, JPEG, and WebP images are supported" }, + "ecommerce.fileTooLarge": { zh:"单张图片不能超过 50MB", en:"An image cannot exceed 50MB" }, + "ecommerce.taskSubmitted": { zh:"任务已提交,可在历史中恢复", en:"Task submitted and recoverable from history" }, + "ecommerce.taskLoadFailed": { zh:"无法读取任务状态", en:"Unable to load task status" }, + "ecommerce.previewDownloaded": { zh:"作品已开始下载", en:"Work download started" }, + "ecommerce.platformMeta": { zh:"平台", en:"Provider" }, + "ecommerce.modelMeta": { zh:"模型", en:"Model" }, + "ecommerce.sizeMeta": { zh:"分辨率", en:"Resolution" }, + "ecommerce.candidatesMeta": { zh:"候选", en:"Candidates" }, + "ecommerce.durationMeta": { zh:"耗时", en:"Duration" }, + "ecommerce.detectedGarmentMeta": { zh:"服装识别", en:"Garment detection" }, + "ecommerce.seconds": { zh:"{count} 秒", en:"{count}s" }, + "ecommerce.imagesCount": { zh:"{count} 张", en:"{count} images" }, + "ecommerce.downloadPreview": { zh:"下载预览", en:"Download Preview" }, + "ecommerce.downloadWork": { zh:"下载作品", en:"Download Work" }, + "ecommerce.qualityReview": { zh:"质量验收", en:"Quality Review" }, + "ecommerce.exportFinal": { zh:"导出上架成片", en:"Export Listing Image" }, + "ecommerce.saveAsset": { zh:"保存到素材库", en:"Save to Assets" }, + "ecommerce.chooseAsset": { zh:"从素材库选择", en:"Choose from Assets" }, + "ecommerce.chooseDestination": { zh:"选择素材库保存位置", en:"Choose asset destination" }, + "ecommerce.saveHere": { zh:"保存到此分组", en:"Save to this category" }, + "ecommerce.destinationHint": { zh:"将已审核的上架成片保存到“{name}”", en:"Save the approved listing image to “{name}”" }, + "ecommerce.qualityTitle": { zh:"上架前人工验收", en:"Pre-listing Review" }, + "ecommerce.qualityIntro": { zh:"生成图只有在所有检查项确认后,才会标记为上架成片。", en:"The result becomes a listing image only after every check is confirmed." }, + "ecommerce.reviewNote": { zh:"验收备注(可选)", en:"Review note (optional)" }, + "ecommerce.cancel": { zh:"取消", en:"Cancel" }, + "ecommerce.approve": { zh:"确认通过", en:"Approve" }, + "ecommerce.modelImage": { zh:"模特图", en:"Model Image" }, + "ecommerce.garmentImage": { zh:"服装产品图", en:"Garment Image" }, + "ecommerce.personImage": { zh:"人物原图", en:"Person Image" }, + "ecommerce.poseImage": { zh:"动作参考图", en:"Pose Reference" }, + "ecommerce.sourceImage": { zh:"原始图片", en:"Source Image" }, + "ecommerce.propImage": { zh:"新道具图片", en:"New Prop Image" }, + "ecommerce.subjectImage": { zh:"主体图片", en:"Subject Image" }, + "ecommerce.backgroundImage": { zh:"背景参考图(可选)", en:"Background Reference (optional)" }, + "ecommerce.required": { zh:"必需", en:"Required" }, + "ecommerce.optional": { zh:"可选", en:"Optional" }, + "ecommerce.dropOrChoose": { zh:"拖放、粘贴或点击上传", en:"Drop, paste, or click to upload" }, + "ecommerce.universalGuideTitle": { zh:"参考素材", en:"Reference assets" }, + "ecommerce.universalGuideHint": { zh:"已预置模特、服装、鞋子、配饰/道具、动作和场景;还可继续添加更多参考图。拖动手柄可调整图号。", en:"Model, garment, shoes, accessory/prop, pose, and scene are ready. Add more references as needed. Drag handles to reorder image numbers." }, + "ecommerce.dragReorder": { zh:"拖拽调整图片编号", en:"Drag to reorder image numbers" }, + "ecommerce.imageNumber": { zh:"图 {count}", en:"Image {count}" }, + "ecommerce.addReference": { zh:"添加参考图", en:"Add reference" }, + "ecommerce.referenceLabel": { zh:"这张图是什么", en:"What is this image" }, + "ecommerce.referenceLabelHint": { zh:"例如:白色真丝衬衫、银色项链", en:"For example: white silk shirt, silver necklace" }, + "ecommerce.referenceInstruction": { zh:"只针对这张图的要求", en:"Instruction for this image" }, + "ecommerce.referenceInstructionHint": { zh:"例如:保留领口和胸前 Logo", en:"For example: preserve the neckline and chest logo" }, + "ecommerce.refSubject": { zh:"主体/模特", en:"Subject / model" }, + "ecommerce.refUpper": { zh:"上装", en:"Upper garment" }, + "ecommerce.refLower": { zh:"下装", en:"Lower garment" }, + "ecommerce.refFullGarment": { zh:"连衣裙/套装", en:"Dress / full outfit" }, + "ecommerce.refShoes": { zh:"鞋靴", en:"Shoes" }, + "ecommerce.refAccessory": { zh:"首饰/配饰", en:"Accessory" }, + "ecommerce.refProp": { zh:"道具/商品", en:"Prop / product" }, + "ecommerce.refPose": { zh:"动作参考", en:"Pose reference" }, + "ecommerce.refScene": { zh:"场景/背景", en:"Scene / background" }, + "ecommerce.refStyle": { zh:"风格/光影", en:"Style / lighting" }, + "ecommerce.presetModel": { zh:"模特", en:"Model" }, + "ecommerce.presetGarment": { zh:"服装", en:"Garment" }, + "ecommerce.presetShoes": { zh:"鞋子", en:"Shoes" }, + "ecommerce.presetAccessory": { zh:"配饰/道具", en:"Accessory / prop" }, + "ecommerce.presetProp": { zh:"道具", en:"Prop" }, + "ecommerce.presetPose": { zh:"姿势参考", en:"Pose reference" }, + "ecommerce.presetScene": { zh:"场景参考", en:"Scene reference" }, + "ecommerce.presetStyle": { zh:"风格参考", en:"Style reference" }, + "ecommerce.compositionTitle": { zh:"自动组合提示词", en:"Automatic composition prompt" }, + "ecommerce.compositionHint": { zh:"系统会按图号和参考图类型自动生成完整提示词;下方只需填写额外要求。", en:"The system automatically builds the full prompt from image numbers and reference roles; use the field below only for extra requirements." }, + "ecommerce.compositionExample": { zh:"自动效果:图1模特换上图2服装,穿图3鞋子,戴/拿图4配饰或道具,做图5动作,在图6场景里。", en:"Auto result: Image 1 model wears Image 2 garment and Image 3 shoes, wears/holds Image 4 accessory or prop, follows Image 5 pose, in Image 6 scene." }, + "ecommerce.finalInstruction": { zh:"额外要求(可选)", en:"Extra instruction (optional)" }, + "ecommerce.finalInstructionHint": { zh:"可不填。需要时补充:保留 Logo、项链戴在锁骨位置、包用右手拿等。", en:"Optional. Add details such as preserving logos, placing a necklace at the collarbone, or holding a bag in the right hand." }, + "ecommerce.universalSubjectRequired": { zh:"全能模式至少需要一张已上传的主体/模特图", en:"Universal mode requires at least one uploaded subject/model image" }, + "ecommerce.universalInstructionRequired": { zh:"请描述最终画面中各参考图的组合方式", en:"Describe how the references should be combined" }, + "ecommerce.fromAssets": { zh:"素材库", en:"Assets" }, + "ecommerce.replace": { zh:"更换", en:"Replace" }, + "ecommerce.remove": { zh:"移除", en:"Remove" }, + "ecommerce.garmentCategory": { zh:"服装类型", en:"Garment category" }, + "ecommerce.categoryAuto": { zh:"自动识别", en:"Auto detect" }, + "ecommerce.upperBody": { zh:"上装", en:"Upper body" }, + "ecommerce.lowerBody": { zh:"下装", en:"Lower body" }, + "ecommerce.dress": { zh:"连衣裙/连体服", en:"Dress / one-piece" }, + "ecommerce.extraInstruction": { zh:"补充要求(可选)", en:"Extra instruction (optional)" }, + "ecommerce.extraInstructionHint": { zh:"例如:保持外套敞开,保留腰带", en:"For example: keep the jacket open and preserve the belt" }, + "ecommerce.poseSource": { zh:"目标动作", en:"Target pose" }, + "ecommerce.uploadPose": { zh:"上传参考图", en:"Upload reference" }, + "ecommerce.posePreset": { zh:"使用动作模板", en:"Use pose preset" }, + "ecommerce.targetDescription": { zh:"要替换的原道具", en:"Prop to replace" }, + "ecommerce.targetDescriptionHint": { zh:"例如:模特左手的黑色手提包", en:"For example: the black handbag in the model's left hand" }, + "ecommerce.azimuth": { zh:"水平角度", en:"Azimuth" }, + "ecommerce.elevation": { zh:"俯仰角度", en:"Elevation" }, + "ecommerce.viewPreset": { zh:"常用视角", en:"View preset" }, + "ecommerce.frontView": { zh:"正面", en:"Front" }, + "ecommerce.leftThreeQuarter": { zh:"左前 45°", en:"Front-left 45°" }, + "ecommerce.rightThreeQuarter": { zh:"右前 45°", en:"Front-right 45°" }, + "ecommerce.sideView": { zh:"侧面", en:"Side" }, + "ecommerce.topView": { zh:"轻俯拍", en:"High angle" }, + "ecommerce.distance": { zh:"景别", en:"Distance" }, + "ecommerce.close": { zh:"近景", en:"Close" }, + "ecommerce.medium": { zh:"中景", en:"Medium" }, + "ecommerce.wide": { zh:"全景", en:"Wide" }, + "ecommerce.backgroundMode": { zh:"背景来源", en:"Background source" }, + "ecommerce.backgroundPrompt": { zh:"场景描述", en:"Scene description" }, + "ecommerce.backgroundPromptHint": { zh:"描述商品所在环境、台面、光线与氛围", en:"Describe the setting, surface, light, and mood" }, + "ecommerce.backgroundReference": { zh:"参考图", en:"Reference image" }, + "ecommerce.backgroundPreset": { zh:"场景模板", en:"Scene preset" }, + "ecommerce.uploadFailed": { zh:"图片上传失败", en:"Image upload failed" }, + "ecommerce.taskFailed": { zh:"任务生成失败", en:"Generation failed" }, + "ecommerce.inputRequired": { zh:"请补齐必需输入", en:"Please add all required inputs" }, + "ecommerce.noCompatibleModel": { zh:"没有找到兼容的图片编辑模型,请检查 API 设置", en:"No compatible image editing model. Check API settings." }, + "ecommerce.approved": { zh:"已通过上架验收", en:"Approved for listing" }, + "ecommerce.approveAll": { zh:"请确认所有质量检查项", en:"Confirm every quality check" }, + "ecommerce.exportBlocked": { zh:"请先完成质量验收", en:"Complete quality review first" }, + "ecommerce.exported": { zh:"上架成片已导出", en:"Listing image exported" }, + "ecommerce.saved": { zh:"已保存到素材库", en:"Saved to assets" }, + "ecommerce.noAssets": { zh:"当前分组没有图片素材", en:"No image assets in this category" }, + "ecommerce.noTasks": { zh:"暂无电商任务", en:"No e-commerce tasks yet" }, + "ecommerce.retry": { zh:"重新生成", en:"Regenerate" }, + "ecommerce.loadTask": { zh:"查看", en:"Open" }, + "ecommerce.queued": { zh:"排队中", en:"Queued" }, + "ecommerce.running": { zh:"生成中", en:"Running" }, + "ecommerce.succeeded": { zh:"已完成", en:"Completed" }, + "ecommerce.failed": { zh:"失败", en:"Failed" }, + "ecommerce.interrupted": { zh:"已中断", en:"Interrupted" }, + "ecommerce.preview": { zh:"快览", en:"Preview" }, + "ecommerce.publish": { zh:"上架", en:"Listing" }, + "ecommerce.selectCategory": { zh:"请选择保存分组", en:"Choose a destination category" } + }); +})(); diff --git a/static/js/i18n/smart-canvas.js b/static/js/i18n/smart-canvas.js index c4c1455ee..2db7cd395 100644 --- a/static/js/i18n/smart-canvas.js +++ b/static/js/i18n/smart-canvas.js @@ -270,8 +270,11 @@ "smart.shortcutGroup": { zh: "合并选中的图片为组", en: "Group selected image nodes" }, "smart.shortcutUngroup": { zh: "释放选中的分组", en: "Ungroup selected group nodes" }, "smart.shortcutUndo": { zh: "撤销上一步操作", en: "Undo the last action" }, + "smart.shortcutUndoAlt": { zh: "恢复上一步操作", en: "Redo the last action" }, "smart.shortcutCopy": { zh: "复制选中的节点", en: "Copy selected nodes" }, "smart.shortcutPaste": { zh: "粘贴节点或剪贴板图片", en: "Paste nodes or clipboard images" }, + "smart.shortcutAltCopy": { zh: "按住并拖动复制节点", en: "Hold and drag to duplicate nodes" }, + "smart.shortcutAltShiftCopy": { zh: "复制节点并保留连线", en: "Duplicate nodes and keep connections" }, "smart.shortcutAssets": { zh: "打开/关闭资源库", en: "Open/close the asset library" }, "smart.shortcutOverview": { zh: "缩小画布视图", en: "Zoom out to canvas overview" }, "smart.shortcutCreateMenu": { zh: "打开快捷菜单", en: "Open the quick create menu" }, diff --git a/static/js/i18n/studio.js b/static/js/i18n/studio.js index 65774c3af..f677466f9 100644 --- a/static/js/i18n/studio.js +++ b/static/js/i18n/studio.js @@ -5,10 +5,10 @@ "studio.unifiedConsole": { zh: "统一创作控制台", en: "Unified Art Console" }, "studio.describeVision": { zh: "描述你想生成的画面...", en: "Describe your vision..." }, "studio.engineSource": { zh: "引擎来源", en: "Engine Source" }, - "studio.local": { zh: "本地", en: "Local" }, "studio.dimensions": { zh: "尺寸", en: "Dimensions" }, + "studio.resolutionPreset": { zh: "分辨率预设", en: "Resolution preset" }, + "studio.manualInput": { zh: "手动输入", en: "Manual input" }, "studio.renderArt": { zh: "开始生成", en: "Render Art" }, - "studio.renderLocal": { zh: "本地生成", en: "Render Art (Local)" }, "studio.renderCloud": { zh: "云端生成", en: "Render Art (Cloud)" }, "studio.loadMore": { zh: "加载更多归档", en: "Load More Archive" }, "studio.loadingArchives": { zh: "正在加载归档...", en: "Loading Archives..." }, @@ -19,7 +19,6 @@ "studio.processing": { zh: "处理中...", en: "Processing..." }, "studio.uploading": { zh: "上传中...", en: "Uploading..." }, "studio.uploadFailed": { zh: "上传失败", en: "Upload Failed" }, - "studio.localModel": { zh: "本地", en: "Local" }, "studio.cloudProcessing": { zh: "云端处理中...", en: "Cloud Processing..." }, "studio.submittingModelscope": { zh: "正在提交到 ModelScope...", en: "Submitting to ModelScope..." }, "studio.canvasReady": { zh: "画布就绪", en: "Canvas Ready" }, diff --git a/static/js/i18n/validate-i18n.js b/static/js/i18n/validate-i18n.js index 54b8a04fe..f23e7565d 100644 --- a/static/js/i18n/validate-i18n.js +++ b/static/js/i18n/validate-i18n.js @@ -10,7 +10,7 @@ const files = [ 'static/js/i18n/api-settings.js', 'static/js/i18n/canvas.js', 'static/js/i18n/smart-canvas.js', - 'static/js/i18n/comfyui-settings.js', + 'static/js/i18n/ecommerce.js', 'static/js/i18n.js', ]; diff --git a/static/js/i18n/works.js b/static/js/i18n/works.js new file mode 100644 index 000000000..40c0068ff --- /dev/null +++ b/static/js/i18n/works.js @@ -0,0 +1,56 @@ +(function(){ + if(!window.StudioI18n) return; + window.StudioI18n.register({ + "works.title": {zh:"作品管理",en:"Works"}, + "works.all": {zh:"全部作品",en:"All Works"}, + "works.favorites": {zh:"收藏",en:"Favorites"}, + "works.trash": {zh:"回收站",en:"Trash"}, + "works.search": {zh:"搜索提示词、模型或文件名",en:"Search prompt, model, or filename"}, + "works.allTypes": {zh:"全部类型",en:"All Types"}, + "works.refresh": {zh:"刷新作品",en:"Refresh"}, + "works.compareNow": {zh:"随时对比",en:"Compare anytime"}, + "works.emptyTitle": {zh:"还没有作品",en:"No works yet"}, + "works.emptyHint": {zh:"生成完成的图片会自动出现在这里",en:"Generated images will appear here automatically"}, + "works.compare": {zh:"划像对比",en:"Compare"}, + "works.download": {zh:"下载",en:"Download"}, + "works.favorite": {zh:"收藏作品",en:"Favorite"}, + "works.unfavorite": {zh:"取消收藏",en:"Remove favorite"}, + "works.rename": {zh:"重命名",en:"Rename"}, + "works.renameTitle": {zh:"重命名作品",en:"Rename work"}, + "works.workName": {zh:"作品名称",en:"Work name"}, + "works.saveName": {zh:"保存名称",en:"Save name"}, + "works.cancel": {zh:"取消",en:"Cancel"}, + "works.nameRequired": {zh:"作品名称不能为空",en:"Work name is required"}, + "works.renamedDone": {zh:"作品名称已保存",en:"Work name saved"}, + "works.moveToTrash": {zh:"移到回收站",en:"Move to trash"}, + "works.restore": {zh:"恢复",en:"Restore"}, + "works.trashConfirm": {zh:"将这件作品移到回收站?图片文件会保留,可随时恢复。",en:"Move this work to trash? The image file is kept and can be restored."}, + "works.trashedDone": {zh:"作品已移到回收站",en:"Work moved to trash"}, + "works.restoredDone": {zh:"作品已恢复",en:"Work restored"}, + "works.targetImage": {zh:"待核对作品",en:"Target work"}, + "works.chooseLocalTarget": {zh:"选择本地作品",en:"Choose local target"}, + "works.localWork": {zh:"本地作品",en:"Local work"}, + "works.localBase": {zh:"本地基准图",en:"Local base"}, + "works.chooseTargetPrompt": {zh:"请选择作品或本地图片",en:"Choose a work or local image"}, + "works.chooseTwoImages": {zh:"请选择待核对作品和对比基准图",en:"Choose both a target and a comparison base"}, + "works.freeCompare": {zh:"自由划像对比",en:"Free comparison"}, + "works.baseImage": {zh:"对比基准",en:"Comparison base"}, + "works.chooseLocalBase": {zh:"选择本地基准图",en:"Choose local base"}, + "works.compareHint": {zh:"滚轮缩放;按住鼠标中键拖拽平移",en:"Wheel to zoom; middle-drag to pan"}, + "works.needBaseHint": {zh:"请选择一张本地图片作为对比基准",en:"Choose a local image as the comparison base"}, + "works.originalReference": {zh:"原始参考图",en:"Original reference"}, + "works.chooseBasePrompt": {zh:"请选择基准图",en:"Choose a base image"}, + "works.base": {zh:"基准",en:"Base"}, + "works.work": {zh:"作品",en:"Work"}, + "works.ecommerce": {zh:"电商专用",en:"E-commerce"}, + "works.online": {zh:"在线生图",en:"Online Image"}, + "works.image": {zh:"图片",en:"Image"}, + "works.noPrompt": {zh:"没有提示词记录",en:"No prompt recorded"}, + "works.tryOn": {zh:"一键换衣",en:"Virtual Try-on"}, + "works.poseTransfer": {zh:"动作迁移",en:"Pose Transfer"}, + "works.propReplace": {zh:"道具替换",en:"Prop Replace"}, + "works.angleChange": {zh:"角度修改",en:"Angle Change"}, + "works.backgroundChange": {zh:"背景修改",en:"Background Change"}, + "works.universal": {zh:"全能模式",en:"Universal"} + }); +})(); diff --git a/static/js/ltx-director-timeline.js b/static/js/ltx-director-timeline.js deleted file mode 100644 index f8b8a4260..000000000 --- a/static/js/ltx-director-timeline.js +++ /dev/null @@ -1,4111 +0,0 @@ -function isCanvasLTXNode(node) { - return !!(node && node.type === 'ltxDirector'); -} - -const api = (window.comfyAPI && window.comfyAPI.api) ? window.comfyAPI.api : { - async fetchApi(path, opts) { - if (path === '/upload/image' && opts && opts.method === 'POST' && opts.body) { - const fd = new FormData(); - const img = opts.body.get('image'); - if (img) fd.append('files', img, img.name || 'upload.png'); - const resp = await fetch('/api/upload', { method: 'POST', body: fd }); - const data = await resp.json().catch(() => ({})); - const comfyName = data.files?.[0]?.comfy_name || ''; - const parts = String(comfyName).split('/'); - const name = parts[parts.length - 1] || comfyName; - const subfolder = parts.length > 1 ? parts.slice(0, -1).join('/') : ''; - return { - status: resp.status, - async json() { return { name, subfolder }; } - }; - } - return fetch(path, opts); - }, - apiURL(path) { - try { - const raw = String(path || ''); - const qs = raw.includes('?') ? raw.split('?')[1] : raw; - const params = new URLSearchParams(qs); - const filename = params.get('filename') || ''; - const subfolder = params.get('subfolder') || ''; - const full = subfolder ? `${subfolder}/${filename}` : filename; - return `/api/view?filename=${encodeURIComponent(full)}&type=input`; - } catch (_) { - return path; - } - } -}; -const app = (window.comfyAPI && window.comfyAPI.app) ? window.comfyAPI.app : null; - -// --- UI Constants & Configuration --- -const RULER_HEIGHT = 24; -const BLOCK_HEIGHT = 160; // Increased to make the image timeline area much taller -const AUDIO_TRACK_HEIGHT = 80; -const CANVAS_HEIGHT = RULER_HEIGHT + BLOCK_HEIGHT + AUDIO_TRACK_HEIGHT; -const HANDLE_HIT_PX = 14; -const MIN_SEGMENT_LENGTH = 6; -const MAX_THUMBNAIL_DIM = 512; // Increased to maintain quality for taller images - -const HIDDEN_WIDGET_NAMES = ["timeline_data", "local_prompts", "segment_lengths", "guide_strength", "audio_data", "use_custom_audio"]; - -function hideWidget(w) { - if (!w) return; - if (!w._origType && w.type !== "hidden") w._origType = w.type; - // We don't set w.type = "hidden" anymore because it causes rendering issues in Nodes 2.0. - // Instead we use the computeSize = () => [0,0] trick which works in both V1 and V2. - w.hidden = true; - if (!w.options) w.options = {}; - w.options.hidden = true; - w.computeSize = () => [0, 0]; - if (w.element) w.element.style.display = "none"; -} - -function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); } - -// --- Modern Dark/Grey UI CSS (ComfyUI Match) --- -const STYLES = ` - .pr-wrapper { - font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; - display: flex; - flex-direction: column; - gap: 8px; - width: 100%; - height: 100%; - box-sizing: border-box; - padding-bottom: 4px; - } - .pr-wrapper.drag-active { - outline: 2px dashed #888; - background: rgba(255, 255, 255, 0.05); - border-radius: 6px; - } - .pr-toolbar { - display: flex; - justify-content: space-between; - align-items: center; - padding: 2px 0px; - flex-wrap: wrap; - gap: 6px; - } - .pr-actions { - display: flex; - gap: 6px; - flex-wrap: wrap; - } - .pr-btn { - background: #222; - color: #e0e0e0; - border: 1px solid #111; - border-radius: 4px; - padding: 6px 12px; - font-size: 11px; - font-weight: 500; - cursor: pointer; - display: flex; - align-items: center; - gap: 6px; - transition: all 0.2s ease; - } - .pr-btn:hover { - background: #333; - border-color: #555; - } - .pr-btn-danger:hover { - background: #4a1515; - border-color: #cc4444; - color: #ffaaaa; - } - .pr-canvas { - border-radius: 6px; - border: 1px solid #111; - background: #2a2a2a; - cursor: pointer; - width: 100%; - outline: none; - display: block; /* Ensure no inline baseline gaps */ - } - .pr-prop-container { - display: flex; - flex-direction: column; - width: 100%; - flex: 0 0 auto; - flex-shrink: 0; - min-height: 88px; - } - .pr-prompt-label { - flex: 0 0 auto; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; - color: #94a3b8; - margin-bottom: 4px; - } - .pr-prompt-area { - width: 100%; - min-height: 72px; - height: 72px; - flex: 0 0 72px; - background: #222; - color: #e0e0e0; - border: 1px solid #111; - border-radius: 6px; - padding: 8px; - resize: vertical; - font-size: 12px; - line-height: 1.4; - box-sizing: border-box; - outline: none; - transition: border-color 0.2s ease; - } - .pr-prompt-area:focus { - border-color: #888; - } - .pr-audio-info { - width: 100%; - height: 100%; - background: #181818; - color: #aaa; - border: 1px solid #111; - border-radius: 6px; - padding: 10px; - font-size: 12px; - line-height: 1.6; - box-sizing: border-box; - display: none; - } - .pr-audio-info span { color: #fff; font-weight: 500; } - .pr-controls-group { - background: #1e1e1e; - border: 1px solid #333; - border-radius: 6px; - padding: 6px 10px; - display: flex; - flex-direction: column; - gap: 4px; - margin-bottom: 4px; - box-sizing: border-box; - width: 100%; - } - .pr-strength-row { - display: flex; - align-items: center; - gap: 12px; - width: 100%; - box-sizing: border-box; - } - .pr-height-resizer { - height: 6px; - background: #2a2a2a; - cursor: ns-resize; - border-radius: 3px; - margin: 2px 0; - transition: background 0.15s; - border: 1px solid #1e1e1e; - } - .pr-height-resizer:hover { - background: #444; - border-color: #555; - } - .pr-strength-label { - font-size: 11px; - font-weight: 600; - color: #fff; - white-space: nowrap; - margin-left: auto; - } - .pr-strength-slider { - -webkit-appearance: none; - appearance: none; - width: 80px; - height: 4px; - background: #444; - border-radius: 2px; - outline: none; - cursor: pointer; - border: 1px solid #222; - } - .pr-strength-slider::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 12px; - height: 12px; - border-radius: 50%; - background: #aaa; - cursor: pointer; - } - .pr-strength-slider:disabled { - opacity: 0.3; - cursor: not-allowed; - } - .pr-strength-input { - font-size: 12px; - color: #fff; - background: #222; - border: 1px solid #444; - border-radius: 4px; - width: 52px; - text-align: center; - padding: 3px; - } - .pr-strength-input::-webkit-outer-spin-button, - .pr-strength-input::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; - } - .pr-strength-input[type=number] { - -moz-appearance: textfield; - } - .pr-strength-input:disabled { - opacity: 0.35; - cursor: not-allowed; - } - .pr-gap-menu { - position: fixed; - background: #1e1e1e; - border: 1px solid #444; - border-radius: 6px; - padding: 4px; - display: flex; - flex-direction: column; - gap: 4px; - z-index: 9999; - box-shadow: 0 4px 16px rgba(0,0,0,0.6); - } - .pr-gap-menu-btn { - background: #2a2a2a; - color: #e0e0e0; - border: 1px solid #333; - border-radius: 4px; - padding: 6px 14px; - font-size: 11px; - font-family: inherit; - cursor: pointer; - text-align: left; - white-space: nowrap; - display: flex; - align-items: center; - gap: 6px; - transition: background 0.15s ease; - } - .pr-gap-menu-btn:hover { - background: #3a3a3a; - border-color: #666; - } - .pr-player-controls { - display: flex; - justify-content: center; - align-items: center; - gap: 12px; - padding: 2px 0; - flex-wrap: wrap; - width: 100%; - } - .pr-icon-btn { - background: #2a2a2a; - border: 1px solid #444; - color: #eee; - cursor: pointer; - padding: 6px 12px; - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s; - } - .pr-icon-btn * { - pointer-events: none; - } - .pr-icon-btn:hover { - color: #fff; - background: #3a3a3a; - border-color: #666; - } - .pr-icon-btn.active { - color: #4fff8f; - border-color: #4fff8f; - background: #1a3a2a; - } - .pr-seek-bar { - -webkit-appearance: none; - appearance: none; - height: 6px; - background: #444; - border-radius: 3px; - outline: none; - cursor: pointer; - border: 1px solid #222; - } - .pr-seek-bar::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 14px; - height: 14px; - border-radius: 50%; - background: #ff4444; - cursor: pointer; - border: 2px solid #222; - } - .pr-timeline-viewport { - width: 100%; - overflow-x: auto; - overflow-y: hidden; - } - .pr-timeline-viewport::-webkit-scrollbar { - height: 10px; - } - .pr-timeline-viewport::-webkit-scrollbar-track { - background: #151515; - border-radius: 5px; - } - .pr-timeline-viewport::-webkit-scrollbar-thumb { - background: #444; - border-radius: 5px; - border: 1px solid #000; - } - .pr-timeline-viewport::-webkit-scrollbar-thumb:hover { - background: #666; - border-color: #000; - } - .pr-zoom-controls { - display: flex; - align-items: center; - gap: 4px; - margin-left: 12px; - } - .pr-zoom-slider { - width: 80px; - -webkit-appearance: none; - appearance: none; - height: 4px; - background: #444; - border-radius: 2px; - outline: none; - cursor: pointer; - } - .pr-zoom-slider::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 12px; - height: 12px; - border-radius: 50%; - background: #aaa; - cursor: pointer; - } - .pr-right-group { - display: flex; - align-items: center; - gap: 12px; - } - .pr-segment-bounds { - font-size: 12px; - color: #aaa; - font-family: monospace; - } - .pr-timecode { - font-size: 14px; - font-weight: bold; - color: #e0e0e0; - font-family: monospace; - } - .pr-settings-menu { - position: fixed; - background: #1e1e1e; - border: 1px solid #444; - border-radius: 6px; - padding: 10px; - display: flex; - flex-direction: column; - gap: 8px; - z-index: 9999; - box-shadow: 0 4px 20px rgba(0,0,0,0.7); - min-width: 220px; - } - .pr-settings-title { - font-size: 11px; - font-weight: 600; - color: #888; - text-transform: uppercase; - letter-spacing: 0.06em; - padding-bottom: 4px; - border-bottom: 1px solid #333; - margin-bottom: 2px; - } - .pr-settings-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - } - .pr-settings-label { - font-size: 12px; - color: #bbb; - flex: 1; - white-space: nowrap; - } - .pr-number-control { - display: flex; - align-items: center; - border: 1px solid #444; - border-radius: 4px; - background: #2a2a2a; - overflow: hidden; - } - .pr-number-btn { - background: #333; - color: #aaa; - border: none; - width: 20px; - height: 22px; - cursor: pointer; - font-size: 12px; - display: flex; - align-items: center; - justify-content: center; - transition: background 0.15s; - user-select: none; - } - .pr-number-btn:hover { - background: #444; - color: #fff; - } - .pr-settings-input { - background: transparent; - color: #e0e0e0; - border: none; - padding: 0 4px; - font-size: 12px; - width: 50px; - height: 22px; - text-align: center; - font-family: monospace; - outline: none; - -moz-appearance: textfield; - } - .pr-settings-input::-webkit-outer-spin-button, - .pr-settings-input::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; - } - .pr-settings-select { - background: #2a2a2a; - color: #e0e0e0; - border: 1px solid #444; - border-radius: 4px; - padding: 3px 4px; - font-size: 12px; - width: 98px; - cursor: pointer; - } - .pr-settings-divider { - border: none; - border-top: 1px solid #2a2a2a; - margin: 2px 0; - } - .pr-settings-toggle-btn { - width: 100%; - background: #252525; - color: #aaa; - border: 1px solid #333; - border-radius: 4px; - padding: 5px 8px; - font-size: 11px; - cursor: pointer; - text-align: center; - transition: all 0.15s; - } - .pr-settings-toggle-btn:hover { - background: #2e2e2e; - color: #ccc; - border-color: #555; - } - .pr-settings-close-btn { - background: transparent; - color: #888; - border: none; - cursor: pointer; - padding: 2px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 4px; - transition: all 0.15s; - } - .pr-settings-close-btn:hover { - color: #fff; - background: rgba(255,255,255,0.1); - } - .pr-segmented-control { - display: flex; - background: #1e1e1e; - border: 1px solid #333; - border-radius: 6px; - padding: 2px; - width: 110px; - height: 22px; - align-items: center; - box-sizing: border-box; - } - .pr-segment { - flex: 1; - text-align: center; - font-size: 10px; - font-weight: 500; - line-height: 18px; - cursor: pointer; - border-radius: 4px; - color: #888; - transition: all 0.15s ease; - } - .pr-segment.active { - background: #333; - color: #fff; - } - .pr-segment:hover:not(.active) { - color: #ccc; - } -`; - -if (!document.getElementById("prompt-relay-styles")) { - const styleEl = document.createElement("style"); - styleEl.id = "prompt-relay-styles"; - styleEl.textContent = STYLES; - document.head.appendChild(styleEl); -} - -// --- Icons --- -const ICONS = { - upload: ``, - audio: ``, - trash: ``, - text: ``, - play: ``, - pause: ``, - loop: ``, - minus: ``, - plus: ``, - fit: ``, - gear: ``, - close: `` -}; - -// --- Data Models --- -function parseInitial(jsonStr) { - let parsed = { segments: [], audioSegments: [] }; - try { - if (jsonStr) { - const p = JSON.parse(jsonStr); - if (Array.isArray(p.segments)) parsed.segments = p.segments; - if (Array.isArray(p.audioSegments)) parsed.audioSegments = p.audioSegments; - } - } catch (e) { } - - let currentStart = 0; - for (let seg of parsed.segments) { - if (seg.start === undefined) { - seg.start = currentStart; - currentStart += seg.length; - } - // Guarantee ID assignment to prevent node loading drag breaks - if (!seg.id) { - seg.id = Date.now().toString() + Math.random().toString(36).substr(2, 5); - } - } - - for (let seg of parsed.audioSegments) { - if (!seg.id) { - seg.id = Date.now().toString() + Math.random().toString(36).substr(2, 5); - } - if (seg.trimStart === undefined) seg.trimStart = 0; - } - - return parsed; -} - -class TimelineEditor { - constructor(node, container, domWidget) { - this.node = node; - this.container = container; - this.domWidget = domWidget; - this._canvasMode = isCanvasLTXNode(node); - this._onCanvasCommit = null; - this._onCanvasResize = null; - - // Track heights (dynamic) - this.rulerHeight = RULER_HEIGHT; - this.blockHeight = BLOCK_HEIGHT; - this.audioTrackHeight = AUDIO_TRACK_HEIGHT; - this.canvasHeight = CANVAS_HEIGHT; - - // Core data - this.timeline = { segments: [], audioSegments: [] }; - this.selectionType = "image"; // "image" or "audio" - this.selectedIndex = -1; - - // Interactions - this._isDragging = false; - this._dragType = null; - this._dragStartX = 0; - this._dragInitialTimeline = null; - this.zoomLevel = 1.0; - this._lastZoom = 1.0; - this._lastScale = 1.0; - this._dragTargetId = null; - this._dragTargetIdRight = null; - this._previewSegments = null; - this._lastWidth = 0; - this._hoveredGapIdx = -1; - this._isHovering = false; - - // Playback state - this.currentFrame = 0; - this.isPlaying = false; - this.isLooping = false; - this.audioContext = null; - this.activeAudioNodes = []; - this.playbackStartTime = 0; - this.playbackStartFrame = 0; - this._playLoopId = null; - - // --- Ghost dragging state --- - this._ghostSegmentId = null; - this._ghostTrack = null; - this._ghostInitialTimeline = null; - - // Attach to Python widgets - this._gapMenu = null; // Active gap popup menu element - this._gapMenuDismisser = null; - - if (!this._canvasMode && this.node.widgets) { - this.durationFramesWidget = this.node.widgets.find(w => w.name === "duration_frames"); - this.durationSecondsWidget = this.node.widgets.find(w => w.name === "duration_seconds"); - this.frameRateWidget = this.node.widgets.find(w => w.name === "frame_rate"); - this.timelineDataWidget = this.node.widgets.find(w => w.name === "timeline_data"); - this.localPromptsWidget = this.node.widgets.find(w => w.name === "local_prompts"); - this.segmentLengthsWidget = this.node.widgets.find(w => w.name === "segment_lengths"); - this.guideStrengthWidget = this.node.widgets.find(w => w.name === "guide_strength"); - this.displayModeWidget = this.node.widgets.find(w => w.name === "display_mode"); - this.timeline = parseInitial(this.timelineDataWidget?.value); - } else { - this.timeline = parseInitial(this.node.ltxTimelineData || '{}'); - } - - this.loadImages(); - - this.createDOM(); - if (this.timeline.segments.length > 0) { - this.selectedIndex = 0; - } - this.updateUIFromSelection(); - this.commitChanges(true); - if (!this._canvasMode) { - setTimeout(() => this.hideSettingsWidgets(), 0); - } - - let isSyncing = false; - - const origDurationFramesCallback = this.durationFramesWidget?.callback; - if (!this._canvasMode && this.durationFramesWidget) { - this.durationFramesWidget.callback = (...args) => { - if (origDurationFramesCallback) origDurationFramesCallback.apply(this.durationFramesWidget, args); - - if (!isSyncing && this.durationSecondsWidget) { - isSyncing = true; - this.durationSecondsWidget.value = parseFloat((this.getDurationFrames() / this.getFrameRate()).toFixed(3)); - isSyncing = false; - } - - this.commitChanges(); - }; - } - - const origDurationSecondsCallback = this.durationSecondsWidget?.callback; - if (!this._canvasMode && this.durationSecondsWidget) { - this.durationSecondsWidget.callback = (...args) => { - if (origDurationSecondsCallback) origDurationSecondsCallback.apply(this.durationSecondsWidget, args); - - if (!isSyncing && this.durationFramesWidget) { - isSyncing = true; - const newFrames = Math.max(1, Math.round(this.durationSecondsWidget.value * this.getFrameRate())); - this.durationFramesWidget.value = newFrames; - if (this.durationFramesWidget.callback) this.durationFramesWidget.callback(newFrames); - isSyncing = false; - } - }; - } - - const origFrameRateCallback = this.frameRateWidget?.callback; - if (!this._canvasMode && this.frameRateWidget) { - this.frameRateWidget.callback = (...args) => { - if (origFrameRateCallback) origFrameRateCallback.apply(this.frameRateWidget, args); - if (!isSyncing && this.durationSecondsWidget) { - isSyncing = true; - this.durationSecondsWidget.value = parseFloat((this.getDurationFrames() / this.getFrameRate()).toFixed(3)); - isSyncing = false; - } - }; - } - - const origDisplayModeCallback = this.displayModeWidget?.callback; - if (!this._canvasMode && this.displayModeWidget) { - this.displayModeWidget.callback = (...args) => { - if (origDisplayModeCallback) origDisplayModeCallback.apply(this.displayModeWidget, args); - this.updateWidgetVisibility(); - this.updateUIFromSelection(); - this.render(); - }; - this.updateWidgetVisibility(); // Initial trigger - } - - // Polling is much more reliable in Comfy than ResizeObserver due to scale transforms - this._renderLoop = requestAnimationFrame(() => this.checkResize()); - } - - destroy() { - this._destroyed = true; - cancelAnimationFrame(this._renderLoop); - this._renderLoop = null; - this.pauseAudio(); - window.removeEventListener("keydown", this.handleKeyDown, true); - window.removeEventListener("paste", this.handlePaste, true); - if (this._boundWindowMouseMove) { - window.removeEventListener("mousemove", this._boundWindowMouseMove); - this._boundWindowMouseMove = null; - } - if (this._boundWindowMouseUp) { - window.removeEventListener("mouseup", this._boundWindowMouseUp); - this._boundWindowMouseUp = null; - } - this.dismissContextMenu(); - this.dismissGapMenu(); - this.dismissSettingsMenu(); - if (this._isDragging) { - this._isDragging = false; - this._previewSegments = null; - this._ghostTrack = null; - } - if (this.wrapper) { - this.wrapper.remove(); - this.wrapper = null; - } - } - - getDurationFrames() { - if (this._canvasMode) { - const v = parseInt(this.node.durationFrames, 10); - return v > 0 ? v : 120; - } - return parseInt((this.durationFramesWidget && this.durationFramesWidget.value > 0) ? this.durationFramesWidget.value : 24, 10); - } - - getFrameRate() { - if (this._canvasMode) { - const v = parseInt(this.node.frameRate, 10); - return v > 0 ? v : 24; - } - return parseInt((this.frameRateWidget && this.frameRateWidget.value > 0) ? this.frameRateWidget.value : 24, 10); - } - - // Grow the timeline duration to fit `requiredFrames` if it is currently shorter. - // The timeline only ever grows — never shrinks — through this method. - growTimelineIfNeeded(requiredFrames) { - const current = this.getDurationFrames(); - if (requiredFrames <= current) return; // already big enough - - const newFrames = Math.ceil(requiredFrames); - if (this._canvasMode) { - this.node.durationFrames = newFrames; - const fps = this.getFrameRate(); - this.node.durationSeconds = Math.round((newFrames / fps) * 1000) / 1000; - if (this._onCanvasCommit) this._onCanvasCommit(); - } else { - if (this.durationFramesWidget) { - this.durationFramesWidget.value = newFrames; - } - if (this.durationSecondsWidget) { - this.durationSecondsWidget.value = parseFloat((newFrames / this.getFrameRate()).toFixed(3)); - } - if (window.app && window.app.graph) { - window.app.graph.setDirtyCanvas(true, true); - } - } - } - - // Returns the maximum allowed zoom level, computed so that at max zoom - // the viewport shows exactly 4 seconds of the visual timeline. - getMaxZoom() { - const visualDurationSecs = this.getVisualDurationFrames() / this.getFrameRate(); - const baseMaxZoom = Math.max(1, visualDurationSecs / 4); - - // Limit max zoom to prevent canvas width from exceeding browser limits (causing crash) - const viewportWidth = this.viewport ? this.viewport.clientWidth : 1000; - const MAX_CANVAS_WIDTH = 32768; // Extended limit for modern browsers - const limitMaxZoom = MAX_CANVAS_WIDTH / Math.max(1, viewportWidth); - - return Math.max(1, Math.min(baseMaxZoom, limitMaxZoom)); - } - - // Returns the visual timeline length in frames: - // the furthest segment end (across both tracks) × 1.30, with a floor of getDurationFrames(). - // This is used for all rendering/positioning — the actual output duration is getDurationFrames(). - getVisualDurationFrames() { - let furthest = 0; - for (const seg of this.timeline.segments) { - furthest = Math.max(furthest, seg.start + seg.length); - } - for (const seg of this.timeline.audioSegments) { - furthest = Math.max(furthest, seg.start + seg.length); - } - const outputDuration = this.getDurationFrames(); - if (furthest <= 0) return outputDuration; - return Math.max(outputDuration, Math.ceil(furthest * 1.30)); - } - - // Sync the zoom slider's max attribute to the current getMaxZoom() value, - // clamping zoomLevel if it now exceeds the new max. - updateZoomSliderMax() { - if (!this.zoomSlider) return; - const maxZoom = this.getMaxZoom(); - this.zoomSlider.max = maxZoom.toFixed(2); - if (this.zoomLevel > maxZoom) { - this.zoomLevel = maxZoom; - this.zoomSlider.value = maxZoom; - // Resize the canvas to match the clamped zoom - const viewportWidth = this.viewport ? this.viewport.clientWidth : 0; - if (viewportWidth > 0) { - const newCanvasWidth = Math.max(viewportWidth, viewportWidth * this.zoomLevel); - this.canvas.style.width = newCanvasWidth + "px"; - this.resizeCanvas(newCanvasWidth); - } - } - } - - loadImages() { - for (const seg of this.timeline.segments) { - if (seg.imageB64 && !seg.imgObj) { - seg.imgObj = new Image(); - seg.imgObj.onload = () => this.render(); - seg.imgObj.src = seg.imageB64; - } - } - } - - createDOM() { - this.wrapper = document.createElement("div"); - this.wrapper.className = "pr-wrapper"; - - this.wrapper.addEventListener("mouseenter", () => { this._isHovering = true; }); - this.wrapper.addEventListener("mouseleave", () => { this._isHovering = false; }); - if (this._canvasMode) { - this.wrapper.addEventListener("mousedown", (e) => { - if (e.target.closest(".port")) return; - e.stopPropagation(); - }); - } - - this.handleKeyDown = (e) => { - const activeTag = document.activeElement ? document.activeElement.tagName : ""; - if (activeTag === "INPUT" || activeTag === "TEXTAREA") return; - - if ((e.key === "Delete" || e.key === "Backspace") && this.selectedIndex !== -1 && this._isHovering) { - this.deleteSelectedSegment(); - e.preventDefault(); - if (!this._canvasMode) e.stopImmediatePropagation(); - } else if ((e.key === " " || e.code === "Space") && this._isHovering) { - this.togglePlay(); - e.stopPropagation(); - e.stopImmediatePropagation(); - e.preventDefault(); - } - }; - window.addEventListener("keydown", this.handleKeyDown, true); - - this.handlePaste = (e) => { - if (this._isHovering) { - const activeTag = document.activeElement ? document.activeElement.tagName : ""; - if (activeTag === "INPUT" || activeTag === "TEXTAREA") return; - - if (e.clipboardData && e.clipboardData.files && e.clipboardData.files.length > 0) { - const imageFiles = Array.from(e.clipboardData.files).filter(f => f.type.startsWith("image/")); - if (imageFiles.length > 0) { - this.handleImageUpload(imageFiles, this.currentFrame); - e.preventDefault(); - e.stopPropagation(); - } - } - } - }; - window.addEventListener("paste", this.handlePaste, true); - - // --- Toolbar --- - const toolbar = document.createElement("div"); - toolbar.className = "pr-toolbar"; - - const actionGroup = document.createElement("div"); - actionGroup.className = "pr-actions"; - - this.fileInput = document.createElement("input"); - this.fileInput.type = "file"; - this.fileInput.accept = "image/*"; - this.fileInput.multiple = true; - this.fileInput.style.display = "none"; - this.fileInput.addEventListener("change", (e) => this.handleImageUpload(e.target.files)); - - this.audioFileInput = document.createElement("input"); - this.audioFileInput.type = "file"; - this.audioFileInput.accept = "audio/*"; - this.audioFileInput.multiple = true; - this.audioFileInput.style.display = "none"; - this.audioFileInput.addEventListener("change", (e) => this.handleAudioUpload(e.target.files)); - - const uploadBtn = document.createElement("button"); - uploadBtn.className = "pr-btn"; - uploadBtn.innerHTML = `${ICONS.upload} Add Image`; - uploadBtn.addEventListener("click", () => this.fileInput.click()); - - const uploadAudioBtn = document.createElement("button"); - uploadAudioBtn.className = "pr-btn"; - uploadAudioBtn.innerHTML = `${ICONS.audio} Add Audio`; - uploadAudioBtn.addEventListener("click", () => this.audioFileInput.click()); - - const addTextBtn = document.createElement("button"); - addTextBtn.className = "pr-btn"; - addTextBtn.innerHTML = `${ICONS.text} Add Text`; - addTextBtn.addEventListener("click", () => this.addTextSegmentFreeSpace()); - - const deleteBtn = document.createElement("button"); - deleteBtn.className = "pr-btn pr-btn-danger"; - deleteBtn.innerHTML = `${ICONS.trash} Delete`; - deleteBtn.addEventListener("click", () => this.deleteSelectedSegment()); - - actionGroup.appendChild(this.fileInput); - actionGroup.appendChild(this.audioFileInput); - actionGroup.appendChild(uploadBtn); - actionGroup.appendChild(addTextBtn); - actionGroup.appendChild(uploadAudioBtn); - actionGroup.appendChild(deleteBtn); - toolbar.appendChild(actionGroup); - - const rightGroup = document.createElement("div"); - rightGroup.className = "pr-right-group"; - - this.segmentBoundsDisplay = document.createElement("div"); - this.segmentBoundsDisplay.className = "pr-segment-bounds"; - this.segmentBoundsDisplay.textContent = "Start: - | End: -"; - - this.timeCodeDisplay = document.createElement("div"); - this.timeCodeDisplay.className = "pr-timecode"; - this.timeCodeDisplay.textContent = this.formatTime(0); - - const settingsBtn = document.createElement("button"); - settingsBtn.className = "pr-btn"; - settingsBtn.style.padding = "6px"; - settingsBtn.style.justifyContent = "center"; - settingsBtn.style.width = "28px"; - settingsBtn.style.height = "28px"; - settingsBtn.style.boxSizing = "border-box"; - settingsBtn.innerHTML = ICONS.gear; - settingsBtn.title = "Settings"; - settingsBtn.addEventListener("click", (e) => { - e.stopPropagation(); - if (this._settingsMenu) { - this.dismissSettingsMenu(); - } else { - this.showSettingsMenu(settingsBtn); - } - }); - - const toggleBtn = document.createElement("button"); - toggleBtn.className = "pr-btn"; - toggleBtn.style.padding = "6px 8px"; - toggleBtn.style.fontSize = "11px"; - toggleBtn.style.marginRight = "0px"; - toggleBtn.textContent = "Custom Audio: OFF"; - toggleBtn.title = "Toggle Custom Audio Output"; - - const updateToggleStyle = (isOn) => { - toggleBtn.textContent = isOn ? "Custom Audio: ON" : "Custom Audio: OFF"; - if (isOn) { - toggleBtn.style.background = "#1c222d"; - toggleBtn.style.borderColor = "#283142"; - toggleBtn.style.color = "#e0e0e0"; - } else { - toggleBtn.style.background = "#222"; - toggleBtn.style.borderColor = "#111"; - toggleBtn.style.color = "#e0e0e0"; - } - }; - - toggleBtn.addEventListener("click", (e) => { - e.stopPropagation(); - if (this._canvasMode) { - this.node.useCustomAudio = !this.node.useCustomAudio; - updateToggleStyle(this.node.useCustomAudio); - if (this._onCanvasCommit) this._onCanvasCommit(); - } else { - const widget = this.node.widgets?.find(w => w.name === "use_custom_audio"); - if (widget) { - widget.value = !widget.value; - updateToggleStyle(widget.value); - this.node.setDirtyCanvas(true, true); - } - } - }); - - setTimeout(() => { - if (this._canvasMode) { - updateToggleStyle(!!this.node.useCustomAudio); - } else { - const widget = this.node.widgets?.find(w => w.name === "use_custom_audio"); - if (widget) updateToggleStyle(widget.value); - } - }, 100); - - const helpBtn = document.createElement("button"); - helpBtn.className = "pr-btn"; - helpBtn.style.padding = "6px"; - helpBtn.style.justifyContent = "center"; - helpBtn.style.width = "28px"; - helpBtn.style.height = "28px"; - helpBtn.style.boxSizing = "border-box"; - helpBtn.innerHTML = "?"; - helpBtn.title = "Help / Documentation"; - helpBtn.addEventListener("click", (e) => { - e.stopPropagation(); - window.open("https://github.com/WhatDreamsCost/WhatDreamsCost-ComfyUI", "_blank"); - }); - - const btnGroup = document.createElement("div"); - btnGroup.style.display = "flex"; - btnGroup.style.gap = "6px"; - btnGroup.style.alignItems = "center"; - btnGroup.appendChild(toggleBtn); - btnGroup.appendChild(helpBtn); - btnGroup.appendChild(settingsBtn); - rightGroup.appendChild(btnGroup); - - toolbar.appendChild(rightGroup); - - // --- Canvas & Viewport --- - this.viewport = document.createElement("div"); - this.viewport.className = "pr-timeline-viewport"; - - this.viewport.addEventListener("wheel", (e) => { - if (e.ctrlKey || e.metaKey) { - e.preventDefault(); - e.stopPropagation(); - - let zoomDelta = e.deltaY > 0 ? -0.5 : 0.5; - this.zoomLevel = Math.max(1, Math.min(this.getMaxZoom(), this.zoomLevel + zoomDelta)); - if (this.zoomSlider) this.zoomSlider.value = this.zoomLevel; - - const oldWidth = this.canvas.offsetWidth; - const newWidth = this.viewport.clientWidth * this.zoomLevel; - const mouseX = e.clientX - this.viewport.getBoundingClientRect().left; - const scrollRatio = (this.viewport.scrollLeft + mouseX) / oldWidth; - - this.canvas.style.width = newWidth + "px"; - this.viewport.scrollLeft = scrollRatio * newWidth - mouseX; - } - }, { passive: false, capture: true }); - - this.canvas = document.createElement("canvas"); - this.canvas.className = "pr-canvas"; - this.ctx = this.canvas.getContext("2d"); - this.canvas.style.width = "100%"; - - this.viewport.appendChild(this.canvas); - - this.canvas.addEventListener("mousedown", (e) => { - if (this._canvasMode) e.stopPropagation(); - this.onMouseDown(e); - }); - this.canvas.addEventListener("contextmenu", (e) => this.onContextMenu(e)); - this.canvas.style.height = `${CANVAS_HEIGHT}px`; - - // --- Content Area Container --- - const propContainer = document.createElement("div"); - propContainer.className = "pr-prop-container"; - - const promptLabel = document.createElement("div"); - promptLabel.className = "pr-prompt-label"; - promptLabel.textContent = "Segment prompt"; - - // --- Text Area (Image/Text) --- - this.promptInput = document.createElement("textarea"); - this.promptInput.className = "pr-prompt-area"; - this.promptInput.placeholder = "Enter prompt for selected segment..."; - this.promptInput.addEventListener("input", () => { - if (this.selectionType === "audio") return; - const seg = this.timeline.segments[this.selectedIndex]; - if (!seg) return; - seg.prompt = this.promptInput.value; - this.commitChanges(); - }); - - // --- Audio Info Area --- - this.audioInfoArea = document.createElement("div"); - this.audioInfoArea.className = "pr-audio-info"; - - propContainer.appendChild(promptLabel); - propContainer.appendChild(this.promptInput); - propContainer.appendChild(this.audioInfoArea); - - this.wrapper.addEventListener("dragover", (e) => { - e.preventDefault(); - this.wrapper.classList.add("drag-active"); - - const { x, y } = this.getMousePos(e); - const logicalWidth = this.canvas.offsetWidth; - const totalFrames = this.getVisualDurationFrames(); - if (!logicalWidth || totalFrames <= 0) return; - - const isAudioTrack = y > RULER_HEIGHT + this.blockHeight; - const trackType = isAudioTrack ? "audio" : "image"; - const arrToModify = isAudioTrack ? this.timeline.audioSegments : this.timeline.segments; - - if (!this._ghostSegmentId || this._ghostTrack !== trackType) { - this._ghostSegmentId = "GHOST_" + Date.now(); - this._ghostTrack = trackType; - this._ghostInitialTimeline = JSON.parse(JSON.stringify(arrToModify)); - - const frameRate = this.getFrameRate(); - const newLength = Math.max(1, frameRate * 1); - - let mouseFrameX = x * (totalFrames / logicalWidth); - let startFrame = clamp(Math.round(mouseFrameX - newLength / 2), 0, totalFrames - newLength); - - this._ghostInitialTimeline.push({ - id: this._ghostSegmentId, - start: startFrame, - length: newLength, - type: "ghost" - }); - } - - let mouseFrameX = x * (totalFrames / logicalWidth); - const ghost = this._ghostInitialTimeline.find(s => s.id === this._ghostSegmentId); - let D_mouse_start = mouseFrameX - ghost.length / 2; - - this._previewSegments = this._applyCenterDragPhysics( - this._ghostInitialTimeline, - this._ghostSegmentId, - D_mouse_start, - mouseFrameX, - totalFrames, - totalFrames, - logicalWidth - ); - this.render(); - }); - - this.wrapper.addEventListener("dragleave", (e) => { - const rect = this.wrapper.getBoundingClientRect(); - if (e.clientX < rect.left || e.clientX >= rect.right || - e.clientY < rect.top || e.clientY >= rect.bottom) { - this.wrapper.classList.remove("drag-active"); - this._ghostSegmentId = null; - this._ghostTrack = null; - this._ghostInitialTimeline = null; - this._previewSegments = null; - this.render(); - } - }); - - this.wrapper.addEventListener("drop", (e) => { - e.preventDefault(); - e.stopPropagation(); - this.wrapper.classList.remove("drag-active"); - - let targetFrameStart = null; - let targetTrack = this._ghostTrack || "image"; - - if (this._ghostSegmentId && this._previewSegments) { - const ghost = this._previewSegments.find(s => s.id === this._ghostSegmentId); - if (ghost) { - targetFrameStart = ghost.resolvedStart !== undefined ? ghost.resolvedStart : ghost.start; - } - } - this._ghostSegmentId = null; - this._ghostTrack = null; - this._ghostInitialTimeline = null; - this._previewSegments = null; - this.render(); - - if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { - const imageFiles = []; - const audioFiles = []; - for (let file of e.dataTransfer.files) { - if (file.type.startsWith("audio/")) audioFiles.push(file); - if (file.type.startsWith("image/")) imageFiles.push(file); - } - - // Let implicit intent handle mixing drops: use the track we hovered over - // for the first type we process, or fallback. - if (audioFiles.length > 0 && (targetTrack === "audio" || imageFiles.length === 0)) { - this.handleAudioUpload(audioFiles, targetFrameStart); - } else if (imageFiles.length > 0) { - this.handleImageUpload(imageFiles, targetFrameStart); - } - } - }); - - this._boundWindowMouseMove = (e) => { - if (this._destroyed) return; - if (!this.wrapper?.isConnected) { - if (this._isDragging) this.onMouseUp(e); - return; - } - this.onMouseMove(e); - }; - this._boundWindowMouseUp = (e) => { - if (this._destroyed) return; - if (!this.wrapper?.isConnected) { - if (this._isDragging) this.onMouseUp(e); - return; - } - this.onMouseUp(e); - }; - window.addEventListener("mousemove", this._boundWindowMouseMove); - window.addEventListener("mouseup", this._boundWindowMouseUp); - - // --- Player Controls --- - const playerControls = document.createElement("div"); - playerControls.className = "pr-player-controls"; - - this.playBtn = document.createElement("button"); - this.playBtn.className = "pr-icon-btn"; - this.playBtn.style.padding = "4px"; - this.playBtn.innerHTML = ICONS.play; - this.playBtn.title = "Play/Pause Audio"; - this.playBtn.addEventListener("click", () => this.togglePlay()); - - this.loopBtn = document.createElement("button"); - this.loopBtn.className = "pr-icon-btn"; - this.loopBtn.style.padding = "4px"; - this.loopBtn.innerHTML = ICONS.loop; - this.loopBtn.title = "Toggle Loop"; - this.loopBtn.addEventListener("click", () => this.toggleLoop()); - - this.seekBar = document.createElement("input"); - this.seekBar.type = "range"; - this.seekBar.className = "pr-seek-bar"; - this.seekBar.min = "0"; - this.seekBar.value = "0"; - this.seekBar.style.flex = "1"; // take up remaining space - this.seekBar.addEventListener("input", (e) => { - this.currentFrame = parseInt(e.target.value, 10); - this.render(); - if (this.isPlaying) { - this.playAudio(); - } - }); - - // --- Zoom Controls --- - const zoomControls = document.createElement("div"); - zoomControls.className = "pr-zoom-controls"; - - const zoomOutBtn = document.createElement("button"); - zoomOutBtn.className = "pr-icon-btn"; - zoomOutBtn.style.padding = "4px"; - zoomOutBtn.innerHTML = ICONS.minus; - zoomOutBtn.title = "Zoom Out"; - zoomOutBtn.addEventListener("click", () => { - const currentZoom = parseFloat(this.zoomSlider.value); - this.zoomSlider.value = Math.max(1, currentZoom - 0.5); - this.zoomSlider.dispatchEvent(new Event("input")); - }); - - this.zoomSlider = document.createElement("input"); - this.zoomSlider.type = "range"; - this.zoomSlider.className = "pr-zoom-slider"; - this.zoomSlider.min = "1"; - this.zoomSlider.max = "1"; // Updated dynamically via updateZoomSliderMax() - this.zoomSlider.step = "0.1"; - this.zoomSlider.value = "1"; - this.zoomSlider.title = "Zoom Level"; - this.zoomSlider.addEventListener("input", (e) => { - this.zoomLevel = parseFloat(e.target.value); - - const viewportWidth = this.viewport.clientWidth; - const newCanvasWidth = Math.max(viewportWidth, viewportWidth * this.zoomLevel); - - this.canvas.style.width = newCanvasWidth + "px"; - this.resizeCanvas(newCanvasWidth); - this._lastWidth = viewportWidth; - this._lastZoom = this.zoomLevel; - - // Keep playhead centered - const totalFrames = this.getVisualDurationFrames(); - const playheadRatio = this.currentFrame / totalFrames; - const newPlayheadX = playheadRatio * newCanvasWidth; - this.viewport.scrollLeft = newPlayheadX - (viewportWidth / 2); - }); - - const zoomInBtn = document.createElement("button"); - zoomInBtn.className = "pr-icon-btn"; - zoomInBtn.style.padding = "4px"; - zoomInBtn.innerHTML = ICONS.plus; - zoomInBtn.title = "Zoom In"; - zoomInBtn.addEventListener("click", () => { - const currentZoom = parseFloat(this.zoomSlider.value); - this.zoomSlider.value = Math.min(this.getMaxZoom(), currentZoom + 0.5); - this.zoomSlider.dispatchEvent(new Event("input")); - }); - - const zoomFitBtn = document.createElement("button"); - zoomFitBtn.className = "pr-icon-btn"; - zoomFitBtn.style.padding = "4px"; - zoomFitBtn.style.marginLeft = "4px"; - zoomFitBtn.innerHTML = ICONS.fit; - zoomFitBtn.title = "Zoom to Fit (show full timeline)"; - zoomFitBtn.addEventListener("click", () => { - this.zoomLevel = 1; - this.zoomSlider.value = 1; - const viewportWidth = this.viewport.clientWidth; - this.canvas.style.width = viewportWidth + "px"; - this.resizeCanvas(viewportWidth); - this._lastWidth = viewportWidth; - this._lastZoom = 1; - this.viewport.scrollLeft = 0; - }); - - zoomControls.appendChild(zoomOutBtn); - zoomControls.appendChild(this.zoomSlider); - zoomControls.appendChild(zoomInBtn); - zoomControls.appendChild(zoomFitBtn); - - playerControls.appendChild(this.playBtn); - playerControls.appendChild(this.loopBtn); - playerControls.appendChild(this.seekBar); - playerControls.appendChild(zoomControls); - - - - // --- Guide Strength Slider --- - this.strengthRow = document.createElement("div"); - this.strengthRow.className = "pr-strength-row"; - - const strengthLabel = document.createElement("span"); - strengthLabel.className = "pr-strength-label"; - strengthLabel.textContent = "Guide Strength:"; - - this.strengthValue = document.createElement("input"); - this.strengthValue.type = "text"; - this.strengthValue.className = "pr-strength-input"; - this.strengthValue.value = "1.00"; - this.strengthValue.disabled = true; - this.strengthValue.style.cursor = "ew-resize"; - - // Dragging logic for guide strength - let isDragging = false; - let startX = 0; - let startVal = 0; - let hasMoved = false; - - this.strengthValue.addEventListener("mousedown", (e) => { - if (this.strengthValue.disabled) return; - startX = e.clientX; - startVal = parseFloat(this.strengthValue.value) || 1.0; - hasMoved = false; - - const onMouseMove = (moveEvent) => { - const deltaX = moveEvent.clientX - startX; - if (Math.abs(deltaX) > 3) { - hasMoved = true; - isDragging = true; - } - - if (isDragging) { - moveEvent.preventDefault(); - const sensitivity = 0.002; - let newVal = startVal + deltaX * sensitivity; - - if (newVal < 0) newVal = 0; - if (newVal > 1) newVal = 1; - - this.strengthValue.value = newVal.toFixed(2); - - if (this.selectionType === "image" && this.timeline.segments[this.selectedIndex]) { - const seg = this.timeline.segments[this.selectedIndex]; - if (seg.type !== "text") { - seg.guideStrength = newVal; - this.commitChanges(); - } - } - } - }; - - const onMouseUp = () => { - document.removeEventListener("mousemove", onMouseMove); - document.removeEventListener("mouseup", onMouseUp); - - if (!hasMoved) { - this.strengthValue.focus(); - this.strengthValue.select(); - } - isDragging = false; - }; - - document.addEventListener("mousemove", onMouseMove); - document.addEventListener("mouseup", onMouseUp); - }); - - this.strengthValue.addEventListener("change", (e) => { - let val = parseFloat(e.target.value); - if (isNaN(val)) val = 1; - val = Math.max(0, Math.min(1, val)); - this.strengthValue.value = val.toFixed(2); - if (this.selectionType === "image" && this.timeline.segments[this.selectedIndex]) { - const seg = this.timeline.segments[this.selectedIndex]; - if (seg.type !== "text") { - seg.guideStrength = val; - this.commitChanges(); - } - } - }); - - this.strengthRow.appendChild(this.timeCodeDisplay); - this.strengthRow.appendChild(this.segmentBoundsDisplay); - this.strengthRow.appendChild(strengthLabel); - this.strengthRow.appendChild(this.strengthValue); - - - this.wrapper.appendChild(toolbar); - this.wrapper.appendChild(this.viewport); - - const controlsGroup = document.createElement("div"); - controlsGroup.className = "pr-controls-group"; - controlsGroup.appendChild(this.strengthRow); - controlsGroup.appendChild(playerControls); - this.wrapper.appendChild(controlsGroup); - this.wrapper.appendChild(propContainer); - - this.container.appendChild(this.wrapper); - } - - checkResize() { - if (this._destroyed || !this.viewport) return; - const viewportWidth = this.viewport.clientWidth; - const currentScale = this.getRenderScale(); - - if (viewportWidth > 0 && (this._lastWidth !== viewportWidth || this._lastZoom !== this.zoomLevel || this._lastScale !== currentScale)) { - this._lastWidth = viewportWidth; - this._lastZoom = this.zoomLevel; - this._lastScale = currentScale; - - const newCanvasWidth = Math.max(viewportWidth, viewportWidth * this.zoomLevel); - this.canvas.style.width = newCanvasWidth + "px"; - this.resizeCanvas(newCanvasWidth); - } - this._renderLoop = requestAnimationFrame(() => this.checkResize()); - } - - getRenderScale() { - const dpr = window.devicePixelRatio || 1; - let graphScale = 1; - try { - if (window.app && window.app.canvas && window.app.canvas.ds && window.app.canvas.ds.scale) { - graphScale = window.app.canvas.ds.scale; - } - } catch (e) { } - // Scale up if zoomed in, but don't drop below 1x DPR if zoomed out - return dpr * Math.max(1, graphScale); - } - - resizeCanvas(widthPx) { - const scale = this.getRenderScale(); - const targetWidth = Math.round(widthPx * scale); - const targetHeight = Math.round(this.canvasHeight * scale); - - this.canvas.width = targetWidth; - this.canvas.height = targetHeight; - this.ctx.setTransform(scale, 0, 0, scale, 0, 0); - this.render(); - } - - // Helper to map mouse events accurately regardless of canvas scaling - getMousePos(e) { - const rect = this.canvas.getBoundingClientRect(); - - const scaleX = this.canvas.offsetWidth / rect.width; - const scaleY = this.canvas.offsetHeight / rect.height; - - const x = (e.clientX - rect.left) * scaleX; - const y = (e.clientY - rect.top) * scaleY; - return { x, y }; - } - - // --- Async Image Upload Logic (Handles multiple images simultaneously) --- - async handleImageUpload(files, targetFrameStart = null, explicitLength = null) { - const frameRate = this.getFrameRate(); - const durationFrames = this.getDurationFrames(); - const newLength = explicitLength !== null ? explicitLength : frameRate * 1; // Default to 1 second long - - for (let file of files) { - if (!file.type.startsWith("image/")) continue; - - await new Promise(async (resolve) => { - try { - const body = new FormData(); - body.append("image", file); - const resp = await api.fetchApi("/upload/image", { method: "POST", body }); - if (resp.status !== 200) { resolve(); return; } - - const data = await resp.json(); - const filename = data.name; - const subfolder = data.subfolder || ""; - const imageFile = subfolder ? subfolder + "/" + filename : filename; - const imgUrl = api.apiURL(`/view?filename=${encodeURIComponent(filename)}&type=input&subfolder=${encodeURIComponent(subfolder)}`); - - const img = new Image(); - img.onload = () => { - - let newStart = targetFrameStart; - if (newStart === null) { - // Fallback: find the first free slot, or append past the end - newStart = 0; - this.timeline.segments.sort((a, b) => a.start - b.start); - for (let i = 0; i < this.timeline.segments.length; i++) { - let seg = this.timeline.segments[i]; - if (newStart + newLength <= seg.start) break; - newStart = Math.max(newStart, seg.start + seg.length); - } - } - - // Use the visual timeline as the physics bound so segments can - // land anywhere in the padded visual area without touching duration_frames. - const currentDuration = this.getVisualDurationFrames(); - - if (targetFrameStart !== null) { - // Resolve physics to push existing segments - let tempId = "TEMP_" + Date.now(); - this.timeline.segments.push({ id: tempId, start: newStart, length: newLength, type: "temp" }); - let result = this._applyCenterDragPhysics(this.timeline.segments, tempId, newStart, newStart + newLength / 2, currentDuration, currentDuration, 1); - - // Update original segments with resolved physics to preserve imgObj - for (let shiftedSeg of result) { - let original = this.timeline.segments.find(s => s.id === shiftedSeg.id); - if (original) { - original.start = shiftedSeg.resolvedStart !== undefined ? shiftedSeg.resolvedStart : shiftedSeg.start; - } - } - - let tempSeg = this.timeline.segments.find(s => s.id === tempId); - newStart = tempSeg.start; - this.timeline.segments = this.timeline.segments.filter(s => s.id !== tempId); - targetFrameStart = newStart + newLength; // For the next file in batch - } - - // Use the full intended length — the timeline has already been grown to fit. - let constrainedLength = newLength; - - const seg = { - id: Date.now().toString() + Math.random().toString(36).substr(2, 5), - start: newStart, - length: constrainedLength, - prompt: "", - type: "image", - imageFile: imageFile, - imageB64: imgUrl - }; - - const displayImg = new Image(); - displayImg.onload = () => { - seg.imgObj = displayImg; - this.render(); - resolve(); // Resolve promise letting next image process - }; - displayImg.src = imgUrl; - - this.timeline.segments.push(seg); - this.timeline.segments.sort((a, b) => a.start - b.start); - this.selectionType = "image"; - this.selectedIndex = this.timeline.segments.findIndex(s => s.id === seg.id); - - this.updateUIFromSelection(); - this.commitChanges(true); - }; - img.src = imgUrl; - } catch (err) { - console.error("[PromptRelay] Image upload failed", err); - resolve(); - } - }); - } - this.fileInput.value = ""; - } - - // --- Async Audio Upload Logic --- - async handleAudioUpload(files, targetFrameStart = null) { - const frameRate = this.getFrameRate(); - const durationFrames = this.getDurationFrames(); - - for (let file of files) { - if (!file.type.startsWith("audio/")) continue; - - await new Promise(async (resolve) => { - try { - const body = new FormData(); - body.append("image", file); - const resp = await api.fetchApi("/upload/image", { method: "POST", body }); - if (resp.status !== 200) { resolve(); return; } - - const data = await resp.json(); - const filename = data.name; - const subfolder = data.subfolder || ""; - const audioFile = subfolder ? subfolder + "/" + filename : filename; - - const arrayBuffer = await file.arrayBuffer(); - const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); - const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer); - const clipDurationSecs = audioBuffer.duration; - const clipFrames = Math.max(1, Math.ceil(clipDurationSecs * frameRate)); - - const channelData = audioBuffer.getChannelData(0); - const peaks = []; - const numPeaks = 200; - const step = Math.floor(channelData.length / numPeaks); - for (let i = 0; i < numPeaks; i++) { - let max = 0; - for (let j = 0; j < step; j++) { - const val = Math.abs(channelData[i * step + j]); - if (val > max) max = val; - } - peaks.push(max); - } - - let newLength = clipFrames; - let newStart = targetFrameStart; - - if (newStart === null) { - // Find the first free slot, or place past the end of all existing audio - newStart = 0; - this.timeline.audioSegments.sort((a, b) => a.start - b.start); - for (let i = 0; i < this.timeline.audioSegments.length; i++) { - let seg = this.timeline.audioSegments[i]; - if (newStart + newLength <= seg.start) break; - newStart = Math.max(newStart, seg.start + seg.length); - } - } - - // Use the visual timeline as the physics bound so segments can - // land anywhere in the padded visual area without touching duration_frames. - const currentDuration = this.getVisualDurationFrames(); - - if (targetFrameStart !== null) { - let tempId = "TEMP_" + Date.now(); - this.timeline.audioSegments.push({ id: tempId, start: newStart, length: newLength, type: "temp" }); - let result = this._applyCenterDragPhysics(this.timeline.audioSegments, tempId, newStart, newStart + newLength / 2, currentDuration, currentDuration, 1); - - for (let shiftedSeg of result) { - let original = this.timeline.audioSegments.find(s => s.id === shiftedSeg.id); - if (original) original.start = shiftedSeg.resolvedStart !== undefined ? shiftedSeg.resolvedStart : shiftedSeg.start; - } - - let tempSeg = this.timeline.audioSegments.find(s => s.id === tempId); - newStart = tempSeg.start; - this.timeline.audioSegments = this.timeline.audioSegments.filter(s => s.id !== tempId); - targetFrameStart = newStart + newLength; - } - - // Use the full clip length — timeline has already grown to fit. - let constrainedLength = newLength; - - const seg = { - id: Date.now().toString() + Math.random().toString(36).substr(2, 5), - type: "audio", - start: newStart, - length: constrainedLength, - trimStart: 0, - audioDurationFrames: clipFrames, - audioFile: audioFile, - fileName: file.name, - waveformPeaks: peaks - }; - - this.timeline.audioSegments.push(seg); - this.timeline.audioSegments.sort((a, b) => a.start - b.start); - this.selectionType = "audio"; - this.selectedIndex = this.timeline.audioSegments.findIndex(s => s.id === seg.id); - - this.updateUIFromSelection(); - this.commitChanges(true); - this.render(); - resolve(); - } catch (err) { - console.error("[PromptRelay] Audio processing failed", err); - resolve(); - } - }); - } - this.audioFileInput.value = ""; - } - - deleteSelectedSegment() { - if (this.selectionType === "audio") { - if (this.timeline.audioSegments.length === 0 || this.selectedIndex === -1) return; - this.timeline.audioSegments.splice(this.selectedIndex, 1); - this.selectedIndex = Math.max(-1, this.selectedIndex - 1); - } else { - if (this.timeline.segments.length === 0 || this.selectedIndex === -1) return; - this.timeline.segments.splice(this.selectedIndex, 1); - this.selectedIndex = Math.max(-1, this.selectedIndex - 1); - } - this.updateUIFromSelection(); - this.commitChanges(); - this.render(); - } - - formatTime(frames, dropSuffix = false) { - const mode = this.displayModeWidget ? this.displayModeWidget.value : "seconds"; - if (mode === "seconds") { - const secs = frames / this.getFrameRate(); - return dropSuffix ? secs.toFixed(2) : secs.toFixed(2) + "s"; - } - return dropSuffix ? Math.round(frames).toString() : Math.round(frames) + " frames"; - } - - updateWidgetVisibility() { - const mode = this.displayModeWidget ? this.displayModeWidget.value : "seconds"; - - if (this.durationFramesWidget) { - // Always visible regardless of display mode - this.durationFramesWidget.type = "INT"; - if (!this.durationFramesWidget.options) this.durationFramesWidget.options = {}; - this.durationFramesWidget.options.hidden = false; - this.durationFramesWidget.hidden = false; - delete this.durationFramesWidget.computeSize; - } - if (this.durationSecondsWidget) { - // Always visible regardless of display mode - this.durationSecondsWidget.type = "FLOAT"; - if (!this.durationSecondsWidget.options) this.durationSecondsWidget.options = {}; - this.durationSecondsWidget.options.hidden = false; - this.durationSecondsWidget.hidden = false; - delete this.durationSecondsWidget.computeSize; - } - - // Force node resize and redraw deferred to next tick - setTimeout(() => { - if (this.node && this.node.computeSize) { - const sz = this.node.computeSize(); - this.node.size[1] = sz[1]; - if (window.app && window.app.graph) { - window.app.graph.setDirtyCanvas(true, true); - } - } - }, 0); - } - - updateUIFromSelection() { - let seg = null; - if (this.selectedIndex >= 0) { - if (this.selectionType === "audio") { - const origSeg = this.timeline.audioSegments[this.selectedIndex]; - if (origSeg) { - const previewIsAudio = this._ghostTrack === 'audio' || (this._previewSegments && this._ghostTrack === null && this.selectionType === 'audio'); - const arr = (this._previewSegments && previewIsAudio) ? this._previewSegments : this.timeline.audioSegments; - seg = arr.find(s => s.id === origSeg.id) || origSeg; - } - } else { - const origSeg = this.timeline.segments[this.selectedIndex]; - if (origSeg) { - const previewIsImage = this._ghostTrack === 'image' || (this._previewSegments && this._ghostTrack === null && this.selectionType === 'image'); - const arr = (this._previewSegments && previewIsImage) ? this._previewSegments : this.timeline.segments; - seg = arr.find(s => s.id === origSeg.id) || origSeg; - } - } - } - - if (this.selectionType === "audio" && seg) { - this.promptInput.style.display = "none"; - this.strengthRow.style.display = "flex"; - this.audioInfoArea.style.display = "block"; - this.audioInfoArea.innerHTML = ` - File: ${seg.fileName || "Unknown"}
- Length: ${this.formatTime(seg.audioDurationFrames)} Output Length: ${this.formatTime(seg.length)}
- Trim-in: ${this.formatTime(Math.round(seg.trimStart))} Trim-Out: ${this.formatTime(Math.round(seg.audioDurationFrames - (seg.trimStart + seg.length)))} - `; - this.strengthValue.value = "1.00"; - this.strengthValue.disabled = true; - } else { - this.audioInfoArea.style.display = "none"; - this.promptInput.style.display = "block"; - this.strengthRow.style.display = "flex"; - - if (seg) { - this.promptInput.value = seg.prompt || ""; - this.promptInput.disabled = false; - - const isImage = seg.type !== "text"; - const strength = isImage ? (seg.guideStrength ?? 1.0) : 1.0; - this.strengthValue.value = strength.toFixed(2); - this.strengthValue.disabled = !isImage; - } else { - this.promptInput.value = ""; - this.promptInput.disabled = true; - this.strengthValue.value = "1.00"; - this.strengthValue.disabled = true; - } - } - - if (this.segmentBoundsDisplay) { - if (seg) { - const startStr = this.formatTime(seg.start, true); - const endStr = this.formatTime(seg.start + seg.length, true); - this.segmentBoundsDisplay.textContent = `Start: ${startStr} | End: ${endStr}`; - } else { - this.segmentBoundsDisplay.textContent = "Start: - | End: -"; - } - } - } - - // --- Rendering logic --- - render() { - const width = this.canvas.offsetWidth || this._lastWidth; - const height = this.canvasHeight; - const totalFrames = this.getVisualDurationFrames(); - - if (!width || width <= 0) return; - - this.ctx.clearRect(0, 0, width, height); - - - - // Render Track Backgrounds - this.ctx.fillStyle = "#111"; // Image track bg - this.ctx.fillRect(0, RULER_HEIGHT, width, this.blockHeight); - this.ctx.fillStyle = "#111"; // Audio track bg - this.ctx.fillRect(0, RULER_HEIGHT + this.blockHeight, width, this.audioTrackHeight); - - - - // Determine which track the preview belongs to. - // _ghostTrack is set during HTML file drag-and-drop. - // During canvas mouse drags, _ghostTrack is null, so fall back to selectionType. - const previewIsAudio = this._ghostTrack === 'audio' || - (this._previewSegments && this._ghostTrack === null && this.selectionType === 'audio'); - - let renderSegments = (this._previewSegments && !previewIsAudio) - ? this._previewSegments : this.timeline.segments; - - let renderAudioSegments = (this._previewSegments && previewIsAudio) - ? this._previewSegments : this.timeline.audioSegments; - - - - const activeSegId = this.timeline.segments[this.selectedIndex]?.id; - const activeAudioSegId = this.timeline.audioSegments[this.selectedIndex]?.id; - - // Sort segments so that the selected one is drawn last (on top) - const isImageSelection = this.selectionType === "image"; - const sortedSegments = [...renderSegments].sort((a, b) => { - const aSel = isImageSelection && a.id === activeSegId; - const bSel = isImageSelection && b.id === activeSegId; - return aSel - bSel; - }); - - const isAudioSelection = this.selectionType === "audio"; - const sortedAudioSegments = [...renderAudioSegments].sort((a, b) => { - const aSel = isAudioSelection && a.id === activeAudioSegId; - const bSel = isAudioSelection && b.id === activeAudioSegId; - return aSel - bSel; - }); - - // --- Draw Image/Text Segments --- - for (let i = 0; i < sortedSegments.length; i++) { - const seg = sortedSegments[i]; - const startX = (seg.start / totalFrames) * width; - const pxWidth = (seg.length / totalFrames) * width; - const isSelected = (this.selectionType === "image" && seg.id === activeSegId); - - const originalSeg = this.timeline.segments.find(s => s.id === seg.id); - const imgObj = originalSeg ? originalSeg.imgObj : seg.imgObj; - - if ((this._isDragging && this.selectionType === "image" && seg.id === this._dragTargetId) || (this._ghostSegmentId && seg.id === this._ghostSegmentId)) { - this.ctx.globalAlpha = 0.65; - } else { - this.ctx.globalAlpha = 1.0; - } - - if (seg.type === "ghost") { - this.ctx.fillStyle = "#2a2a2a"; - this.ctx.fillRect(startX, RULER_HEIGHT, pxWidth, this.blockHeight); - - this.ctx.strokeStyle = "#777"; - this.ctx.lineWidth = 2; - this.ctx.setLineDash([5, 5]); - this.ctx.strokeRect(startX, RULER_HEIGHT + 1, pxWidth, this.blockHeight - 2); - this.ctx.setLineDash([]); - - this.ctx.fillStyle = "#aaa"; - this.ctx.textAlign = "center"; - this.ctx.textBaseline = "middle"; - this.ctx.font = "bold 12px sans-serif"; - this.ctx.fillText("Drop to Place", startX + pxWidth / 2, RULER_HEIGHT + this.blockHeight / 2); - } else { - this.ctx.fillStyle = seg.type === "text" ? "#000b12" : "#000"; - this.ctx.fillRect(startX, RULER_HEIGHT + 1, pxWidth, this.blockHeight - 2); - } - - if (imgObj && imgObj.complete && imgObj.naturalWidth > 0 && seg.type !== "ghost") { - const imgRatio = imgObj.naturalWidth / imgObj.naturalHeight; - const boxRatio = pxWidth / this.blockHeight; - let drawW, drawH, drawX, drawY; - if (imgRatio > boxRatio) { - drawW = pxWidth; drawH = pxWidth / imgRatio; - drawX = startX; drawY = RULER_HEIGHT + (this.blockHeight - drawH) / 2; - } else { - drawH = this.blockHeight; drawW = this.blockHeight * imgRatio; - drawY = RULER_HEIGHT; drawX = startX + (pxWidth - drawW) / 2; - } - - // Clip to segment bounds so tiled images don't bleed into adjacent segments - this.ctx.save(); - this.ctx.beginPath(); - this.ctx.rect(startX, RULER_HEIGHT + 1, pxWidth, this.blockHeight - 2); - this.ctx.clip(); - - if (imgRatio > boxRatio) { - // Fits width, vertical letterboxing (black bars top/bottom) — keep as is - this.ctx.drawImage(imgObj, drawX, drawY, drawW, drawH); - } else { - // Fits height, horizontal letterboxing (black bars left/right) — tile horizontally - this.ctx.drawImage(imgObj, drawX, drawY, drawW, drawH); - - // Tile left - let leftX = drawX - drawW; - while (leftX + drawW > startX) { - this.ctx.drawImage(imgObj, leftX, drawY, drawW, drawH); - leftX -= drawW; - } - - // Tile right - let rightX = drawX + drawW; - while (rightX < startX + pxWidth) { - this.ctx.drawImage(imgObj, rightX, drawY, drawW, drawH); - rightX += drawW; - } - } - this.ctx.restore(); - - // --- Prompt subtitle overlay --- - if (seg.prompt && seg.type !== "ghost" && pxWidth > 24) { - const overlayH = Math.round(this.blockHeight * 0.20); - const overlayY = RULER_HEIGHT + this.blockHeight - overlayH; - - this.ctx.save(); - this.ctx.beginPath(); - this.ctx.rect(startX, overlayY, pxWidth, overlayH); - this.ctx.clip(); - - // Translucent background - this.ctx.fillStyle = "rgba(0, 0, 0, 0.60)"; - this.ctx.fillRect(startX, overlayY, pxWidth, overlayH); - - // Text - const fontSize = Math.min(11, overlayH * 0.58); - this.ctx.font = `${fontSize}px sans-serif`; - this.ctx.fillStyle = "#e0e3ed"; - this.ctx.textAlign = "center"; - this.ctx.textBaseline = "middle"; - - // Measure and truncate to single line - const maxTextW = pxWidth - 10; - let label = seg.prompt; - if (this.ctx.measureText(label).width > maxTextW) { - while (label.length > 0 && this.ctx.measureText(label + "…").width > maxTextW) { - label = label.slice(0, -1); - } - label += "…"; - } - - this.ctx.fillText(label, startX + pxWidth / 2, overlayY + overlayH / 2); - this.ctx.restore(); - } - } else if (seg.type === "text") { - const pad = 8; - const boxW = pxWidth - pad * 2; - if (boxW > 12) { - this.ctx.save(); - this.ctx.beginPath(); - this.ctx.rect(startX + pad, RULER_HEIGHT + pad, boxW, this.blockHeight - pad * 2); - this.ctx.clip(); - this.ctx.fillStyle = "#e0e3ed"; - this.ctx.font = "11px sans-serif"; - this.ctx.textAlign = "center"; - this.ctx.textBaseline = "top"; - const label = seg.prompt || "(no prompt)"; - const words = label.split(" "); - const lineH = 15; - let line = ""; - let lines = []; - for (const word of words) { - const test = line ? line + " " + word : word; - if (this.ctx.measureText(test).width > boxW && line) { - lines.push(line); - line = word; - } else { - line = test; - } - } - if (line) lines.push(line); - - const maxLines = Math.max(1, Math.floor((this.blockHeight - pad * 2) / lineH)); - if (lines.length > maxLines) { - lines = lines.slice(0, maxLines); - lines[lines.length - 1] += "…"; - } - - const totalTextHeight = lines.length * lineH; - let ty = RULER_HEIGHT + (this.blockHeight - totalTextHeight) / 2 + 2; - - for (const l of lines) { - this.ctx.fillText(l, startX + pxWidth / 2, ty); - ty += lineH; - } - this.ctx.restore(); - } - } - - if (isSelected) { - this.ctx.strokeStyle = "#fff"; - this.ctx.lineWidth = 2; - this.ctx.strokeRect(startX, RULER_HEIGHT + 1, pxWidth, this.blockHeight - 2); - this.ctx.fillStyle = "#fff"; - this.ctx.beginPath(); - this.ctx.roundRect(startX, RULER_HEIGHT + this.blockHeight / 2 - 12, 4, 24, 2); - this.ctx.fill(); - this.ctx.beginPath(); - this.ctx.roundRect(startX + pxWidth - 4, RULER_HEIGHT + this.blockHeight / 2 - 12, 4, 24, 2); - this.ctx.fill(); - } else { - this.ctx.strokeStyle = "#000"; - this.ctx.lineWidth = 1.5; - this.ctx.strokeRect(startX, RULER_HEIGHT + 1, pxWidth, this.blockHeight - 2); - } - this.ctx.globalAlpha = 1.0; - } - - // --- Draw Audio Segments --- - for (let i = 0; i < sortedAudioSegments.length; i++) { - const seg = sortedAudioSegments[i]; - const startX = (seg.start / totalFrames) * width; - const pxWidth = (seg.length / totalFrames) * width; - const isSelected = (this.selectionType === "audio" && seg.id === activeAudioSegId); - const trackY = RULER_HEIGHT + this.blockHeight; - - if ((this._isDragging && this.selectionType === "audio" && seg.id === this._dragTargetId) || (this._ghostSegmentId && seg.id === this._ghostSegmentId)) { - this.ctx.globalAlpha = 0.65; - } else { - this.ctx.globalAlpha = 1.0; - } - - if (seg.type === "ghost") { - this.ctx.fillStyle = "#1a1a1a"; - this.ctx.fillRect(startX, trackY, pxWidth, this.audioTrackHeight); - this.ctx.strokeStyle = "#555"; - this.ctx.lineWidth = 2; - this.ctx.setLineDash([5, 5]); - this.ctx.strokeRect(startX, trackY, pxWidth, this.audioTrackHeight); - this.ctx.setLineDash([]); - this.ctx.fillStyle = "#888"; - this.ctx.textAlign = "center"; - this.ctx.textBaseline = "middle"; - this.ctx.font = "bold 12px sans-serif"; - this.ctx.fillText("Drop Audio", startX + pxWidth / 2, trackY + this.audioTrackHeight / 2); - } else { - this.drawAudioSegmentVisuals(this.ctx, seg, isSelected, trackY, this.audioTrackHeight, startX, pxWidth); - } - this.ctx.globalAlpha = 1.0; - } - - // --- Draw Ruler & Divider AFTER segments to prevent overlap --- - // Ruler Background - this.ctx.fillStyle = "#1e1e1e"; - this.ctx.fillRect(0, 0, width, RULER_HEIGHT); - - // Crisp Ruler Text - this.ctx.fillStyle = "#aaa"; - this.ctx.textAlign = "center"; - this.ctx.textBaseline = "middle"; - this.ctx.font = "10px sans-serif"; - - const frameRate = this.getFrameRate(); - const mode = this.displayModeWidget ? this.displayModeWidget.value : "seconds"; - - // Define logical steps for both modes - let steps; - if (mode === "seconds") { - steps = [0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600]; - } else { - steps = [1, 2, 5, 10, 24, 48, 120, 240, 480, 960, 1920]; - } - - const minSpacingPx = 60; - let majorStep = steps[steps.length - 1]; - for (let i = 0; i < steps.length; i++) { - const stepFrames = mode === "seconds" ? steps[i] * frameRate : steps[i]; - const spacingPx = (stepFrames / totalFrames) * width; - if (spacingPx >= minSpacingPx) { - majorStep = steps[i]; - break; - } - } - - const majorStepFrames = mode === "seconds" ? majorStep * frameRate : majorStep; - - let minorStep; - if (mode === "seconds") { - if (majorStep <= 0.2) minorStep = majorStep / 2; - else if (majorStep <= 1) minorStep = majorStep / 5; - else if (majorStep <= 5) minorStep = 1; - else if (majorStep <= 15) minorStep = 5; - else if (majorStep <= 30) minorStep = 10; - else if (majorStep <= 60) minorStep = 10; - else minorStep = majorStep / 5; - } else { - if (majorStep <= 5) minorStep = 1; - else if (majorStep <= 10) minorStep = 2; - else if (majorStep <= 24) minorStep = 6; - else if (majorStep <= 48) minorStep = 12; - else minorStep = majorStep / 5; - } - const minorStepFrames = mode === "seconds" ? minorStep * frameRate : minorStep; - - this.ctx.fillStyle = "#444"; - const totalMinorTicks = Math.floor(totalFrames / minorStepFrames); - for (let i = 0; i <= totalMinorTicks; i++) { - const frameVal = i * minorStepFrames; - if (Math.abs(frameVal % majorStepFrames) < 0.1) continue; - - const x = (frameVal / totalFrames) * width; - this.ctx.fillRect(Math.floor(x), RULER_HEIGHT - 3, 1, 3); - } - - this.ctx.fillStyle = "#aaa"; - const totalMajorTicks = Math.floor(totalFrames / majorStepFrames); - for (let i = 0; i <= totalMajorTicks; i++) { - const frameVal = i * majorStepFrames; - const x = (frameVal / totalFrames) * width; - - this.ctx.fillStyle = "#aaa"; - this.ctx.fillRect(Math.floor(x), RULER_HEIGHT - 6, 1, 6); - - if (frameVal > 0 && frameVal < totalFrames) { - this.ctx.textAlign = "center"; - this.ctx.fillText(this.formatTime(frameVal, true), x, RULER_HEIGHT / 2); - } - } - - this.ctx.textAlign = "left"; - const zeroLabel = mode === "seconds" ? "0" : this.formatTime(0, true); - this.ctx.fillText(zeroLabel, 4, RULER_HEIGHT / 2); - - // Divider - this.ctx.fillStyle = "#333"; - this.ctx.fillRect(0, RULER_HEIGHT + this.blockHeight, width, 1); - - // Draw gap "+" buttons - if (!this._isDragging) { - const BTN_R = 12; - const gapRegions = this.getGapRegions(); - for (let i = 0; i < gapRegions.length; i++) { - const gap = gapRegions[i]; - if (gap.widthPx < BTN_R * 2 + 8) continue; - const hov = this._hoveredGapIdx === i; - const BTN_W = 18; - const BTN_H = 18; - this.ctx.beginPath(); - this.ctx.roundRect(gap.centerX - BTN_W / 2, gap.centerY - BTN_H / 2, BTN_W, BTN_H, 4); - this.ctx.fillStyle = hov ? "rgba(255,255,255,0.15)" : "rgba(255,255,255,0.05)"; - this.ctx.fill(); - this.ctx.fillStyle = hov ? "#fff" : "#888"; - this.ctx.font = "14px sans-serif"; - this.ctx.textAlign = "center"; - this.ctx.textBaseline = "middle"; - this.ctx.fillText("+", gap.centerX, gap.centerY + 1); - } - } - - // --- Out-of-duration shadow overlay --- - // Draw a translucent black mask over the region beyond the actual output duration - // so the user can clearly see which content will be included in the render. - const outputFrames = this.getDurationFrames(); - if (outputFrames < totalFrames) { - const cutoffX = (outputFrames / totalFrames) * width; - // Semi-transparent black overlay on both tracks - this.ctx.fillStyle = "rgba(0, 0, 0, 0.45)"; - this.ctx.fillRect(cutoffX, RULER_HEIGHT, width - cutoffX, this.blockHeight + this.audioTrackHeight); - // Subtle tinted ruler overlay - this.ctx.fillStyle = "rgba(0, 0, 0, 0.25)"; - this.ctx.fillRect(cutoffX, 0, width - cutoffX, RULER_HEIGHT); - /* - // Dashed boundary line at the output duration cutoff - this.ctx.save(); - this.ctx.strokeStyle = "rgba(255, 80, 80, 0.7)"; - this.ctx.lineWidth = 1.5; - this.ctx.setLineDash([5, 4]); - this.ctx.beginPath(); - this.ctx.moveTo(cutoffX, 0); - this.ctx.lineTo(cutoffX, CANVAS_HEIGHT); - this.ctx.stroke(); - this.ctx.setLineDash([]); - this.ctx.restore(); - */ - } - - // --- Draw Playhead --- - const playheadX = (this.currentFrame / totalFrames) * width; - - // Playhead Line - this.ctx.beginPath(); - this.ctx.moveTo(playheadX, 14); - this.ctx.lineTo(playheadX, this.canvasHeight); - this.ctx.strokeStyle = "#ff4444"; - this.ctx.lineWidth = 1.5; - this.ctx.stroke(); - - // Playhead Handle (Polygon above numbers) - this.ctx.fillStyle = "#ff4444"; - this.ctx.beginPath(); - this.ctx.moveTo(playheadX - 6, 0); - this.ctx.lineTo(playheadX + 6, 0); - this.ctx.lineTo(playheadX + 6, 8); - this.ctx.lineTo(playheadX, 14); - this.ctx.lineTo(playheadX - 6, 8); - this.ctx.fill(); - - // Draw vertical grab bar on the right edge of viewport for resizing width - const grabBarW = 4; - const grabBarH = 50; - const grabBarX = this.viewport.scrollLeft + this.viewport.clientWidth - grabBarW - 3; - const grabBarY = RULER_HEIGHT + (this.blockHeight + this.audioTrackHeight - grabBarH) / 2; - - this.ctx.fillStyle = "rgba(40, 40, 40, 0.6)"; - this.ctx.beginPath(); - this.ctx.roundRect(grabBarX, grabBarY, grabBarW, grabBarH, 2); - this.ctx.fill(); - - // Draw horizontal grab bar at the bottom of viewport for resizing height - const hBarW = 50; - const hBarH = 4; - const hBarX = this.viewport.scrollLeft + (this.viewport.clientWidth - hBarW) / 2; - const hBarY = this.canvasHeight - hBarH - 3; // 3px from the bottom edge - - this.ctx.fillStyle = "rgba(20, 20, 20, 0.8)"; - this.ctx.beginPath(); - this.ctx.roundRect(hBarX, hBarY, hBarW, hBarH, 2); - this.ctx.fill(); - - this.updatePlayerUI(); - } - - drawAudioSegmentVisuals(ctx, seg, isSelected, yOffset, trackHeight, startX, pxWidth) { - ctx.fillStyle = isSelected ? "#2a4a3a" : "#1a2a1a"; - ctx.fillRect(startX, yOffset + 2, pxWidth, trackHeight - 3); - - if (seg.waveformPeaks && pxWidth > 0) { - ctx.fillStyle = isSelected ? "rgba(100, 255, 100, 0.6)" : "rgba(100, 255, 100, 0.3)"; - const startRatio = seg.trimStart / seg.audioDurationFrames; - const endRatio = (seg.trimStart + seg.length) / seg.audioDurationFrames; - const peakCount = seg.waveformPeaks.length; - const centerY = yOffset + trackHeight / 2; - - ctx.beginPath(); - for (let i = 0; i < pxWidth; i++) { - const pixelRatio = i / pxWidth; - const globalRatio = startRatio + pixelRatio * (endRatio - startRatio); - const peakIdx = Math.floor(globalRatio * peakCount); - - if (peakIdx >= 0 && peakIdx < peakCount) { - const val = seg.waveformPeaks[peakIdx]; - const amp = (val * (trackHeight - 12) / 2) * 0.9; - ctx.fillRect(startX + i, centerY - amp, 1, amp * 2); - } - } - } - - ctx.strokeStyle = isSelected ? "#4fff8f" : "#000"; - ctx.lineWidth = 1.5; - ctx.strokeRect(startX, yOffset + 2, pxWidth, trackHeight - 3); - - if (isSelected) { - ctx.fillStyle = "#4fff8f"; - ctx.beginPath(); - ctx.roundRect(startX, yOffset + trackHeight / 2 - 12, 4, 24, 2); - ctx.fill(); - ctx.beginPath(); - ctx.roundRect(startX + pxWidth - 4, yOffset + trackHeight / 2 - 12, 4, 24, 2); - ctx.fill(); - } - - ctx.fillStyle = "#ccc"; - ctx.font = "11px sans-serif"; - ctx.textBaseline = "top"; - ctx.textAlign = "left"; - ctx.save(); - ctx.beginPath(); - ctx.rect(startX, yOffset + 2, pxWidth, trackHeight - 3); - ctx.clip(); - - let text = seg.fileName || "Audio Track"; - const maxWidth = pxWidth - 12; - if (ctx.measureText(text).width > maxWidth && maxWidth > 0) { - while (text.length > 0 && ctx.measureText(text + "...").width > maxWidth) { - text = text.slice(0, -1); - } - text = text + "..."; - } - - ctx.fillText(text, startX + 6, yOffset + 8); - ctx.restore(); - } - - - // --- Interaction Logic --- - getHitTest(mouseX, mouseY) { - const width = this.canvas.offsetWidth; - const totalFrames = this.getVisualDurationFrames(); - - // Check Playhead Handle first - const playheadX = (this.currentFrame / totalFrames) * width; - if (mouseY <= 24 && Math.abs(mouseX - playheadX) <= 12) { - return { type: "playhead" }; - } - - if (mouseY <= RULER_HEIGHT) { - return { type: "ruler" }; - } - - if (mouseY < RULER_HEIGHT || mouseY > this.canvasHeight) return null; - - const isAudioTrack = mouseY > RULER_HEIGHT + this.blockHeight; - const trackSegments = isAudioTrack ? this.timeline.audioSegments : this.timeline.segments; - const trackType = isAudioTrack ? "audio" : "image"; - - if (trackSegments.length === 0) return null; - - // The variables width and totalFrames are already declared above. - - let sortedSegments = [...trackSegments] - .map((s, i) => ({ ...s, originalIndex: i })) - .sort((a, b) => a.start - b.start); - - const HANDLE_CORE = 4; - - for (let i = 0; i < sortedSegments.length; i++) { - const seg = sortedSegments[i]; - const startX = (seg.start / totalFrames) * width; - const pxWidth = (seg.length / totalFrames) * width; - const endX = startX + pxWidth; - - const prevSeg = sortedSegments[i - 1]; - const nextSeg = sortedSegments[i + 1]; - - const isLeftJoint = prevSeg && prevSeg.start + prevSeg.length === seg.start; - if (!isLeftJoint) { - if (Math.abs(mouseX - startX) <= HANDLE_HIT_PX) { - return { type: "edge", index: seg.originalIndex, dir: "left", track: trackType }; - } - } - - const isRightJoint = nextSeg && nextSeg.start === seg.start + seg.length; - if (isRightJoint) { - const dx = mouseX - endX; - if (Math.abs(dx) <= HANDLE_HIT_PX) { - if (dx < -HANDLE_CORE) { - return { type: "edge", index: seg.originalIndex, dir: "right", track: trackType }; - } else if (dx > HANDLE_CORE) { - return { type: "edge", index: nextSeg.originalIndex, dir: "left", track: trackType }; - } else { - return { type: "joint", leftIndex: seg.originalIndex, rightIndex: nextSeg.originalIndex, track: trackType }; - } - } - } else { - if (Math.abs(mouseX - endX) <= HANDLE_HIT_PX) { - return { type: "edge", index: seg.originalIndex, dir: "right", track: trackType }; - } - } - } - - for (let i = 0; i < sortedSegments.length; i++) { - const seg = sortedSegments[i]; - const startX = (seg.start / totalFrames) * width; - const pxWidth = (seg.length / totalFrames) * width; - const endX = startX + pxWidth; - - if (mouseX >= startX && mouseX < endX) { - return { type: "center", index: seg.originalIndex, track: trackType }; - } - } - - return null; - } - - onMouseDown(e) { - if (e.button !== 0) return; - const { x, y } = this.getMousePos(e); - - const isOverDivider = Math.abs(y - (RULER_HEIGHT + this.blockHeight)) <= 4; - if (isOverDivider) { - this._isDragging = true; - this._dragType = "divider"; - this._startBlockHeight = this.blockHeight; - this._startAudioTrackHeight = this.audioTrackHeight; - this._startY = y; - return; - } - - const isAtBottom = Math.abs(y - this.canvasHeight) <= 15; - if (isAtBottom) { - this._isDragging = true; - this._dragType = "height_resize"; - this._startBlockHeight = this.blockHeight; - this._startY = y; - document.body.style.userSelect = "none"; - return; - } - - const viewRect = this.viewport.getBoundingClientRect(); - const isAtRightEdge = Math.abs(e.clientX - viewRect.right) <= 20; - if (isAtRightEdge) { - this._isDragging = true; - this._dragType = "width_resize"; - this._startNodeWidth = this._canvasMode ? (this.node.w || 1000) : this.node.size[0]; - this._startX = e.clientX; - document.body.style.userSelect = "none"; - return; - } - - if (y >= RULER_HEIGHT && y <= this.canvasHeight) { - const BTN_R = 12; - const gapRegions = this.getGapRegions(); - for (let i = 0; i < gapRegions.length; i++) { - const gap = gapRegions[i]; - if (gap.widthPx < BTN_R * 2 + 8) continue; - const dx = x - gap.centerX, dy2 = y - gap.centerY; - if (dx * dx + dy2 * dy2 <= BTN_R * BTN_R) { - if (gap.track === "audio") { - // Direct to audio upload - this.promptAddAudioInGap(gap.frameStart, gap.frameEnd); - } else { - this.showGapMenu(e.clientX, e.clientY, gap); - } - return; - } - } - } - - const hit = this.getHitTest(x, y); - if (!hit) { - // Only deselect if they clicked the same track but hit empty space - const clickedTrack = y > RULER_HEIGHT + this.blockHeight ? "audio" : "image"; - if (this.selectionType === clickedTrack) { - this.selectedIndex = -1; - this.updateUIFromSelection(); - } - this.render(); - return; - } - - if (hit.type === "playhead" || hit.type === "ruler") { - this._isDragging = true; - this._dragType = "playhead"; - const logicalWidth = this.canvas.offsetWidth; - const totalFrames = this.getVisualDurationFrames(); - let mouseFrameX = x * (totalFrames / logicalWidth); - this.currentFrame = clamp(mouseFrameX, 0, totalFrames); - this.render(); - if (this.isPlaying) { - this.playAudio(); - } - return; - } - - this.selectionType = hit.track; - const targetArray = hit.track === "audio" ? this.timeline.audioSegments : this.timeline.segments; - - if (hit.type === "joint") { - this.selectedIndex = hit.leftIndex; - this.updateUIFromSelection(); - this._dragType = "joint"; - this._dragTargetId = targetArray[hit.leftIndex].id; - this._dragTargetIdRight = targetArray[hit.rightIndex].id; - } else if (hit.type === "center") { - this.selectedIndex = hit.index; - this.updateUIFromSelection(); - this._dragType = "center"; - } else { - if (this.selectedIndex !== hit.index) { - this.selectedIndex = hit.index; - this.updateUIFromSelection(); - } - this._dragType = hit.dir; - } - - this._isDragging = true; - this._previewSegments = null; - this._dragStartX = x; - this._dragInitialTimeline = JSON.parse(JSON.stringify(targetArray)); - - if (hit.type !== "joint") { - this._dragTargetId = targetArray[hit.index].id; - } - this.render(); - } - - onMouseMove(e) { - const { x: mouseX, y: mouseY } = this.getMousePos(e); - - if (!this._isDragging) { - let newHoveredGapIdx = -1; - const BTN_R = 12; - const gapRegions = this.getGapRegions(); - for (let i = 0; i < gapRegions.length; i++) { - const gap = gapRegions[i]; - if (gap.widthPx < BTN_R * 2 + 8) continue; - const dx = mouseX - gap.centerX, dy2 = mouseY - gap.centerY; - if (dx * dx + dy2 * dy2 <= BTN_R * BTN_R) { newHoveredGapIdx = i; break; } - } - if (this._hoveredGapIdx !== newHoveredGapIdx) { - this._hoveredGapIdx = newHoveredGapIdx; - this.render(); - } - - const isOverDivider = Math.abs(mouseY - (RULER_HEIGHT + this.blockHeight)) <= 4; - const isAtBottom = Math.abs(mouseY - this.canvasHeight) <= 15; - const viewRect = this.viewport.getBoundingClientRect(); - const isAtRightEdge = Math.abs(e.clientX - viewRect.right) <= 20; - const hit = this.getHitTest(mouseX, mouseY); - if (isOverDivider || isAtBottom) { - this.canvas.style.cursor = "ns-resize"; - } else if (isAtRightEdge) { - this.canvas.style.cursor = "ew-resize"; - } else if (newHoveredGapIdx >= 0) { - this.canvas.style.cursor = "pointer"; - } else if (hit?.type === "edge") { - this.canvas.style.cursor = "ew-resize"; - } else if (hit?.type === "joint") { - this.canvas.style.cursor = "col-resize"; - } else if (hit?.type === "center") { - this.canvas.style.cursor = "grab"; - } else if (hit?.type === "playhead") { - this.canvas.style.cursor = "ew-resize"; - } else { - this.canvas.style.cursor = "default"; - } - return; - } - - if (this._dragType === "divider") { - this.canvas.style.cursor = "ns-resize"; - const deltaY = mouseY - this._startY; - - const minBlockH = 50; - const minAudioH = 50; - - let newBlockHeight = this._startBlockHeight + deltaY; - let newAudioTrackHeight = this._startAudioTrackHeight - deltaY; - - if (newBlockHeight < minBlockH) { - newBlockHeight = minBlockH; - newAudioTrackHeight = this._startBlockHeight + this._startAudioTrackHeight - minBlockH; - } - if (newAudioTrackHeight < minAudioH) { - newAudioTrackHeight = minAudioH; - newBlockHeight = this._startBlockHeight + this._startAudioTrackHeight - minAudioH; - } - - this.blockHeight = newBlockHeight; - this.audioTrackHeight = newAudioTrackHeight; - - this.render(); - return; - } - - if (this._dragType === "height_resize") { - this.canvas.style.cursor = "ns-resize"; - const deltaY = mouseY - this._startY; - - this.blockHeight = Math.max(100, this._startBlockHeight + deltaY); - this.canvasHeight = this.rulerHeight + this.blockHeight + this.audioTrackHeight; - - this.canvas.style.height = `${this.canvasHeight}px`; - - this.resizeCanvas(this.canvas.offsetWidth); - this.render(); - - if (this._canvasMode) { - this.node.h = Math.max(520, this.canvasHeight + 235); - if (this._onCanvasResize) this._onCanvasResize(); - } else if (this.node && this.node.computeSize) { - const sz = this.node.computeSize(); - this.node.size[1] = sz[1]; - if (window.app && window.app.graph) { - window.app.graph.setDirtyCanvas(true, true); - } - } - return; - } - - if (this._dragType === "width_resize") { - this.canvas.style.cursor = "ew-resize"; - const deltaX = e.clientX - this._startX; - - if (this._canvasMode) { - this.node.w = Math.max(480, this._startNodeWidth + deltaX); - if (this._onCanvasResize) this._onCanvasResize(); - } else { - this.node.size[0] = Math.max(300, this._startNodeWidth + deltaX); - if (window.app && window.app.graph) { - window.app.graph.setDirtyCanvas(true, true); - } - } - return; - } - - if (this._dragType === "playhead") { - this.canvas.style.cursor = "ew-resize"; - const logicalWidth = this.canvas.offsetWidth; - const totalFrames = this.getVisualDurationFrames(); - let mouseFrameX = mouseX * (totalFrames / logicalWidth); - this.currentFrame = clamp(mouseFrameX, 0, totalFrames); - this.render(); - if (this.isPlaying) { - this.playAudio(); // Scrub (restart from new position) - } - return; - } - - this.canvas.style.cursor = this._dragType === "center" ? "grabbing" : - this._dragType === "joint" ? "col-resize" : "ew-resize"; - - const logicalWidth = this.canvas.offsetWidth; - const totalFrames = this.getVisualDurationFrames(); - const durationFrames = totalFrames; - const dragDelta = Math.round((mouseX - this._dragStartX) * (totalFrames / logicalWidth)); - - let t = JSON.parse(JSON.stringify(this._dragInitialTimeline)); - - // --- Rolling Edit (Slide Edit) --- - if (this._dragType === "joint") { - let leftIdx = t.findIndex(s => s.id === this._dragTargetId); - let rightIdx = t.findIndex(s => s.id === this._dragTargetIdRight); - - if (leftIdx >= 0 && rightIdx >= 0) { - let origLeft = this._dragInitialTimeline.find(s => s.id === this._dragTargetId); - let origRight = this._dragInitialTimeline.find(s => s.id === this._dragTargetIdRight); - - let maxDeltaRight = origRight.length - MIN_SEGMENT_LENGTH; - let maxDeltaLeft = origLeft.length - MIN_SEGMENT_LENGTH; - - if (this.selectionType === "audio") { - // Drag LEFT: right clip extends left by un-trimming its head. - // Can only un-trim as much as the right clip has been trimmed (trimStart >= 0). - maxDeltaLeft = Math.min(maxDeltaLeft, origRight.trimStart || 0); - // Drag RIGHT: left clip extends right by consuming its remaining tail audio. - // Can only extend as far as the left clip's unplayed tail allows. - let availLeftTail = (origLeft.audioDurationFrames || origLeft.length) - ((origLeft.trimStart || 0) + origLeft.length); - maxDeltaRight = Math.min(maxDeltaRight, availLeftTail); - } - - let safeDelta = clamp(dragDelta, -maxDeltaLeft, maxDeltaRight); - - t[leftIdx].length = origLeft.length + safeDelta; - t[rightIdx].start = origRight.start + safeDelta; - t[rightIdx].length = origRight.length - safeDelta; - - if (this.selectionType === "audio") { - t[rightIdx].trimStart = origRight.trimStart + safeDelta; - } - } - } - // --- Edge & Center Drags --- - else { - const targetIdx = t.findIndex((s) => s.id === this._dragTargetId); - if (targetIdx < 0) return; - - if (this._dragType === "right") { - let newLen = t[targetIdx].length + dragDelta; - let maxPossibleLength = totalFrames - t[targetIdx].start; - let nextSeg = t.find(s => s.start >= t[targetIdx].start + t[targetIdx].length && s.id !== t[targetIdx].id); - if (nextSeg) { - maxPossibleLength = nextSeg.start - t[targetIdx].start; - } - - if (this.selectionType === "audio") { - maxPossibleLength = Math.min(maxPossibleLength, (t[targetIdx].audioDurationFrames || t[targetIdx].length) - (t[targetIdx].trimStart || 0)); - } - - t[targetIdx].length = Math.max(MIN_SEGMENT_LENGTH, Math.min(newLen, maxPossibleLength)); - - } else if (this._dragType === "left") { - let newStart = t[targetIdx].start + dragDelta; - let minPossibleStart = 0; - let prevSeg = t.slice().reverse().find(s => s.start + s.length <= t[targetIdx].start && s.id !== t[targetIdx].id); - if (prevSeg) { - minPossibleStart = prevSeg.start + prevSeg.length; - } - - if (this.selectionType === "audio") { - minPossibleStart = Math.max(minPossibleStart, t[targetIdx].start - (t[targetIdx].trimStart || 0)); - } - - let maxStart = t[targetIdx].start + t[targetIdx].length - MIN_SEGMENT_LENGTH; - newStart = Math.max(minPossibleStart, Math.min(newStart, maxStart)); - - let diff = newStart - t[targetIdx].start; - t[targetIdx].start = newStart; - t[targetIdx].length -= diff; - if (this.selectionType === "audio") { - t[targetIdx].trimStart += diff; - } - - } else if (this._dragType === "center") { - let initT = this._dragInitialTimeline; - let dIdx = initT.findIndex(s => s.id === this._dragTargetId); - if (dIdx < 0) return; - let D = JSON.parse(JSON.stringify(initT[dIdx])); - - let D_mouse_start = D.start + dragDelta; - let mouseFrameX = mouseX * (totalFrames / logicalWidth); - - t = this._applyCenterDragPhysics(initT, D.id, D_mouse_start, mouseFrameX, durationFrames, totalFrames, logicalWidth); - } - } - - this._previewSegments = t; - this.updateUIFromSelection(); // Live update of trim values - this.render(); - } - - _applyCenterDragPhysics(initT, D_id, D_mouse_start, mouseFrameX, durationFrames, totalFrames, logicalWidth) { - let t_copy = JSON.parse(JSON.stringify(initT)); - let dIdx = t_copy.findIndex(s => s.id === D_id); - if (dIdx < 0) return t_copy; - - let D = t_copy[dIdx]; - let D_clamped_start = clamp(D_mouse_start, 0, durationFrames - D.length); - - let baseSegments = t_copy.filter(s => s.id !== D.id); - - let insertIdx = baseSegments.length; - for (let i = 0; i < baseSegments.length; i++) { - let centerBase = baseSegments[i].start + baseSegments[i].length / 2; - if (mouseFrameX < centerBase) { - insertIdx = i; - break; - } - } - - let leftBound = insertIdx > 0 ? baseSegments[insertIdx - 1].start + baseSegments[insertIdx - 1].length : 0; - let rightBound = insertIdx < baseSegments.length ? baseSegments[insertIdx].start : durationFrames; - - if (rightBound - leftBound >= D.length) { - D_clamped_start = clamp(D_clamped_start, leftBound, rightBound - D.length); - } else { - let gapCenter = (leftBound + rightBound) / 2; - D_clamped_start = gapCenter - D.length / 2; - } - - let t_test = []; - for (let i = 0; i < insertIdx; i++) { - t_test.push({ ...baseSegments[i], original_start: baseSegments[i].start }); - } - t_test.push({ ...D, start: D_clamped_start, original_start: D_clamped_start }); - let D_index = insertIdx; - - for (let i = insertIdx; i < baseSegments.length; i++) { - t_test.push({ ...baseSegments[i], original_start: baseSegments[i].start }); - } - - for (let i = D_index + 1; i < t_test.length; i++) { - let prev = t_test[i - 1]; - t_test[i].start = Math.max(t_test[i].original_start, prev.start + prev.length); - } - - for (let i = D_index - 1; i >= 0; i--) { - let next = t_test[i + 1]; - t_test[i].start = Math.min(t_test[i].original_start, next.start - t_test[i].length); - } - - let rightCursor = durationFrames; - for (let i = t_test.length - 1; i >= 0; i--) { - if (t_test[i].start + t_test[i].length > rightCursor) { - t_test[i].start = rightCursor - t_test[i].length; - } - rightCursor = t_test[i].start; - } - let leftCursor = 0; - for (let i = 0; i < t_test.length; i++) { - if (t_test[i].start < leftCursor) { - t_test[i].start = leftCursor; - } - leftCursor = t_test[i].start + t_test[i].length; - } - - let result = t_test.map(s => { - let clean = { ...s }; - delete clean.original_start; - return clean; - }); - - let draggedPreview = result.find(s => s.id === D.id); - if (draggedPreview) { - draggedPreview.resolvedStart = draggedPreview.start; - } - - return result; - } - - onMouseUp(e) { - document.body.style.userSelect = ""; - if (this._isDragging) { - if (this._previewSegments) { - const targetArray = this.selectionType === "audio" ? this.timeline.audioSegments : this.timeline.segments; - - const mappedArray = this._previewSegments.map(ps => { - const orig = targetArray.find(s => s.id === ps.id); - let finalStart = ps.resolvedStart !== undefined ? ps.resolvedStart : ps.start; - let newPs = { ...ps, start: finalStart }; - if (orig && orig.imgObj) newPs.imgObj = orig.imgObj; - delete newPs.resolvedStart; - return newPs; - }); - - if (this.selectionType === "audio") { - this.timeline.audioSegments = mappedArray; - if (this._dragTargetId) this.selectedIndex = this.timeline.audioSegments.findIndex(s => s.id === this._dragTargetId); - } else { - this.timeline.segments = mappedArray; - if (this._dragTargetId) this.selectedIndex = this.timeline.segments.findIndex(s => s.id === this._dragTargetId); - } - } - - this._isDragging = false; - this._previewSegments = null; - this._ghostTrack = null; - this.canvas.style.cursor = "default"; - this.commitChanges(); - } - } - - // --- Backend Data Sync --- - commitChanges(skipRender = false) { - let sortedSegments = [...this.timeline.segments].sort((a, b) => a.start - b.start); - let contiguousLengths = []; - let contiguousPrompts = []; - let currentCursor = 0; - const durationFrames = this.getDurationFrames(); - - // Build segment lengths clipped at the duration cutoff. - // - Gaps before the first segment, or between segments, are absorbed into the adjacent - // segment's length (same as before), but are also clipped at durationFrames. - // - Segments that start at or past the cutoff are excluded entirely. - // - Segments that cross the cutoff are trimmed so their end = durationFrames exactly. - let pendingGap = 0; - for (let seg of sortedSegments) { - // Skip segments entirely outside the duration. - if (seg.start >= durationFrames) break; - - if (seg.start > currentCursor) { - // Gap between the cursor and this segment — clip it at the cutoff too. - const gapLength = Math.min(seg.start, durationFrames) - currentCursor; - if (contiguousLengths.length > 0) { - contiguousLengths[contiguousLengths.length - 1] += gapLength; - } else { - pendingGap += gapLength; - } - } - - // Clip segment end at the duration cutoff. - const clippedEnd = Math.min(seg.start + seg.length, durationFrames); - const clippedLength = clippedEnd - seg.start; - - contiguousLengths.push(clippedLength + pendingGap); - contiguousPrompts.push(seg.prompt || ""); - pendingGap = 0; - currentCursor = seg.start + seg.length; // advance by the real (unclipped) end for gap detection - } - - // If segments don't fill to the end of the duration, pad the last segment to reach it. - const clampedCursor = Math.min(currentCursor, durationFrames); - if (contiguousLengths.length > 0 && clampedCursor < durationFrames) { - contiguousLengths[contiguousLengths.length - 1] += durationFrames - clampedCursor; - } - - const toSave = { - segments: sortedSegments.map(s => { - const { imgObj, ...rest } = s; - return rest; - }), - audioSegments: (this.timeline.audioSegments || []).map(s => ({ ...s })) - }; - - const jsonStr = JSON.stringify(toSave); - const imgStrengths = sortedSegments - .filter(s => s.type !== "text") - .map(s => (s.guideStrength !== undefined ? s.guideStrength : 1.0).toFixed(2)); - - if (this._canvasMode) { - this.node.ltxTimelineData = jsonStr; - this.node.ltxLocalPrompts = contiguousPrompts.join(" | "); - this.node.ltxSegmentLengths = contiguousLengths.join(","); - this.node.ltxGuideStrength = imgStrengths.join(","); - if (this._onCanvasCommit) this._onCanvasCommit(); - } else { - if (this.timelineDataWidget) this.timelineDataWidget.value = jsonStr; - if (this.localPromptsWidget) { - this.localPromptsWidget.value = contiguousPrompts.join(" | "); - } - if (this.segmentLengthsWidget) { - this.segmentLengthsWidget.value = contiguousLengths.join(","); - } - if (this.guideStrengthWidget) { - this.guideStrengthWidget.value = imgStrengths.join(","); - } - setTimeout(() => { - if (this.node && this.node.computeSize) { - const sz = this.node.computeSize(); - this.node.size[1] = sz[1]; - if (app && app.graph) app.graph.setDirtyCanvas(true, true); - } - }, 0); - } - - this.updateZoomSliderMax(); - - if (!skipRender) this.render(); - } - - // --- Gap Region Calculation --- - getGapRegions() { - const totalFrames = this.getVisualDurationFrames(); - const outputFrames = this.getDurationFrames(); - const width = this.canvas.offsetWidth || this._lastWidth || 0; - const gaps = []; - if (!width) return gaps; - - // Image gaps - let cursor = 0; - const sortedImg = [...this.timeline.segments].sort((a, b) => a.start - b.start); - for (const seg of sortedImg) { - if (seg.start > cursor) { - const x0 = (cursor / totalFrames) * width; - const x1 = (seg.start / totalFrames) * width; - gaps.push({ track: 'image', frameStart: cursor, frameEnd: seg.start, centerX: (x0 + x1) / 2, centerY: RULER_HEIGHT + this.blockHeight / 2, widthPx: x1 - x0 }); - } - cursor = seg.start + seg.length; - } - if (cursor < outputFrames) { - const x0 = (cursor / totalFrames) * width; - const x1 = (outputFrames / totalFrames) * width; - gaps.push({ track: 'image', frameStart: cursor, frameEnd: outputFrames, centerX: (x0 + x1) / 2, centerY: RULER_HEIGHT + this.blockHeight / 2, widthPx: x1 - x0 }); - } - - // Audio gaps - cursor = 0; - const sortedAud = [...this.timeline.audioSegments].sort((a, b) => a.start - b.start); - for (const seg of sortedAud) { - if (seg.start > cursor) { - const x0 = (cursor / totalFrames) * width; - const x1 = (seg.start / totalFrames) * width; - gaps.push({ track: 'audio', frameStart: cursor, frameEnd: seg.start, centerX: (x0 + x1) / 2, centerY: RULER_HEIGHT + this.blockHeight + this.audioTrackHeight / 2, widthPx: x1 - x0 }); - } - cursor = seg.start + seg.length; - } - if (cursor < outputFrames) { - const x0 = (cursor / totalFrames) * width; - const x1 = (outputFrames / totalFrames) * width; - gaps.push({ track: 'audio', frameStart: cursor, frameEnd: outputFrames, centerX: (x0 + x1) / 2, centerY: RULER_HEIGHT + this.blockHeight + this.audioTrackHeight / 2, widthPx: x1 - x0 }); - } - - return gaps; - } - - promptAddAudioInGap(frameStart, frameEnd) { - const fi = document.createElement("input"); - fi.type = "file"; - fi.accept = "audio/*"; - fi.addEventListener("change", (ev) => { - if (ev.target.files?.[0]) this.handleAudioUpload([ev.target.files[0]], frameStart); - }); - fi.click(); - } - - // --- Context Menu --- - onContextMenu(e) { - e.preventDefault(); - const { x: mouseX, y: mouseY } = this.getMousePos(e); - - const trackHeight = this.blockHeight; - const isAudioTrack = mouseY >= RULER_HEIGHT + trackHeight && mouseY <= RULER_HEIGHT + trackHeight + this.audioTrackHeight; - const isImageTrack = mouseY >= RULER_HEIGHT && mouseY <= RULER_HEIGHT + trackHeight; - - const logicalWidth = this.canvas.offsetWidth || 1; - const totalFrames = this.getVisualDurationFrames(); - const cursor = mouseX * (totalFrames / logicalWidth); - - let clickedSeg = null; - let trackType = ""; - - if (isAudioTrack) { - clickedSeg = this.timeline.audioSegments.find(s => cursor >= s.start && cursor <= s.start + s.length); - trackType = "audio"; - } else if (isImageTrack) { - clickedSeg = this.timeline.segments.find(s => cursor >= s.start && cursor <= s.start + s.length); - trackType = clickedSeg ? clickedSeg.type : ""; - } - - if (clickedSeg) { - this.showContextMenu(e.clientX, e.clientY, clickedSeg, trackType); - } else if (isAudioTrack || isImageTrack) { - const gapRegions = this.getGapRegions(); - const currentTrack = isAudioTrack ? "audio" : "image"; - let gap = gapRegions.find(g => cursor >= g.frameStart && cursor <= g.frameEnd && g.track === currentTrack); - - if (!gap) { - const startFrame = Math.round(cursor); - gap = { - track: currentTrack, - frameStart: startFrame, - frameEnd: startFrame + Math.max(1, this.getFrameRate()) - }; - } - gap.clickedFrame = cursor; - - this.showGapContextMenu(e.clientX, e.clientY, gap); - } - } - - showContextMenu(clientX, clientY, seg, trackType) { - this.dismissContextMenu(); - const menu = document.createElement("div"); - menu.className = "pr-gap-menu"; - menu.style.left = `${clientX + 6}px`; - menu.style.top = `${clientY - 10}px`; - - const isImage = trackType !== "audio" && trackType !== "text" && seg.imageB64; - - if (isImage) { - const copyBtn = document.createElement("button"); - copyBtn.className = "pr-gap-menu-btn"; - copyBtn.innerHTML = `Copy Image`; - copyBtn.onclick = async () => { - try { - const res = await fetch(seg.imageB64); - const blob = await res.blob(); - await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]); - } catch (err) { - console.error("Failed to copy image", err); - } - this.dismissContextMenu(); - }; - menu.appendChild(copyBtn); - - const saveBtn = document.createElement("button"); - saveBtn.className = "pr-gap-menu-btn"; - saveBtn.innerHTML = `Save Image`; - saveBtn.onclick = () => { - const a = document.createElement("a"); - a.href = seg.imageB64; - a.download = "timeline_image.jpg"; - a.click(); - this.dismissContextMenu(); - }; - menu.appendChild(saveBtn); - - const openBtn = document.createElement("button"); - openBtn.className = "pr-gap-menu-btn"; - openBtn.innerHTML = `Open Image in New Tab`; - openBtn.onclick = () => { - const win = window.open(); - if (win) { - win.document.write(``); - win.document.close(); - } - this.dismissContextMenu(); - }; - menu.appendChild(openBtn); - } - - if (trackType !== "audio") { - const copyPromptBtn = document.createElement("button"); - copyPromptBtn.className = "pr-gap-menu-btn"; - copyPromptBtn.innerHTML = `Copy Prompt`; - copyPromptBtn.onclick = async () => { - try { - await navigator.clipboard.writeText(seg.prompt || ""); - } catch (err) { - console.error("Failed to copy prompt", err); - } - this.dismissContextMenu(); - }; - menu.appendChild(copyPromptBtn); - } - - const copySegBtn = document.createElement("button"); - copySegBtn.className = "pr-gap-menu-btn"; - copySegBtn.innerHTML = `Copy Segment`; - copySegBtn.onclick = () => { - this._copiedSegment = { ...seg, id: Date.now().toString() + Math.random().toString(36).substr(2, 5) }; - this._copiedSegmentTrack = trackType === "audio" ? "audio" : "image"; - this.dismissContextMenu(); - }; - menu.appendChild(copySegBtn); - - const currentTrack = trackType === "audio" ? "audio" : "image"; - if (this._copiedSegment && this._copiedSegmentTrack === currentTrack) { - const pasteReplaceBtn = document.createElement("button"); - pasteReplaceBtn.className = "pr-gap-menu-btn"; - pasteReplaceBtn.innerHTML = `Paste & Replace`; - pasteReplaceBtn.onclick = () => { - const newSeg = { - ...this._copiedSegment, - id: Date.now().toString() + Math.random().toString(36).substr(2, 5), - start: seg.start, - length: this._copiedSegment.length - }; - const targetArray = currentTrack === "audio" ? this.timeline.audioSegments : this.timeline.segments; - const idx = targetArray.findIndex(s => s.id === seg.id); - if (idx >= 0) targetArray[idx] = newSeg; - this.commitChanges(); - this.dismissContextMenu(); - }; - menu.appendChild(pasteReplaceBtn); - } - - const delBtn = document.createElement("button"); - delBtn.className = "pr-gap-menu-btn"; - delBtn.innerHTML = `Delete`; - delBtn.style.color = "#ff4444"; - delBtn.onclick = () => { - this.selectionType = trackType === "audio" ? "audio" : "image"; - const list = trackType === "audio" ? this.timeline.audioSegments : this.timeline.segments; - this.selectedIndex = list.findIndex(s => s.id === seg.id); - this.deleteSelectedSegment(); - this.dismissContextMenu(); - }; - menu.appendChild(delBtn); - - document.body.appendChild(menu); - this._contextMenu = menu; - - setTimeout(() => { - this._contextMenuDismisser = (ev) => { if (!menu.contains(ev.target)) this.dismissContextMenu(); }; - document.addEventListener("pointerdown", this._contextMenuDismisser, true); - }, 0); - } - - showGapContextMenu(clientX, clientY, gap) { - this.dismissContextMenu(); - const menu = document.createElement("div"); - menu.className = "pr-gap-menu"; - menu.style.left = `${clientX + 6}px`; - menu.style.top = `${clientY - 10}px`; - - const currentTrack = gap.track === "audio" ? "audio" : "image"; - - if (this._copiedSegment && this._copiedSegmentTrack === currentTrack) { - const pasteBtn = document.createElement("button"); - pasteBtn.className = "pr-gap-menu-btn"; - pasteBtn.innerHTML = `Paste Segment`; - pasteBtn.onclick = () => { - const startFrame = Math.round(gap.clickedFrame !== undefined ? gap.clickedFrame : gap.frameStart); - const gapLength = gap.frameEnd - startFrame; - - const newSeg = { - ...this._copiedSegment, - id: Date.now().toString() + Math.random().toString(36).substr(2, 5), - start: startFrame, - length: Math.min(this._copiedSegment.length, gapLength) - }; - const targetArray = currentTrack === "audio" ? this.timeline.audioSegments : this.timeline.segments; - targetArray.push(newSeg); - targetArray.sort((a, b) => a.start - b.start); - this.commitChanges(); - this.dismissContextMenu(); - }; - menu.appendChild(pasteBtn); - } - - if (currentTrack === "image") { - const textBtn = document.createElement("button"); - textBtn.className = "pr-gap-menu-btn"; - textBtn.innerHTML = `${ICONS.text} Text Segment`; - textBtn.onclick = () => { - this.addSegmentInGap(gap.frameStart, gap.frameEnd, "text"); - this.dismissContextMenu(); - }; - menu.appendChild(textBtn); - - const imgBtn = document.createElement("button"); - imgBtn.className = "pr-gap-menu-btn"; - imgBtn.innerHTML = `${ICONS.upload} Image Segment`; - imgBtn.onclick = () => { - this.dismissContextMenu(); - const fi = document.createElement("input"); - fi.type = "file"; fi.accept = "image/*"; - fi.addEventListener("change", (ev) => { - if (ev.target.files?.[0]) { - const gapLength = gap.frameEnd - gap.frameStart; - this.handleImageUpload([ev.target.files[0]], gap.frameStart, gapLength); - } - }); - fi.click(); - }; - menu.appendChild(imgBtn); - } - - document.body.appendChild(menu); - this._contextMenu = menu; - setTimeout(() => { - this._contextMenuDismisser = (ev) => { if (!menu.contains(ev.target)) this.dismissContextMenu(); }; - document.addEventListener("pointerdown", this._contextMenuDismisser, true); - }, 0); - } - dismissContextMenu() { - if (this._contextMenu) { this._contextMenu.remove(); this._contextMenu = null; } - if (this._contextMenuDismisser) { document.removeEventListener("pointerdown", this._contextMenuDismisser, true); this._contextMenuDismisser = null; } - } - - // --- Gap Popup Menu --- - showGapMenu(clientX, clientY, gap) { - this.dismissGapMenu(); - const menu = document.createElement("div"); - menu.className = "pr-gap-menu"; - menu.style.left = `${clientX + 6}px`; - menu.style.top = `${clientY - 10}px`; - - const textBtn = document.createElement("button"); - textBtn.className = "pr-gap-menu-btn"; - textBtn.innerHTML = `${ICONS.text} Text Segment`; - textBtn.addEventListener("click", () => { - this.addSegmentInGap(gap.frameStart, gap.frameEnd, "text"); - this.dismissGapMenu(); - }); - - const imgBtn = document.createElement("button"); - imgBtn.className = "pr-gap-menu-btn"; - imgBtn.innerHTML = `${ICONS.upload} Image Segment`; - imgBtn.addEventListener("click", () => { - this.dismissGapMenu(); - const fi = document.createElement("input"); - fi.type = "file"; fi.accept = "image/*"; - fi.addEventListener("change", (ev) => { - if (ev.target.files?.[0]) { - const gapLength = gap.frameEnd - gap.frameStart; - this.handleImageUpload([ev.target.files[0]], gap.frameStart, gapLength); - } - }); - fi.click(); - }); - - menu.appendChild(textBtn); - menu.appendChild(imgBtn); - const currentTrack = gap.track === "audio" ? "audio" : "image"; - if (this._copiedSegment && this._copiedSegmentTrack === currentTrack) { - const pasteBtn = document.createElement("button"); - pasteBtn.className = "pr-gap-menu-btn"; - pasteBtn.innerHTML = `Paste Segment`; - pasteBtn.onclick = () => { - const gapLength = gap.frameEnd - gap.frameStart; - - let finalLength = Math.min(this._copiedSegment.length, gapLength); - if (currentTrack === "image") { - finalLength = gapLength; - } - - const newSeg = { - ...this._copiedSegment, - id: Date.now().toString() + Math.random().toString(36).substr(2, 5), - start: gap.frameStart, - length: finalLength - }; - const targetArray = currentTrack === "audio" ? this.timeline.audioSegments : this.timeline.segments; - targetArray.push(newSeg); - targetArray.sort((a, b) => a.start - b.start); - this.commitChanges(); - this.dismissGapMenu(); - }; - menu.appendChild(pasteBtn); - } - - document.body.appendChild(menu); - this._gapMenu = menu; - setTimeout(() => { - this._gapMenuDismisser = (ev) => { if (!menu.contains(ev.target)) this.dismissGapMenu(); }; - document.addEventListener("pointerdown", this._gapMenuDismisser, true); - }, 0); - } - - dismissGapMenu() { - if (this._gapMenu) { this._gapMenu.remove(); this._gapMenu = null; } - if (this._gapMenuDismisser) { document.removeEventListener("pointerdown", this._gapMenuDismisser, true); this._gapMenuDismisser = null; } - } - - // --- Settings Menu --- - // Widgets that are managed by the settings menu (hidden from node by default). - get _settingsWidgetNames() { - return ["display_mode", "epsilon", "divisible_by", "img_compression"]; - } - - // Hide all settings widgets on the node (called on init). - hideSettingsWidgets() { - for (const name of this._settingsWidgetNames) { - const w = this.node.widgets?.find(w => w.name === name); - if (w) hideWidget(w); - - // Also remove corresponding input slot if it exists and is NOT connected - // to prevent overlapping issues in classic ComfyUI (nodes v1) - if (this.node.inputs) { - const inputIdx = this.node.inputs.findIndex(i => i.name === name); - if (inputIdx !== -1) { - const input = this.node.inputs[inputIdx]; - if (input.link == null) { - this.node.removeInput(inputIdx); - } - } - } - } - this.updateWidgetVisibility(); - - // Workaround: toggle display mode to force ComfyUI to refresh the node - if (this.displayModeWidget) { - const origVal = this.displayModeWidget.value; - const otherVal = origVal === "frames" ? "seconds" : "frames"; - - this.displayModeWidget.value = otherVal; - if (this.displayModeWidget.callback) this.displayModeWidget.callback(otherVal); - - this.displayModeWidget.value = origVal; - if (this.displayModeWidget.callback) this.displayModeWidget.callback(origVal); - } - } - - // Restore all settings widgets on the node. - showSettingsWidgets() { - for (const name of this._settingsWidgetNames) { - const w = this.node.widgets?.find(w => w.name === name); - if (!w) continue; - - const typeMap = { - display_mode: "combo", epsilon: "FLOAT", divisible_by: "INT", - img_compression: "INT", - }; - w.type = typeMap[name] || w._origType || "number"; - w.hidden = false; - if (w.options) w.options.hidden = false; - delete w.computeSize; - if (w.element) w.element.style.display = ""; - } - this.updateWidgetVisibility(); - - // Workaround: toggle display mode to force ComfyUI to refresh the node - if (this.displayModeWidget) { - const origVal = this.displayModeWidget.value; - const otherVal = origVal === "frames" ? "seconds" : "frames"; - - this.displayModeWidget.value = otherVal; - if (this.displayModeWidget.callback) this.displayModeWidget.callback(otherVal); - - this.displayModeWidget.value = origVal; - if (this.displayModeWidget.callback) this.displayModeWidget.callback(origVal); - } - } - - _makeSettingRow(label, inputEl) { - const row = document.createElement("div"); - row.className = "pr-settings-row"; - const lbl = document.createElement("span"); - lbl.className = "pr-settings-label"; - lbl.textContent = label; - row.appendChild(lbl); - row.appendChild(inputEl); - return row; - } - - _showCanvasSettingsMenu(anchorEl) { - const menu = document.createElement("div"); - menu.className = "pr-settings-menu"; - const title = document.createElement("div"); - title.className = "pr-settings-title"; - title.textContent = "Timeline Settings"; - menu.appendChild(title); - - const addNum = (label, key, step, min, max, isFloat) => { - const inp = document.createElement("input"); - inp.type = "number"; - inp.className = "pr-settings-input"; - inp.step = String(step); - inp.min = String(min); - inp.max = String(max); - inp.value = this.node[key]; - inp.addEventListener("change", () => { - let val = isFloat ? parseFloat(inp.value) : parseInt(inp.value, 10); - if (isNaN(val)) val = this.node[key]; - val = Math.max(min, Math.min(max, val)); - this.node[key] = val; - if (key === "durationFrames" || key === "frameRate") { - const fps = this.getFrameRate(); - this.node.durationSeconds = Math.round((this.getDurationFrames() / fps) * 1000) / 1000; - } else if (key === "durationSeconds") { - const fps = this.getFrameRate(); - this.node.durationFrames = Math.max(1, Math.round(this.node.durationSeconds * fps)); - } - if (this._onCanvasCommit) this._onCanvasCommit(); - this.render(); - }); - menu.appendChild(this._makeSettingRow(label, inp)); - }; - - addNum("Duration (seconds)", "durationSeconds", 0.01, 0.1, 1000, true); - addNum("Duration (frames)", "durationFrames", 1, 1, 10000, false); - addNum("Frame rate", "frameRate", 1, 1, 240, false); - addNum("Width (0=auto)", "customWidth", 32, 0, 8192, false); - addNum("Height (0=auto)", "customHeight", 32, 0, 8192, false); - addNum("Seed", "noiseSeed", 1, 0, 4294967295, false); - addNum("Epsilon", "epsilon", 0.0001, 0.0001, 0.99, true); - addNum("Img compression", "imgCompression", 1, 0, 100, false); - - const gp = document.createElement("textarea"); - gp.className = "pr-prompt-area"; - gp.style.minHeight = "48px"; - gp.value = this.node.globalPrompt || ""; - gp.addEventListener("input", () => { - this.node.globalPrompt = gp.value; - if (this._onCanvasCommit) this._onCanvasCommit(); - }); - menu.appendChild(this._makeSettingRow("Global prompt", gp)); - - const rect = anchorEl.getBoundingClientRect(); - menu.style.position = "fixed"; - menu.style.left = `${rect.left}px`; - menu.style.top = `${rect.bottom + 4}px`; - menu.style.zIndex = "10000"; - document.body.appendChild(menu); - this._settingsMenu = menu; - setTimeout(() => { - this._settingsMenuDismisser = (ev) => { - if (!menu.contains(ev.target) && ev.target !== anchorEl) this.dismissSettingsMenu(); - }; - document.addEventListener("pointerdown", this._settingsMenuDismisser, true); - }, 0); - } - - showSettingsMenu(anchorEl) { - this.dismissSettingsMenu(); - if (this._canvasMode) { - this._showCanvasSettingsMenu(anchorEl); - return; - } - const menu = document.createElement("div"); - menu.className = "pr-settings-menu"; - - // Title & Close Button Container - const titleContainer = document.createElement("div"); - titleContainer.className = "pr-settings-title"; - titleContainer.style.display = "flex"; - titleContainer.style.justifyContent = "space-between"; - titleContainer.style.alignItems = "center"; - - const titleText = document.createElement("span"); - titleText.textContent = "Timeline Settings"; - titleContainer.appendChild(titleText); - - const closeBtn = document.createElement("button"); - closeBtn.className = "pr-settings-close-btn"; - closeBtn.innerHTML = ICONS.close; - closeBtn.title = "Close Settings"; - closeBtn.addEventListener("click", () => this.dismissSettingsMenu()); - titleContainer.appendChild(closeBtn); - - menu.appendChild(titleContainer); - - // Helper: fire a widget's callback safely - const fireCallback = (w, val) => { - w.value = val; - if (w.callback) { - try { w.callback(val, app.canvas, this.node, null, null); } catch (e) { } - } - if (window.app && window.app.graph) window.app.graph.setDirtyCanvas(true, true); - }; - - // --- Display Mode --- - const dmWidget = this.node.widgets?.find(w => w.name === "display_mode"); - if (dmWidget) { - const ctrl = document.createElement("div"); - ctrl.className = "pr-segmented-control"; - - const framesSeg = document.createElement("div"); - framesSeg.className = "pr-segment"; - framesSeg.textContent = "Frames"; - - const secondsSeg = document.createElement("div"); - secondsSeg.className = "pr-segment"; - secondsSeg.textContent = "Seconds"; - - const updateActive = (val) => { - if (val === "frames") { - framesSeg.classList.add("active"); - secondsSeg.classList.remove("active"); - } else { - secondsSeg.classList.add("active"); - framesSeg.classList.remove("active"); - } - }; - - updateActive(dmWidget.value); - - const onSegClick = (val) => { - fireCallback(dmWidget, val); - updateActive(val); - // Update ruler/timecode immediately - if (this.updateWidgetVisibility) this.updateWidgetVisibility(); - if (this.updateUIFromSelection) this.updateUIFromSelection(); - this.render(); - }; - - framesSeg.addEventListener("click", () => onSegClick("frames")); - secondsSeg.addEventListener("click", () => onSegClick("seconds")); - - ctrl.appendChild(secondsSeg); - ctrl.appendChild(framesSeg); - - menu.appendChild(this._makeSettingRow("Display Mode", ctrl)); - } - - const divider1 = document.createElement("hr"); - divider1.className = "pr-settings-divider"; - menu.appendChild(divider1); - - // Helper to create scrubbable number control with horizontal buttons - const createScrubbableNumberControl = (w, step, min, max, isFloat = false) => { - const container = document.createElement("div"); - container.className = "pr-number-control"; - - const decBtn = document.createElement("button"); - decBtn.className = "pr-number-btn"; - decBtn.textContent = "-"; - - const inp = document.createElement("input"); - inp.type = "number"; - inp.className = "pr-settings-input"; - inp.value = w.value; - inp.step = step.toString(); - inp.min = min.toString(); - inp.max = max.toString(); - - const incBtn = document.createElement("button"); - incBtn.className = "pr-number-btn"; - incBtn.textContent = "+"; - - decBtn.addEventListener("click", () => { - let val = parseFloat(inp.value) - step; - if (val < min) val = min; - inp.value = isFloat ? val.toFixed(4) : Math.round(val); - fireCallback(w, parseFloat(inp.value)); - }); - - incBtn.addEventListener("click", () => { - let val = parseFloat(inp.value) + step; - if (val > max) val = max; - inp.value = isFloat ? val.toFixed(4) : Math.round(val); - fireCallback(w, parseFloat(inp.value)); - }); - - inp.addEventListener("change", () => { - let val = parseFloat(inp.value); - if (isNaN(val)) val = w.value; - if (val < min) val = min; - if (val > max) val = max; - inp.value = isFloat ? val.toFixed(4) : Math.round(val); - fireCallback(w, parseFloat(inp.value)); - }); - - // Dragging logic - let isDragging = false; - let startX = 0; - let startVal = 0; - let hasMoved = false; - - inp.style.cursor = "ew-resize"; - - inp.addEventListener("mousedown", (e) => { - startX = e.clientX; - startVal = parseFloat(inp.value); - hasMoved = false; - - const onMouseMove = (moveEvent) => { - const deltaX = moveEvent.clientX - startX; - if (Math.abs(deltaX) > 3) { - hasMoved = true; - isDragging = true; - } - - if (isDragging) { - moveEvent.preventDefault(); - const sensitivity = isFloat ? 0.001 : 0.5; - let newVal = startVal + deltaX * sensitivity; - - if (newVal < min) newVal = min; - if (newVal > max) newVal = max; - - inp.value = isFloat ? newVal.toFixed(4) : Math.round(newVal); - fireCallback(w, parseFloat(inp.value)); - } - }; - - const onMouseUp = () => { - document.removeEventListener("mousemove", onMouseMove); - document.removeEventListener("mouseup", onMouseUp); - - if (!hasMoved) { - inp.focus(); - inp.select(); - } - isDragging = false; - }; - - document.addEventListener("mousemove", onMouseMove); - document.addEventListener("mouseup", onMouseUp); - }); - - container.appendChild(decBtn); - container.appendChild(inp); - container.appendChild(incBtn); - - return container; - }; - - // --- Epsilon --- - const epsWidget = this.node.widgets?.find(w => w.name === "epsilon"); - if (epsWidget) { - menu.appendChild(this._makeSettingRow("Epsilon", createScrubbableNumberControl(epsWidget, 0.0001, 0.0001, 0.99, true))); - } - - // --- Divisible By --- - const divByWidget = this.node.widgets?.find(w => w.name === "divisible_by"); - if (divByWidget) { - menu.appendChild(this._makeSettingRow("Divisible By", createScrubbableNumberControl(divByWidget, 1, 1, 256, false))); - } - - // --- Img Compression --- - const compWidget = this.node.widgets?.find(w => w.name === "img_compression"); - if (compWidget) { - menu.appendChild(this._makeSettingRow("Img Compression", createScrubbableNumberControl(compWidget, 1, 0, 100, false))); - } - - // --- Global Prompt Toggle --- - const globalPromptWidget = this.node.widgets?.find(w => w.name === "global_prompt"); - if (globalPromptWidget) { - const cb = document.createElement("input"); - cb.type = "checkbox"; - cb.checked = !(globalPromptWidget.options && globalPromptWidget.options.hidden); - cb.style.cursor = "pointer"; - cb.addEventListener("change", () => { - const isVisible = cb.checked; - if (!globalPromptWidget.options) globalPromptWidget.options = {}; - globalPromptWidget.options.hidden = !isVisible; - - if (isVisible) { - delete globalPromptWidget.computeSize; - globalPromptWidget.hidden = false; - if (globalPromptWidget.element) globalPromptWidget.element.style.display = ""; - } else { - globalPromptWidget.computeSize = () => [0, 0]; - globalPromptWidget.hidden = true; - if (globalPromptWidget.element) globalPromptWidget.element.style.display = "none"; - } - - // Force refresh via display mode double-toggle trick - if (this.displayModeWidget) { - const origVal = this.displayModeWidget.value; - const otherVal = origVal === "frames" ? "seconds" : "frames"; - this.displayModeWidget.value = otherVal; - if (this.displayModeWidget.callback) this.displayModeWidget.callback(otherVal); - this.displayModeWidget.value = origVal; - if (this.displayModeWidget.callback) this.displayModeWidget.callback(origVal); - } - }); - menu.appendChild(this._makeSettingRow("Use Global Prompt", cb)); - } - - - // --- Show/Hide on Node Toggle --- - const toggleBtn = document.createElement("button"); - toggleBtn.className = "pr-settings-toggle-btn"; - const widgetsVisible = !!(this.node.widgets?.find(w => w.name === "display_mode" && !(w.options && w.options.hidden))); - toggleBtn.textContent = widgetsVisible ? "Hide Widgets on Node" : "Show Widgets on Node"; - toggleBtn.addEventListener("click", () => { - const nowVisible = !!(this.node.widgets?.find(w => w.name === "display_mode" && !(w.options && w.options.hidden))); - if (nowVisible) { - this.hideSettingsWidgets(); - toggleBtn.textContent = "Show Widgets on Node"; - } else { - this.showSettingsWidgets(); - toggleBtn.textContent = "Hide Widgets on Node"; - } - }); - menu.appendChild(toggleBtn); - - // Position the menu below the anchor button (pop down) - document.body.appendChild(menu); - const rect = anchorEl.getBoundingClientRect(); - const menuW = menu.offsetWidth || 230; - const menuH = menu.offsetHeight || 350; - let left = rect.right - menuW; - let top = rect.bottom + 6; - if (left < 4) left = 4; - // Fallback to top if it overflows the bottom of the screen - if (top + menuH > window.innerHeight - 4) { - top = rect.top - menuH - 6; - } - menu.style.left = `${left}px`; - menu.style.top = `${top}px`; - - this._settingsMenu = menu; - setTimeout(() => { - this._settingsDismisser = (ev) => { - if (!menu.contains(ev.target) && !anchorEl.contains(ev.target)) this.dismissSettingsMenu(); - }; - document.addEventListener("mousedown", this._settingsDismisser); - }, 0); - } - - dismissSettingsMenu() { - if (this._settingsMenu) { this._settingsMenu.remove(); this._settingsMenu = null; } - if (this._settingsDismisser) { document.removeEventListener("mousedown", this._settingsDismisser); this._settingsDismisser = null; } - if (this._settingsMenuDismisser) { document.removeEventListener("pointerdown", this._settingsMenuDismisser, true); this._settingsMenuDismisser = null; } - } - - - addSegmentInGap(frameStart, frameEnd, type = "text") { - const seg = { - id: Date.now().toString() + Math.random().toString(36).substr(2, 5), - start: frameStart, length: frameEnd - frameStart, - prompt: "", type, - }; - this.timeline.segments.push(seg); - this.timeline.segments.sort((a, b) => a.start - b.start); - this.selectionType = "image"; - this.selectedIndex = this.timeline.segments.findIndex(s => s.id === seg.id); - this.updateUIFromSelection(); - this.commitChanges(); - } - - addTextSegmentFreeSpace() { - const frameRate = this.getFrameRate(); - const newLength = Math.max(1, frameRate); // 1 second default - const sorted = [...this.timeline.segments].sort((a, b) => a.start - b.start); - let newStart = 0; - for (const seg of sorted) { - if (newStart + newLength <= seg.start) break; - newStart = Math.max(newStart, seg.start + seg.length); - } - // Place the segment at the first free slot in the visual timeline (no output duration change). - const durationFrames = this.getVisualDurationFrames(); - const seg = { - id: Date.now().toString() + Math.random().toString(36).substr(2, 5), - start: newStart, length: Math.min(newLength, Math.max(newLength, durationFrames - newStart)), - prompt: "", type: "text", - }; - this.timeline.segments.push(seg); - this.timeline.segments.sort((a, b) => a.start - b.start); - this.selectionType = "image"; - this.selectedIndex = this.timeline.segments.findIndex(s => s.id === seg.id); - this.updateUIFromSelection(); - this.commitChanges(); - } - - // --- Audio Player Engine --- - updatePlayerUI() { - if (!this.playBtn || !this.loopBtn) return; - this.playBtn.innerHTML = this.isPlaying ? ICONS.pause : ICONS.play; - if (this.isLooping) { - this.loopBtn.classList.add("active"); - } else { - this.loopBtn.classList.remove("active"); - } - if (this.seekBar) { - this.seekBar.max = this.getVisualDurationFrames(); - this.seekBar.value = this.currentFrame; - } - if (this.timeCodeDisplay) { - this.timeCodeDisplay.textContent = this.formatTime(this.currentFrame); - } - } - - togglePlay() { - if (this.isPlaying) { - this.pauseAudio(); - } else { - if (this.currentFrame >= this.getVisualDurationFrames()) { - this.currentFrame = 0; - } - this.playAudio(); - } - } - - toggleLoop() { - this.isLooping = !this.isLooping; - this.updatePlayerUI(); - } - - async playAudio() { - this.pauseAudio(true); // clear any existing playback, but don't suspend context if scrubbing - - this._playCounter = (this._playCounter || 0) + 1; - const playId = this._playCounter; - this._currentPlayId = playId; - this.isPlaying = true; - - if (!this.audioContext) { - this.audioContext = new (window.AudioContext || window.webkitAudioContext)(); - } - if (this.audioContext.state !== 'running') { - try { await this.audioContext.resume(); } catch (e) { } - } - if (this._currentPlayId !== playId || !this.isPlaying) return; - - this.updatePlayerUI(); - - const frameRate = this.getFrameRate(); - this.playbackStartFrame = this.currentFrame; - this.playbackStartTime = this.audioContext.currentTime; - - // Decode and schedule all audio segments that happen AT or AFTER currentFrame - for (let seg of this.timeline.audioSegments) { - const segStartFrame = seg.start; - const segEndFrame = seg.start + seg.length; - - if (segEndFrame <= this.currentFrame) continue; - - try { - // Build audio buffer: fetch from server URL if audioFile is set, otherwise fall back to audioB64 - let audioBuffer; - if (seg.audioFile) { - const audioUrl = api.apiURL(`/view?filename=${encodeURIComponent(seg.audioFile.split("/").pop())}&type=input&subfolder=${encodeURIComponent(seg.audioFile.includes("/") ? seg.audioFile.split("/").slice(0, -1).join("/") : "")}`); - const resp = await fetch(audioUrl); - const arrayBuffer = await resp.arrayBuffer(); - audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer); - } else if (seg.audioB64) { - const binaryString = window.atob(seg.audioB64); - const len = binaryString.length; - const bytes = new Uint8Array(len); - for (let i = 0; i < len; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - audioBuffer = await this.audioContext.decodeAudioData(bytes.buffer); - } else { - continue; - } - if (this._currentPlayId !== playId || !this.isPlaying) return; - - const framesToSkipInSegment = Math.max(0, this.currentFrame - segStartFrame); - const waitFrames = Math.max(0, segStartFrame - this.currentFrame); - const waitTimeSec = waitFrames / frameRate; - - const fileOffsetFrames = seg.trimStart + framesToSkipInSegment; - const fileOffsetSec = fileOffsetFrames / frameRate; - - const playDurationFrames = seg.length - framesToSkipInSegment; - const playDurationSec = playDurationFrames / frameRate; - - if (playDurationSec <= 0) continue; - - const bufferNode = this.audioContext.createBufferSource(); - bufferNode.buffer = audioBuffer; - bufferNode["connect"](this.audioContext.destination); - - const startTime = this.audioContext.currentTime + waitTimeSec; - bufferNode.start(startTime, fileOffsetSec, playDurationSec); - - this.activeAudioNodes.push(bufferNode); - } catch (err) { - console.error("Playback decode error for segment:", err); - } - } - - if (this._currentPlayId !== playId || !this.isPlaying) return; - - const loop = () => { - if (!this.isPlaying || this._currentPlayId !== playId) return; - - const elapsedSec = this.audioContext.currentTime - this.playbackStartTime; - const elapsedFrames = elapsedSec * frameRate; - - this.currentFrame = this.playbackStartFrame + elapsedFrames; - - const visualDurationFrames = this.getVisualDurationFrames(); - const durationFrames = this.getDurationFrames(); - - if (this.isLooping) { - const loopBound = (this.playbackStartFrame >= durationFrames) ? visualDurationFrames : durationFrames; - if (this.currentFrame >= loopBound) { - this.currentFrame = 0; - this.playAudio(); // Restart playback - return; - } - } else { - if (this.currentFrame >= visualDurationFrames) { - this.currentFrame = visualDurationFrames; - this.pauseAudio(); - this.render(); - return; - } - } - - this.render(); - this._playLoopId = requestAnimationFrame(loop); - }; - - this._playLoopId = requestAnimationFrame(loop); - } - - pauseAudio(isScrubbing = false) { - this.isPlaying = false; - this._currentPlayId = null; - - if (!isScrubbing && this.audioContext && this.audioContext.state === 'running') { - try { this.audioContext.suspend(); } catch (e) { } - } - - for (let node of this.activeAudioNodes) { - try { node.stop(); } catch (e) { } - try { node.disconnect(); } catch (e) { } - } - this.activeAudioNodes = []; - - if (this._playLoopId) { - cancelAnimationFrame(this._playLoopId); - this._playLoopId = null; - } - this.updatePlayerUI(); - } -} - -// --- Node Registration Hooks --- -const APPENDED_WIDGET_DEFAULTS = [ - ["timeline_data", "{}"], - ["local_prompts", ""], - ["segment_lengths", ""], -]; - -window.CanvasLTXTimelineEditor = TimelineEditor; -window.LTXParseInitial = parseInitial; - -function ltxMigrateLegacySegments(node) { - if (node.ltxTimelineData || !node.ltxSegments?.length) return; - const segments = node.ltxSegments.map(seg => { - const out = { - id: seg.id || (Date.now().toString() + Math.random().toString(36).substr(2, 5)), - start: Number(seg.start) || 0, - length: Math.max(1, Number(seg.length) || 1), - prompt: seg.prompt || "", - type: seg.type === "image" ? "image" : "text", - color: seg.color, - guideStrength: seg.strength ?? 1 - }; - if (seg.imageRef?.comfy_name) out.imageFile = seg.imageRef.comfy_name; - if (seg.imageRef?.url) out.imageB64 = seg.imageRef.url; - return out; - }); - node.ltxTimelineData = JSON.stringify({ segments, audioSegments: [] }); -} -window.ltxMigrateLegacySegments = ltxMigrateLegacySegments; - -if (app && app.registerExtension) app.registerExtension({ - name: "LTXDirector", - async beforeRegisterNodeDef(nodeType, nodeData, app) { - if (nodeData.name === "LTXDirector") { - - const onNodeCreated = nodeType.prototype.onNodeCreated; - nodeType.prototype.onNodeCreated = function () { - if (onNodeCreated) onNodeCreated.apply(this, arguments); - - for (const [name, def] of APPENDED_WIDGET_DEFAULTS) { - if (!this.widgets?.find(w => w.name === name)) { - this.addWidget("string", name, def, () => { }); - } - } - for (const w of this.widgets) { - if (HIDDEN_WIDGET_NAMES.includes(w.name)) hideWidget(w); - } - - // Set default width to be wider on creation (approx 2.5x default ~220px) - this.size[0] = 1000; - - // Force default for img_compression if not set (ComfyUI sometimes skips optional defaults) - const compWidget = this.widgets?.find(w => w.name === "img_compression"); - if (compWidget && (compWidget.value === undefined || compWidget.value === null || compWidget.value === 0)) { - compWidget.value = 18; - } - - // Hide global prompt by default on creation without destroying its DOM element - const globalPromptWidget = this.widgets?.find(w => w.name === "global_prompt"); - if (globalPromptWidget) { - if (!globalPromptWidget.options) globalPromptWidget.options = {}; - globalPromptWidget.options.hidden = true; - globalPromptWidget.hidden = true; - globalPromptWidget.computeSize = () => [0, 0]; - setTimeout(() => { - if (globalPromptWidget.element) globalPromptWidget.element.style.display = "none"; - }, 0); - } - - const container = document.createElement("div"); - const widget = this.addDOMWidget("timeline_ui", "timeline_ui", container, { - getValue: () => "", - setValue: () => { }, - }); - - widget.computeSize = function (width) { - const canvasH = self._timelineEditor ? self._timelineEditor.canvasHeight : CANVAS_HEIGHT; - return [width, canvasH + 235]; - }; - - const self = this; - setTimeout(() => { - try { - self._timelineEditor = new TimelineEditor(self, container, widget); - } catch (err) { - console.error("[PromptRelay] timeline editor init failed:", err); - } - }, 0); - }; - - const onRemoved = nodeType.prototype.onRemoved; - nodeType.prototype.onRemoved = function () { - this._timelineEditor?.destroy(); - return onRemoved?.apply(this, arguments); - }; - - const onConfigure = nodeType.prototype.onConfigure; - nodeType.prototype.onConfigure = function (info) { - const out = onConfigure?.apply(this, arguments); - for (const [name, def] of APPENDED_WIDGET_DEFAULTS) { - const w = this.widgets.find(x => x.name === name); - if (w && (w.value == null || w.value === "")) w.value = def; - } - - setTimeout(() => { - if (this._timelineEditor) { - this._timelineEditor.timeline = parseInitial(this._timelineEditor.timelineDataWidget?.value); - this._timelineEditor.loadImages(); - this._timelineEditor.selectionType = "image"; - this._timelineEditor.selectedIndex = clamp( - this._timelineEditor.selectedIndex, -1, - Math.max(-1, this._timelineEditor.timeline.segments.length - 1) - ); - this._timelineEditor.updateUIFromSelection(); - this._timelineEditor.render(); - } - }, 0); - return out; - }; - } - }, -}); diff --git a/static/js/runtime-sync.js b/static/js/runtime-sync.js new file mode 100644 index 000000000..68af20847 --- /dev/null +++ b/static/js/runtime-sync.js @@ -0,0 +1,129 @@ +(function(){ + const TOPICS = ['canvas','project','asset','prompt','platform','workflow','preference','history','session','task']; + const STORAGE = { + theme: ['studio_theme','canvas_theme'], + language: ['studio_lang'], + ui_scale: ['studio_ui_scale_mode'], + default_image_provider: ['studio_default_image_provider'], + default_image_model: ['studio_default_image_model'], + default_video_provider: ['studio_default_video_provider'], + default_video_model: ['studio_default_video_model'], + default_chat_provider: ['studio_default_chat_provider'], + default_chat_model: ['studio_default_chat_model'], + online_generation_settings: ['studio_online_generation_settings_v1'], + ecommerce_settings: ['studio_ecommerce_settings_v2'], + }; + const state = { values:{}, revision:0, actorId:'', socket:null, reconnectTimer:null, backoff:1000, applying:false }; + state.actorId = localStorage.getItem('client_id') || localStorage.getItem('canvas_sync_actor_id') || `web-${crypto.randomUUID?.() || Math.random().toString(36).slice(2)}`; + localStorage.setItem('client_id', state.actorId); + localStorage.setItem('canvas_sync_actor_id', state.actorId); + + function isTop(){ try { return window.top === window; } catch(e) { return false; } } + function localValues(){ + const values = {}; + Object.entries(STORAGE).forEach(([name, keys]) => { + for(const key of keys){ + const value = localStorage.getItem(key); + if(value){ values[name] = value; break; } + } + }); + return values; + } + function applyValues(values){ + state.applying = true; + try { + Object.entries(values || {}).forEach(([name, value]) => { + const keys = STORAGE[name] || []; + keys.forEach(key => localStorage.setItem(key, String(value))); + }); + if(values?.theme) window.StudioTheme?.apply?.(values.theme); + if(values?.language) window.StudioI18n?.set?.(values.language, {sync:false}); + if(values?.ui_scale) window.StudioScale?.apply?.(values.ui_scale); + document.querySelectorAll('iframe').forEach(frame => { + try { frame.contentWindow?.postMessage({type:'canvas.preferences', values}, '*'); } catch(e) {} + }); + } finally { state.applying = false; } + } + async function readPreferences(){ + const response = await fetch('/api/preferences', {cache:'no-store'}); + if(!response.ok) throw new Error(`preferences HTTP ${response.status}`); + const data = await response.json(); + state.values = data.values || {}; + state.revision = Number(data.revision || 0); + if(state.revision === 0){ + const existing = localValues(); + if(Object.keys(existing).length){ + return writePreferences(existing, 0, true); + } + } + applyValues(state.values); + return data; + } + async function writePreferences(values, baseRevision, importIfEmpty){ + const response = await fetch('/api/preferences', { + method:'PUT', headers:{'Content-Type':'application/json'}, + body:JSON.stringify({values, base_revision:Number(baseRevision || 0), actor_id:state.actorId, import_if_empty:!!importIfEmpty}) + }); + const data = await response.json().catch(() => ({})); + if(response.status === 409){ + const latest = data.detail || {}; + state.values = latest.values || {}; + state.revision = Number(latest.revision || 0); + // 冲突阶段不广播旧偏好,避免把正在输入的 iframe 重绘到上一版状态。 + const merged = {...state.values, ...values}; + return writePreferences(merged, state.revision, false); + } + if(!response.ok) throw new Error(data.detail || `preferences HTTP ${response.status}`); + state.values = data.values || {}; + state.revision = Number(data.revision || 0); + applyValues(state.values); + return data; + } + function setPreference(name, value){ + if(!Object.prototype.hasOwnProperty.call(STORAGE, name)) return Promise.resolve(); + if(state.applying) return new Promise(resolve => setTimeout(() => resolve(setPreference(name, value)), 25)); + if(!isTop()){ + try { return window.top.RuntimeSync?.setPreference(name, value) || Promise.resolve(); } catch(e) { return Promise.resolve(); } + } + return writePreferences({...state.values, [name]:value}, state.revision, false).catch(() => {}); + } + function dispatchMessage(message){ + window.dispatchEvent(new CustomEvent('canvas-realtime-message', {detail:message})); + document.querySelectorAll('iframe').forEach(frame => { + try { frame.contentWindow?.postMessage(message, '*'); } catch(e) {} + }); + } + function connectEvents(){ + if(!isTop() || !location.host) return; + clearTimeout(state.reconnectTimer); + const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; + const socket = new WebSocket(`${protocol}://${location.host}/ws/events`); + state.socket = socket; + socket.onopen = () => { + socket.send(JSON.stringify({type:'auth', client_id:state.actorId})); + state.backoff = 1000; + readPreferences().catch(() => {}); + dispatchMessage({type:'sync.reconnected', topics:TOPICS}); + }; + socket.onmessage = event => { + let message; try { message = JSON.parse(event.data); } catch(e) { return; } + if(message.type === 'entity.changed' && message.actor_id === state.actorId) return; + if(message.type === 'entity.changed' && message.topic === 'preference') readPreferences().catch(() => {}); + dispatchMessage(message); + }; + socket.onclose = () => { + if(state.socket !== socket) return; + state.reconnectTimer = setTimeout(connectEvents, state.backoff); + state.backoff = Math.min(state.backoff * 2, 8000); + }; + socket.onerror = () => { try { socket.close(); } catch(e) {} }; + } + window.RuntimeSync = {state, readPreferences, setPreference, connectEvents}; + window.addEventListener('message', event => { + if(event.data?.type === 'canvas.preferences') applyValues(event.data.values || {}); + }); + if(isTop()){ + readPreferences().catch(() => {}); + connectEvents(); + } +})(); diff --git a/static/js/smart-canvas.js b/static/js/smart-canvas.js index fa482cb70..f0ec1ffcb 100644 --- a/static/js/smart-canvas.js +++ b/static/js/smart-canvas.js @@ -228,6 +228,7 @@ let cropAspectPreset = 'free'; let cropAspectRatio = null; let imageEditMode = 'crop'; let imageEditModeTouched = false; +let imageResizeScale = 0.5; let editDrawState = null; let editTextItems = []; let editTextSelectedId = ''; @@ -357,9 +358,9 @@ let settings = { }; const MS_GEN_MODELS = { zimage: { label:'ZImage', modelId:'Tongyi-MAI/Z-Image-Turbo', supportsImage:false, endpoint:'/generate' }, - qwen_edit: { label:'Qwen Edit', modelId:'Qwen/Qwen-Image-Edit-2511', supportsImage:true, endpoint:'/api/angle/generate' }, - klein_edit: { label:'Klein', modelId:'black-forest-labs/FLUX.2-klein-9B', supportsImage:true, endpoint:'/api/ms/generate' }, - custom: { label:tr('smart.custom') || '自定义', modelId:'', acceptsImage:true, endpoint:'/api/ms/generate' } + qwen_edit: { label:'Qwen Edit', modelId:'Qwen/Qwen-Image-Edit-2511', supportsImage:true, endpoint:'' }, + klein_edit: { label:'Klein', modelId:'black-forest-labs/FLUX.2-klein-9B', supportsImage:true, endpoint:'' }, + custom: { label:tr('smart.custom') || '自定义', modelId:'', acceptsImage:true, endpoint:'' } }; const SIZE_MAP = { square: {'1k':'1024x1024','2k':'2048x2048','4k':'4096x4096'}, @@ -552,6 +553,70 @@ function bindSmartPreviewImageFallbacks(root=document){ }); }); } +const SMART_SELECTED_HIGH_RES_DELAY = 320; +let smartSelectedHighResTimer = 0; +let smartSelectedHighResSeq = 0; +const smartSelectedHighResLoaded = new Set(); +const smartSelectedHighResLoading = new Map(); +function smartImageEditorIsOpen(){ + return Boolean(imageEditModal?.classList?.contains('open')); +} +function preloadSmartSelectedHighRes(src){ + if(!src || smartSelectedHighResLoaded.has(src)) return Promise.resolve(true); + if(smartSelectedHighResLoading.has(src)) return smartSelectedHighResLoading.get(src); + const task = new Promise(resolve => { + const img = new Image(); + img.decoding = 'async'; + img.onload = async () => { + try { if(img.decode) await img.decode(); } catch(e) {} + smartSelectedHighResLoaded.add(src); + resolve(true); + }; + img.onerror = () => resolve(false); + img.src = src; + }).finally(() => smartSelectedHighResLoading.delete(src)); + smartSelectedHighResLoading.set(src, task); + return task; +} +function syncSmartSelectedImageResolution(root=world){ + const selectedImages = []; + root.querySelectorAll?.('.image-node img[data-preview-src][data-original-src]').forEach(img => { + if(img.dataset.previewKind === 'video') return; + const nodeEl = img.closest('.image-node'); + const selectedNode = Boolean(nodeEl?.dataset?.id && isNodeSelected(nodeEl.dataset.id)); + const preview = img.dataset.previewSrc || ''; + const original = img.dataset.originalSrc || ''; + if(!selectedNode){ + delete img.dataset.selectedHighResTarget; + if(preview && img.getAttribute('src') !== preview) img.src = preview; + return; + } + const target = displayMediaUrl({url:smartOriginalMediaUrl(original)}); + if(!target) return; + img.dataset.selectedHighResTarget = target; + if(smartSelectedHighResLoaded.has(target)){ + if(img.getAttribute('src') !== target) img.src = target; + return; + } + if(preview && img.getAttribute('src') !== preview) img.src = preview; + selectedImages.push({img, target}); + }); + if(smartSelectedHighResTimer) clearTimeout(smartSelectedHighResTimer); + const seq = ++smartSelectedHighResSeq; + if(!selectedImages.length || smartImageEditorIsOpen()) return; + smartSelectedHighResTimer = setTimeout(async () => { + smartSelectedHighResTimer = 0; + if(seq !== smartSelectedHighResSeq || smartImageEditorIsOpen()) return; + await Promise.all(selectedImages.map(item => preloadSmartSelectedHighRes(item.target))); + if(seq !== smartSelectedHighResSeq || smartImageEditorIsOpen()) return; + selectedImages.forEach(({img, target}) => { + if(!img.isConnected || img.dataset.selectedHighResTarget !== target) return; + const nodeEl = img.closest('.image-node'); + if(!nodeEl?.dataset?.id || !isNodeSelected(nodeEl.dataset.id)) return; + if(smartSelectedHighResLoaded.has(target) && img.getAttribute('src') !== target) img.src = target; + }); + }, SMART_SELECTED_HIGH_RES_DELAY); +} function cloneSmartSettings(source=settings){ try { return JSON.parse(JSON.stringify(source || {})); @@ -823,12 +888,11 @@ const initialSmartSettings = cloneSmartSettings(settings); let canvasDefaultSmartSettings = cloneSmartSettings(settings); let recentSmartSettingsByMode = {}; function smartSettingsModeKey(source=settings){ - const engine = ['api','volcengine','modelscope','comfy','runninghub'].includes(source?.engine) ? source.engine : 'api'; + const engine = ['api','volcengine','runninghub'].includes(source?.engine) ? source.engine : 'api'; if(engine === 'api') return `api:${source?.apiKind === 'video' ? 'video' : 'image'}`; if(engine === 'volcengine') return `volcengine:${source?.apiKind === 'video' ? 'video' : 'image'}`; - if(engine === 'comfy') return `comfy:${['text','enhance','edit','custom'].includes(source?.comfyMode) ? source.comfyMode : 'text'}`; if(engine === 'runninghub') return 'runninghub'; - return 'modelscope'; + return 'api:image'; } function loadRecentSmartSettings(){ try { @@ -863,7 +927,7 @@ function rememberRecentSmartSettings(source=settings, node=null){ saveRecentSmartSettings(); } function applyRecentSmartSettingsForCurrentMode(){ - const requestedEngine = ['api','volcengine','modelscope','comfy','runninghub'].includes(settings.engine) ? settings.engine : 'api'; + const requestedEngine = ['api','volcengine','runninghub'].includes(settings.engine) ? settings.engine : 'api'; const requestedApiKind = settings.apiKind === 'video' ? 'video' : 'image'; const key = smartSettingsModeKey(settings); const saved = recentSmartSettingsForMode(key); @@ -970,7 +1034,7 @@ function exceedsFourKStandard(width, height){ function withOutpaintDisplaySettings(node, baseSettings){ const size = validOutpaintSize(node); if(!size) return baseSettings; - const engine = ['api','volcengine','modelscope','comfy','runninghub'].includes(baseSettings?.engine) ? baseSettings.engine : 'api'; + const engine = ['api','volcengine','runninghub'].includes(baseSettings?.engine) ? baseSettings.engine : 'api'; const next = { ...baseSettings, resolution:'custom', @@ -1102,6 +1166,37 @@ function toast(text){ clearTimeout(toast._timer); toast._timer = setTimeout(() => el.classList.remove('show'), 1800); } +let generationCompleteSoundAt = 0; +function playGenerationCompleteSound(){ + const now = Date.now(); + if(now - generationCompleteSoundAt < 1200) return; + generationCompleteSoundAt = now; + try { + const AudioCtx = window.AudioContext || window.webkitAudioContext; + if(!AudioCtx) return; + const ctx = playGenerationCompleteSound._ctx || (playGenerationCompleteSound._ctx = new AudioCtx()); + const play = () => { + const start = ctx.currentTime + 0.015; + [ + {freq:660, at:0, duration:0.12}, + {freq:880, at:0.12, duration:0.16} + ].forEach(tone => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(tone.freq, start + tone.at); + gain.gain.setValueAtTime(0.0001, start + tone.at); + gain.gain.exponentialRampToValueAtTime(0.075, start + tone.at + 0.018); + gain.gain.exponentialRampToValueAtTime(0.0001, start + tone.at + tone.duration); + osc.connect(gain).connect(ctx.destination); + osc.start(start + tone.at); + osc.stop(start + tone.at + tone.duration + 0.02); + }); + }; + if(ctx.state === 'suspended') ctx.resume().then(play).catch(() => {}); + else play(); + } catch(e) {} +} function selectedNode(){ return nodes.find(n => n.id === selectedId) || null; } function clearSelection(){ savePromptDraftForCurrent(); @@ -1127,6 +1222,7 @@ function syncSelectionUi(){ item.classList.toggle('image-selected', selectedImage.nodeId === id && selectedImage.index === index); }); }); + syncSmartSelectedImageResolution(world); syncRunButtonState(); scheduleConnectionLayerRefresh(); } @@ -2461,11 +2557,7 @@ function normalizeApiSizeSettings(prefix=''){ if(settings[resKey] === 'auto' && !settings[ratioKey]) settings[ratioKey] = 'square'; } async function ensureComfyWorkflow(name){ - if(!name) return null; - if(comfyWorkflowCache[name]) return comfyWorkflowCache[name]; - const data = await fetch(`/api/workflows/${encodeURIComponent(name)}`).then(r => r.ok ? r.json() : null).catch(() => null); - if(data) comfyWorkflowCache[name] = data; - return data; + return null; } function currentComfyFields(){ return comfyWorkflowCache[settings.comfyWorkflow]?.config?.fields || []; @@ -2532,7 +2624,7 @@ function renderDynamicParams(){ if(!dynamicParams) return; const keepOpen = openControlState(); const scrollState = dynamicParamsScrollSnapshot(); - settings.engine = ['api','volcengine','modelscope','comfy','runninghub'].includes(settings.engine) ? settings.engine : 'api'; + settings.engine = ['api','volcengine','runninghub'].includes(settings.engine) ? settings.engine : 'api'; settings.apiKind = settings.apiKind === 'video' ? 'video' : 'image'; clearVolcengineSelectionOutsideVolcengine(settings); engineSelect.value = settings.engine; @@ -3915,12 +4007,9 @@ async function loadConfig(){ try { const cfg = await fetch('/api/config').then(r => r.json()); apiProviders = Array.isArray(cfg.api_providers) ? cfg.api_providers : []; - comfyInstanceCount = Math.max(1, (Array.isArray(cfg.comfy_instances) ? cfg.comfy_instances : []).filter(Boolean).length || 1); // 提供商配置已就绪即先渲染参数面板,避免等工作流/RunningHub 预取完成后参数才「突然刷新出来」。 sanitizeSmartApiSelection(settings); updateProviderModels(); - const wf = await fetch('/api/workflows').then(r => r.json()).catch(() => ({workflows:[]})); - comfyWorkflows = Array.isArray(wf.workflows) ? wf.workflows : []; runningHubWorkflowCache = {}; const rhProvider = apiProviders.find(p => p.id === 'runninghub'); const rhWorkflowIds = (rhProvider?.rh_workflows || []).map(item => String(item.workflowId || item.id || '').trim()).filter(Boolean); @@ -4751,6 +4840,7 @@ function refreshAssetLibrarySoon(delay=120){ }, delay); } function handleAssetLibraryUpdatedMessage(data={}){ + if(data.type === 'entity.changed' && data.topic !== 'asset') return; const remoteUpdatedAt = Number(data.updated_at || 0); if(remoteUpdatedAt && remoteUpdatedAt <= Number(assetLibraryUpdatedAt || 0)) return; refreshAssetLibrarySoon(); @@ -5011,9 +5101,11 @@ function scheduleCanvasMergeReload(delay=200){ canvasSyncTimer = setTimeout(() => { mergeReloadCanvasNow(); }, delay); } function handleCanvasUpdatedMessage(data={}){ - if(!data || data.type !== 'canvas_updated') return; - if(!canvasId || data.canvas_id !== canvasId) return; - if(data.client_id && data.client_id === smartClientId) return; // 自己发的,忽略 + if(!data || (data.type !== 'canvas_updated' && !(data.type === 'entity.changed' && data.topic === 'canvas'))) return; + const remoteCanvasId = data.entity_id || data.canvas_id; + const remoteActorId = data.actor_id || data.client_id; + if(!canvasId || (remoteCanvasId !== canvasId && remoteCanvasId !== 'global')) return; + if(remoteActorId && remoteActorId === smartClientId) return; // 自己发的,忽略 if(canvasSyncInFlight) return; // 我正在保存,保存完成/409 合并会处理 const remoteUpdatedAt = Number(data.updated_at || 0); if(remoteUpdatedAt && remoteUpdatedAt <= Number(canvas?.updated_at || 0)) return; @@ -5043,16 +5135,17 @@ function connectAssetLibrarySyncSocket(){ let retryTimer = null; const connect = () => { try { - socket = new WebSocket(`${protocol}://${host}/ws/stats?client_id=${clientId}`); + socket = new WebSocket(`${protocol}://${host}/ws/events?client_id=${clientId}`); } catch(e) { retryTimer = setTimeout(connect, 3000); return; } + socket.onopen = () => socket.send(JSON.stringify({ type: 'auth', client_id: clientId })); socket.onmessage = event => { try { const data = JSON.parse(event.data); - if(data?.type === 'asset_library_updated') handleAssetLibraryUpdatedMessage(data); - if(data?.type === 'canvas_updated') handleCanvasUpdatedMessage(data); + if(data?.type === 'asset_library_updated' || (data?.type === 'entity.changed' && data.topic === 'asset')) handleAssetLibraryUpdatedMessage(data); + if(data?.type === 'canvas_updated' || (data?.type === 'entity.changed' && data.topic === 'canvas')) handleCanvasUpdatedMessage(data); } catch(e) {} }; socket.onclose = () => { @@ -5624,12 +5717,14 @@ async function saveCanvas(){ logs:storageCanvas.logs || [], settings:storageCanvas.settings, base_updated_at:storageCanvas.updated_at || canvas.updated_at || 0, + base_revision:storageCanvas.revision || canvas.revision || 0, client_id:smartClientId }) }); if(res.ok){ const data = await res.json(); if(data.canvas && data.canvas.updated_at) canvas.updated_at = data.canvas.updated_at; + if(data.canvas && data.canvas.revision) canvas.revision = data.canvas.revision; } else if(res.status === 409) { // 冲突:别人先保存了。合并对方的状态(节点 id 合并、图片取并集,谁都不丢), // 然后用对方最新的 updated_at 作为基底重存,把合并结果落盘——而不是直接覆盖对方。 @@ -5831,7 +5926,7 @@ function pasteAssetsFromInbox(){ toast(`已粘贴 ${created.length} 个素材到画布`); return true; } -function duplicateForAltDrag(node){ +function duplicateForAltDrag(node, preserveConnections=false){ const ids = (isNodeSelected(node.id) ? selectedNodeIds() : [node.id]); const sourceNodes = ids.map(id => nodes.find(n => n.id === id)).filter(Boolean); if(!sourceNodes.length) return node; @@ -5843,13 +5938,31 @@ function duplicateForAltDrag(node){ return copy; }); copies.forEach(copy => { - if(Array.isArray(copy.inputNodeIds)) copy.inputNodeIds = copy.inputNodeIds.map(id => idMap.get(id)).filter(Boolean); - if(copy.sourceNodeId) copy.sourceNodeId = idMap.get(copy.sourceNodeId) || ''; + if(Array.isArray(copy.inputNodeIds)){ + copy.inputNodeIds = preserveConnections + ? copy.inputNodeIds.map(id => idMap.get(id) || id).filter(Boolean) + : []; + } + if(copy.sourceNodeId) copy.sourceNodeId = preserveConnections ? (idMap.get(copy.sourceNodeId) || copy.sourceNodeId) : ''; }); - const idSet = new Set(sourceNodes.map(n => n.id)); - const copiedConnections = (canvas.connections || []).filter(c => idSet.has(c.from) && idSet.has(c.to)); - const newConnections = copiedConnections.map(conn => ({...conn, from:idMap.get(conn.from), to:idMap.get(conn.to)})).filter(conn => conn.from && conn.to && conn.from !== conn.to); - canvas.connections = [...(canvas.connections || []), ...newConnections]; + if(preserveConnections){ + const idSet = new Set(sourceNodes.map(n => n.id)); + const newConnections = (canvas.connections || []) + .filter(conn => idSet.has(conn.from) || idSet.has(conn.to)) + .map(conn => ({...conn, from:idMap.get(conn.from) || conn.from, to:idMap.get(conn.to) || conn.to})) + .filter(conn => conn.from && conn.to && conn.from !== conn.to); + const nextConnections = [...(canvas.connections || [])]; + newConnections.forEach(conn => { + const kind = conn.kind || 'flow'; + if(nextConnections.some(c => c.from === conn.from && c.to === conn.to && (c.kind || 'flow') === kind)) return; + nextConnections.push(conn); + const toNode = nodes.find(n => n.id === conn.to) || copies.find(n => n.id === conn.to); + if(toNode && (conn.kind || 'flow') === 'input'){ + toNode.inputNodeIds = Array.from(new Set([...(toNode.inputNodeIds || []), conn.from])); + } + }); + canvas.connections = nextConnections; + } nodes.push(...copies); selectedId = ''; selectedIds = []; @@ -5926,7 +6039,7 @@ function renderConnections(){ isCascade && Boolean(cascadeState) && cascadeState !== 'done' ? 'conn-cascade-wait' : '', isCascade && cascadeState === 'active' ? 'conn-cascade-active' : '', isHistory ? 'conn-history' : '', - isSelectedLine ? 'conn-selected' : selectedConnIds.size ? 'conn-dim' : '' + isSelectedLine ? 'conn-selected' : '' ].filter(Boolean).join(' '); const color = isCascade ? '#16a34a' : isHistory ? 'rgba(100,116,139,0.46)' : kind === 'input' ? 'rgba(100,116,139,0.62)' : 'rgba(148,163,184,0.62)'; const opacity = isPendingLine ? '.82' : '1'; @@ -6196,6 +6309,16 @@ function imageResolutionBadgeHtml(img){ const label = imageResolutionLabel(img); return label ? `${escapeHtml(label)}` : ''; } +function imageNameLabel(img, fallback='image'){ + const raw = String(img?.name || fileNameFromUrl(img?.url || '') || fallback || 'image').trim(); + return raw || 'image'; +} +function imageNameBadgeHtml(img, options={}){ + if(!img?.url) return ''; + const label = imageNameLabel(img); + const outsideClass = options.outside ? ' image-name-badge-outside' : ''; + return `${escapeHtml(label)}`; +} function thumbDisplaySize(img, maxSize){ const limit = Math.max(28, Math.round(Number(maxSize) || 96)); const size = mediaLayoutSize(img); @@ -6527,6 +6650,7 @@ function addSmartGenerationLog({run, outputs=[], runMs=0, error=''}) { name:item.name || item.filename || '' }); }).filter(item => item?.url); + if(!error && outputItems.length) playGenerationCompleteSound(); const entry = { id:uid('log'), createdAt:Date.now(), @@ -6914,7 +7038,7 @@ function smartGroupBodyHtml(node){ const canDelete = ref.nodeId === node.id; return `
${escapeHtml(summary)}
-
${singleMediaHtml(ref.item, innerW, innerH)}${imageResolutionBadgeHtml(ref.item)}${canDelete ? `` : ''}
+
${singleMediaHtml(ref.item, innerW, innerH)}${imageNameBadgeHtml(ref.item)}${imageResolutionBadgeHtml(ref.item)}${canDelete ? `` : ''}
`; } const groupMaxVisibleRows = (groupThumbLayout.compactMembers || []).length ? Number(groupThumbLayout.rows || 1) : SMART_GROUP_MAX_VISIBLE_ROWS; @@ -6924,7 +7048,7 @@ function smartGroupBodyHtml(node){
${escapeHtml(summary)}
${refThumbs.map(ref => { const canDelete = ref.nodeId === node.id; - return `
${thumbMediaHtml(ref.item)}${imageResolutionBadgeHtml(ref.item)}${canDelete ? `` : ''}
`; + return `
${thumbMediaHtml(ref.item)}${imageNameBadgeHtml(ref.item)}${imageResolutionBadgeHtml(ref.item)}${canDelete ? `` : ''}
`; }).join('')}
`; } @@ -6958,9 +7082,9 @@ function nodeBodyHtml(node, layout){ if(imgs.length > 1){ const visibleRows = Math.max(1, Math.min(MEDIA_GROUP_MAX_VISIBLE_ROWS, Number(layout.visibleRows || layout.rows || 1))); const maxHeight = visibleRows * Number(layout.thumb || 96) + Math.max(0, visibleRows - 1) * 8; - return `
${imgs.map((img, i) => `
${thumbMediaHtml(img)}${imageResolutionBadgeHtml(img)}
`).join('')}
`; + return `
${imgs.map((img, i) => `
${thumbMediaHtml(img)}${imageNameBadgeHtml(img, {outside:true})}${imageResolutionBadgeHtml(img)}
`).join('')}
`; } - if(imgs[0]) return `
${singleMediaHtml(imgs[0], layout.width, layout.height)}${imageResolutionBadgeHtml(imgs[0])}
`; + if(imgs[0]) return `
${singleMediaHtml(imgs[0], layout.width, layout.height)}${imageNameBadgeHtml(imgs[0], {outside:true})}${imageResolutionBadgeHtml(imgs[0])}
`; return `
${escapeHtml(tr('smart.createImportNode'))} @@ -7264,6 +7388,7 @@ function render(){ renderMinimap(); if(window.lucide) lucide.createIcons(); bindSmartPreviewImageFallbacks(world); + syncSmartSelectedImageResolution(world); measureSmartNodeImages(); refreshRunTimerPills(); return; @@ -7923,6 +8048,29 @@ function bindNodeEvents(){ deleteImage(id, Number(btn.dataset.imageIndex)); }); }); + el.querySelectorAll('.image-name-badge').forEach(badge => { + const item = badge.closest('[data-image-index]'); + const targetNodeId = item?.dataset.refNodeId || id; + const imageIndex = Number(item?.dataset.refImageIndex ?? item?.dataset.imageIndex ?? 0); + badge.addEventListener('mousedown', e => { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + }, true); + badge.addEventListener('click', e => { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + }, true); + badge.addEventListener('dblclick', e => { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + clearImageClickTimer(); + suppressImageClickUntil = Date.now() + 260; + renameSmartNodeImage(targetNodeId, imageIndex); + }, true); + }); el.querySelectorAll('.smart-video-play').forEach(btn => { btn.addEventListener('mousedown', e => { e.preventDefault(); @@ -7957,7 +8105,7 @@ function bindNodeEvents(){ }); item.addEventListener('mousedown', e => { if(e.target.closest('video,audio')) return; - if(e.button !== 0 || e.target.closest('.image-delete')) return; + if(e.button !== 0 || e.target.closest('.image-delete,.image-name-badge')) return; if(e.detail < 2) return; e.preventDefault(); e.stopPropagation(); @@ -7976,7 +8124,7 @@ function bindNodeEvents(){ }, true); item.addEventListener('click', e => { if(e.target.closest('video,audio')) return; - if(e.target.closest('.image-delete')) return; + if(e.target.closest('.image-delete,.image-name-badge')) return; e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); @@ -8014,7 +8162,7 @@ function bindNodeEvents(){ }); item.addEventListener('dblclick', e => { if(e.target.closest('video,audio')) return; - if(e.target.closest('.image-delete')) return; + if(e.target.closest('.image-delete,.image-name-badge')) return; e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); @@ -8084,7 +8232,7 @@ function bindNodeEvents(){ if(document.activeElement?.blur) document.activeElement.blur(); let node = nodes.find(n => n.id === id); if(!node) return; - if(e.altKey) node = duplicateForAltDrag(node); + if(e.altKey) node = duplicateForAltDrag(node, e.shiftKey); let dragIds = selectedIds.includes(node.id) ? selectedIds.slice() : [node.id]; if(isSmartGroupNode(node)){ const memberIds = smartGroupMembers(node).map(member => member.id); @@ -8330,6 +8478,24 @@ function deleteImage(id, imageIndex){ render(); scheduleSave(); } +async function renameSmartNodeImage(nodeId, imageIndex){ + const node = nodes.find(n => n.id === nodeId); + const index = Math.max(0, Number(imageIndex) || 0); + const image = node?.images?.[index]; + if(!node || !image) return; + const current = imageNameLabel(image); + const name = await openAssetNameDialog({title:'重命名图片', value:current, placeholder:'图片名称', cancelValue:null}); + if(name === null) return; + const next = String(name || '').trim(); + if(!next || next === current) return; + pushUndo(); + image.name = next; + selectedId = node.id; + selectedIds = []; + selectedImage = {nodeId:node.id, index}; + render(); + scheduleSave(); +} function currentEditImage(){ const node = nodes.find(n => n.id === cropState?.nodeId); const index = Number(cropState?.imageIndex || 0); @@ -8653,7 +8819,7 @@ function setImageEditMode(mode, userTouched=false){ if(userTouched) imageEditModeTouched = true; const prev = imageEditMode; if(mode !== 'brush') removeEditTextInlineEditor(true); - imageEditMode = ['preview','crop','outpaint','mask','brush','grid'].includes(mode) ? mode : 'preview'; + imageEditMode = ['preview','crop','outpaint','mask','brush','resize','grid'].includes(mode) ? mode : 'preview'; const cropCanvasEl = document.getElementById('cropCanvas'); const previewStageEl = document.getElementById('previewStage'); const editStageEl = document.getElementById('imageEditStage'); @@ -8681,6 +8847,7 @@ function setImageEditMode(mode, userTouched=false){ } cropCanvasEl.classList.toggle('mask-mode', imageEditMode === 'mask'); cropCanvasEl.classList.toggle('brush-mode', imageEditMode === 'brush'); + cropCanvasEl.classList.toggle('resize-mode', imageEditMode === 'resize'); cropCanvasEl.classList.toggle('grid-mode', imageEditMode === 'grid'); cropCanvasEl.classList.toggle('outpaint-mode', imageEditMode === 'outpaint'); syncGridCustomCursor(); @@ -8689,10 +8856,12 @@ function setImageEditMode(mode, userTouched=false){ document.getElementById('imageCropTools')?.classList.toggle('active', imageEditMode === 'crop'); document.getElementById('imageMaskTools').classList.toggle('active', imageEditMode === 'mask'); document.getElementById('imageBrushTools').classList.toggle('active', imageEditMode === 'brush'); + document.getElementById('imageResizeTools')?.classList.toggle('active', imageEditMode === 'resize'); document.getElementById('imageGridTools').classList.toggle('active', imageEditMode === 'grid'); if(imageEditMode === 'grid' && gridOperationMode === 'join' && !canGridJoinCurrentNode()) gridOperationMode = 'split'; syncGridOperationControls(); syncGridGapValue(); + syncImageResizeControls(); const applyBtn = document.getElementById('imageEditApplyBtn'); document.getElementById('compareToggleBtn').style.display = isPreview && !isVideoPreview ? 'inline-flex' : 'none'; document.getElementById('panoramaToggleBtn').style.display = isPreview && !isVideoPreview ? 'inline-flex' : 'none'; @@ -8707,14 +8876,20 @@ function setImageEditMode(mode, userTouched=false){ ensureImageEditBaseSize(true); applyImageEditZoom(); applyBtn.style.display = ''; - const icon = imageEditMode === 'crop' ? 'crop' : imageEditMode === 'outpaint' ? 'expand' : imageEditMode === 'mask' ? 'brush' : imageEditMode === 'brush' ? 'paintbrush' : 'grid-3x3'; - const labelKey = imageEditMode === 'crop' ? 'canvas.applyCrop' : imageEditMode === 'outpaint' ? 'canvas.applyOutpaint' : imageEditMode === 'mask' ? 'canvas.applyMask' : imageEditMode === 'brush' ? 'canvas.applyBrush' : 'canvas.applyGrid'; - const titleKey = imageEditMode === 'crop' ? 'canvas.cropImage' : imageEditMode === 'outpaint' ? 'canvas.outpaintImage' : imageEditMode === 'mask' ? 'canvas.maskEdit' : imageEditMode === 'brush' ? 'canvas.brushEdit' : 'canvas.modeGrid'; - const subKey = imageEditMode === 'crop' ? 'canvas.cropHint' : imageEditMode === 'outpaint' ? 'canvas.outpaintHint' : imageEditMode === 'mask' ? 'canvas.maskHint2' : imageEditMode === 'brush' ? 'canvas.brushHint' : 'canvas.gridHint'; - document.getElementById('imageEditTitle').textContent = tr(titleKey); - document.getElementById('imageEditSub').textContent = tr(subKey); - const applyLabel = imageEditMode === 'grid' && gridOperationMode === 'join' ? '输出拼接' : tr(labelKey); - applyBtn.innerHTML = `${applyLabel}`; + if(imageEditMode === 'resize'){ + document.getElementById('imageEditTitle').textContent = '缩放图片'; + document.getElementById('imageEditSub').textContent = '选择缩小倍数,应用会替换当前原图'; + applyBtn.innerHTML = `应用缩放`; + } else { + const icon = imageEditMode === 'crop' ? 'crop' : imageEditMode === 'outpaint' ? 'expand' : imageEditMode === 'mask' ? 'brush' : imageEditMode === 'brush' ? 'paintbrush' : 'grid-3x3'; + const labelKey = imageEditMode === 'crop' ? 'canvas.applyCrop' : imageEditMode === 'outpaint' ? 'canvas.applyOutpaint' : imageEditMode === 'mask' ? 'canvas.applyMask' : imageEditMode === 'brush' ? 'canvas.applyBrush' : 'canvas.applyGrid'; + const titleKey = imageEditMode === 'crop' ? 'canvas.cropImage' : imageEditMode === 'outpaint' ? 'canvas.outpaintImage' : imageEditMode === 'mask' ? 'canvas.maskEdit' : imageEditMode === 'brush' ? 'canvas.brushEdit' : 'canvas.modeGrid'; + const subKey = imageEditMode === 'crop' ? 'canvas.cropHint' : imageEditMode === 'outpaint' ? 'canvas.outpaintHint' : imageEditMode === 'mask' ? 'canvas.maskHint2' : imageEditMode === 'brush' ? 'canvas.brushHint' : 'canvas.gridHint'; + document.getElementById('imageEditTitle').textContent = tr(titleKey); + document.getElementById('imageEditSub').textContent = tr(subKey); + const applyLabel = imageEditMode === 'grid' && gridOperationMode === 'join' ? '输出拼接' : tr(labelKey); + applyBtn.innerHTML = `${applyLabel}`; + } if(imageEditMode === 'crop'){ requestAnimationFrame(() => { resetCropBox(); @@ -8729,7 +8904,7 @@ function setImageEditMode(mode, userTouched=false){ } resizeEditDrawCanvas(); if(imageEditMode === 'grid') refreshGridSplitPreview(); - else if(imageEditMode === 'crop' || imageEditMode === 'outpaint' || prev === 'grid') clearEditDrawing(true); + else if(imageEditMode === 'crop' || imageEditMode === 'resize' || imageEditMode === 'outpaint' || prev === 'grid') clearEditDrawing(true); syncEditDrawingHistoryButtons(); syncBrushToolButtons(); syncTextToolState(true); @@ -9215,7 +9390,7 @@ function refreshComparePanel(){ return; } const onCurrentLoaded = () => { - rememberPreviewImageResolution(); + if(currentImg.dataset.previewQuick !== '1') rememberPreviewImageResolution(); syncPreviewFrameSize(); updatePreviewMetaHint(); }; @@ -9269,9 +9444,26 @@ function refreshComparePanel(){ currentImg.src = fallback; }; const previewSrc = displayMediaUrl(editing.image || curUrl); - if(currentImg.getAttribute('src') !== previewSrc) { + const quickPreviewSrc = smartMediaPreviewUrl(editing.image || curUrl, 1536); + const previewToken = `${editing.node?.id || ''}:${editing.index ?? 0}:${Date.now()}`; + currentImg.dataset.previewSrcToken = previewToken; + const loadFullPreview = () => { + if(imageEditMode !== 'preview' || !imageEditModal.classList.contains('open')) return; + if(currentImg.dataset.previewSrcToken !== previewToken) return; + currentImg.dataset.previewQuick = ''; + if(currentImg.getAttribute('src') !== previewSrc) currentImg.src = previewSrc; + }; + if(quickPreviewSrc && quickPreviewSrc !== previewSrc){ currentImg.dataset.proxyFallbackTried = ''; + currentImg.dataset.previewQuick = '1'; + if(currentImg.getAttribute('src') !== quickPreviewSrc) currentImg.src = quickPreviewSrc; + requestAnimationFrame(() => setTimeout(loadFullPreview, 120)); + } else if(currentImg.getAttribute('src') !== previewSrc) { + currentImg.dataset.proxyFallbackTried = ''; + currentImg.dataset.previewQuick = ''; currentImg.src = previewSrc; + } else { + currentImg.dataset.previewQuick = ''; } if(currentImg.complete && currentImg.naturalWidth) requestAnimationFrame(onCurrentLoaded); const sources = previewCompareSources(); @@ -9990,6 +10182,55 @@ function syncGridCustomUndoBtn(){ btn.disabled = gridCustomHistory.length === 0; btn.style.opacity = gridCustomHistory.length === 0 ? '0.4' : '1'; } +function clampImageResizeScale(value){ + const num = Number(value); + if(!Number.isFinite(num)) return 0.5; + return Math.max(0.05, Math.min(1, Math.round(num * 100) / 100)); +} +function imageResizeDimensions(){ + const img = document.getElementById('cropImage'); + const sourceW = Math.max(1, Math.round(Number(img?.naturalWidth || 0))); + const sourceH = Math.max(1, Math.round(Number(img?.naturalHeight || 0))); + const scale = clampImageResizeScale(imageResizeScale); + return { + sourceW, + sourceH, + scale, + targetW:Math.max(1, Math.round(sourceW * scale)), + targetH:Math.max(1, Math.round(sourceH * scale)) + }; +} +function syncImageResizeControls(){ + imageResizeScale = clampImageResizeScale(imageResizeScale); + const range = document.getElementById('imageResizeScaleRange'); + const input = document.getElementById('imageResizeScaleInput'); + const label = document.getElementById('imageResizeResolution'); + const overlay = document.getElementById('resizeResolutionOverlay'); + const dims = imageResizeDimensions(); + const text = `${dims.targetW}×${dims.targetH}`; + if(range && Number(range.value) !== dims.scale) range.value = String(dims.scale); + if(input && Number(input.value) !== dims.scale) input.value = String(dims.scale); + if(label) label.textContent = text; + if(overlay) overlay.textContent = text; +} +function setImageResizeScale(value){ + imageResizeScale = clampImageResizeScale(value); + syncImageResizeControls(); +} +async function resizedImageBlobFromEditor(){ + const img = document.getElementById('cropImage'); + if(!img?.naturalWidth || !img?.naturalHeight) return null; + const dims = imageResizeDimensions(); + const canvasEl = document.createElement('canvas'); + canvasEl.width = dims.targetW; + canvasEl.height = dims.targetH; + const ctx = canvasEl.getContext('2d'); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, 0, 0, dims.targetW, dims.targetH); + const blob = await new Promise(resolve => canvasEl.toBlob(resolve, 'image/png')); + return blob ? {blob, ...dims} : null; +} function applyImageEditZoom(scaleOverride=null){ ensureImageEditBaseSize(); if(!imageEditBaseW) return; @@ -10006,6 +10247,7 @@ function applyImageEditZoom(scaleOverride=null){ clampCrop(); renderCropBox(); } if(imageEditMode === 'grid') refreshGridSplitPreview(); + syncImageResizeControls(); syncImageEditOverflow(); updateZoomLabel(); } function ensureImageEditBaseSize(force=false){ @@ -10391,7 +10633,7 @@ function openImageEditor(nodeId, imageIndex=0){ cropState = {nodeId, imageIndex, x:0, y:0, w:0, h:0}; gridCustomMode = false; gridCustomLines = []; gridCustomHistory = []; gridCustomDrag = null; gridCustomOrientation = 'h'; gridOperationMode = 'split'; gridJoinLayout = null; gridJoinDrag = null; gridJoinImageCache = new Map(); gridJoinUserMoved = false; gridJoinGroupId = ''; - imageEditZoom = 1.0; imageEditBaseW = 0; imageEditBaseH = 0; imageEditModeTouched = false; + imageEditZoom = 1.0; imageEditBaseW = 0; imageEditBaseH = 0; imageResizeScale = 0.5; imageEditModeTouched = false; cropAspectPreset = 'free'; cropAspectRatio = null; syncCropRatioButtons(); editTextItems = []; editTextSelectedId = ''; editTextDrag = null; editTextDirty = false; const toggle = document.getElementById('gridCustomToggle'); @@ -10428,16 +10670,20 @@ function openImageEditor(nodeId, imageIndex=0){ .filter(Boolean) .filter((u, i, arr) => u !== primaryEditorSrc && arr.indexOf(u) === i); let editorFallbackIndex = 0; + const editorSrcToken = `${nodeId}:${imageIndex}:${Date.now()}`; + img.dataset.editorSrcToken = editorSrcToken; + img.dataset.editorQuick = ''; img.onload = () => { const targetImage = node.images?.[imageIndex]; // 兜底用的是代理/缩放图,naturalWidth 不是原图真实尺寸,别污染节点的 natural_w/h。 - if(editorFallbackIndex === 0 && targetImage && img.naturalWidth && img.naturalHeight && (!targetImage.natural_w || !targetImage.natural_h)){ + const loadedPrimary = img.dataset.editorQuick !== '1' && img.getAttribute('src') === primaryEditorSrc; + if(loadedPrimary && editorFallbackIndex === 0 && targetImage && img.naturalWidth && img.naturalHeight && (!targetImage.natural_w || !targetImage.natural_h)){ targetImage.natural_w = img.naturalWidth; targetImage.natural_h = img.naturalHeight; scheduleSave(); } imageEditBaseW = img.clientWidth; imageEditBaseH = img.clientHeight; - updateZoomLabel(); resizeEditDrawCanvas(); resetEditDrawingHistory(); clearEditDrawing(true); resetCropBox(); + updateZoomLabel(); syncImageResizeControls(); resizeEditDrawCanvas(); resetEditDrawingHistory(); clearEditDrawing(true); resetCropBox(); if(!imageEditModeTouched) setImageEditMode('preview'); else refreshComparePanel(); if(!panoramaState.enabled) updatePreviewMetaHint(); @@ -10451,7 +10697,20 @@ function openImageEditor(nodeId, imageIndex=0){ // 裁剪/涂抹等导出操作照常可用。而带 crossOrigin 会让浏览器对“缩略图已无 CORS 缓存的同源图”重新发起 // CORS 请求并失败——表现就是预览先闪一下(命中缓存)随即变成破损图。 img.removeAttribute('crossorigin'); - img.src = primaryEditorSrc; + const quickEditorSrc = smartMediaPreviewUrl(image, 2048); + const loadFullEditorImage = () => { + if(!cropState || cropState.nodeId !== nodeId || cropState.imageIndex !== imageIndex) return; + if(!imageEditModal.classList.contains('open') || img.dataset.editorSrcToken !== editorSrcToken) return; + img.dataset.editorQuick = ''; + if(img.getAttribute('src') !== primaryEditorSrc) img.src = primaryEditorSrc; + }; + if(quickEditorSrc && quickEditorSrc !== primaryEditorSrc){ + img.dataset.editorQuick = '1'; + img.src = quickEditorSrc; + requestAnimationFrame(() => setTimeout(loadFullEditorImage, 120)); + } else { + img.src = primaryEditorSrc; + } setImageEditMode('preview'); updatePreviewNavButtons(); refreshIcons(); @@ -10462,7 +10721,7 @@ function closeImageEditor(){ document.querySelector('.image-edit-panel')?.classList.remove('video-preview-mode'); const img = document.getElementById('cropImage'); const previewVideo = document.getElementById('previewCurrentVideo'); - img.onload = null; img.onerror = null; img.removeAttribute('src'); delete img.dataset.proxyFallbackTried; img.style.width = ''; img.style.height = ''; img.style.maxWidth = ''; img.style.maxHeight = ''; + img.onload = null; img.onerror = null; img.removeAttribute('src'); delete img.dataset.proxyFallbackTried; delete img.dataset.editorSrcToken; delete img.dataset.editorQuick; img.style.width = ''; img.style.height = ''; img.style.maxWidth = ''; img.style.maxHeight = ''; img.style.position = ''; img.style.left = ''; img.style.top = ''; if(previewVideo){ previewVideo.pause?.(); @@ -10475,13 +10734,13 @@ function closeImageEditor(){ clearEditDrawing(true); cropState = null; cropDrag = null; editDrawState = null; resetEditDrawingHistory(); gridCustomDrag = null; gridJoinDrag = null; gridJoinLayout = null; gridJoinImageCache = new Map(); gridJoinUserMoved = false; gridOperationMode = 'split'; gridJoinGroupId = ''; previewNavState = {nodeId:'', index:0, count:0}; - imageEditZoom = 1.0; imageEditBaseW = 0; imageEditBaseH = 0; imageEditModeTouched = false; + imageEditZoom = 1.0; imageEditBaseW = 0; imageEditBaseH = 0; imageResizeScale = 0.5; imageEditModeTouched = false; cropAspectPreset = 'free'; cropAspectRatio = null; syncCropRatioButtons(); disposePanoramaPreview(); previewPanDrag = null; previewCompareDrag = false; imageEditPanDrag = null; resetPreviewTransform(); document.getElementById('imageEditStage')?.classList.remove('overflow-x', 'overflow-y', 'preview-mode'); const cropCanvasEl = document.getElementById('cropCanvas'); - cropCanvasEl?.classList.remove('grid-custom-h', 'grid-custom-v', 'outpaint-mode', 'outpaint-warning', 'dragging-image', 'text-mode'); + cropCanvasEl?.classList.remove('grid-custom-h', 'grid-custom-v', 'outpaint-mode', 'outpaint-warning', 'dragging-image', 'text-mode', 'resize-mode'); cropCanvasEl?.classList.remove('grid-join-mode'); document.getElementById('cropImage')?.classList.remove('grid-join-hidden'); const joinCanvas = document.getElementById('gridJoinCanvas'); @@ -10612,10 +10871,10 @@ async function uploadImageBlobs(blobs){ const data = await fetch('/api/ai/upload', {method:'POST', body:form}).then(r => r.json()); return data.files || []; } -function replaceEditedImage(file){ +function replaceEditedImage(file, extra={}){ const {node, index} = currentEditImage(); if(!node || !file) return false; - node.images[index] = {...(node.images[index] || {}), url:file.url, name:file.name, kind:file.kind || mediaKindForItem(file), natural_w:0, natural_h:0}; + node.images[index] = {...(node.images[index] || {}), url:file.url, name:file.name, kind:file.kind || mediaKindForItem(file), natural_w:0, natural_h:0, ...extra}; if((node.images || []).length === 1){ delete node.w; delete node.h; } selectedId = node.id; selectedImage = {nodeId:node.id, index}; return true; @@ -10838,11 +11097,35 @@ async function applyImageGridJoin(){ toast('已输出拼接图片'); } } +async function applyImageResize(){ + if(!cropState) return; + const {node, image} = currentEditImage(); + if(!node || !image) return; + let resized = null; + try { + resized = await resizedImageBlobFromEditor(); + } catch(err) { + toast('缩放失败:当前图片无法写入画布,请换成本地图片或重新上传后再试'); + return; + } + if(!resized?.blob) return; + const base = safeExportFileName((downloadNameForMediaItem(image, 'image') || image.name || 'image').replace(/\.[^.]+$/, ''), 'image'); + const suffix = `${Math.round(resized.scale * 100)}pct`; + const file = await uploadCroppedBlob(resized.blob, `${base}_resize_${suffix}.png`); + if(!file) return; + if(!replaceEditedImage(file, {kind:'image', role:image.role || '', natural_w:resized.targetW, natural_h:resized.targetH})){ + return; + } + closeImageEditor(); + render(); + scheduleSave(); +} function applyImageEdit(){ if(imageEditMode === 'preview') return; if(imageEditMode === 'outpaint') return applyImageOutpaint(); if(imageEditMode === 'mask') return applyImageMask(); if(imageEditMode === 'brush') return applyImageBrush(); + if(imageEditMode === 'resize') return applyImageResize(); if(imageEditMode === 'grid') return applyImageGridSplit(); return applyImageCrop(); } @@ -13313,26 +13596,10 @@ function loadNodePromptDraftToInput(node){ } } async function createSmartComfyTask(payload){ - const res = await fetch('/api/canvas-comfy-tasks', { - method:'POST', - headers:{'Content-Type':'application/json'}, - body:JSON.stringify(payload) - }); - if(!res.ok) throw new Error(await smartResponseErrorMessage(res, tr('smart.errRunFailed'))); - return res.json(); + throw new Error('本地生成功能已移除,请改用在线 API 生成。'); } async function waitSmartComfyTaskResult(taskId){ - if(!taskId) throw new Error(tr('smart.errRunFailed')); - while(true){ - const res = await fetch(`/api/canvas-comfy-tasks/${encodeURIComponent(taskId)}`); - if(!res.ok) throw new Error(await smartResponseErrorMessage(res, tr('smart.errRunFailed'))); - const data = await res.json(); - const readyResult = data?.result || data?.outputs || data?.images || data?.videos || data?.audios || data?.texts; - if(readyResult && resultMediaUrls(readyResult).length) return data.result || data; - if(data.status === 'succeeded') return data.result || {}; - if(data.status === 'failed') throw new Error(data.error || tr('smart.errRunFailed')); - await sleep(1600); - } + throw new Error('本地生成功能已移除,请改用在线 API 生成。'); } async function runQueuedSmartComfyGenerate(payload){ const task = await createSmartComfyTask(payload); @@ -13369,7 +13636,7 @@ function buildPromptRequestForNode(node, defaultImages, ctx=smartLoopContext){ } async function generateUrlsForCurrentSettings(node, prompt, refs, runSettings=settings){ const activeSettings = runSettings || settings; - if(activeSettings.engine === 'comfy') return generateComfyUrlsWithSettings(activeSettings, prompt, refs); + if(activeSettings.engine === 'comfy') throw new Error('本地生成功能已移除,请改用在线 API 生成。'); if(isApiLikeEngine(activeSettings.engine) && activeSettings.apiKind === 'video'){ return {urls:await runApiVideoGeneration(prompt, refs, activeSettings), kind:'video'}; } @@ -13392,59 +13659,7 @@ async function generateUrlsForCurrentSettings(node, prompt, refs, runSettings=se return {urls, kind:mediaKindForUrls(urls, 'image')}; } async function generateComfyUrlsWithSettings(runSettings, prompt, refs){ - const allRefs = refs || []; - const imageRefs = imageRefsOnly(allRefs); - const mode = runSettings.comfyMode || 'text'; - if(mode === 'text'){ - const data = await runQueuedSmartComfyGenerate({prompt, width:Number(runSettings.width || 1024), height:Number(runSettings.height || 1024), workflow_json:'Z-Image.json', type:'zimage', client_id:smartClientId}); - const urls = resultMediaUrls(data); - return {urls, kind:mediaKindForUrls(urls, 'image')}; - } - if(mode === 'enhance'){ - if(!imageRefs.length) throw new Error(tr('smart.errEnhanceNeedRefs')); - const inputName = await comfyNameForRef(imageRefs[0]); - const data = await runQueuedSmartComfyGenerate({workflow_json:'Z-Image-Enhance.json', type:'enhance', params:{"15":{image:inputName},"204":{value:Number(runSettings.enhanceStrength ?? 0.5)}}, client_id:smartClientId}); - const urls = resultMediaUrls(data); - return {urls, kind:mediaKindForUrls(urls, 'image')}; - } - if(mode === 'edit'){ - if(!imageRefs.length) throw new Error(tr('smart.errEditNeedRefs')); - const names = []; - for(const ref of imageRefs.slice(0, 3)) names.push(await comfyNameForRef(ref)); - const data = await runQueuedSmartComfyGenerate({prompt, workflow_json:'Flux2-Klein.json', type:'klein', params:{"168":{text:prompt},"158":{noise_seed:Math.floor(Math.random()*1000000)},"278":{image:names[0] || ""},"270":{image:names[1] || ""},"292":{image:names[2] || ""},"313":{value:Boolean(names[1])},"314":{value:Boolean(names[2])}}, client_id:smartClientId}); - const urls = resultMediaUrls(data); - return {urls, kind:mediaKindForUrls(urls, 'image')}; - } - const workflowName = runSettings.comfyWorkflow || comfyWorkflows[0]?.name || ''; - if(!workflowName) throw new Error(tr('smart.errNeedWorkflow')); - const wf = await fetch(`/api/workflows/${encodeURIComponent(workflowName)}`).then(async r => { - if(!r.ok) throw new Error(await r.text()); - return r.json(); - }); - const fields = wf.config?.fields || []; - const values = {}; - fields.filter(f => comfyFieldKind(f) === 'prompt').forEach((field, index) => { - values[field.id] = index === 0 ? prompt : (field.default ?? ''); - }); - const assignMediaFields = async (mediaFields, mediaRefs) => { - for(let i = 0; i < mediaFields.length && i < mediaRefs.length; i++){ - values[mediaFields[i].id] = await comfyNameForRef(mediaRefs[i]); - } - }; - await assignMediaFields(fields.filter(f => comfyFieldKind(f) === 'image'), imageRefs); - await assignMediaFields(fields.filter(f => comfyFieldKind(f) === 'video'), videoRefsOnly(allRefs)); - await assignMediaFields(fields.filter(f => comfyFieldKind(f) === 'audio'), audioRefsOnly(allRefs)); - fields.filter(f => comfyFieldKind(f) === 'setting').forEach(field => { - if(comfyRandomEnabledField(field) && smartComfyRandomActiveFor(runSettings, field.id)){ - values[field.id] = smartComfyRandomValue(field); - } else { - values[field.id] = runSettings.comfyParams?.[field.id] ?? field.default; - } - }); - const result = await runQueuedSmartComfyGenerate({prompt, workflow_json:workflowName, params:comfyParamsFromWorkflowValues(wf.config || {fields:[]}, values), type:'workflow-custom', client_id:smartClientId}); - const urls = resultMediaUrls(result); - const fallbackKind = result.videos?.length ? 'video' : result.audios?.length ? 'audio' : result.texts?.length ? 'text' : 'image'; - return {urls, kind:mediaKindForUrls(urls, fallbackKind)}; + throw new Error('本地生成功能已移除,请改用在线 API 生成。'); } async function runCascadeStepIntoNode(sourceNode, targetNode, inputRefs, ctx=smartLoopContext){ const outputNode = targetNode || sourceNode; @@ -14030,13 +14245,6 @@ async function runGeneration(){ } render(); try { - if(settings.engine === 'comfy'){ - await runComfyGeneration(pendingNode, prompt, refs, pendingNode, pendingMeta); - if(sourceVisualState) restoreSourceVisualState(node, sourceVisualState); - addSmartGenerationLog({run:runLog, outputs:pendingNode.images || [], runMs:nowMs() - runLogStart}); - settings = previousSettings; - return; - } if(isApiLikeEngine(settings.engine) && settings.apiKind === 'video'){ const outVideos = await runApiVideoGeneration(prompt, refs); if(!outVideos.length) throw new Error(tr('smart.errNoOutVideos')); @@ -14325,55 +14533,7 @@ async function urlToBase64(url){ } function sleep(ms){ return new Promise(resolve => setTimeout(resolve, ms)); } async function runComfyGeneration(node, prompt, refs, pendingNode, meta){ - const allRefs = refs || []; - refs = imageRefsOnly(allRefs); - const mode = settings.comfyMode || 'text'; - if(mode === 'text') return runComfyText(node, prompt, pendingNode, meta); - if(mode === 'enhance') return runComfyEnhance(node, refs, pendingNode, meta); - if(mode === 'edit') return runComfyEdit(node, prompt, refs, pendingNode, meta); - const workflowName = settings.comfyWorkflow || comfyWorkflows[0]?.name || ''; - if(!workflowName) throw new Error(tr('smart.errNeedWorkflow')); - const wf = await fetch(`/api/workflows/${encodeURIComponent(workflowName)}`).then(async r => { - if(!r.ok) throw new Error(await r.text()); - return r.json(); - }); - const fields = wf.config?.fields || []; - const values = {}; - fields.filter(f => comfyFieldKind(f) === 'prompt').forEach((field, index) => { - values[field.id] = index === 0 ? prompt : (field.default ?? ''); - }); - const assignMediaFields = async (mediaFields, mediaRefs) => { - for(let i = 0; i < mediaFields.length && i < mediaRefs.length; i++){ - values[mediaFields[i].id] = await comfyNameForRef(mediaRefs[i]); - } - }; - await assignMediaFields(fields.filter(f => comfyFieldKind(f) === 'image'), refs); - await assignMediaFields(fields.filter(f => comfyFieldKind(f) === 'video'), videoRefsOnly(allRefs)); - await assignMediaFields(fields.filter(f => comfyFieldKind(f) === 'audio'), audioRefsOnly(allRefs)); - fields.filter(f => comfyFieldKind(f) === 'setting').forEach(field => { - if(comfyRandomEnabledField(field) && smartComfyRandomActive(field.id)){ - values[field.id] = smartComfyRandomValue(field); - } else { - values[field.id] = settings.comfyParams?.[field.id] ?? field.default; - } - }); - const result = await runQueuedSmartComfyGenerate({prompt, workflow_json:workflowName, params:comfyParamsFromWorkflowValues(wf.config || {fields:[]}, values), type:'workflow-custom', client_id:smartClientId}); - const urls = resultMediaUrls(result); - if(!urls.length) throw new Error(tr('smart.errComfyNoImages')); - const kind = mediaKindForUrls(urls, result.videos?.length ? 'video' : result.audios?.length ? 'audio' : result.texts?.length ? 'text' : 'image'); - const ext = kind === 'video' ? 'mp4' : kind === 'audio' ? 'mp3' : 'png'; - const out = urls.map((url, i) => ({url, name:`comfy-${i + 1}.${ext}`, kind})).filter(x => x.url); - if(!out.length) throw new Error(tr('smart.errComfyEmpty')); - const outputUrls = out.map(o => o.url); - if(pendingNode){ - finalizePendingNode(pendingNode, outputUrls, meta, kind); - } else { - const created = createNode((node.x || 0) + nodeRect(node).width + 40, node.y || 0, out); - attachRunMeta(created, meta); - addConnection(node.id, created.id); - } - clearPromptInput({preserveDraft:true}); - scheduleSave(); + throw new Error('本地生成功能已移除,请改用在线 API 生成。'); } async function runComfyText(node, prompt, pendingNode, meta){ const data = await runQueuedSmartComfyGenerate({prompt, width:Number(settings.width || 1024), height:Number(settings.height || 1024), workflow_json:'Z-Image.json', type:'zimage', client_id:smartClientId}); @@ -14422,22 +14582,7 @@ async function runComfyEdit(node, prompt, refs, pendingNode, meta){ scheduleSave(); } async function comfyNameForRef(ref){ - if(ref.comfy_name) return ref.comfy_name; - const response = await fetch(ref.url); - if(!response.ok) return ref.name || ref.url; - const blob = await response.blob(); - const form = new FormData(); - form.append('files', blob, ref.name || 'smart-ref.png'); - const data = await fetch('/api/upload', {method:'POST', body:form}).then(async r => { - if(!r.ok) throw new Error(await r.text()); - return r.json(); - }); - const name = data.files?.[0]?.comfy_name || ref.name || ref.url; - const node = ref.nodeId ? nodes.find(n => n.id === ref.nodeId) : null; - const image = node?.images?.find(img => img.url === ref.url) || (nodes || []).flatMap(n => n.images || []).find(img => img?.url === ref.url); - if(image) image.comfy_name = name; - ref.comfy_name = name; - return name; + throw new Error('本地生成功能已移除,请改用在线 API 生成。'); } function smartPendingTasks(node){ if(!node || !Array.isArray(node.pendingTasks)) return []; @@ -16372,6 +16517,9 @@ document.getElementById('editTextCanvas')?.addEventListener('dblclick', event => refreshGridSplitPreview(); }); }); +['imageResizeScaleRange','imageResizeScaleInput'].forEach(id => { + document.getElementById(id)?.addEventListener('input', event => setImageResizeScale(event.target.value)); +}); document.querySelectorAll('[data-panorama-ratio]').forEach(btn => { btn.addEventListener('click', event => { event.preventDefault(); @@ -16455,11 +16603,11 @@ window.addEventListener('studio-theme-change', event => applyTheme(event.detail? try { const apiChannel = new BroadcastChannel('studio-api'); apiChannel.onmessage = async event => { - if(event.data?.type === 'providers-changed' || event.data?.type === 'workflows-changed' || event.data?.type === 'comfy-instances-changed'){ + if(event.data?.type === 'providers-changed'){ await refreshSmartConfigFromSettings(); } - if(event.data?.type === 'asset_library_updated') handleAssetLibraryUpdatedMessage(event.data); - if(event.data?.type === 'canvas_updated') handleCanvasUpdatedMessage(event.data); + if(event.data?.type === 'asset_library_updated' || (event.data?.type === 'entity.changed' && event.data.topic === 'asset')) handleAssetLibraryUpdatedMessage(event.data); + if(event.data?.type === 'canvas_updated' || (event.data?.type === 'entity.changed' && event.data.topic === 'canvas')) handleCanvasUpdatedMessage(event.data); }; } catch(e) {} window.addEventListener('focus', () => { @@ -16468,13 +16616,24 @@ window.addEventListener('focus', () => { window.addEventListener('message', event => { if(event.origin && event.origin !== location.origin) return; if(event.data?.type === 'studio-theme') applyTheme(event.data.theme || 'light'); - if(event.data?.type === 'providers-changed' || event.data?.type === 'workflows-changed' || event.data?.type === 'comfy-instances-changed') refreshSmartConfigFromSettings(); - if(event.data?.type === 'asset_library_updated') handleAssetLibraryUpdatedMessage(event.data); - if(event.data?.type === 'canvas_updated') handleCanvasUpdatedMessage(event.data); + if(event.data?.type === 'providers-changed') refreshSmartConfigFromSettings(); + if(event.data?.type === 'asset_library_updated' || (event.data?.type === 'entity.changed' && event.data.topic === 'asset')) handleAssetLibraryUpdatedMessage(event.data); + if(event.data?.type === 'canvas_updated' || (event.data?.type === 'entity.changed' && event.data.topic === 'canvas')) handleCanvasUpdatedMessage(event.data); + if(event.data?.type === 'entity.changed' && event.data.topic === 'platform') refreshSmartConfigFromSettings(); if(event.data?.type === 'studio-lang' && window.StudioI18n) { window.StudioI18n.set(event.data.lang || 'zh'); } }); +window.addEventListener('canvas-realtime-message', event => { + const data = event.detail || {}; + if(data.type === 'entity.changed' && data.topic === 'asset') handleAssetLibraryUpdatedMessage(data); + if(data.type === 'entity.changed' && data.topic === 'canvas') handleCanvasUpdatedMessage(data); + if(data.type === 'entity.changed' && ['platform','workflow'].includes(data.topic)) refreshSmartConfigFromSettings(); + if(data.type === 'sync.reconnected') { + refreshAssetLibrarySoon(0); + if(canvasId) scheduleCanvasMergeReload(0); + } +}); window.addEventListener('studio-lang-change', () => { renderDynamicParams(); renderInputThumbsRow(selectedNode()); diff --git a/static/js/theme.js b/static/js/theme.js index 1aa81ae70..4138865de 100644 --- a/static/js/theme.js +++ b/static/js/theme.js @@ -4,12 +4,25 @@ const SCALE_KEY = 'studio_ui_scale_mode'; const SCALE_OPTIONS = ['auto', '100', '115', '125', '140']; + const systemPreference = window.matchMedia?.('(prefers-color-scheme: dark)'); + + function currentThemeMode(){ + const saved = localStorage.getItem(KEY) || localStorage.getItem(LEGACY_KEY) || 'system'; + return ['light','dark','system'].includes(saved) ? saved : 'system'; + } + + function resolvedTheme(theme=currentThemeMode()){ + if(theme === 'system') return systemPreference?.matches ? 'dark' : 'light'; + return theme === 'dark' ? 'dark' : 'light'; + } + function currentTheme(){ - return localStorage.getItem(KEY) || localStorage.getItem(LEGACY_KEY) || 'light'; + return resolvedTheme(); } function applyTheme(theme){ - const next = theme === 'dark' ? 'dark' : 'light'; + const mode = ['light','dark','system'].includes(theme) ? theme : currentThemeMode(); + const next = resolvedTheme(mode); const dark = next === 'dark'; document.documentElement.classList.toggle('studio-theme-dark', dark); document.documentElement.classList.toggle('theme-dark', dark); @@ -17,7 +30,7 @@ document.body.classList.toggle('studio-theme-dark', dark); document.body.classList.toggle('theme-dark', dark); } - window.dispatchEvent(new CustomEvent('studio-theme-change', { detail: { theme: next } })); + window.dispatchEvent(new CustomEvent('studio-theme-change', { detail: { theme: next, mode } })); } function ensureScaleStyle(){ @@ -138,6 +151,7 @@ } catch(e) {} applyScale(next); if(shouldBroadcast) broadcastScale(next); + if(shouldBroadcast) (window.RuntimeSync || window.top?.RuntimeSync)?.setPreference?.('ui_scale', next); } let resizeTimer = null; @@ -154,12 +168,14 @@ window.StudioTheme = { key: KEY, get: currentTheme, + getMode: currentThemeMode, apply: applyTheme, set(theme){ - const next = theme === 'dark' ? 'dark' : 'light'; - localStorage.setItem(KEY, next); - localStorage.setItem(LEGACY_KEY, next); - applyTheme(next); + const mode = ['light','dark','system'].includes(theme) ? theme : 'system'; + localStorage.setItem(KEY, mode); + localStorage.setItem(LEGACY_KEY, mode); + applyTheme(mode); + (window.RuntimeSync || window.top?.RuntimeSync)?.setPreference?.('theme', mode); } }; @@ -172,11 +188,11 @@ set: setScaleMode }; - applyTheme(currentTheme()); + applyTheme(currentThemeMode()); applyScale(currentScaleMode()); document.addEventListener('DOMContentLoaded', () => { - applyTheme(currentTheme()); + applyTheme(currentThemeMode()); applyScale(currentScaleMode()); }); window.addEventListener('message', event => { @@ -184,8 +200,17 @@ if(event.data?.type === 'studio-ui-scale') setScaleMode(event.data.mode, false); }); window.addEventListener('storage', event => { - if(event.key === KEY || event.key === LEGACY_KEY) applyTheme(currentTheme()); + if(event.key === KEY || event.key === LEGACY_KEY) applyTheme(currentThemeMode()); if(event.key === SCALE_KEY) applyScale(currentScaleMode()); }); window.addEventListener('resize', scheduleAutoScaleRefresh); + systemPreference?.addEventListener?.('change', () => { + if(currentThemeMode() === 'system') applyTheme('system'); + }); + + if(!window.RuntimeSync){ + const script = document.createElement('script'); + script.src = '/static/js/runtime-sync.js'; + document.head.appendChild(script); + } })(); diff --git a/static/js/works.js b/static/js/works.js new file mode 100644 index 000000000..d821b2780 --- /dev/null +++ b/static/js/works.js @@ -0,0 +1,245 @@ +(function(){ + 'use strict'; + const state = {works:[],tab:'all',search:'',kind:'',compareWork:null,compareViewer:null,localBaseUrl:'',localTargetUrl:'',renameWork:null}; + const el = {}; + const byId = id => document.getElementById(id); + const t = key => window.StudioI18n?.t?.(key) || key; + const escapeHtml = value => String(value ?? '').replace(/[&<>'"]/g,ch => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[ch])); + + function cache(){ + ['worksCount','worksTabs','worksSearch','worksKind','worksRefresh','worksQuickCompare','worksGrid','worksEmpty','worksCompareDialog','compareWorkName','compareFavorite','closeWorksCompare','compareTargetSelect','compareTargetFileButton','compareTargetFile','compareBaseSelect','compareBaseFileButton','compareBaseFile','compareHint','worksCompareStage','worksBeforeImage','worksAfterImage','worksAfterClip','worksCompareHandle','worksZoomOut','worksZoomReset','worksZoomIn','worksFullscreen','compareMeta','compareDownload','worksRenameDialog','worksRenameForm','worksRenameInput','closeWorksRename','cancelWorksRename','worksToast'].forEach(id => el[id]=byId(id)); + } + async function fetchJson(url,options={}){ + const response = await fetch(url,options); + const data = await response.json().catch(() => ({})); + if(!response.ok) throw new Error(data.detail || `HTTP ${response.status}`); + return data; + } + function toast(message){ + el.worksToast.textContent = message; + el.worksToast.classList.add('show'); + clearTimeout(toast.timer); + toast.timer=setTimeout(()=>el.worksToast.classList.remove('show'),2200); + } + function visibleWorks(){ + const search=state.search.trim().toLowerCase(); + return state.works.filter(item => { + if(state.tab==='trash') return item.trashed && (!search || `${item.name} ${item.prompt} ${item.model} ${item.operation}`.toLowerCase().includes(search)) && (!state.kind || item.kind===state.kind); + if(item.trashed) return false; + if(state.tab==='favorite' && !item.favorite) return false; + if(state.kind && item.kind!==state.kind) return false; + if(search && !`${item.name} ${item.prompt} ${item.model} ${item.operation}`.toLowerCase().includes(search)) return false; + return true; + }); + } + function kindLabel(item){ + const operations={try_on:'works.tryOn',pose_transfer:'works.poseTransfer',prop_replace:'works.propReplace',angle_change:'works.angleChange',background_change:'works.backgroundChange',universal:'works.universal'}; + if(item.kind==='ecommerce') return operations[item.operation] ? t(operations[item.operation]) : t('works.ecommerce'); + if(item.kind==='online') return t('works.online'); + return item.kind || t('works.image'); + } + function dateText(timestamp){ + const value=Number(timestamp || 0); + return value ? new Date(value*1000).toLocaleString() : '—'; + } + function renderKinds(){ + const current=state.kind; + const kinds=[...new Set(state.works.filter(item=>state.tab==='trash'?item.trashed:!item.trashed).map(item=>item.kind).filter(Boolean))].sort(); + el.worksKind.innerHTML=``+kinds.map(kind=>``).join(''); + state.kind=kinds.includes(current)?current:''; + el.worksKind.value=state.kind; + } + function render(){ + const works=visibleWorks(); + el.worksCount.textContent=String(works.length); + el.worksGrid.classList.toggle('hidden',works.length===0); + el.worksEmpty.classList.toggle('hidden',works.length!==0); + el.worksTabs.querySelectorAll('[data-tab]').forEach(button=>button.classList.toggle('active',button.dataset.tab===state.tab)); + el.worksGrid.innerHTML=works.map(item=>`
+ + ${item.trashed?'':``} +

${escapeHtml(item.name)}

${escapeHtml(item.prompt || t('works.noPrompt'))}

+
${escapeHtml(item.model || '—')}${escapeHtml(dateText(item.created_at))}
+
${escapeHtml(t('works.download'))}
+
`).join(''); + el.worksGrid.querySelectorAll('[data-compare-work]').forEach(button=>button.addEventListener('click',()=>openCompare(button.dataset.compareWork))); + el.worksGrid.querySelectorAll('[data-favorite-work]').forEach(button=>button.addEventListener('click',()=>toggleFavorite(button.dataset.favoriteWork))); + el.worksGrid.querySelectorAll('[data-rename-work]').forEach(button=>button.addEventListener('click',()=>openRename(button.dataset.renameWork))); + el.worksGrid.querySelectorAll('[data-trash-work]').forEach(button=>button.addEventListener('click',()=>setTrashed(button.dataset.trashWork,button.dataset.trashValue==='true'))); + } + async function loadWorks(){ + el.worksRefresh.disabled=true; + try { + const data=await fetchJson('/api/works?limit=1000&include_trashed=true',{cache:'no-store'}); + state.works=data.works || []; + renderKinds(); + render(); + } catch(error){ toast(error.message); } + finally { el.worksRefresh.disabled=false; } + } + async function toggleFavorite(workId){ + const work=state.works.find(item=>item.id===workId); + if(!work) return; + try { + const data=await fetchJson(`/api/works/${encodeURIComponent(work.id)}/favorite`,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({favorite:!work.favorite})}); + Object.assign(work,data.work || {favorite:!work.favorite}); + if(state.compareWork?.id===work.id) state.compareWork=work; + render(); + syncCompareFavorite(); + } catch(error){ toast(error.message); } + } + + async function updateMetadata(workId,changes){ + const data=await fetchJson(`/api/works/${encodeURIComponent(workId)}/metadata`,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(changes)}); + const index=state.works.findIndex(item=>item.id===workId); + if(index>=0) state.works[index]=data.work; + if(state.compareWork?.id===workId) state.compareWork=data.work; + renderKinds();render();syncCompareFavorite(); + return data.work; + } + + async function setTrashed(workId,trashed){ + if(trashed && !window.confirm(t('works.trashConfirm'))) return; + try { + await updateMetadata(workId,{trashed}); + if(state.compareWork?.id===workId && trashed) closeCompare(); + toast(t(trashed?'works.trashedDone':'works.restoredDone')); + } catch(error){ toast(error.message); } + } + + function openRename(workId){ + const work=state.works.find(item=>item.id===workId);if(!work)return; + state.renameWork=work;el.worksRenameInput.value=work.name || '';el.worksRenameDialog.showModal(); + requestAnimationFrame(()=>{el.worksRenameInput.focus();el.worksRenameInput.select();}); + } + + function closeRename(){state.renameWork=null;el.worksRenameDialog.close();} + + async function saveRename(event){ + event.preventDefault(); + const work=state.renameWork;if(!work)return; + const name=el.worksRenameInput.value.trim(); + if(!name){toast(t('works.nameRequired'));return;} + try{await updateMetadata(work.id,{name});closeRename();toast(t('works.renamedDone'));}catch(error){toast(error.message);} + } + function availableWorks(){return state.works.filter(item=>!item.trashed);} + function comparisonOptions(work){ + const options=[]; + if(work?.source_url) options.push({value:'source',label:t('works.originalReference'),url:work.source_url}); + availableWorks().filter(item=>item.id!==work?.id).slice(0,200).forEach(item=>options.push({value:item.id,label:item.name,url:item.url})); + return options; + } + function renderCompareMeta(work){ + const values=work?[work.model,work.width&&work.height?`${work.width}×${work.height}`:'',dateText(work.created_at)].filter(Boolean):[]; + el.compareMeta.innerHTML=values.map(value=>`${escapeHtml(value)}`).join(''); + } + function syncCompareFavorite(){ + el.compareFavorite.disabled=!state.compareWork; + el.compareFavorite.textContent=state.compareWork?.favorite?'★':'☆'; + el.compareFavorite.title=state.compareWork?t(state.compareWork.favorite?'works.unfavorite':'works.favorite'):t('works.localWork'); + } + + function renderTargetOptions(preferred=''){ + const targets=availableWorks().slice(0,500); + const selectedTrash=state.works.find(item=>item.id===preferred && item.trashed); + if(selectedTrash) targets.unshift(selectedTrash); + const options=targets.map(item=>``); + if(state.localTargetUrl) options.unshift(``); + if(!options.length) options.push(``); + el.compareTargetSelect.innerHTML=options.join(''); + if(preferred && [...el.compareTargetSelect.options].some(item=>item.value===preferred)) el.compareTargetSelect.value=preferred; + } + + function selectedTarget(){ + if(el.compareTargetSelect.value==='local' && state.localTargetUrl) return {id:'',name:t('works.localWork'),url:state.localTargetUrl,local:true}; + return state.works.find(item=>item.id===el.compareTargetSelect.value) || null; + } + + function renderBaseOptions(work){ + const previous=el.compareBaseSelect.value; + const options=comparisonOptions(work); + if(state.localBaseUrl) options.unshift({value:'local',label:t('works.localBase'),url:state.localBaseUrl}); + el.compareBaseSelect.innerHTML=options.length?options.map(item=>``).join(''):``; + if(previous && [...el.compareBaseSelect.options].some(item=>item.value===previous)) el.compareBaseSelect.value=previous; + } + + function applyComparison(reset=true){ + const target=selectedTarget(); + state.compareWork=target && !target.local ? target : null; + const baseUrl=el.compareBaseSelect.selectedOptions[0]?.dataset?.url || ''; + const targetUrl=target?.url || ''; + state.compareViewer.setImages(baseUrl,targetUrl); + if(reset) state.compareViewer.reset(); + el.compareWorkName.textContent=target?.name || t('works.freeCompare'); + el.compareHint.textContent=baseUrl&&targetUrl?t('works.compareHint'):t('works.chooseTwoImages'); + renderCompareMeta(state.compareWork); + syncCompareFavorite(); + el.compareDownload.disabled=!targetUrl; + el.compareDownload.onclick=()=>target&&downloadWork(target); + } + + function syncCompareTarget(){ + const target=selectedTarget(); + renderBaseOptions(target); + applyComparison(); + } + + function openCompare(workId=''){ + const preferred=state.works.some(item=>item.id===workId)?workId:(availableWorks()[0]?.id || (state.localTargetUrl?'local':'')); + renderTargetOptions(preferred); + if(preferred) el.compareTargetSelect.value=preferred; + syncCompareTarget(); + el.worksCompareDialog.showModal(); + requestAnimationFrame(()=>state.compareViewer.refresh()); + } + function downloadWork(work){ + const link=document.createElement('a');link.href=work.url;link.download=work.name || 'work';document.body.appendChild(link);link.click();link.remove(); + } + function closeCompare(){ + if(state.localBaseUrl){ URL.revokeObjectURL(state.localBaseUrl); state.localBaseUrl=''; } + if(state.localTargetUrl){ URL.revokeObjectURL(state.localTargetUrl); state.localTargetUrl=''; } + state.compareWork=null; + state.compareViewer.exitFullscreen(); + el.worksCompareDialog.close(); + } + function validImageFile(file){return !!file && (String(file.type||'').startsWith('image/') || /\.(png|jpe?g|webp)$/i.test(file.name||''));} + function bind(){ + el.worksTabs.addEventListener('click',event=>{const button=event.target.closest('[data-tab]');if(button){state.tab=button.dataset.tab;renderKinds();render();}}); + el.worksSearch.addEventListener('input',()=>{state.search=el.worksSearch.value;render();}); + el.worksKind.addEventListener('change',()=>{state.kind=el.worksKind.value;render();}); + el.worksRefresh.addEventListener('click',loadWorks); + el.worksQuickCompare.addEventListener('click',()=>openCompare()); + el.closeWorksCompare.addEventListener('click',closeCompare); + el.worksCompareDialog.addEventListener('cancel',event=>{event.preventDefault();closeCompare();}); + el.compareTargetSelect.addEventListener('change',syncCompareTarget); + el.compareBaseSelect.addEventListener('change',()=>applyComparison()); + el.compareFavorite.addEventListener('click',()=>state.compareWork&&toggleFavorite(state.compareWork.id)); + el.compareTargetFileButton.addEventListener('click',()=>el.compareTargetFile.click()); + el.compareTargetFile.addEventListener('change',event=>{ + const file=event.target.files?.[0];if(!validImageFile(file))return; + if(state.localTargetUrl)URL.revokeObjectURL(state.localTargetUrl); + state.localTargetUrl=URL.createObjectURL(file);renderTargetOptions('local');el.compareTargetSelect.value='local';syncCompareTarget();event.target.value=''; + }); + el.compareBaseFileButton.addEventListener('click',()=>el.compareBaseFile.click()); + el.compareBaseFile.addEventListener('change',event=>{ + const file=event.target.files?.[0]; if(!validImageFile(file)) return; + if(state.localBaseUrl) URL.revokeObjectURL(state.localBaseUrl); + state.localBaseUrl=URL.createObjectURL(file); + renderBaseOptions(selectedTarget());el.compareBaseSelect.value='local';applyComparison();event.target.value=''; + }); + el.worksRenameForm.addEventListener('submit',saveRename); + el.closeWorksRename.addEventListener('click',closeRename); + el.cancelWorksRename.addEventListener('click',closeRename); + el.worksRenameDialog.addEventListener('cancel',event=>{event.preventDefault();closeRename();}); + window.addEventListener('message',event=>{if(event.data?.type==='entity.changed'&&event.data.topic==='history')loadWorks();}); + window.addEventListener('studio-lang-change',()=>{renderKinds();render();if(el.worksCompareDialog.open){renderTargetOptions(state.compareWork?.id || (state.localTargetUrl?'local':''));syncCompareTarget();}}); + } + async function init(){ + cache(); + state.compareViewer=new window.CompareViewer({root:el.worksCompareStage,before:el.worksBeforeImage,after:el.worksAfterImage,afterClip:el.worksAfterClip,handle:el.worksCompareHandle,zoomLabel:el.worksZoomReset,zoomInButton:el.worksZoomIn,zoomOutButton:el.worksZoomOut,fullscreenButton:el.worksFullscreen}); + bind(); + await loadWorks(); + } + window.WorksManager={state,loadWorks,openCompare}; + document.addEventListener('DOMContentLoaded',init,{once:true}); +})(); diff --git a/static/klein.html b/static/klein.html deleted file mode 100644 index 2854a29d1..000000000 --- a/static/klein.html +++ /dev/null @@ -1,866 +0,0 @@ - - - - - - - - Flux Klein | 极简一体化终端 - - - - - - - - - - - - -
-
-
-

FLUX KLEIN

-

Next-Gen Generative Interface

-
-
- -
-
-
-
- - Input Prompt -
- -
- -
-
- - Reference Layers - -
-
-
- - - Main - - -
-
- - - Aux A - - -
-
- - - Aux B - - -
-
-
- -
-
- - Engine -
-
- - -
-
-
- Connecting... -
- -
- - -
- -
-
-
- -

Canvas Ready

-
- - - -
-
-
- -
-
-

Archives

-
-
-
-
- Load More Archive -
-
-
- - - - - - - diff --git a/static/online.html b/static/online.html index eae6f0d91..3977eaeac 100644 --- a/static/online.html +++ b/static/online.html @@ -16,25 +16,69 @@ } catch(e) {} })(); - - - - - - + + + + + + - + @@ -98,24 +396,20 @@

Reference Images -

-
-
- - Main - -
-
- - Aux A - -
-
- - Aux B - + +
+ + + + + +
+
+ +

Drop / Paste / Upload multiple images

+
@@ -194,12 +488,29 @@

+

Canvas Ready

- + + +
@@ -211,9 +522,27 @@

+
+
+
+ + 生成任务栏 +
+ 0 active +
+
+
+ +

+