diff --git a/README.md b/README.md index 1b4bd5af..1fdec7d4 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,13 @@ token. Step 3 is not optional: Flow's project-creation endpoint went with the migration, so without a pinned project every request fails `NO_FLOW_PROJECT`. You can also pass `flow_project_id` per project on `POST /api/projects`. +**Using the Muse provider (`muse2api`)?** It needs a second service: the +[muse2api](https://github.com/crisng95/muse2api) gateway, cloned from its own +repo and started separately (default `http://127.0.0.1:18610`), with +`MUSE2API_URL` / `MUSE2API_KEY` exported before step 4. The Flow tab and +extension are not needed for muse2api requests. Full setup: +[Muse Media Provider](#muse-media-provider-provider-muse2api). + ### Configuration | Env var | Default | What it does | @@ -270,6 +277,14 @@ You can also pass `flow_project_id` per project on `POST /api/projects`. | `MEDIA_PROVIDER` | `flow` | `flow` (Google Flow via extension) or `assistant` (route all generation to the AI assistant). | | `ASSISTANT_PROVIDER_TIMEOUT_S` | `1800` | How long a request waits for a worker to complete its provider job before failing. | | `ASSISTANT_PROVIDER_POLL_S` | `15` | How often to poll the provider-job row while waiting for completion. | +| `MUSE2API_URL` | — | Base URL of a running [muse2api](https://github.com/crisng95/muse2api) gateway — a separate service you start from its own repo (see [Muse Media Provider](#muse-media-provider-provider-muse2api)). Setting it makes the `muse2api` provider available. | +| `MUSE2API_KEY` | — | The gateway's `MUSE2API_API_KEY` (sent as `Authorization: Bearer`). | +| `MUSE2API_IMAGE_MODEL` / `MUSE2API_VIDEO_MODEL` | `muse-image` / `muse-video` | Model ids sent to the gateway. | +| `MUSE2API_VIDEO_SECONDS` | `8` | Clip length requested for i2v. | +| `MUSE2API_TIMEOUT_S` | `1800` | Max wait for one image call or video task (covers the gateway's own account failover). | +| `MUSE2API_POLL_S` | `5` | Video task poll interval. | +| `MUSE2API_MAX_CONCURRENT` / `MUSE2API_COOLDOWN_S` | `2` / `0` | Worker throttling for this provider. | +| `MUSE2API_ALLOW_DEGRADED` | `0` | `1` renders chained scenes and r2v as plain i2v (muse.ai takes a first frame only). | ### Assistant Media Provider (`MEDIA_PROVIDER=assistant`) @@ -299,6 +314,101 @@ See `agent/worker/assistant_worker.py` for a reference worker implementing the worker side of the protocol. Video upscale is not supported on this provider and fails loudly. +### Muse Media Provider (`provider: muse2api`) + +Renders through a [muse2api](https://github.com/crisng95/muse2api) gateway, which +exposes the muse.ai web app as an OpenAI-compatible API: + +``` +Flow Kit worker ──> Muse2APIProvider ──HTTP──> muse2api service ──> muse.ai + (this repo, :8100) (separate repo, :18610) +``` + +> **muse2api is a separate service — Flow Kit does not ship or start it.** +> Flow Kit only contains the client (`agent/services/muse2api_client.py`) and +> the provider adapter. Before using `provider: muse2api` you must clone the +> [muse2api repo](https://github.com/crisng95/muse2api), start its server, and +> keep it running alongside the Flow Kit server. With no gateway running, every +> muse2api request fails with `muse2api connection_error`. + +#### 1. Start the muse2api service (from its own repo) + +```bash +git clone https://github.com/crisng95/muse2api.git +cd muse2api +python3 -m venv .venv && source .venv/bin/activate # Python 3.10+ +pip install -e . +cp .env.example .env +``` + +Edit `.env`: + +| Variable | Set to | Why | +|----------|--------|-----| +| `MUSE2API_DRIVER` | `browser` | The default `mock` returns fake media — fine for a dry run, useless for real renders. | +| `MUSE2API_CHROMIUM_PATH` | path to Chromium/Chrome (or leave empty to auto-detect) | The browser driver drives muse.ai through Chromium. | +| `MUSE2API_API_KEY` | any secret | Flow Kit sends it as `MUSE2API_KEY`. Left empty, one is generated into `data/api_key`. | +| `MUSE2API_HOST` | `0.0.0.0` only if Flow Kit runs on another machine | Default `127.0.0.1` accepts local connections only. | + +Start it and leave it running (its own terminal, tmux, or a service manager): + +```bash +python -m muse2api # → http://127.0.0.1:18610 +# or, from the same folder: docker compose up -d --build (binds 0.0.0.0:18610) +``` + +Import at least one logged-in muse.ai account — the gateway renders nothing +without one. Export the cookies `hatch_sess`, `hatch_gw`, `hatch_vml` and +`hatch_native_auth_device` from a browser signed in to muse.ai: + +```bash +KEY=$(cat data/api_key) # or your MUSE2API_API_KEY +curl http://127.0.0.1:18610/admin/accounts \ + -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ + -d '{"label":"acc1","cookies":{"hatch_sess":"...","hatch_gw":"...","hatch_vml":"...","hatch_native_auth_device":"..."}}' + +curl -s http://127.0.0.1:18610/readyz +# {"ready": true, "driver": {...}, "accounts": {"available": 1, ...}} +``` + +`ready: false` or `accounts.available: 0` means Flow Kit requests will fail with +`no_account_available` — fix the gateway first. Account management, renewal and +the rest of the gateway's options are documented in the muse2api README. + +#### 2. Point Flow Kit at it + +Set these before starting the Flow Kit server (they are read once at startup — +restart after changing them): + +```bash +export MUSE2API_URL=http://127.0.0.1:18610 # where the gateway listens +export MUSE2API_KEY= +export DEFAULT_PROVIDER=muse2api # optional: make it the default +``` + +Check that Flow Kit sees it: + +```bash +curl -s http://127.0.0.1:8100/api/providers/status # muse2api → "available": true +``` + +`available: true` only means `MUSE2API_URL` is set — Flow Kit does not probe the +gateway. Use `/readyz` above to confirm the service itself is up. + +#### 3. Use it + +Pass `"provider": "muse2api"` on any request (or rely on `DEFAULT_PROVIDER`). +Flow Kit calls `POST /v1/images/generations` for images and `POST /v1/videos` + +`GET /v1/videos/{id}` for i2v, sends input frames inline as data URLs (so the +gateway may run on another host), and downloads every result into +`output/_shared/muse2api/` as a `file://` URL with a minted UUID `media_id` — +so `/fk-refresh-urls` is never needed for it. + +Supports image and i2v; no edit, upscale or TTS, and no end frame or r2v unless +`MUSE2API_ALLOW_DEGRADED=1`. Entity reference images are not sent (muse.ai takes +none), so character consistency across scenes is weaker than on Flow. +Troubleshooting lives in `/fk-provider` and `/fk-doctor`. + ### Post-production (provider-neutral) Concat, finalize, and review work on clips from any provider — each clip is diff --git a/agent/config.py b/agent/config.py index 9c72cfc2..5ec2ac3f 100644 --- a/agent/config.py +++ b/agent/config.py @@ -60,7 +60,7 @@ # /api/provider-jobs. See agent/sdk/services/assistant_provider.py and # agent/worker/assistant_worker.py for the full protocol. # Default media provider for requests that don't specify one ("flow" | -# "assistant" | any registered provider name). MEDIA_PROVIDER is kept as a +# "assistant" | "muse2api" | any registered provider name). MEDIA_PROVIDER is kept as a # legacy alias — DEFAULT_PROVIDER wins if both are set. DEFAULT_PROVIDER = os.environ.get( "DEFAULT_PROVIDER", os.environ.get("MEDIA_PROVIDER", "flow") @@ -71,6 +71,24 @@ ASSISTANT_MAX_CONCURRENT = int(os.environ.get("ASSISTANT_MAX_CONCURRENT", "2")) ASSISTANT_COOLDOWN_S = float(os.environ.get("ASSISTANT_COOLDOWN_S", "0")) +# "muse2api": render through a muse2api gateway (https://github.com/crisng95/muse2api), +# which fronts the muse.ai web app with an OpenAI-compatible API. The provider +# is registered always and becomes available once MUSE2API_URL is set; the key +# is the gateway's own MUSE2API_API_KEY. See agent/sdk/services/muse2api_provider.py. +MUSE2API_URL = os.environ.get("MUSE2API_URL", "").strip().rstrip("/") +MUSE2API_KEY = os.environ.get("MUSE2API_KEY", "") +MUSE2API_IMAGE_MODEL = os.environ.get("MUSE2API_IMAGE_MODEL", "muse-image") +MUSE2API_VIDEO_MODEL = os.environ.get("MUSE2API_VIDEO_MODEL", "muse-video") +MUSE2API_VIDEO_SECONDS = int(os.environ.get("MUSE2API_VIDEO_SECONDS", "8")) +# Covers the gateway's own failover (up to 3 accounts x its 240s/600s timeouts). +MUSE2API_TIMEOUT_S = float(os.environ.get("MUSE2API_TIMEOUT_S", "1800")) +MUSE2API_POLL_S = float(os.environ.get("MUSE2API_POLL_S", "5")) +MUSE2API_MAX_CONCURRENT = int(os.environ.get("MUSE2API_MAX_CONCURRENT", "2")) +MUSE2API_COOLDOWN_S = float(os.environ.get("MUSE2API_COOLDOWN_S", "0")) +# muse.ai takes a first frame only. With this on, chained scenes drop the end +# frame and r2v renders as i2v; off, both fail loudly (same rule as Flow). +MUSE2API_ALLOW_DEGRADED = os.environ.get("MUSE2API_ALLOW_DEGRADED", "0") == "1" + # ─── Worker ────────────────────────────────────────────────── POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "5")) VIDEO_POLL_INTERVAL = int(os.environ.get("VIDEO_POLL_INTERVAL", "10")) # polling interval for video/upscale status diff --git a/agent/db/schema.py b/agent/db/schema.py index 3f696daa..68e3981b 100644 --- a/agent/db/schema.py +++ b/agent/db/schema.py @@ -8,6 +8,11 @@ _db_connection: aiosqlite.Connection | None = None _db_lock = asyncio.Lock() +# Guards get_db()'s lazy connect. Separate from _db_lock because crud calls +# get_db() while holding that one. Rebuilt per event loop: a lock that has +# been waited on is bound to its loop, and pytest runs each test in a new one. +_db_init_lock: asyncio.Lock | None = None +_db_init_loop: asyncio.AbstractEventLoop | None = None SCHEMA = """ CREATE TABLE IF NOT EXISTS character ( @@ -356,17 +361,38 @@ async def init_db(): logger.info("Database initialized at %s", DB_PATH) +def _get_db_init_lock() -> asyncio.Lock: + global _db_init_lock, _db_init_loop + loop = asyncio.get_running_loop() + if _db_init_lock is None or _db_init_loop is not loop: + _db_init_lock = asyncio.Lock() + _db_init_loop = loop + return _db_init_lock + + async def get_db() -> aiosqlite.Connection: - """Return the shared database connection, creating it if needed.""" + """Return the shared database connection, creating it if needed. + + The first two callers used to race here: both saw None, both connected, + the second overwrote the global, and the first then ran its PRAGMAs on + the second's connection while a statement was open on it — failing with + "database table is locked" and leaking a connection (whose worker thread + keeps the interpreter alive). Connect under a lock, set up a local, and + publish it only once it is ready. + """ global _db_connection - if _db_connection is None: - _db_connection = await aiosqlite.connect(str(DB_PATH)) - _db_connection.row_factory = aiosqlite.Row - await _db_connection.execute("PRAGMA journal_mode=WAL") - await _db_connection.execute("PRAGMA foreign_keys=ON") - # Force WAL checkpoint so this connection sees all committed writes - # from previous processes (e.g. after hot-reload) - await _db_connection.execute("PRAGMA wal_checkpoint(PASSIVE)") + if _db_connection is not None: + return _db_connection + async with _get_db_init_lock(): + if _db_connection is None: + conn = await aiosqlite.connect(str(DB_PATH)) + conn.row_factory = aiosqlite.Row + await conn.execute("PRAGMA journal_mode=WAL") + await conn.execute("PRAGMA foreign_keys=ON") + # Force WAL checkpoint so this connection sees all committed writes + # from previous processes (e.g. after hot-reload) + await conn.execute("PRAGMA wal_checkpoint(PASSIVE)") + _db_connection = conn return _db_connection diff --git a/agent/sdk/services/muse2api_provider.py b/agent/sdk/services/muse2api_provider.py new file mode 100644 index 00000000..28af6c53 --- /dev/null +++ b/agent/sdk/services/muse2api_provider.py @@ -0,0 +1,195 @@ +"""Muse2APIProvider — MediaProvider adapter for a muse2api gateway. + + Flow Kit worker ──> Muse2APIProvider ──HTTP──> muse2api ──> muse.ai + +muse2api (https://github.com/crisng95/muse2api) wraps the muse.ai web app in +an OpenAI-compatible API and owns everything upstream: the account pool, +failover, cooldowns and the browser driver. This provider only translates a +:class:`ProviderJob` into gateway calls and the answer back into the +Flow-shaped result dict the rest of the pipeline parses, so scenes rendered +here look exactly like Flow or assistant output downstream. + +Finished media is downloaded into ``output/_shared/muse2api/`` and recorded as +a ``file://`` URL. Gateway media links are only as durable as the gateway's +data dir (and only reachable where the gateway is), and ``file://`` URLs +never expire, so ``/fk-refresh-urls`` has nothing to do for them. + +What muse.ai can and cannot do shapes the capabilities: + +* image — text-to-image. Reference images are not an input the + gateway accepts, so entity refs are dropped (logged). +* video (i2v) — first frame + prompt. +* chained video — no end-frame input: fails unless MUSE2API_ALLOW_DEGRADED=1, + which drops the end frame and renders plain i2v. +* video_refs — only with MUSE2API_ALLOW_DEGRADED=1, as i2v off the scene + image (or the first reference). +* edit_image, upscale, audio — not offered (``/v1/images/edits`` is 501). +""" + +from __future__ import annotations + +import asyncio +import logging +import uuid +from pathlib import Path +from urllib.parse import unquote, urlparse + +from agent import config +from agent.sdk.services.assistant_provider import _image_result, _video_result +from agent.sdk.services.provider_base import ( + KIND_IMAGE, + KIND_VIDEO, + KIND_VIDEO_REFS, + MediaProvider, + ProviderCapabilities, + ProviderJob, +) +from agent.services.muse2api_client import Muse2APIClient, Muse2APIError, to_data_url + +logger = logging.getLogger(__name__) + +_ASPECT = {"VERTICAL": "9:16", "HORIZONTAL": "16:9"} +_VIDEO_EXT = {"video/mp4": "mp4", "video/webm": "webm", "video/quicktime": "mov"} + + +def _image_ext(data: bytes) -> str: + if data[:8] == b"\x89PNG\r\n\x1a\n": + return "png" + if data[:3] == b"\xff\xd8\xff": + return "jpg" + if data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return "webp" + return "png" + + +class Muse2APIProvider(MediaProvider): + """Renders media through a muse2api gateway over HTTP.""" + + name = "muse2api" + display_name = "Muse (muse2api gateway)" + + def __init__(self, client: Muse2APIClient | None = None) -> None: + self.client = client or Muse2APIClient( + config.MUSE2API_URL, config.MUSE2API_KEY, + timeout_s=config.MUSE2API_TIMEOUT_S, poll_s=config.MUSE2API_POLL_S, + ) + self.allow_degraded = config.MUSE2API_ALLOW_DEGRADED + self.output_dir = config.SHARED_OUTPUT_DIR / "muse2api" + # Per instance, because r2v is only on offer when degrading is allowed. + self.capabilities = ProviderCapabilities( + generate_image=True, + edit_image=False, + generate_video_i2v=True, + generate_video_r2v=self.allow_degraded, + upscale=False, + generate_audio=False, + max_concurrent=config.MUSE2API_MAX_CONCURRENT, + cooldown_s=config.MUSE2API_COOLDOWN_S, + ) + + def is_available(self) -> bool: + """Available once MUSE2API_URL is set. Gateway health (accounts, driver) + is the gateway's business: a request it cannot serve fails with its + own error code and is retried by the worker like any other failure.""" + return self.client.configured + + def check(self, job: ProviderJob) -> str | None: + if not self.client.configured: + return "muse2api provider is not configured — set MUSE2API_URL" + if job.kind == KIND_VIDEO_REFS and not self.allow_degraded: + return ("muse2api has no reference-to-video input — set " + "MUSE2API_ALLOW_DEGRADED=1 to render it as i2v off the scene image") + return super().check(job) + + # ── MediaProvider interface ── + + async def run(self, job: ProviderJob) -> dict: + try: + if job.kind == KIND_IMAGE: + return await self._run_image(job) + if job.kind in (KIND_VIDEO, KIND_VIDEO_REFS): + return await self._run_video(job) + except Muse2APIError as e: + logger.error("muse2api: job %s (%s) failed: %s", job.job_id[:12], job.kind, e) + return {"error": str(e)} + except (OSError, ValueError) as e: + # Unreadable input image, full disk, bad base64 … + logger.error("muse2api: job %s (%s) failed: %s", job.job_id[:12], job.kind, e) + return {"error": f"muse2api: {e}"} + return {"error": f"muse2api provider does not support job kind '{job.kind}'"} + + async def _run_image(self, job: ProviderJob) -> dict: + prompt = job.prompt + composition = (job.extra or {}).get("composition") + if composition: + prompt = f"{prompt}\n\nComposition: {composition}" + if job.reference_urls: + logger.info("muse2api: job %s — %d reference image(s) dropped, the " + "gateway takes no image refs", job.job_id[:12], len(job.reference_urls)) + data = await self.client.generate_image( + prompt, model=config.MUSE2API_IMAGE_MODEL, size=_ASPECT.get(job.orientation)) + url = await self._save(job.job_id, data, _image_ext(data)) + mid = str(uuid.uuid4()) + logger.info("muse2api: image %s ready (media_id=%s)", job.job_id[:12], mid[:8]) + return _image_result(mid, url) + + async def _run_video(self, job: ProviderJob) -> dict: + prefix = "vertical" if job.orientation == "VERTICAL" else "horizontal" + start = job.start_url + if job.kind == KIND_VIDEO_REFS: + start = start or (job.reference_urls[0] if job.reference_urls else None) + if not start: + return {"error": "No valid reference images for r2v"} + logger.warning("muse2api: job %s — r2v degraded to i2v", job.job_id[:12]) + if not start: + return {"error": f"No {prefix} image for scene"} + if job.end_url: + if not self.allow_degraded: + return {"error": "muse2api has no end-frame input for chained video — " + "set MUSE2API_ALLOW_DEGRADED=1 to render plain i2v"} + logger.warning("muse2api: job %s — end frame dropped, rendering plain i2v", + job.job_id[:12]) + + image = await to_data_url(start) + seconds = int(job.duration_s) if job.duration_s else config.MUSE2API_VIDEO_SECONDS + task = await self.client.create_video( + job.prompt, model=config.MUSE2API_VIDEO_MODEL, + size=_ASPECT.get(job.orientation), seconds=seconds, image=image) + task_id = task.get("id") + if not task_id: + return {"error": "muse2api: video task created without an id"} + logger.info("muse2api: video %s submitted as %s", job.job_id[:12], task_id) + + result = await self.client.wait_video(task_id) + data = await self.client.download(result["url"]) + ext = _VIDEO_EXT.get((result.get("mime") or "").split(";")[0], "mp4") + url = await self._save(job.job_id, data, ext) + mid = str(uuid.uuid4()) + logger.info("muse2api: video %s ready (media_id=%s)", job.job_id[:12], mid[:8]) + return _video_result(mid, url) + + async def _save(self, job_id: str, data: bytes, ext: str) -> str: + path = self.output_dir / f"{job_id}.{ext}" + + def _write() -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + + await asyncio.to_thread(_write) + return "file://" + str(path.resolve()) + + async def register_existing_image( + self, url: str, *, name: str = "", project_id: str = "" + ) -> dict: + """Mint a UUID for an image we already have — the gateway has no ids.""" + parsed = urlparse(url or "") + if parsed.scheme in ("http", "https"): + canonical = url + else: + local = Path(unquote(parsed.path)) if parsed.scheme == "file" else Path(url) + if not local.is_file(): + return {"error": f"muse2api: local image not found for '{name}': {url}"} + canonical = "file://" + str(local.resolve()) + mid = str(uuid.uuid4()) + logger.info("muse2api: registered existing image '%s' → media_id=%s", name, mid[:8]) + return {"data": {"media": [{"name": mid}], "url": canonical}} diff --git a/agent/sdk/services/registry.py b/agent/sdk/services/registry.py index 904d0261..ee942cb7 100644 --- a/agent/sdk/services/registry.py +++ b/agent/sdk/services/registry.py @@ -11,6 +11,8 @@ - ``assistant`` — the local assistant (e.g. Pax/Muse) via the persistent provider-job queue (``provider_job`` table + ``/api/provider-jobs``). Always available, needs no Chrome. + - ``muse2api`` — a muse2api gateway (muse.ai behind an OpenAI-compatible + API) over HTTP. Available once ``MUSE2API_URL`` is set. Extra backends (a Sora adapter, a local SDXL worker, …) can be registered at startup and are then selectable per request through the ``provider`` @@ -25,6 +27,7 @@ from agent.config import DEFAULT_PROVIDER from agent.sdk.services.assistant_provider import AssistantProvider from agent.sdk.services.flow_provider import FlowProvider +from agent.sdk.services.muse2api_provider import Muse2APIProvider from agent.sdk.services.provider_base import MediaProvider if TYPE_CHECKING: @@ -45,6 +48,7 @@ def __init__( if flow_client is not None: providers["flow"] = FlowProvider(flow_client) providers["assistant"] = AssistantProvider() + providers["muse2api"] = Muse2APIProvider() if extra: providers.update(extra) self._providers = providers diff --git a/agent/services/muse2api_client.py b/agent/services/muse2api_client.py new file mode 100644 index 00000000..ac43697c --- /dev/null +++ b/agent/services/muse2api_client.py @@ -0,0 +1,221 @@ +"""HTTP client for a muse2api gateway (https://github.com/crisng95/muse2api). + +muse2api exposes the muse.ai web app as an OpenAI-compatible API. Flow Kit +only needs the media half of it: + + POST /v1/images/generations prompt -> image(s) (synchronous) + POST /v1/videos prompt (+first frame) -> task + GET /v1/videos/{task_id} task status; result.url when succeeded + GET /v1/media/{name} download generated media (public) + GET /readyz driver health + available accounts + +Every ``/v1/*`` call carries ``Authorization: Bearer ``. Errors +come back as an OpenAI error body (``{"error": {"message", "type", "code"}}``) +and are raised here as :class:`Muse2APIError` carrying that code, so the +provider can report ``no_account_available`` / ``upstream_quota_exhausted`` +and the rest verbatim instead of a bare HTTP status. + +Input images are always sent inline as data URLs. Flow Kit's own media lives +at ``file://`` paths (or signed URLs only this machine can reach), and the +gateway may run on another host, so letting it fetch URLs itself would break +as soon as the two are split. +""" + +from __future__ import annotations + +import asyncio +import base64 +import logging +import mimetypes +import time +from pathlib import Path +from urllib.parse import unquote, urlparse + +import httpx + +logger = logging.getLogger(__name__) + +TERMINAL_OK = "succeeded" +TERMINAL_FAILED = ("failed", "cancelled") + + +class Muse2APIError(Exception): + """A muse2api call failed. ``code`` is the gateway's error code when known.""" + + def __init__(self, message: str, *, status: int | None = None, code: str = "") -> None: + super().__init__(message) + self.message = message + self.status = status + self.code = code + + def __str__(self) -> str: + prefix = f"muse2api {self.code}" if self.code else "muse2api" + if self.status: + prefix += f" (HTTP {self.status})" + return f"{prefix}: {self.message}" + + +def _error_from_response(resp: httpx.Response) -> Muse2APIError: + try: + body = resp.json() + except ValueError: + body = None + err = body.get("error") if isinstance(body, dict) else None + if isinstance(err, dict): + return Muse2APIError(err.get("message") or resp.reason_phrase, + status=resp.status_code, code=err.get("code") or "") + return Muse2APIError(resp.text[:300] or resp.reason_phrase, status=resp.status_code) + + +async def to_data_url(ref: str, *, timeout: float = 60.0) -> str: + """Turn a file:// path, local path, http(s) URL or data URL into a data URL.""" + ref = (ref or "").strip() + if not ref: + raise ValueError("empty image reference") + if ref.startswith("data:"): + return ref + parsed = urlparse(ref) + if parsed.scheme in ("http", "https"): + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + resp = await client.get(ref) + resp.raise_for_status() + data = resp.content + mime = resp.headers.get("content-type", "").split(";")[0].strip() + mime = mime or mimetypes.guess_type(parsed.path)[0] or "image/png" + else: + path = Path(unquote(parsed.path)) if parsed.scheme == "file" else Path(ref) + if not path.is_file(): + raise FileNotFoundError(f"image not found: {ref}") + data = await asyncio.to_thread(path.read_bytes) + mime = mimetypes.guess_type(path.name)[0] or "image/png" + return f"data:{mime};base64,{base64.b64encode(data).decode()}" + + +class Muse2APIClient: + """Thin async client for the muse2api media endpoints.""" + + def __init__(self, base_url: str, api_key: str = "", *, + timeout_s: float = 1800.0, poll_s: float = 5.0, + transport: httpx.AsyncBaseTransport | None = None) -> None: + self.base_url = (base_url or "").rstrip("/") + self.api_key = api_key + self.timeout_s = timeout_s + self.poll_s = poll_s + self._transport = transport # tests inject httpx.MockTransport here + + @property + def configured(self) -> bool: + return bool(self.base_url) + + def _headers(self) -> dict: + return {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {} + + def _url(self, path: str) -> str: + return path if path.startswith(("http://", "https://")) else f"{self.base_url}{path}" + + async def _request(self, method: str, path: str, *, timeout: float = 60.0, + **kwargs) -> dict: + try: + async with httpx.AsyncClient(timeout=timeout, transport=self._transport) as client: + resp = await client.request(method, self._url(path), + headers=self._headers(), **kwargs) + except httpx.TimeoutException as e: + raise Muse2APIError(f"{method} {path} timed out after {timeout:.0f}s", + code="timeout") from e + except httpx.HTTPError as e: + raise Muse2APIError(f"{method} {path} failed: {e}", + code="connection_error") from e + if resp.status_code >= 400: + raise _error_from_response(resp) + try: + return resp.json() + except ValueError as e: + raise Muse2APIError(f"{method} {path} returned non-JSON", + status=resp.status_code) from e + + # ── Health ── + + async def readyz(self) -> dict: + return await self._request("GET", "/readyz", timeout=10.0) + + # ── Images ── + + async def generate_image(self, prompt: str, *, model: str, size: str | None) -> bytes: + """Generate one image and return its bytes (b64_json: no second download).""" + body = {"prompt": prompt, "model": model, "n": 1, "response_format": "b64_json"} + if size: + body["size"] = size + data = await self._request("POST", "/v1/images/generations", + json=body, timeout=self.timeout_s) + items = data.get("data") or [] + if not items: + raise Muse2APIError("image generation returned no data", code="empty_result") + item = items[0] + if item.get("b64_json"): + return base64.b64decode(item["b64_json"]) + if item.get("url"): + return await self.download(item["url"]) + raise Muse2APIError("image generation returned neither b64_json nor url", + code="empty_result") + + # ── Videos ── + + async def create_video(self, prompt: str, *, model: str, size: str | None, + seconds: int | None, image: str | None) -> dict: + body: dict = {"prompt": prompt, "model": model} + if size: + body["size"] = size + if seconds: + body["seconds"] = seconds + if image: + body["image"] = image + return await self._request("POST", "/v1/videos", json=body, timeout=120.0) + + async def get_video(self, task_id: str) -> dict: + return await self._request("GET", f"/v1/videos/{task_id}", timeout=30.0) + + async def wait_video(self, task_id: str) -> dict: + """Poll a video task until it finishes. Returns the task's ``result``.""" + deadline = time.monotonic() + self.timeout_s + transient = 0 + while True: + try: + task = await self.get_video(task_id) + transient = 0 + except Muse2APIError as e: + # A blip on the poll is not a failed render; a 404 is (the + # gateway restarted and lost the task, or the id is wrong). + if e.status == 404 or transient >= 3: + raise + transient += 1 + logger.warning("muse2api: poll %s failed (%s), retrying", task_id, e) + task = {} + status = (task.get("status") or "").lower() + if status == TERMINAL_OK: + result = task.get("result") or {} + if not result.get("url"): + raise Muse2APIError(f"video task {task_id} succeeded without a url", + code="empty_result") + return result + if status in TERMINAL_FAILED: + raise Muse2APIError(task.get("error") or f"video task {task_id} {status}", + code=f"task_{status}") + if time.monotonic() >= deadline: + raise Muse2APIError(f"video task {task_id} still '{status or '?'}' after " + f"{self.timeout_s:.0f}s", code="timeout") + await asyncio.sleep(self.poll_s) + + # ── Media ── + + async def download(self, url: str) -> bytes: + """Fetch generated media. ``/v1/media`` is public, but a key never hurts.""" + try: + async with httpx.AsyncClient(timeout=300.0, follow_redirects=True, + transport=self._transport) as client: + resp = await client.get(self._url(url), headers=self._headers()) + except httpx.HTTPError as e: + raise Muse2APIError(f"download {url} failed: {e}", + code="connection_error") from e + if resp.status_code >= 400: + raise _error_from_response(resp) + return resp.content diff --git a/skills/fk-doctor.md b/skills/fk-doctor.md index 7e8c69ec..56ebbff1 100644 --- a/skills/fk-doctor.md +++ b/skills/fk-doctor.md @@ -52,7 +52,7 @@ curl -s "http://127.0.0.1:8100/api/provider-jobs?limit=20" ``` Bucket the failures by `error_message` prefix, print a table, and for each bucket give the fix from the taxonomy. -For requests with a `provider` other than `flow`, also read the linked +For requests with a `provider` other than `flow` or `muse2api`, also read the linked provider job (`provider_job_id` on the request row): a `QUEUED` job with no worker means `assistant_worker.py` isn't running; a `FAILED` job carries the worker's `error_message`. @@ -265,6 +265,26 @@ Check the job row first: `GET /api/provider-jobs?provider=assistant&limit=20`. | `400 provider 'flow' does not support audio` | TTS routed to flow | Use `assistant` or `local` for `/api/tts/generate` | | `Cannot fetch video for scene ...` in review | Remote URL dead and no Flow fallback (not connected, or non-Flow media) | Re-check the URL; for Flow media connect the extension, for local files verify the path exists | +### I. muse2api gateway errors (`provider: muse2api`) + +No provider job is involved — the worker calls the gateway directly, so the +request's `error_message` is the whole story. It reads +`muse2api (HTTP ): `, where `` is the gateway's +own OpenAI error code. Check the gateway with `curl -s $MUSE2API_URL/readyz`. + +| Error | Cause | Fix | +|---|---|---| +| Requests stay `PENDING`, `muse2api.available: false` in `/api/providers/status` | `MUSE2API_URL` unset | Set `MUSE2API_URL` (+ `MUSE2API_KEY`) and restart the server | +| `muse2api connection_error` | Gateway down or wrong URL | Start muse2api; verify `MUSE2API_URL` | +| `muse2api invalid_api_key (HTTP 401)` | Key mismatch | `MUSE2API_KEY` must equal the gateway's `MUSE2API_API_KEY` | +| `muse2api no_account_available (HTTP 503)` | No usable muse.ai account (`readyz` shows `accounts.available: 0`) | Import/renew accounts via the gateway's `/admin/accounts` | +| `muse2api upstream_auth_failed` | The muse.ai session expired | Re-export cookies and update the account on the gateway | +| `muse2api upstream_quota_exhausted (HTTP 429)` | Account out of quota | Wait out the cooldown or add accounts | +| `muse2api upstream_refused` / `task_failed` | muse.ai answered but produced nothing usable (often a content refusal) | Rephrase the prompt; the worker retries it as usual | +| `muse2api timeout` | Image call or video task exceeded `MUSE2API_TIMEOUT_S` | Check the gateway logs; raise the env var if renders are just slow | +| `muse2api has no end-frame input for chained video` | Chained scene on muse2api | `MUSE2API_ALLOW_DEGRADED=1` (plain i2v), or render that scene on `flow` | +| `... does not support job kind 'edit_image'` / `'upscale'` | Not offered by muse.ai | Use `flow` for edits and upscale | + ## Output format Always end with a prescription block: diff --git a/skills/fk-gen-chain-videos.md b/skills/fk-gen-chain-videos.md index 0c4f4d55..f4447141 100644 --- a/skills/fk-gen-chain-videos.md +++ b/skills/fk-gen-chain-videos.md @@ -7,10 +7,12 @@ Usage: `/gen-chain-videos ` Usage: `/fk-gen-chain-videos [--provider ]` Pass `"provider": ""` on each item in the `/api/requests/batch` -payload (`flow` | `assistant`; default is the server `DEFAULT_PROVIDER`). +payload (`flow` | `assistant` | `muse2api`; default is the server `DEFAULT_PROVIDER`). The assistant provider queues a provider job — make sure a worker is running (see `/fk-provider`). Chained start/end frames are passed as `start_url` / -`end_url` on the provider job, so chaining works on any provider. +`end_url` on the provider job, so chaining works on any provider that takes an +end frame. `muse2api` does not: chained scenes fail there unless +`MUSE2API_ALLOW_DEGRADED=1`, which renders them as plain i2v. This creates smooth transitions between scenes in a chain by using the **NEXT scene's image as the endImage** of the current scene's video, so the last frame of scene N matches the first frame of scene N+1 → seamless concat. diff --git a/skills/fk-gen-images.md b/skills/fk-gen-images.md index e3901c65..fbbcc3fb 100644 --- a/skills/fk-gen-images.md +++ b/skills/fk-gen-images.md @@ -7,7 +7,7 @@ Usage: `/fk-gen-images ` Usage: `/fk-gen-images [--provider ]` Pass `"provider": ""` on each item in the `/api/requests/batch` -payload (`flow` | `assistant`; default is the server `DEFAULT_PROVIDER`). +payload (`flow` | `assistant` | `muse2api`; default is the server `DEFAULT_PROVIDER`). The assistant provider queues a provider job — make sure a worker is running (see `/fk-provider`). Assistant image output lands as `file://` URLs, which never expire. diff --git a/skills/fk-gen-refs.md b/skills/fk-gen-refs.md index 43568260..d3407437 100644 --- a/skills/fk-gen-refs.md +++ b/skills/fk-gen-refs.md @@ -7,7 +7,7 @@ Usage: `/fk-gen-refs ` Usage: `/fk-gen-refs [--provider ]` Pass `"provider": ""` on each item in the `/api/requests/batch` -payload (`flow` | `assistant`; default is the server `DEFAULT_PROVIDER`). +payload (`flow` | `assistant` | `muse2api`; default is the server `DEFAULT_PROVIDER`). The assistant provider queues a provider job — make sure a worker is running (see `/fk-provider`). diff --git a/skills/fk-gen-videos.md b/skills/fk-gen-videos.md index 70bab8eb..b0e62124 100644 --- a/skills/fk-gen-videos.md +++ b/skills/fk-gen-videos.md @@ -7,7 +7,7 @@ Usage: `/fk-gen-videos ` Usage: `/fk-gen-videos [--provider ]` Pass `"provider": ""` on each item in the `/api/requests/batch` -payload (`flow` | `assistant`; default is the server `DEFAULT_PROVIDER`). +payload (`flow` | `assistant` | `muse2api`; default is the server `DEFAULT_PROVIDER`). The assistant provider queues a provider job — make sure a worker is running (see `/fk-provider`). Assistant image output lands as `file://` URLs, which never expire. diff --git a/skills/fk-provider.md b/skills/fk-provider.md index d9d177a0..c51e5404 100644 --- a/skills/fk-provider.md +++ b/skills/fk-provider.md @@ -2,7 +2,9 @@ FlowKit is provider-neutral. A **media provider** is the backend that renders media: `flow` (Google Flow via the Chrome extension), `assistant` (Muse or any -HTTP-capable external worker), or any future registered provider. +HTTP-capable external worker), `muse2api` (muse.ai through a +[muse2api](https://github.com/crisng95/muse2api) gateway), or any future +registered provider. Every generation request, thumbnail, and TTS call accepts an optional `provider`. Omit it and the server default (`DEFAULT_PROVIDER`, usually @@ -48,10 +50,44 @@ audio — it is rejected with 400.) - `flow`: image, edit_image, i2v (image-to-video), r2v, upscale. **No audio.** - `assistant`: image, edit_image, i2v, r2v, **audio (TTS)**. No upscale. +- `muse2api`: image, i2v. No edit_image, upscale or audio. No end frame and no + r2v unless `MUSE2API_ALLOW_DEGRADED=1` (then both render as plain i2v). + Entity reference images are not sent — muse.ai takes none. Asking a provider for a kind it doesn't support returns 400 with a message naming the right backend to use instead. +## The muse2api provider needs a gateway + +`muse2api` calls a running [muse2api](https://github.com/crisng95/muse2api) +service over HTTP — Flow Kit → `Muse2APIProvider` → muse2api → muse.ai. The +gateway owns the muse.ai accounts, failover and cooldowns; Flow Kit only needs +its URL and key: + +```bash +# muse2api side (its own repo): python -m muse2api → http://127.0.0.1:18610 +export MUSE2API_URL=http://127.0.0.1:18610 +export MUSE2API_KEY= # data/api_key if auto-generated +# optional: DEFAULT_PROVIDER=muse2api to make it the default backend +``` + +`muse2api.available` is true once `MUSE2API_URL` is set. Check the gateway +itself with `curl -s $MUSE2API_URL/readyz` — `accounts.available: 0` means +every request will fail `no_account_available` (import an account on the +gateway via its `/admin/accounts`). + +Output is downloaded into `output/_shared/muse2api/` and stored as `file://` +URLs, so like the assistant it never needs `/fk-refresh-urls`. + +| Error (request `error_message`) | Meaning | Fix | +|---|---|---| +| `muse2api provider is not configured` | `MUSE2API_URL` unset | set it and restart the server | +| `muse2api connection_error` | gateway not reachable | start muse2api / fix the URL | +| `muse2api invalid_api_key (HTTP 401)` | wrong key | use the gateway's `MUSE2API_API_KEY` | +| `muse2api no_account_available (HTTP 503)` | no usable muse.ai account | add/renew accounts on the gateway | +| `muse2api upstream_quota_exhausted (HTTP 429)` | account out of quota | wait for cooldown or add accounts | +| `muse2api has no end-frame input` | chained scene | `MUSE2API_ALLOW_DEGRADED=1`, or use `flow` | + ## The assistant provider needs a worker When a request uses `provider: assistant`, the server creates a **provider diff --git a/skills/fk-thumbnail.md b/skills/fk-thumbnail.md index e05f5068..3243e0b8 100644 --- a/skills/fk-thumbnail.md +++ b/skills/fk-thumbnail.md @@ -5,8 +5,8 @@ Usage: `/fk-thumbnail [project_id] [--provider ]` Usage: `/fk-thumbnail [project_id] [--provider ]` -Pass `"provider"` in the `generate-thumbnail` payload (`flow` | `assistant`; -default is the server `DEFAULT_PROVIDER`). The assistant provider queues a +Pass `"provider"` in the `generate-thumbnail` payload (`flow` | `assistant` | +`muse2api`; default is the server `DEFAULT_PROVIDER`). The assistant provider queues a provider job — make sure a worker is running (see `/fk-provider`). Flow thumbnail generation needs the Chrome extension; assistant needs none. diff --git a/tests/unit/test_db_connection.py b/tests/unit/test_db_connection.py new file mode 100644 index 00000000..3cea2a53 --- /dev/null +++ b/tests/unit/test_db_connection.py @@ -0,0 +1,35 @@ +"""get_db() must open exactly one connection however many callers race it. + +Two racing first callers used to each open a connection, leak one, and run +PRAGMAs on the other mid-statement ("database table is locked" in +test_assistant_provider on CI). +""" +import asyncio + +from agent.db import schema + + +async def test_concurrent_first_calls_open_one_connection(test_db, monkeypatch): + await schema.close_db() + assert schema._db_connection is None + + real_connect = schema.aiosqlite.connect + opened = [] + + def counting_connect(*args, **kwargs): + conn = real_connect(*args, **kwargs) + opened.append(conn) + return conn + + monkeypatch.setattr(schema.aiosqlite, "connect", counting_connect) + + conns = await asyncio.gather(*(schema.get_db() for _ in range(8))) + + try: + assert len(opened) == 1 + assert all(c is conns[0] for c in conns) + finally: + # Close any extras so a failure here doesn't leak worker threads. + for conn in opened: + if conn is not schema._db_connection: + await conn.close() diff --git a/tests/unit/test_muse2api_provider.py b/tests/unit/test_muse2api_provider.py new file mode 100644 index 00000000..2dc9cff0 --- /dev/null +++ b/tests/unit/test_muse2api_provider.py @@ -0,0 +1,200 @@ +"""Tests for the muse2api media provider (HTTP gateway to muse.ai). + +The gateway is faked with httpx.MockTransport, so these run offline and pin +the wire contract: paths, bearer auth, the payload fields, task polling, and +the OpenAI error body. +""" +import base64 +import json + +import httpx +import pytest + +from agent.sdk.services import muse2api_provider as mp +from agent.sdk.services.provider_base import ( + KIND_EDIT_IMAGE, + KIND_IMAGE, + KIND_UPSCALE, + KIND_VIDEO, + KIND_VIDEO_REFS, + ProviderJob, +) +from agent.sdk.services.registry import ProviderRegistry +from agent.sdk.services.result_handler import parse_result +from agent.services.muse2api_client import Muse2APIClient +from agent.worker._parsing import _is_uuid + +PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16 +MP4 = b"\x00\x00\x00\x18ftypmp42" + b"\x00" * 8 +BASE = "http://muse.test" + + +class FakeGateway: + """Minimal muse2api: images, video tasks that finish after N polls, media.""" + + def __init__(self, polls_until_done=2, video_status="succeeded", key="k"): + self.polls_until_done = polls_until_done + self.video_status = video_status + self.key = key + self.calls: list[tuple[str, str, dict | None]] = [] + self._polls = 0 + + def __call__(self, request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) if request.content else None + self.calls.append((request.method, request.url.path, body)) + path = request.url.path + if path.startswith("/v1/media/"): + return httpx.Response(200, content=MP4, headers={"content-type": "video/mp4"}) + if request.headers.get("authorization") != f"Bearer {self.key}": + return httpx.Response(401, json={"error": { + "message": "invalid or missing API key", "type": "authentication_error", + "code": "invalid_api_key"}}) + if path == "/v1/images/generations": + return httpx.Response(200, json={"created": 1, "data": [ + {"b64_json": base64.b64encode(PNG).decode(), "revised_prompt": None}]}) + if path == "/v1/videos" and request.method == "POST": + return httpx.Response(200, json={"id": "task_1", "status": "queued"}) + if path == "/v1/videos/task_1": + self._polls += 1 + if self._polls < self.polls_until_done: + return httpx.Response(200, json={"id": "task_1", "status": "running"}) + if self.video_status != "succeeded": + return httpx.Response(200, json={"id": "task_1", "status": self.video_status, + "error": "upstream refused", "result": None}) + return httpx.Response(200, json={"id": "task_1", "status": "succeeded", "result": { + "url": f"{BASE}/v1/media/vid_abc.mp4", "mime": "video/mp4"}}) + return httpx.Response(404, json={"error": {"message": "nope", "code": "not_found"}}) + + +@pytest.fixture +def gateway(): + return FakeGateway() + + +@pytest.fixture +def provider(tmp_path, gateway, monkeypatch): + monkeypatch.setattr(mp.config, "MUSE2API_ALLOW_DEGRADED", False) + client = Muse2APIClient(BASE, "k", timeout_s=5, poll_s=0.01, + transport=httpx.MockTransport(gateway)) + prov = mp.Muse2APIProvider(client) + prov.output_dir = tmp_path / "muse2api" + return prov + + +@pytest.fixture +def start_image(tmp_path): + p = tmp_path / "start.png" + p.write_bytes(PNG) + return "file://" + str(p) + + +async def test_image_is_saved_locally_with_minted_uuid(provider, gateway): + job = ProviderJob(job_id="img1", kind=KIND_IMAGE, prompt="a fox", + orientation="HORIZONTAL", reference_urls=("file:///x.png",), + extra={"composition": "wide establishing shot"}) + result = await provider.run(job) + + gen = parse_result(result, "GENERATE_IMAGE") + assert gen.success, result + assert _is_uuid(gen.media_id) + assert gen.url.startswith("file://") and gen.url.endswith("/muse2api/img1.png") + assert (provider.output_dir / "img1.png").read_bytes() == PNG + + method, path, body = gateway.calls[0] + assert (method, path) == ("POST", "/v1/images/generations") + assert body["size"] == "16:9" + assert body["model"] == "muse-image" + assert body["response_format"] == "b64_json" + assert "wide establishing shot" in body["prompt"] + + +async def test_video_sends_first_frame_inline_and_polls(provider, gateway, start_image): + job = ProviderJob(job_id="vid1", kind=KIND_VIDEO, prompt="fox runs", + orientation="VERTICAL", start_url=start_image) + result = await provider.run(job) + + gen = parse_result(result, "GENERATE_VIDEO") + assert gen.success, result + assert _is_uuid(gen.media_id) + assert (provider.output_dir / "vid1.mp4").read_bytes() == MP4 + + create = next(c for c in gateway.calls if c[:2] == ("POST", "/v1/videos")) + body = create[2] + assert body["size"] == "9:16" + assert body["seconds"] == 8 + assert body["image"] == "data:image/png;base64," + base64.b64encode(PNG).decode() + polls = [c for c in gateway.calls if c[1] == "/v1/videos/task_1"] + assert len(polls) == 2 + + +async def test_failed_video_task_surfaces_gateway_error(tmp_path, start_image): + gw = FakeGateway(polls_until_done=1, video_status="failed") + client = Muse2APIClient(BASE, "k", timeout_s=5, poll_s=0.01, + transport=httpx.MockTransport(gw)) + prov = mp.Muse2APIProvider(client) + prov.output_dir = tmp_path + result = await prov.run(ProviderJob(job_id="v", kind=KIND_VIDEO, prompt="p", + start_url=start_image)) + assert "upstream refused" in result["error"] + + +async def test_openai_error_body_is_reported(tmp_path): + client = Muse2APIClient(BASE, "wrong", transport=httpx.MockTransport(FakeGateway())) + prov = mp.Muse2APIProvider(client) + prov.output_dir = tmp_path + result = await prov.run(ProviderJob(job_id="i", kind=KIND_IMAGE, prompt="p")) + assert result["error"] == "muse2api invalid_api_key (HTTP 401): invalid or missing API key" + + +async def test_chained_video_fails_loudly_unless_degraded(provider, gateway, start_image): + job = ProviderJob(job_id="c", kind=KIND_VIDEO, prompt="p", + start_url=start_image, end_url=start_image) + result = await provider.run(job) + assert "end-frame" in result["error"] + assert gateway.calls == [] + + provider.allow_degraded = True + result = await provider.run(job) + assert parse_result(result, "GENERATE_VIDEO").success + + +async def test_video_without_start_frame_errors(provider): + result = await provider.run(ProviderJob(job_id="n", kind=KIND_VIDEO, prompt="p")) + assert result == {"error": "No vertical image for scene"} + + +def test_capabilities_and_checks(provider, monkeypatch): + assert provider.check(ProviderJob(job_id="a", kind=KIND_IMAGE)) is None + assert provider.check(ProviderJob(job_id="a", kind=KIND_VIDEO)) is None + for kind in (KIND_EDIT_IMAGE, KIND_UPSCALE): + assert "does not support" in provider.check(ProviderJob(job_id="a", kind=kind)) + assert "MUSE2API_ALLOW_DEGRADED" in provider.check( + ProviderJob(job_id="a", kind=KIND_VIDEO_REFS)) + + monkeypatch.setattr(mp.config, "MUSE2API_ALLOW_DEGRADED", True) + degraded = mp.Muse2APIProvider(provider.client) + assert degraded.capabilities.generate_video_r2v + assert degraded.check(ProviderJob(job_id="a", kind=KIND_VIDEO_REFS)) is None + + +def test_unconfigured_provider_is_unavailable(): + prov = mp.Muse2APIProvider(Muse2APIClient("")) + assert not prov.is_available() + assert "MUSE2API_URL" in prov.check(ProviderJob(job_id="a", kind=KIND_IMAGE)) + + +async def test_register_existing_image_mints_uuid(provider, start_image): + result = await provider.register_existing_image(start_image, name="Fox") + mid = result["data"]["media"][0]["name"] + assert _is_uuid(mid) + assert result["data"]["url"] == start_image + + missing = await provider.register_existing_image("file:///nope.png", name="Fox") + assert "not found" in missing["error"] + + +def test_registry_includes_muse2api(): + registry = ProviderRegistry(flow_client=None) + assert isinstance(registry.get("muse2api"), mp.Muse2APIProvider) + names = [p["name"] for p in registry.status()] + assert "muse2api" in names