diff --git a/app/_launch_runtime.py b/app/_launch_runtime.py index eb376a5e0..81254cee0 100644 --- a/app/_launch_runtime.py +++ b/app/_launch_runtime.py @@ -608,6 +608,7 @@ def _new_generation_job( recovered: bool = False, reserve_generation: bool = True, provenance: dict | None = None, + publish_task: bool = True, ) -> dict: frozen_params = copy.deepcopy(params) execution_mode.validate_generation(workspace) @@ -679,7 +680,7 @@ def _new_generation_job( if reserve_generation: register_generation_job(_gen_lock, job) publisher = globals().get("_publish_generation_task") - if callable(publisher): + if publish_task and callable(publisher): try: task = publisher(job) if isinstance(task, dict): @@ -10868,7 +10869,8 @@ async def generate(request: Request): try: prepare_generation_inputs(body, _generation_model_def, requested_workspace, uploads_dir=os.path.join(os.getcwd(), "uploads"), - workspace_dir=_workspace_dir(requested_workspace)) + workspace_dir=_workspace_dir(requested_workspace), + prepared_images=getattr(request, "prepared_studio_images", False) is True) except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error try: @@ -11309,6 +11311,9 @@ async def generate(request: Request): # Capture workspace at submission time — NOT at execution time workspace = body.pop("workspace", None) or _get_active_workspace() + admission = getattr(request, "admit_generation_command", None) + if callable(admission): + return admission(body, workspace, provenance) job_out_dir = _workspace_dir(workspace) h3_preplan_pending = isinstance( @@ -26297,7 +26302,10 @@ def _recovery_job_summary(record: dict) -> dict: @api.get("/api/v1/jobs/recovery") def get_generation_queue_recovery(): """Return crash leftovers that are not active in this server process.""" - candidates = _durable_generation_queue.list(exclude_ids=_jobs.keys()) + with _queue_recovery_lock: + _image_generation_commands.restore_recovery(item["name"] for item in _list_workspaces()) + candidates = _image_generation_commands.filter_recovery( + _durable_generation_queue.list(exclude_ids=_jobs.keys())) return {"jobs": [_recovery_job_summary(record) for record in candidates]} @@ -26311,7 +26319,9 @@ def resume_generation_queue(): resumed: list[dict] = [] threads: list[threading.Thread] = [] with _queue_recovery_lock: - candidates = _durable_generation_queue.list(exclude_ids=_jobs.keys()) + _image_generation_commands.restore_recovery(item["name"] for item in _list_workspaces()) + candidates = _image_generation_commands.filter_recovery( + _durable_generation_queue.list(exclude_ids=_jobs.keys())) for record in candidates: job_id = str(record.get("id") or "").strip() params = record.get("params") @@ -26357,6 +26367,9 @@ def resume_generation_queue(): def discard_generation_queue(): """Clear only inactive recovery candidates; never cancel live work.""" with _queue_recovery_lock: + _image_generation_commands.restore_recovery(item["name"] for item in _list_workspaces()) + _image_generation_commands.discard_recovery( + _durable_generation_queue.list(exclude_ids=_jobs.keys())) removed = _durable_generation_queue.discard(exclude_ids=_jobs.keys()) return {"discarded": removed} @@ -36150,7 +36163,7 @@ def _upsert_canonical_task( return existing -def _publish_generation_task(job: dict) -> dict: +def _generation_task_fields(job: dict) -> dict: legacy_id = str(job.get("id") or "") workspace = str(job.get("workspace") or "default") details = _public_generation_details(job.get("params")) @@ -36214,9 +36227,9 @@ def _publish_generation_task(job: dict) -> dict: task_title = "Tools · Upscale" elif str(provenance.get("capability") or "") == "revoice": task_title = "Tools · Revoice" - return _upsert_canonical_task( - workspace, - task_id, + return dict( + workspace=workspace, + id=task_id, root_id=root_task_id, parent_id=parent_task_id, kind=mode, @@ -36249,6 +36262,12 @@ def _publish_generation_task(job: dict) -> dict: ) +def _publish_generation_task(job: dict) -> dict: + fields = _generation_task_fields(job) + workspace, task_id = fields.pop("workspace"), fields.pop("id") + return _upsert_canonical_task(workspace, task_id, **fields) + + def _observe_generation_job_state(record: dict) -> None: """Forward atomic lifecycle changes to the canonical task event stream.""" job = dict(record) @@ -36831,11 +36850,20 @@ def _classic_redirect(): # Optional external agents use exactly the same admission endpoints and task IDs. from routers.wangp_mcp import create_wangp_mcp_router from services.wangp_agent_adapters import application_handlers as wangp_agent_handlers +from services.image_generation_runtime import create_image_generation_commands +from routers.image_generation_commands import ( + create_image_generation_commands_router, image_command_catalog, image_command_handlers, +) +from services.workspace_commands import catalog as workspace_command_catalog + +_image_generation_commands = create_image_generation_commands(globals()) +api.include_router(create_image_generation_commands_router(_image_generation_commands)) api.include_router(create_wangp_mcp_router( handlers={"models": lambda args: get_model_options(args['model_type']) if args.get('model_type') else list_models(), "processors": wangp_capabilities, "status": get_status, "generate": generate, "recast": recast_endpoint, "upscale": tools_upscale, - **wangp_agent_handlers(api)}, + **wangp_agent_handlers(api), **image_command_handlers(_image_generation_commands)}, journal_path=os.path.join(os.path.dirname(__file__), "settings", "wangp-mcp-requests.sqlite3"), + command_operations=[*workspace_command_catalog()["operations"], *image_command_catalog()], )) # ============================================================================ diff --git a/app/routers/image_generation_commands.py b/app/routers/image_generation_commands.py new file mode 100644 index 000000000..9b32269f0 --- /dev/null +++ b/app/routers/image_generation_commands.py @@ -0,0 +1,125 @@ +"""HTTP and MCP projections of the executable image command contract.""" +from __future__ import annotations + +from fastapi import APIRouter, Request +from pydantic import BaseModel, ConfigDict, Field, StrictStr, ValidationError, field_validator +from services.image_generation_spec import image_generation_schema +from services.studio_image_spec import studio_image_schema +from services.image_generation_commands import command_error + + +class ReferenceResolutionInput(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + references: list[StrictStr] = Field(min_length=1, max_length=64) + + +class UISubmissionContext(BaseModel): + """Optional attribution, never a source of permissions or target IDs.""" + model_config = ConfigDict(extra="forbid", strict=True) + workflowId: StrictStr | None = Field(default=None, min_length=1, max_length=200) + runId: StrictStr | None = Field(default=None, min_length=1, max_length=200) + + @field_validator("workflowId", "runId") + @classmethod + def nonblank(cls, value): + if value is not None and (not value.strip() or value != value.strip()): + raise ValueError("Use an exact non-blank context ID") + return value + + +def _ui_context(request): + raw = request.headers.get("X-Hocus-UI-Context", "{}") + if len(raw) > 2048: + raise command_error(422, "invalid_ui_context", "Submission context is too long") + try: + return UISubmissionContext.model_validate_json(raw).model_dump(exclude_none=True) + except ValidationError as error: + raise command_error(422, "invalid_ui_context", "Use only exact workflowId and runId attribution") from error + + +def image_command_catalog(): + spec = image_generation_schema() + studio = studio_image_schema() + studio_input = dict(studio["input"]) + definitions = studio_input.pop("$defs", {}) + definitions["StudioCommandInput"] = studio_input + envelope = {"type": "object", "additionalProperties": False, + "properties": {"version": {"type": "integer", "enum": [1, 2]}, + "operation": {"const": "generation.image"}, + "intent_id": spec["intent_id"], "input": {"type": "object"}}, + "required": ["version", "operation", "intent_id", "input"], + "$defs": definitions, + "oneOf": [ + {"properties": {"version": {"const": 1}, "input": spec["input"]}}, + {"properties": {"version": {"const": 2}, "input": {"$ref": "#/$defs/StudioCommandInput"}}}, + ]} + receipt_input = {"type": "object", "additionalProperties": False, + "properties": {"workspace": spec["input"]["properties"]["workspace"], + "intent_id": spec["intent_id"]}, "required": ["workspace", "intent_id"]} + return [{"name": "generation.image", "version": 2, "supportedVersions": [1, 2], "domain": "studio", "mutation": True, + "description": "Admit an image job with an installed model and explicit output workspace. Version 1 is a single text-to-image request; version 2 accepts the complete typed Studio image parameters, canonical references, LoRAs and image processors. Preserve literal prompts and reuse intent_id only for retries. The receipt proves admission; inspect its task for completion.", + "inputSchema": envelope}, + {"name": "generation.receipt", "version": 1, "domain": "studio", "mutation": False, + "description": "Read an immutable image admission and its current canonical task in the exact original output workspace.", + "inputSchema": {"type": "object", "additionalProperties": False, + "properties": {"version": {"type": "integer", "const": 1}, + "operation": {"const": "generation.receipt"}, "input": receipt_input}, + "required": ["version", "operation", "input"]}}] + + +def image_command_handlers(service): + async def submit(arguments): + if not isinstance(arguments, dict) or set(arguments) != {"version", "intent_id", "input"}: + raise command_error(422, "invalid_command", "Use version, intent_id and input for the image tool") + return await service.submit({**arguments, "operation": "generation.image"}, trusted_tool="external_agent") + + def receipt(arguments): + if (not isinstance(arguments, dict) or set(arguments) != {"version", "input"} + or type(arguments.get("version")) is not int or arguments["version"] != 1 + or not isinstance(arguments["input"], dict) + or set(arguments["input"]) != {"workspace", "intent_id"}): + raise command_error(422, "invalid_command", "Use version 1 with workspace and intent_id") + return service.receipt(**arguments["input"]) + + return {"generation.image": submit, "generation.receipt": receipt} + + +def create_image_generation_commands_router(service): + router = APIRouter() + + @router.get("/api/v1/generation/commands") + def catalog(): + return {"version": 2, "operations": image_command_catalog()} + + @router.post("/api/v1/generation/commands") + async def submit(request: Request): + try: + command = await request.json() + except ValueError as error: + raise command_error(422, "invalid_command", "Command must be valid JSON") from error + surface = request.headers.get("X-Hocus-UI-Surface", "studio") + if surface not in {"studio", "wizard"}: + raise command_error(422, "invalid_ui_surface", "Choose a known initiating UI surface") + # Declared UI attribution, as on the native Studio endpoint. This is + # never used for authorization. MCP supplies its own external context. + return await service.submit(command, trusted_tool="wizard" if surface == "wizard" else None, + submission_context=_ui_context(request)) + + @router.get("/api/v1/generation/commands/receipt") + def receipt(workspace: str, intent_id: str): + return service.receipt(workspace, intent_id) + + @router.post("/api/v1/generation/commands/references") + def references(body: ReferenceResolutionInput): + """Read-only migration of exact legacy UI paths into canonical URLs.""" + resolve = getattr(service, "canonicalize_reference", None) + if not callable(resolve): + raise command_error(503, "reference_resolution_unavailable", "Reference resolution is unavailable") + if any(not 1 <= len(value) <= 8192 for value in body.references): + raise command_error(422, "invalid_reference", "An exact bounded media reference is required") + try: + return {"references": [resolve(value) for value in body.references]} + except (ValueError, OSError) as error: + raise command_error(422, "invalid_reference", str(error)) from error + + return router diff --git a/app/services/image_generation_commands.py b/app/services/image_generation_commands.py new file mode 100644 index 000000000..cadbbe0f2 --- /dev/null +++ b/app/services/image_generation_commands.py @@ -0,0 +1,277 @@ +"""Shared image admission using native preparation, tasks and generation FIFO. + +The receipt proves admission. TaskRegistry remains the progress authority and +the native generation queue remains the sole execution/recovery mechanism. +""" +from __future__ import annotations + +from copy import deepcopy +import re +import sqlite3 +import time +import uuid + +from fastapi import HTTPException +from services.image_generation_spec import freeze_image_generation_spec, ImageGenerationSpecError +from services.task_command_admission import TaskCommandConflict +from services.wangp_submission import JsonRequest + + +def command_error(status: int, code: str, message: str): + return HTTPException(status, {"code": code, "message": message, "retryable": status >= 500}) + + +def validate_image_model(params, *, model_definition, model_downloaded, allow_references=False): + definition = model_definition(params["model_type"]) + if not definition or not definition.get("image_outputs") or definition.get("returns_audio"): + raise command_error(422, "unsupported_model", "Choose an exact text-to-image model from the model catalog") + if definition.get("at_least_one_image_ref_needed") and not (allow_references and params.get("image_refs")): + raise command_error(422, "reference_required", "This model requires references; choose a text-to-image model") + if not model_downloaded(params["model_type"]): + raise command_error(409, "model_unavailable", "Required model files are not installed; install them before submitting") + match = re.fullmatch(r"([1-9][0-9]{1,4})x([1-9][0-9]{1,4})", params["resolution"]) + if not match or any(not 64 <= int(value) <= 4096 or int(value) % 8 for value in match.groups()): + raise command_error(422, "invalid_resolution", "Resolution must be WIDTHxHEIGHT, each 64..4096 and a multiple of 8") + return definition + + +class ImageGenerationCommands: + def __init__(self, *, registry, prepare, preflight, make_job, task_fields, + dispatch, persist_recovery, active_job_ids, prepare_studio=None, runtime_defaults=None): + self.registry = registry + self.prepare = prepare + self.preflight = preflight + self.make_job = make_job + self.task_fields = task_fields + self.dispatch = dispatch + self.persist_recovery = persist_recovery + self.active_job_ids = active_job_ids + self.prepare_studio = prepare_studio + self.runtime_defaults = runtime_defaults or (lambda: {}) + self.owner = uuid.uuid4().hex + + def _registry(self, workspace): + # Exact physical output location; no active-browser fallback and no + # collection-ID substitution. The native resolver enforces containment. + if not isinstance(workspace, str) or not re.fullmatch(r"(?:default|[A-Za-z0-9][A-Za-z0-9_-]*)", workspace): + raise command_error(422, "invalid_workspace", "Use an explicit valid output workspace") + return self.registry(workspace) + + @staticmethod + def _validate_replay(entry, frozen): + if (entry["operation"] != "generation.image" or entry["digest"] != frozen["fingerprint"] + or entry["fingerprint_version"] != frozen["fingerprint_version"]): + raise TaskCommandConflict("intent_id was already used with different parameters or preconditions") + + def _dispatch_admitted(self, registry, entry): + try: + self._dispatch_pending(registry, entry) + except HTTPException as error: + if error.status_code >= 500: + raise + raise command_error(503, "admission_recovery_needed", "Admission is durable; consult its receipt and task before recovery") from error + + def _dispatch_pending(self, registry, entry): + task = registry.get(entry["task_id"]) + if not task or task["status"] != "queued" or entry["dispatch_owner"] is not None: + return + runtime = entry["effective"]["runtime"] + job = self.make_job(deepcopy(runtime["params"]), runtime["workspace"], + job_id=task["backend_job_id"], created_at=task["created_at"], + reserve_generation=False, publish_task=False, + provenance=deepcopy(runtime["provenance"])) + # A failure here keeps admission pending and safely retryable. Unlike + # legacy best-effort persistence, this path must not dispatch on failure. + self.persist_recovery(job) + if registry.claim_command_dispatch(entry["intent_id"], self.owner): + try: + self.dispatch(job) + except Exception: + # Dispatch may have started before raising. Never release the + # claim or infer that a transport retry should start it again. + raise command_error(503, "dispatch_uncertain", "Admission is durable; inspect its task before explicitly recovering") from None + + def _admit(self, frozen, body, workspace, provenance): + registry = self._registry(workspace) + provenance = deepcopy(provenance) + provenance["command"]["command_id"] = frozen["original"]["intent_id"] + native_params = {**deepcopy(self.runtime_defaults()), **deepcopy(body)} + job = self.make_job(native_params, workspace, reserve_generation=False, publish_task=False, provenance=provenance) + effective = deepcopy(frozen["effective"]) + effective["runtime"] = {"params": deepcopy(job["params"]), "workspace": workspace, + "provenance": deepcopy(job["provenance"])} + admitted = registry.admit_command_task( + intent_id=frozen["original"]["intent_id"], operation="generation.image", + digest=frozen["fingerprint"], original=frozen["original"], effective=effective, + task_fields=self.task_fields(job), fingerprint_version=frozen["fingerprint_version"], + ) + entry = registry.command_admission(frozen["original"]["intent_id"]) + self._dispatch_admitted(registry, entry) + return admitted + + @staticmethod + def _provenance(frozen, trusted_tool, context): + command = {"command_id": frozen["original"]["intent_id"]} + for source, target in (("workflowId", "workflow_id"), ("runId", "run_id")): + if context and context.get(source): + command[target] = context[source] + result = {"actor": "wizard" if trusted_tool == "wizard" else "user", + "capability": "generation.image", "command": command} + collection = frozen["original"]["input"].get("workspace_collection_id") + if collection is not None: + result["workspace_id"] = collection + return result + + async def submit(self, command, *, trusted_tool=None, submission_context=None): + try: + frozen, params = self._freeze(command) + registry = self._registry(params["workspace"]) + previous = registry.command_admission(command["intent_id"]) + if previous is not None: + self._validate_replay(previous, frozen) + self._dispatch_admitted(registry, previous) + return {"receipt": previous["receipt"], "replayed": True} + if command["version"] == 2: + if self.prepare_studio is None: + raise command_error(422, "unsupported_version", "Studio image commands are unavailable in this runtime") + params, resources = self.prepare_studio(params) + frozen["effective"]["resources"] = resources + else: + self.preflight(params) + request = JsonRequest({**deepcopy(params), "provenance": self._provenance( + frozen, trusted_tool, submission_context)}, trusted_tool=trusted_tool) + request.prepared_studio_images = command["version"] == 2 + # This callback is an in-process capability, never a JSON option. + # The native facade performs its ordinary validation first and then + # transfers admission to the same canonical task/worker adapter. + request.admit_generation_command = lambda body, workspace, provenance: self._admit(frozen, body, workspace, provenance) + return await self.prepare(request) + except ImageGenerationSpecError as error: + raise command_error(422, "invalid_command", str(error)) from error + except TaskCommandConflict as error: + raise command_error(409, "intent_conflict", str(error)) from error + except (OSError, sqlite3.Error) as error: + raise command_error(503, "storage_unavailable", "Command storage is unavailable; retry with the same intention") from error + + @staticmethod + def _freeze(command): + if isinstance(command, dict) and type(command.get("version")) is int and command["version"] == 2: + from services.studio_image_spec import freeze_studio_image_spec + frozen = freeze_studio_image_spec(command) + params = {**deepcopy(frozen["effective"]["input"]["params"]), + "workspace": frozen["effective"]["input"]["workspace"]} + else: + frozen = freeze_image_generation_spec(command) + params = frozen["effective"]["input"] + return frozen, params + + def receipt(self, workspace, intent_id): + if not isinstance(intent_id, str) or not 1 <= len(intent_id) <= 160: + raise command_error(422, "invalid_command", "An exact intent_id is required") + try: + registry = self._registry(workspace) + entry = registry.command_admission(intent_id) + if entry is None: + raise command_error(404, "receipt_not_found", "No admission exists for this intention in this workspace") + return {"receipt": entry["receipt"], "task": registry.get(entry["task_id"])} + except (OSError, sqlite3.Error) as error: + raise command_error(503, "storage_unavailable", "Command storage is unavailable") from error + + def restore_recovery(self, workspaces): + """Rebuild only the existing recovery projection; never start inference.""" + try: + self._restore_recovery(workspaces) + except (OSError, sqlite3.Error) as error: + raise command_error(503, "storage_unavailable", "Recovery storage is unavailable; no queue records were discarded") from error + + def _restore_recovery(self, workspaces): + active = set(self.active_job_ids()) + for workspace in workspaces: + try: + registry = self._registry(workspace) + except HTTPException as error: + # `_list_workspaces` includes every outputs/ subdirectory. + # A backup folder such as "old copy" must not 422 list/resume/discard. + if error.status_code == 422: + continue + raise + for entry in registry.command_recovery_candidates(): + task = registry.get(entry["task_id"]) + if not task or task["status"] != "interrupted" or task["backend_job_id"] in active: + continue + runtime = entry["effective"]["runtime"] + # No model preflight: recovering the editable request must also + # work while the original model is unavailable. + self.persist_recovery({"id": task["backend_job_id"], "status": "interrupted", + "created_at": task["created_at"], **deepcopy(runtime)}) + + @staticmethod + def _recovery_identity(record): + if not isinstance(record, dict): + return False + provenance = record.get("provenance") + if provenance is None: + return None + if not isinstance(provenance, dict): + return False + capability = provenance.get("capability") + if capability is not None and not isinstance(capability, str): + return False + if capability != "generation.image": + return None + command = provenance.get("command") + if not isinstance(command, dict): + return False + intent_id = command.get("command_id") + if not isinstance(intent_id, str) or not 1 <= len(intent_id) <= 160 or not intent_id.strip(): + return False + return intent_id + + def _recovery_task(self, record): + """Link one leftover to its admission, or withhold it. + + Non-image leftovers return None so the native queue can recover them. + A linked image leftover returns ``(registry, task)``. An image row that + cannot be matched (missing admission, invalid queue metadata or job-id + drift) returns False so this one row is skipped. Storage failures, + including corrupt canonical admissions, remain errors: discard must + not delete recovery records while their tasks cannot be verified. + """ + intent_id = self._recovery_identity(record) + if intent_id is None or intent_id is False: + return intent_id + try: + registry = self._registry(record.get("workspace")) + entry = registry.command_admission(intent_id) + if entry is None or entry["receipt"]["result"]["job_id"] != record.get("id"): + return False + return registry, registry.get(entry["task_id"]) + except HTTPException as error: + if error.status_code in {404, 422}: + return False + raise + except (OSError, sqlite3.Error) as error: + raise command_error(503, "storage_unavailable", "Recovery storage is unavailable; no queue records were discarded") from error + except (TypeError, KeyError): + return False + + def filter_recovery(self, records): + retained = [] + for record in records: + linked = self._recovery_task(record) + if linked is False: + continue + if linked is None or (linked[1] and linked[1]["status"] == "interrupted"): + retained.append(record) + return retained + + def discard_recovery(self, records): + for record in records: + linked = self._recovery_task(record) + if linked and linked[1] and linked[1]["status"] == "interrupted": + registry, task = linked + try: + registry.update(task["id"], status="cancelled", phase="recovery_discarded", + message="Recovery discarded", completed_at=time.time(), recoverable=False) + except (OSError, sqlite3.Error) as error: + raise command_error(503, "storage_unavailable", "Recovery storage is unavailable; no queue records were discarded") from error diff --git a/app/services/image_generation_runtime.py b/app/services/image_generation_runtime.py new file mode 100644 index 000000000..a1e762221 --- /dev/null +++ b/app/services/image_generation_runtime.py @@ -0,0 +1,73 @@ +"""Bind shared image commands to the existing native runtime, without a queue.""" +from copy import deepcopy +import os +import threading + +from services.image_generation_commands import ImageGenerationCommands, validate_image_model, command_error +from services.job_lifecycle import request_cancel + + +def create_image_generation_commands(runtime): + def persist(job): + runtime["_durable_generation_queue"].upsert({ + key: deepcopy(job[key]) for key in ("id", "status", "created_at", "params", "workspace", "provenance") + }) + + def dispatch(job): + thread = threading.Thread(target=runtime["_run_generation_with_preparation"], args=(job["id"],), + name=f"command-generation-{job['id']}", daemon=False) + try: + runtime["_jobs"][job["id"]] = job + runtime["register_generation_job"](runtime["_gen_lock"], job) + runtime["_cancel_h3_idle_release"]() + thread.start() + except Exception: + if thread.ident is None: + # This native Thread never started. Release its FIFO position + # through the existing cancellation lifecycle and expose a + # recoverable interruption, retaining the admission/snapshot. + request_cancel(job, job_id=job["id"], active_states=runtime["_active_gen_states"]) + runtime["_jobs"].pop(job["id"], None) + runtime["_task_registry"](job["workspace"]).update( + job["task_id"], status="interrupted", phase="dispatch_failed", force=True, + message="Worker could not start; use queue recovery", recoverable=True, + ) + raise + + def execution_policy(workspace): + try: + runtime["execution_mode"].validate_generation(workspace) + except runtime["execution_mode"].ExecutionModeError as error: + raise command_error(409, "execution_policy", str(error)) from error + + def preflight(params): + execution_policy(params["workspace"]) + validate_image_model(params, model_definition=runtime["wgp"].get_model_def, + model_downloaded=runtime["_check_model_downloaded"]) + + def resources(): + from services.studio_image_resources import StudioImageResources + return StudioImageResources( + workspace_dir=runtime["_workspace_dir"], uploads_dir=lambda: os.path.join(os.getcwd(), "uploads"), + list_workspaces=runtime["_list_workspaces"], lora_search_dirs=runtime["wgp"].get_lora_search_dirs, + lora_compatible=runtime["_lora_is_compatible_with_model"], + ) + + def prepare_studio(params): + from services.studio_image_preparation import prepare_studio_image + from shared.wangp1272 import processors + return prepare_studio_image( + params, model_definition=runtime["wgp"].get_model_def, + model_downloaded=runtime["_check_model_downloaded"], resources=resources(), + execution_policy=execution_policy, processor_capabilities=processors.capabilities, + validate_processors=processors.validate_selection, processor_settings=processors.validated_settings, + ) + + service = ImageGenerationCommands( + registry=runtime["_task_registry"], prepare=runtime["generate"], preflight=preflight, + make_job=runtime["_new_generation_job"], task_fields=runtime["_generation_task_fields"], + dispatch=dispatch, persist_recovery=persist, active_job_ids=lambda: runtime["_jobs"].keys(), + prepare_studio=prepare_studio, runtime_defaults=lambda: {"mode": "", **runtime["wgp"].primary_settings}, + ) + service.canonicalize_reference = lambda value: resources().canonicalize_legacy(value) + return service diff --git a/app/services/image_generation_spec.py b/app/services/image_generation_spec.py new file mode 100644 index 000000000..b7dd394a4 --- /dev/null +++ b/app/services/image_generation_spec.py @@ -0,0 +1,280 @@ +"""Strict, provider-free contract for the first shared image command. + +This module freezes a text-to-image command before any runtime or queue effect. +It deliberately owns no model catalog, scheduler, request journal, filesystem +path or provenance authority. The canonical runtime must resolve model +availability and trusted provenance after this boundary. + +The transport envelope is:: + + {"version": 1, "operation": "generation.image", "intent_id": "...", + "input": {"workspace": "...", "model_type": "...", "prompt": "...", + "resolution": "...", "num_inference_steps": 1, + "seed": -1, "guidance_scale": 1.0}} + +``original`` is a detached copy of the validated caller envelope. ``effective`` +adds the native image selectors (``generation_mode=image``, ``image_mode=1`` +and ``video_length=1``) without rewriting the original. The content +fingerprint is over the effective operation and input only; transport identity +(``intent_id``) and client metadata are excluded. Client metadata is rejected +instead of being accepted or attributed by this module. + +The initial scope intentionally excludes references, LoRAs, output counts, +post-processing, audio/video/avatar fields and model3d. They need explicit +contracts and resource validation before they can be added here. +""" + +from __future__ import annotations + +from copy import deepcopy +import hashlib +import json +from typing import Annotated, Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, StringConstraints, StrictFloat, StrictInt, ValidationError + + +SCHEMA_VERSION = 1 +OPERATION = "generation.image" +FINGERPRINT_VERSION = 1 +NATIVE_IMAGE_DEFAULTS = { + "generation_mode": "image", "image_mode": 1, "video_length": 1, + "multi_prompts_gen_type": 2, "repeat_generation": 1, "batch_size": 1, + "prompt_enhancer": "", +} + +_MAX_ID_LENGTH = 240 +_MAX_INTENT_LENGTH = 160 +_MAX_PROMPT_LENGTH = 200_000 + +# This list is the public native subset for the first vertical. In particular, +# image_mode and video_length are adapter-owned defaults and are accepted only +# as the exact image values; refs/LoRAs are intentionally absent. +SUPPORTED_INPUT_FIELDS = ( + "workspace", + "model_type", + "prompt", + "negative_prompt", + "resolution", + "num_inference_steps", + "seed", + "guidance_scale", + "image_mode", + "video_length", +) + + +class ImageGenerationSpecError(ValueError): + """Safe validation error with field paths and no submitted values.""" + + def __init__(self, message: str, *, details: list[dict[str, Any]] | None = None): + super().__init__(message) + self.details = list(details or []) + + +class _StrictInput(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + + +_Identity = Annotated[ + str, + StringConstraints(strict=True, min_length=1, max_length=_MAX_ID_LENGTH), +] +_Workspace = Annotated[ + str, + StringConstraints(strict=True, min_length=1, max_length=_MAX_ID_LENGTH, + pattern=r"^(?:default|[A-Za-z0-9][A-Za-z0-9_-]*)$"), +] +_IntentId = Annotated[ + str, + StringConstraints(strict=True, min_length=1, max_length=_MAX_INTENT_LENGTH), +] +_Prompt = Annotated[ + str, + StringConstraints(strict=True, min_length=1, max_length=_MAX_PROMPT_LENGTH), +] +_NegativePrompt = Annotated[ + str, + StringConstraints(strict=True, max_length=_MAX_PROMPT_LENGTH), +] +_Resolution = Annotated[ + str, + StringConstraints(strict=True, min_length=1, max_length=128), +] +_Steps = Annotated[StrictInt, Field(ge=1, le=1000)] +_Seed = Annotated[StrictInt, Field(ge=-(2**63), le=2**63 - 1)] +_Guidance = Annotated[ + StrictFloat, + Field(ge=0, le=1000, allow_inf_nan=False), +] +_ImageSelector = Annotated[StrictInt, Field(ge=1, le=1)] + + +class ImageGenerationInput(_StrictInput): + """Strict input fields supported by ``generation.image``. + + Defaults on ``negative_prompt``, ``image_mode`` and ``video_length`` are + contract-owned. ``exclude_unset=True`` is used for the original input so + omitted fields remain omitted there, while the effective native map has + all three deterministic defaults. + """ + + workspace: _Workspace + model_type: _Identity + prompt: _Prompt + negative_prompt: _NegativePrompt = "" + resolution: _Resolution + num_inference_steps: _Steps + seed: _Seed + guidance_scale: _Guidance + image_mode: _ImageSelector = 1 + video_length: _ImageSelector = 1 + + +class _ImageGenerationEnvelope(_StrictInput): + version: Literal[SCHEMA_VERSION] + operation: Literal[OPERATION] + intent_id: _IntentId + input: dict[str, Any] + + +def _validation_error(exc: ValidationError) -> ImageGenerationSpecError: + details: list[dict[str, Any]] = [] + messages: list[str] = [] + for error in exc.errors(include_input=False): + location = ".".join(str(part) for part in error.get("loc", ())) or "command" + message = str(error.get("msg") or "Invalid value") + details.append({"loc": location, "message": message, "type": error.get("type")}) + messages.append(f"{location}: {message}") + return ImageGenerationSpecError("; ".join(messages) or "Invalid image generation command", details=details) + + +def _require_non_blank(value: str, field: str) -> None: + # Preserve all characters exactly; only reject an identifier/text field + # that contains no meaningful character. No trimming is written back. + if not value.strip(): + raise ImageGenerationSpecError(f"{field} must contain a non-blank value") + + +def _validate_semantics(parsed: ImageGenerationInput) -> None: + _require_non_blank(parsed.workspace, "input.workspace") + _require_non_blank(parsed.model_type, "input.model_type") + _require_non_blank(parsed.prompt, "input.prompt") + _require_non_blank(parsed.resolution, "input.resolution") + + +def _canonical_content(effective: dict[str, Any]) -> dict[str, Any]: + """Return only content-bearing fields used for the stable fingerprint.""" + return { + "version": SCHEMA_VERSION, + "operation": OPERATION, + "input": deepcopy(effective["input"]), + } + + +def _fingerprint(content: dict[str, Any]) -> str: + encoded = json.dumps( + content, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def freeze_image_generation_spec(command: Any) -> dict[str, Any]: + """Validate and freeze one image command without causing an effect. + + The returned dictionaries are detached snapshots. ``intent_id`` remains + in the effective transport projection so the caller can bind it to a + durable receipt, but it is excluded from ``fingerprint``. A different + intent with identical effective input therefore has the same content + fingerprint and remains eligible for an intentional second execution. + """ + if type(command) is not dict: + raise ImageGenerationSpecError("Image generation command must be an object") + if type(command.get("version")) is not int: + raise ImageGenerationSpecError("version must be the integer 1") + if type(command.get("operation")) is not str: + raise ImageGenerationSpecError("operation must be generation.image") + if type(command.get("intent_id")) is not str: + raise ImageGenerationSpecError("intent_id must be a non-empty string") + if type(command.get("input")) is not dict: + raise ImageGenerationSpecError("input must be an object") + + try: + envelope = _ImageGenerationEnvelope.model_validate(command) + parsed = ImageGenerationInput.model_validate(envelope.input) + except ValidationError as exc: + raise _validation_error(exc) from exc + + _require_non_blank(envelope.intent_id, "intent_id") + _validate_semantics(parsed) + # Keep the validated caller envelope byte-for-byte at the value level: + # spelling, omission and numeric representation are part of the receipt's + # original snapshot. Pydantic validation above has already rejected any + # unknown or type-invalid field before this copy is returned. + original = deepcopy(command) + + effective_input = parsed.model_dump(mode="json") + effective_input.update(NATIVE_IMAGE_DEFAULTS) + effective = { + "version": SCHEMA_VERSION, + "operation": OPERATION, + "intent_id": envelope.intent_id, + "input": effective_input, + } + content = _canonical_content(effective) + return { + "original": original, + "effective": effective, + "fingerprint_version": FINGERPRINT_VERSION, + "fingerprint": _fingerprint(content), + } + + +def image_generation_schema() -> dict[str, Any]: + """Publish the versioned, implemented input surface for catalog adapters.""" + input_schema = ImageGenerationInput.model_json_schema() + return { + "version": SCHEMA_VERSION, + "operation": OPERATION, + "intent_id": { + "type": "string", + "minLength": 1, + "maxLength": _MAX_INTENT_LENGTH, + }, + "input": input_schema, + "supported_input_fields": list(SUPPORTED_INPUT_FIELDS), + "effects": deepcopy(NATIVE_IMAGE_DEFAULTS), + "excluded": [ + "client", + "actor", + "permission", + "provenance", + "image_refs", + "image_guide", + "activated_loras", + "loras_multipliers", + "repeat_generation", + "output_count", + "generation_mode", + "model3d", + "video", + "audio", + "avatar", + ], + } + + +__all__ = [ + "FINGERPRINT_VERSION", + "ImageGenerationInput", + "ImageGenerationSpecError", + "OPERATION", + "SCHEMA_VERSION", + "SUPPORTED_INPUT_FIELDS", + "freeze_image_generation_spec", + "image_generation_schema", +] diff --git a/app/services/studio_image_conditioning.py b/app/services/studio_image_conditioning.py new file mode 100644 index 000000000..995e64626 --- /dev/null +++ b/app/services/studio_image_conditioning.py @@ -0,0 +1,30 @@ +"""Reject image inputs that the native selectors would discard.""" + + +def validate_image_selectors(params, definition): + image_selector = params.get("image_prompt_type") or "" + video_selector = params.get("video_prompt_type") or "" + selectors = { + "image_refs": "I" in video_selector, + "image_guide": "V" in video_selector, + "image_mask": all(letter in video_selector for letter in "VA") and "U" not in video_selector, + "image_start": "S" in image_selector, + "image_end": "E" in image_selector, + } + for field, enabled in selectors.items(): + _validate_selected_field(field, params.get(field), enabled, definition) + if selectors["image_end"] and not definition.get("end_frames_always_enabled"): + if not any(letter in image_selector for letter in "SVL"): + raise ValueError("input.params.image_end: this model requires a start frame selector for end frames") + + +def _validate_selected_field(field, value, enabled, definition): + values = value if isinstance(value, list) else [value] + selected = any(values) + generated_start = field == "image_start" and definition.get("black_frame") + if selected and not enabled: + raise ValueError(f"input.params.{field}: its native conditioning selector must be enabled") + if enabled and not selected and not generated_start: + raise ValueError(f"input.params.{field}: its conditioning selector requires an image") + if enabled and selected and any(item == "" for item in values): + raise ValueError(f"input.params.{field}: empty frame slots are not supported in image generation") diff --git a/app/services/studio_image_preparation.py b/app/services/studio_image_preparation.py new file mode 100644 index 000000000..269ac0da4 --- /dev/null +++ b/app/services/studio_image_preparation.py @@ -0,0 +1,76 @@ +"""Read-only model/resource preflight for full Studio image commands.""" +from copy import deepcopy + +from services.image_generation_commands import command_error, validate_image_model +from services.studio_image_resources import validate_lora_multipliers +from services.studio_image_conditioning import validate_image_selectors + + +def _has_reference(value): + return any(value) if isinstance(value, list) else bool(value) + + +def _validate_conditioning(params, definition): + if params.get("image_refs") and not definition.get("image_ref_choices"): + raise ValueError("input.params.image_refs: the model does not support image references") + allowed = definition.get("image_prompt_types_allowed", "") + for field, letter in (("image_start", "S"), ("image_end", "E")): + if _has_reference(params.get(field)) and letter not in allowed: + raise ValueError(f"input.params.{field}: the model does not support this frame input") + if _has_reference(params.get("image_mask")) and not definition.get("inpaint_support"): + raise ValueError("input.params.image_mask: the model does not support inpainting") + if params.get("negative_prompt") and definition.get("no_negative_prompt"): + raise ValueError("input.params.negative_prompt: the model does not use a negative prompt") + maximum = definition.get("guidance_max_phases", 1) + if params.get("guidance_phases", 1) > maximum: + raise ValueError("input.params.guidance_phases exceeds the model's supported phases") + return maximum + + +def _validate_processors(params, capabilities, validate_selection, validated_settings): + spatial = params.get("spatial_upsampling", "") + temporal = params.get("temporal_upsampling", "") + error = validate_selection(spatial, temporal, True) + if error: + raise ValueError(error) + if spatial: + selected = next((item for item in capabilities() if item["value"] == spatial and item["kind"] == "spatial"), None) + if not selected or not selected.get("enabled") or "image" not in selected.get("media", []): + raise ValueError("input.params.spatial_upsampling: select an installed image processor") + submitted = params.get("wangp_processor_settings") or {} + resolved = validated_settings(spatial, submitted) + if set(submitted) != set(resolved): + raise ValueError("input.params.wangp_processor_settings contains settings not supported by the selected processor") + + +def _validate_model_options(params, definition): + steps = params["num_inference_steps"] + if steps < definition.get("inference_steps_min", 1) or steps > definition.get("inference_steps_max", 1000): + raise ValueError("input.params.num_inference_steps is outside this model's declared range") + solver = params.get("sample_solver") + choices = definition.get("sample_solvers") or [] + allowed = [item[1] if isinstance(item, (list, tuple)) else str(item) for item in choices] + if solver and allowed and solver not in allowed: + raise ValueError("input.params.sample_solver is not supported by this model") + if params.get("skip_steps_cache_type") == "first_block" and not definition.get("first_block_cache"): + raise ValueError("input.params.skip_steps_cache_type: this model does not support first-block caching") + + +def prepare_studio_image(params, *, model_definition, model_downloaded, resources, + execution_policy, processor_capabilities, validate_processors, + processor_settings): + """Return detached native parameters and inspected identities before admission.""" + execution_policy(params["workspace"]) + definition = validate_image_model(params, model_definition=model_definition, + model_downloaded=model_downloaded, allow_references=True) + try: + maximum_phases = _validate_conditioning(params, definition) + validate_image_selectors(params, definition) + _validate_model_options(params, definition) + validate_lora_multipliers(params, maximum_phases) + _validate_processors(params, processor_capabilities, validate_processors, processor_settings) + working, media = resources.prepare_media(params) + loras = resources.prepare_loras(params, definition) + return deepcopy(working), [*media, *loras] + except (ValueError, OSError) as error: + raise command_error(422, "invalid_studio_input", str(error)) from error diff --git a/app/services/studio_image_resources.py b/app/services/studio_image_resources.py new file mode 100644 index 000000000..a1c1d0b6a --- /dev/null +++ b/app/services/studio_image_resources.py @@ -0,0 +1,200 @@ +"""Resolve Studio command inputs using the existing media and LoRA locations. + +This is read-only preparation. It neither installs models nor publishes assets. +Canonical URLs retain the source workspace even when output uses another one. +""" +from __future__ import annotations + +from copy import deepcopy +import hashlib +import math +from pathlib import Path +import re +from urllib.parse import parse_qs, quote, unquote, urlsplit + +from services.wangp_submission import resolve_wangp_media, wangp_media_url + + +IMAGE_FIELDS = ("image_refs", "image_start", "image_end", "image_guide", "image_mask") + + +def file_identity(path): + source = Path(path) + before = source.stat() + digest = hashlib.sha256() + with source.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + after = source.stat() + if (before.st_size, before.st_mtime_ns, before.st_ino) != (after.st_size, after.st_mtime_ns, after.st_ino): + raise ValueError("A selected resource changed while being inspected; select it again") + return {"sha256": digest.hexdigest(), "size_bytes": after.st_size} + + +class StudioImageResources: + def __init__(self, *, workspace_dir, uploads_dir, list_workspaces, + lora_search_dirs, lora_compatible): + self.workspace_dir = workspace_dir + self.uploads_dir = uploads_dir + self.list_workspaces = list_workspaces + self.lora_search_dirs = lora_search_dirs + self.lora_compatible = lora_compatible + + def _workspace_names(self): + return {item["name"] for item in self.list_workspaces() if isinstance(item, dict) + and isinstance(item.get("name"), str) and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]*", item["name"])} + + def _source_workspace(self, path): + roots = [(name, Path(self.workspace_dir(name)).resolve()) for name in self._workspace_names()] + # The default output directory contains the named workspace folders. + # Prefer the most specific root instead of relabelling a nested source. + roots.sort(key=lambda item: len(item[1].parts), reverse=True) + return next(((name, root) for name, root in roots if path.is_relative_to(root)), None) + + def _media(self, value): + if value.startswith(("asset_", "asset:", "asset-")): + return self._media(self._asset_url(value)) + parsed = urlsplit(value) + if parsed.scheme or parsed.netloc or parsed.fragment: + raise ValueError("Choose an exact local media URL from the asset catalog") + if parsed.path.startswith("/api/v1/assets/"): + if parsed.query: + raise ValueError("Asset references cannot override their source location") + return self._media(self._asset_url(unquote(parsed.path[len("/api/v1/assets/"):]))) + workspace, source_workspace = self._url_workspace(parsed) + path = resolve_wangp_media(value, workspace, uploads_dir=self.uploads_dir(), + workspace_dir=self.workspace_dir(workspace)) + if source_workspace != "__uploads__": + actual = self._source_workspace(Path(path).resolve()) + if actual is None or actual[0] != source_workspace: + raise ValueError("The reference must name its actual source workspace") + return path, source_workspace + + def _url_workspace(self, parsed): + """Resolve the explicit source root before any file lookup.""" + if parsed.path.startswith("/api/v1/uploads/"): + if parsed.query: + raise ValueError("Upload references cannot override their source location") + return "default", "__uploads__" + if parsed.path.startswith("/api/v1/file/"): + query = parse_qs(parsed.query, keep_blank_values=True) + if set(query) != {"workspace"} or len(query["workspace"]) != 1: + raise ValueError("File references require one explicit source workspace") + workspace = query["workspace"][0] + if workspace not in self._workspace_names(): + raise ValueError("The reference source workspace is not available") + return workspace, workspace + raise ValueError("Choose a canonical upload or workspace file URL") + + def _asset_url(self, identity): + from services.asset_catalog import find_asset + roots = [{"workspace_id": name, "path": self.workspace_dir(name)} for name in self._workspace_names()] + roots.append({"workspace_id": "__uploads__", "path": self.uploads_dir()}) + asset = find_asset(roots, identity) + if not asset or asset.get("kind") != "image": + raise ValueError("Choose an existing image asset ID") + locations = asset.get("locations") or [] + if len(locations) != 1: + raise ValueError("This asset has multiple locations; choose an exact source URL") + location = locations[0] + filename = quote(location["filename"], safe="") + if location["workspace_id"] == "__uploads__": + return f"/api/v1/uploads/{filename}" + return f"/api/v1/file/{filename}?workspace={quote(location['workspace_id'], safe='')}" + + def canonicalize_legacy(self, value): + """Convert an exact legacy path, without matching basenames elsewhere.""" + if value.startswith(("/api/v1/", "asset_", "asset:", "asset-")): + self._media(value) + return value + source = Path(value) + if not source.is_absolute() or not source.is_file(): + raise ValueError("A legacy reference must be an exact existing local path") + resolved = source.resolve() + uploads = Path(self.uploads_dir()).resolve() + if resolved.is_relative_to(uploads): + return wangp_media_url(resolved, "default", uploads_dir=uploads, + workspace_dir=self.workspace_dir("default")) + source = self._source_workspace(resolved) + if source: + workspace, root = source + return wangp_media_url(resolved, workspace, uploads_dir=uploads, workspace_dir=root) + raise ValueError("The legacy reference is outside known media locations") + + def prepare_media(self, params): + working = deepcopy(params) + resources = [] + for field in IMAGE_FIELDS: + raw = working.get(field) + if not raw: + continue + values = raw if isinstance(raw, list) else [raw] + paths = [] + for index, value in enumerate(values): + if value == "": + # Native per-prompt optional frame lists retain empty + # positions. An empty slot is not a selected resource. + paths.append("") + continue + path, workspace = self._media(value) + identity = file_identity(path) + from PIL import Image + with Image.open(path) as picture: + picture.verify() + resources.append({"role": field, "index": index, "url": value, + "workspace": workspace, **identity}) + paths.append(path) + working[field] = paths if isinstance(raw, list) else paths[0] + # Resolution has already happened against each explicit source root; + # native fallback must not reinterpret those URLs in the output folder. + working.pop("canonical_image_refs", None) + return working, resources + + def prepare_loras(self, params, model_definition): + resources = [] + if not params.get("activated_loras"): + return resources + roots = self.lora_search_dirs(params["model_type"]) + for name in params.get("activated_loras") or []: + if Path(name).name != name or "/" in name or "\\" in name: + raise ValueError("Choose an exact LoRA name from this model's catalog") + matches = _lora_candidates(roots, name) + if len(matches) != 1: + raise ValueError("A selected LoRA is missing or ambiguous in the model's search locations") + path = matches.pop() + if not self.lora_compatible(model_definition, path): + raise ValueError("A selected LoRA is incompatible with the selected model") + resources.append({"role": "lora", "name": name, **file_identity(path)}) + return resources + + +def _lora_candidates(roots, name): + matches = set() + for directory in roots: + root = Path(directory).resolve() + candidate = root / name + if candidate.is_file(): + resolved = candidate.resolve() + if not resolved.is_relative_to(root): + raise ValueError("A selected LoRA points outside its model's search location") + matches.add(str(resolved)) + return matches + + +def validate_lora_multipliers(params, maximum_phases): + from shared.utils.loras_mutipliers import parse_loras_multipliers, preparse_loras_multipliers + names = params.get("activated_loras") or [] + text = params.get("loras_multipliers") or "" + multipliers = preparse_loras_multipliers(text) if text else [] + if len(multipliers) > len(names): + raise ValueError("LoRA multiplier count exceeds the selected LoRA count") + for multiplier in multipliers: + for phase in multiplier.split(";"): + for part in phase.split(","): + if not math.isfinite(float(part)): + raise ValueError("LoRA multipliers must be finite numbers") + _, _, error = parse_loras_multipliers(text, len(names), params["num_inference_steps"], + nb_phases=maximum_phases, + model_switch_phase=params.get("model_switch_phase", 1)) + if error: + raise ValueError(error) diff --git a/app/services/studio_image_spec.py b/app/services/studio_image_spec.py new file mode 100644 index 000000000..68a590632 --- /dev/null +++ b/app/services/studio_image_spec.py @@ -0,0 +1,826 @@ +"""Closed, provider-free contract for a complete Studio image submission. + +The first shared image command (``image_generation_spec``) intentionally owns a +small text-to-image surface. Studio, however, has a much larger native +parameter map: image references, LoRAs, model tuning, post-processing and +typed processor settings are all assembled immediately before ``newJob`` in +the browser. This module freezes that map before model/resource resolution +or queue effects. + +The v2 transport envelope is:: + + { + "version": 2, + "operation": "generation.image", + "intent_id": "...", + "input": { + "workspace": "...", + "params": { ... strict native image fields ... } + } + } + +``original`` is a detached copy of the caller envelope. ``effective`` adds +only deterministic adapter defaults and keeps the native image fields intact. +The content fingerprint covers the effective operation, workspace and params; +the transport intent is deliberately excluded. Actor/provenance data and +host paths are rejected here because the runtime owns those authorities. + +Reference values are exact asset identifiers or canonical local API URLs. A +URL is syntax-checked here; source workspace containment, file identity and +model/LoRA compatibility are resolved by the resource/model preflight layer. +No model catalog, settings loader, filesystem path or provider is consulted. +""" + +from __future__ import annotations + +from copy import deepcopy +import hashlib +import json +import re +from typing import Annotated, Any, Literal, Union +from urllib.parse import parse_qs, unquote, urlsplit + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + StringConstraints, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, + ValidationError, + field_validator, + model_validator, +) +from services.image_generation_spec import ImageGenerationSpecError + + +SCHEMA_VERSION = 2 +FINGERPRINT_VERSION = 2 +OPERATION = "generation.image" + +_MAX_ID_LENGTH = 240 +_MAX_INTENT_LENGTH = 160 +_MAX_PROMPT_LENGTH = 200_000 +_MAX_REFERENCE_LENGTH = 8192 +_MAX_REFERENCE_COUNT = 64 + +# These defaults are adapter-owned. Defaults for an installed model must be +# obtained by the later model preflight, never inferred by this provider-free +# boundary. In particular, settings_version is retained when explicitly +# supplied but is intentionally not invented here. +STUDIO_IMAGE_DEFAULTS = { + "generation_mode": "image", + "image_mode": 1, + "video_length": 1, + "multi_prompts_gen_type": 2, + "repeat_generation": 1, + "batch_size": 1, + "prompt_enhancer": "", + "activated_loras": [], + "loras_multipliers": "", + "canonical_image_refs": False, +} + +# The native engine accepts many more keys for video, audio, avatars and +# editor jobs. Fields that have a harmless empty/null image-mode sentinel are +# modelled below as ``INACTIVE_IMAGE_FIELDS`` so a restored Studio snapshot is +# not truncated. Other mode-specific fields remain rejected explicitly. +INACTIVE_IMAGE_FIELDS = ( + "minimax_h3_turbo_mode", + "audio_guide", + "audio_guide2", + "audio_guide3", + "audio_guide4", + "audio_guide5", + "audio_guide6", + "audio_source", + "video_source", + "video_guide", + "video_mask", + "temporal_upsampling", + "audio_prompt_type", + "sliding_window_size", + "sliding_window_overlap", + "sliding_window_memory_override", + "sliding_window_discard_last_frames", + "sliding_window_color_correction_strength", + "sliding_window_overlap_noise", + "keep_frames_video_source", + "keep_frames_video_guide", + "force_fps", + "h3_ref_videos", + "h3_ref_audios", + "minimax_h3_references", + "MMAudio_setting", + "MMAudio_prompt", + "MMAudio_neg_prompt", +) + +INCOMPATIBLE_IMAGE_FIELDS = ( + # Avatar/audio/video controls have no image-mode interpretation. Their + # names are listed here so callers get a stable contract error instead of + # accidentally relying on the generic extra-field message. + "viggle_audio_mode", + "preserve_source_style", + "stage2_steps", + "per_clip_frames", + "per_clip_keyframes", + "perturbation_switch", + "perturbation_layers", + "perturbation_start_perc", + "perturbation_end_perc", + "stg_scale", + "keyframe_conditioning_mode", + "keyframe_inject_mode", + "h3_audio_shift", + "h3_audio_prompt", + "h3_ref_image_size", + "h3_reference_mode", + "h3_model_profile", + "h3_reference_context", + "h3_window_prompts", + "h3_window_plan_signature", + "h3_window_plan", + "minimax_h3_reference_detail", + "minimax_h3_text_encoder", + "minimax_h3_turbo_preset", + "minimax_h3_planning_style", + "minimax_h3_audio_policy", + "minimax_h3_reference_sequence", + "minimax_h3_semantic_bridge_alpha", + "minimax_h3_semantic_bridge_magnitude", + "minimax_h3_multi_window", + "minimax_h3_window_storyboard", + "continue_video", + "voice_reference", + "voice_clone_enabled", + "voice_clone_mode", + "voice_clone_refs", + "tts_dynaudnorm", + "tts_comp_threshold", + "tts_comp_attack", + "tts_comp_release", + "tts_comp_makeup", + "tts_voice_count", + "duration_seconds", + "pause_seconds", + "_audio_sub_mode", + "_music_description", + "_music_instrumental", + "_tts_original_prompt", + "_tts_speaker_name1", + "_tts_speaker_name2", + "sfx_mode", +) + +# Fields in this v2 contract are the image subset of the native generate +# signature plus the post-processing/processor knobs that Studio serializes. +# Keep this inventory explicit; adding a field requires a typed declaration and +# a regression test rather than an unvalidated JSON escape hatch. +SUPPORTED_INPUT_FIELDS = ( + "minimax_h3_turbo_mode", + "workspace", + "workspace_collection_id", + "prompt", + "alt_prompt", + "model_type", + "resolution", + "video_length", + "num_inference_steps", + "guidance_scale", + "seed", + "image_mode", + "generation_mode", + "negative_prompt", + "repeat_generation", + "batch_size", + "activated_loras", + "loras_multipliers", + "image_start", + "image_end", + "image_refs", + "image_guide", + "image_mask", + "video_guide", + "video_mask", + "video_source", + "audio_guide", + "audio_guide2", + "audio_guide3", + "audio_guide4", + "audio_guide5", + "audio_guide6", + "audio_source", + "MMAudio_setting", + "MMAudio_prompt", + "MMAudio_neg_prompt", + "h3_ref_videos", + "h3_ref_audios", + "minimax_h3_references", + "image_prompt_type", + "video_prompt_type", + "frames_positions", + "canonical_image_refs", + "multi_prompts_gen_type", + "image_fit_mode", + "input_video_strength", + "denoising_strength", + "masking_strength", + "video_guide_outpainting", + "control_net_weight", + "control_net_weight2", + "control_net_weight_alt", + "motion_amplitude", + "mask_expand", + "image_refs_relative_size", + "remove_background_images_ref", + "model_mode", + "temporal_upsampling", + "audio_prompt_type", + "sliding_window_size", + "sliding_window_overlap", + "sliding_window_memory_override", + "sliding_window_discard_last_frames", + "sliding_window_color_correction_strength", + "sliding_window_overlap_noise", + "keep_frames_video_source", + "keep_frames_video_guide", + "force_fps", + "flow_shift", + "sample_solver", + "embedded_guidance_scale", + "guidance2_scale", + "guidance3_scale", + "switch_threshold", + "switch_threshold2", + "guidance_phases", + "model_switch_phase", + "alt_guidance_scale", + "alt_scale", + "audio_guidance_scale", + "audio_scale", + "NAG_scale", + "NAG_tau", + "NAG_alpha", + "RIFLEx_setting", + "injection_strength", + "identity_guidance_scale", + "skip_steps_cache_type", + "skip_steps_multiplier", + "skip_steps_start_step_perc", + "settings_version", + "prompt_enhancer", + "spatial_upsampling", + "film_grain_intensity", + "film_grain_saturation", + "progressive_pipeline", + "single_stage_pipeline", + "reference_pipeline", + "progressive_stage1_image_weight", + "progressive_stage2_steps", + "progressive_stage2_sigma", + "progressive_stage3_steps", + "progressive_stage3_sigma", + "progressive_stage3_image_weight", + "override_profile", + "override_attention", + "temperature", + "top_p", + "top_k", + "self_refiner_setting", + "self_refiner_plan", + "self_refiner_f_uncertainty", + "self_refiner_certain_percentage", + "cfg_rescale", + "modality_scale", + "use_gradient_estimation", + "ge_gamma", + "ge_alpha", + "outpaint_lora_strength", + "outpaint_mask_preserve", + "outpaint_official_stack", + "custom_settings", + "wangp_processor_settings", +) + + +class StudioImageSpecError(ImageGenerationSpecError): + """Named alias for callers that distinguish the Studio v2 contract.""" + + +class _ClosedModel(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + + +_Identity = Annotated[ + StrictStr, + StringConstraints(min_length=1, max_length=_MAX_ID_LENGTH), +] +_Workspace = Annotated[ + StrictStr, + StringConstraints( + min_length=1, + max_length=_MAX_ID_LENGTH, + pattern=r"^(?:default|[A-Za-z0-9][A-Za-z0-9_-]*)$", + ), +] +_WorkspaceCollectionId = Annotated[ + StrictStr, + StringConstraints(min_length=1, max_length=200), +] +_IntentId = Annotated[ + StrictStr, + StringConstraints(min_length=1, max_length=_MAX_INTENT_LENGTH), +] +_Prompt = Annotated[ + StrictStr, + StringConstraints(min_length=1, max_length=_MAX_PROMPT_LENGTH), +] +_Text = Annotated[StrictStr, StringConstraints(max_length=_MAX_PROMPT_LENGTH)] +_ShortText = Annotated[StrictStr, StringConstraints(max_length=8192)] +_InactiveText = Literal["", None] +_NonBlankShortText = Annotated[ + StrictStr, + StringConstraints(min_length=1, max_length=8192), +] +_ReferenceText = Annotated[ + StrictStr, + StringConstraints(min_length=1, max_length=_MAX_REFERENCE_LENGTH), +] +_OptionalReferenceText = Annotated[ + StrictStr, + StringConstraints(max_length=_MAX_REFERENCE_LENGTH), +] +_Steps = Annotated[StrictInt, Field(ge=1, le=1000)] +_Seed = Annotated[StrictInt, Field(ge=-(2**63), le=2**63 - 1)] +_Count = Annotated[StrictInt, Field(ge=1, le=100)] +_PhaseCount = Annotated[StrictInt, Field(ge=1, le=16)] +_NonNegativeInt = Annotated[StrictInt, Field(ge=0, le=100_000)] +_Finite = Annotated[StrictFloat, Field(allow_inf_nan=False)] +_Unit = Annotated[StrictFloat, Field(ge=0, le=1, allow_inf_nan=False)] +_Percent = Annotated[StrictFloat, Field(ge=0, le=100, allow_inf_nan=False)] +_ImageSelector = Annotated[StrictInt, Field(ge=1, le=1)] + + +_ASSET_ID = re.compile(r"^asset(?:[_:-])[A-Za-z0-9][A-Za-z0-9._:-]{0,238}$") +_WORKSPACE_QUERY = re.compile(r"^(?:default|[A-Za-z0-9][A-Za-z0-9_-]*)$") + + +def _asset_id(value: str) -> bool: + return bool(_ASSET_ID.fullmatch(value)) + + +def _safe_local_path(path: str) -> bool: + decoded = unquote(path) + if not decoded or "\\" in decoded or "\x00" in decoded: + return False + parts = decoded.split("/") + return all(part not in {"", ".", ".."} for part in parts) + + +def _validate_reference(value: str) -> str: + """Accept exact asset IDs and canonical local API references only.""" + if not isinstance(value, str) or not value.strip() or value != value.strip(): + raise ValueError("reference must be a non-blank string") + # Asset catalog IDs are opaque identifiers, never filesystem paths. Keep + # their spelling exactly as supplied for the original/effective snapshot. + if _asset_id(value): + return value + if "\\" in value or "\x00" in value: + raise ValueError("reference must be a canonical local asset URL or asset ID") + parsed = urlsplit(value) + if parsed.scheme or parsed.netloc or parsed.fragment: + raise ValueError("reference must use a canonical local URL") + _validate_reference_url(parsed) + return value + + +def _validate_reference_url(parsed): + """Check each canonical URL family's path and source-workspace contract.""" + path = parsed.path + if path.startswith("/api/v1/uploads/"): + if parsed.query or not _safe_local_path(path[len("/api/v1/uploads/"):]): + raise ValueError("upload reference must be a canonical local URL") + return + if path.startswith("/api/v1/file/"): + if not _safe_local_path(path[len("/api/v1/file/"):]): + raise ValueError("file reference must identify a safe local asset") + query = parse_qs(parsed.query, keep_blank_values=True) + if set(query) != {"workspace"} or len(query["workspace"]) != 1: + raise ValueError("file references require one workspace query value") + if not _WORKSPACE_QUERY.fullmatch(query["workspace"][0]): + raise ValueError("file reference workspace is invalid") + return + if path.startswith("/api/v1/assets/"): + if parsed.query or not _asset_id(unquote(path[len("/api/v1/assets/"):])): + raise ValueError("asset URL must identify one exact asset ID") + return + raise ValueError("reference must be a canonical local asset URL or asset ID") + + +def _validate_optional_reference(value: str) -> str: + # Restore paths use an empty string sentinel when no start/end image was + # attached. It is distinct from a user-selected reference and is kept + # exactly in the frozen native map. + if value == "": + return value + return _validate_reference(value) + + +# Pydantic does not apply a field validator to an Annotated scalar alias in all +# supported 2.x releases. A tiny custom type would make JSON schema opaque, so +# the models below use this helper through field validators instead. +ImageReference = _ReferenceText +ImageReferenceList = list[ImageReference] +ImageReferenceOrList = Union[_OptionalReferenceText, ImageReferenceList] + + +class WangpProcessorSettings(_ClosedModel): + """Typed settings currently declared by image-capable processors.""" + + spatial_upsampler_strength: _Finite | None = None + spatial_upsampler_face_count: Annotated[StrictInt, Field(ge=0, le=5)] | None = None + spatial_upsampler_h3_strength: _Unit | None = None + spatial_upsampler_prompt: _Text | None = None + spatial_upsampler_reference_images: ImageReferenceList | None = Field(default_factory=list, max_length=_MAX_REFERENCE_COUNT) + spatial_upsampler_dlss_strength: Annotated[StrictFloat, Field(ge=0, le=2, allow_inf_nan=False)] | None = None + + @field_validator("spatial_upsampler_reference_images") + @classmethod + def _check_reference_images(cls, values): + if values is None: + return values + return [_validate_reference(value) for value in values] + + +class StudioImageCustomSettings(_ClosedModel): + """Known model-specific image settings, with no untyped JSON map.""" + + # SenseNova image generation. + sensenova_kv_cache: Literal["Disabled", "Enabled"] | None = None + # HiDream accepts these values internally even though current UI versions + # do not expose controls for them. Keeping them typed prevents a model + # setting from becoming a free-form provider payload. + noise_scale_start: _Finite | None = None + noise_scale_end: _Finite | None = None + noise_clip_std: _Finite | None = None + + +class StudioImageParams(_ClosedModel): + """The closed native parameter family for one Studio image job.""" + + prompt: _Prompt + alt_prompt: _Text = "" + model_type: _Identity + resolution: _NonBlankShortText + video_length: _ImageSelector = 1 + num_inference_steps: _Steps + guidance_scale: _Finite + seed: _Seed + image_mode: _ImageSelector = 1 + generation_mode: Literal["image"] = "image" + negative_prompt: _Text = "" + repeat_generation: _Count = 1 + batch_size: _Count = 1 + activated_loras: list[_NonBlankShortText] = Field(default_factory=list, max_length=64) + loras_multipliers: _ShortText = "" + + # Image conditioning. Resource resolution happens after this snapshot. + image_start: ImageReferenceOrList | None = None + image_end: ImageReferenceOrList | None = None + image_refs: ImageReferenceList | None = Field(default_factory=list, max_length=_MAX_REFERENCE_COUNT) + image_guide: ImageReferenceOrList | None = None + image_mask: ImageReferenceOrList | None = None + image_prompt_type: _ShortText = "" + video_prompt_type: _ShortText = "" + frames_positions: _ShortText = "" + canonical_image_refs: StrictBool = False + + # Image/edit model controls. + multi_prompts_gen_type: _NonNegativeInt = 2 + image_fit_mode: Literal["", "contain", "source", "crop"] = "" + input_video_strength: _Finite | None = None + denoising_strength: _Finite | None = None + masking_strength: _Finite | None = None + video_guide_outpainting: _NonBlankShortText = "" + control_net_weight: _Finite | None = None + control_net_weight2: _Finite | None = None + control_net_weight_alt: _Finite | None = None + motion_amplitude: _Finite | None = None + mask_expand: _Finite | None = None + image_refs_relative_size: _Finite | None = None + remove_background_images_ref: Annotated[StrictInt, Field(ge=0, le=1)] = 0 + model_mode: _NonNegativeInt | None = 0 + + # Studio keeps a few video/audio controls in the shared working set even + # while image mode is active. Their only valid image-mode values are the + # inactive sentinels; retaining those values avoids dropping a complete + # browser snapshot while an active video/audio request fails closed. + temporal_upsampling: _InactiveText = "" + audio_prompt_type: _InactiveText = "" + video_guide: _InactiveText = None + video_mask: _InactiveText = None + video_source: _InactiveText = None + audio_guide: _InactiveText = None + audio_guide2: _InactiveText = None + audio_guide3: _InactiveText = None + audio_guide4: _InactiveText = None + audio_guide5: _InactiveText = None + audio_guide6: _InactiveText = None + audio_source: _InactiveText = None + MMAudio_setting: Annotated[StrictInt, Field(ge=0, le=0)] | None = None + MMAudio_prompt: _InactiveText = None + MMAudio_neg_prompt: _InactiveText = None + h3_ref_videos: ImageReferenceList | None = None + h3_ref_audios: ImageReferenceList | None = None + # H3's full manifest is a separate video command. Empty lists are + # retained here because output restore currently seeds them for every + # generation mode; non-empty references fail closed as an incompatible + # mode. + minimax_h3_references: ImageReferenceList | None = None + minimax_h3_turbo_mode: Annotated[StrictBool, Field(json_schema_extra={"const": False})] | None = None + sliding_window_size: _NonNegativeInt | None = None + sliding_window_overlap: _NonNegativeInt | None = None + sliding_window_memory_override: StrictBool | None = None + sliding_window_discard_last_frames: _NonNegativeInt | None = None + sliding_window_color_correction_strength: _Finite | None = None + sliding_window_overlap_noise: _Finite | None = None + keep_frames_video_source: _InactiveText = None + keep_frames_video_guide: _InactiveText = None + force_fps: _InactiveText = "" + + # Sampling/model controls shared by image families. + flow_shift: _Finite | None = None + sample_solver: _ShortText = "" + embedded_guidance_scale: _Finite | None = None + guidance2_scale: _Finite | None = None + guidance3_scale: _Finite | None = None + switch_threshold: _Finite | None = None + switch_threshold2: _Finite | None = None + guidance_phases: _PhaseCount = 1 + model_switch_phase: _PhaseCount = 1 + alt_guidance_scale: _Finite | None = None + alt_scale: _Finite | None = None + audio_guidance_scale: _Finite | None = None + audio_scale: _Finite | None = None + NAG_scale: _Finite | None = None + NAG_tau: _Finite | None = None + NAG_alpha: _Unit | None = None + RIFLEx_setting: _NonNegativeInt | None = None + injection_strength: _Finite | None = None + identity_guidance_scale: _Finite | None = None + skip_steps_cache_type: Literal["", "first_block"] = "" + skip_steps_multiplier: _Finite | None = None + skip_steps_start_step_perc: _Percent | None = None + settings_version: _Finite | None = None + prompt_enhancer: _ShortText = "" + + # Post-processing and native staged image options. + spatial_upsampling: _ShortText = "" + film_grain_intensity: _Unit | None = None + film_grain_saturation: _Unit | None = None + progressive_pipeline: StrictBool = False + single_stage_pipeline: StrictBool = False + reference_pipeline: StrictBool = False + progressive_stage1_image_weight: _Finite | None = None + progressive_stage2_steps: _NonNegativeInt | None = None + progressive_stage2_sigma: _Finite | None = None + progressive_stage3_steps: _NonNegativeInt | None = None + progressive_stage3_sigma: _Finite | None = None + progressive_stage3_image_weight: _Finite | None = None + override_profile: _ShortText | None = None + override_attention: _ShortText | None = None + temperature: _Finite | None = None + top_p: _Finite | None = None + top_k: _NonNegativeInt | None = None + self_refiner_setting: _NonNegativeInt | None = None + self_refiner_plan: _Text | None = None + self_refiner_f_uncertainty: _Finite | None = None + self_refiner_certain_percentage: _Unit | None = None + cfg_rescale: _Finite | None = None + modality_scale: _Finite | None = None + use_gradient_estimation: StrictBool | None = None + ge_gamma: _Finite | None = None + ge_alpha: _Finite | None = None + outpaint_lora_strength: _Finite | None = None + outpaint_mask_preserve: StrictBool | None = None + outpaint_official_stack: StrictBool | None = None + custom_settings: StudioImageCustomSettings | None = None + wangp_processor_settings: WangpProcessorSettings | None = None + + @field_validator("minimax_h3_turbo_mode") + @classmethod + def _inactive_turbo(cls, value): + if value is True: + raise ValueError("minimax_h3_turbo_mode must be inactive in image mode") + return value + + @field_validator("image_refs") + @classmethod + def _check_image_refs(cls, values): + if values is None: + return values + return [_validate_reference(value) for value in values] + + @field_validator("h3_ref_videos", "h3_ref_audios", "minimax_h3_references") + @classmethod + def _check_inactive_reference_lists(cls, values): + if values is None: + return values + if values: + raise ValueError("reference lists must be empty in image mode") + return values + + @field_validator("image_start", "image_end", "image_guide", "image_mask", mode="after") + @classmethod + def _check_single_or_many_reference(cls, value): + if value is None: + return value + values = value if isinstance(value, list) else [value] + return [_validate_optional_reference(item) for item in values] if isinstance(value, list) else _validate_optional_reference(value) + + @field_validator("activated_loras") + @classmethod + def _check_lora_names(cls, values): + for value in values: + if not value.strip() or "/" in value or "\\" in value or value in {".", ".."}: + raise ValueError("activated_loras must contain exact catalog names") + return values + + @field_validator("resolution") + @classmethod + def _check_resolution_shape(cls, value): + match = re.fullmatch(r"([1-9][0-9]{1,4})x([1-9][0-9]{1,4})", value) + if not match or any(not 64 <= int(part) <= 4096 or int(part) % 8 for part in match.groups()): + raise ValueError("resolution must be WIDTHxHEIGHT, each 64..4096 and divisible by 8") + return value + + @model_validator(mode="after") + def _check_semantics(self): + for field in ("prompt", "model_type", "resolution"): + value = getattr(self, field) + if not value.strip(): + raise ValueError(f"{field} must contain a non-blank value") + if self.canonical_image_refs and not self.image_refs: + raise ValueError("canonical_image_refs requires at least one image_refs entry") + return self + + +class StudioImageInput(_ClosedModel): + workspace: _Workspace + # Optional collection identity is transport provenance, not a native + # generation parameter. The collection registry resolves it later. + workspace_collection_id: _WorkspaceCollectionId | None = None + params: StudioImageParams + + @model_validator(mode="after") + def _check_collection_id(self): + if self.workspace_collection_id is not None and not self.workspace_collection_id.strip(): + raise ValueError("workspace_collection_id must contain a non-blank value") + return self + + +class _StudioImageEnvelope(_ClosedModel): + version: Literal[SCHEMA_VERSION] + operation: Literal[OPERATION] + intent_id: _IntentId + input: StudioImageInput + + @model_validator(mode="after") + def _check_intent(self): + if not self.intent_id.strip(): + raise ValueError("intent_id must contain a non-blank value") + return self + + +def _validation_error(exc: ValidationError) -> ImageGenerationSpecError: + details: list[dict[str, Any]] = [] + messages: list[str] = [] + for error in exc.errors(include_input=False): + location = ".".join(str(part) for part in error.get("loc", ())) or "command" + message = str(error.get("msg") or "Invalid value") + details.append({"loc": location, "message": message, "type": error.get("type")}) + messages.append(f"{location}: {message}") + return StudioImageSpecError( + "; ".join(messages) or "Invalid Studio image generation command", + details=details, + ) + + +def _canonical_content(effective: dict[str, Any]) -> dict[str, Any]: + return { + "version": SCHEMA_VERSION, + "operation": OPERATION, + "input": deepcopy(effective["input"]), + } + + +def _fingerprint(content: dict[str, Any]) -> str: + encoded = json.dumps( + content, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def freeze_studio_image_spec(command: Any) -> dict[str, Any]: + """Validate and detach one complete Studio image command. + + This function is pure. It does not resolve a model, inspect a file, + mutate a registry, load settings or enqueue work. ``original`` retains + caller spelling/omission exactly; ``effective`` is the native projection + consumed by the later preflight boundary. + """ + if type(command) is not dict: + raise StudioImageSpecError("Studio image generation command must be an object") + try: + envelope = _StudioImageEnvelope.model_validate(command) + except ValidationError as exc: + raise _validation_error(exc) from exc + + original = deepcopy(command) + explicit_params = envelope.input.params.model_dump(mode="json", exclude_unset=True) + effective_params = deepcopy(explicit_params) + for key, value in STUDIO_IMAGE_DEFAULTS.items(): + effective_params.setdefault(key, deepcopy(value)) + effective = { + "version": SCHEMA_VERSION, + "operation": OPERATION, + "intent_id": envelope.intent_id, + "input": { + "workspace": envelope.input.workspace, + "params": effective_params, + }, + } + # Keep an explicit collection ID, including an explicit null, while + # leaving the field absent when the caller omitted it. + explicit_input = envelope.input.model_dump(mode="json", exclude_unset=True) + if "workspace_collection_id" in explicit_input: + effective["input"]["workspace_collection_id"] = explicit_input["workspace_collection_id"] + content = _canonical_content(effective) + return { + "original": original, + "effective": effective, + "fingerprint_version": FINGERPRINT_VERSION, + "fingerprint": _fingerprint(content), + } + + +def studio_image_schema() -> dict[str, Any]: + """Return the implemented v2 envelope and its explicit image boundary.""" + input_schema = StudioImageInput.model_json_schema() + envelope_schema = _StudioImageEnvelope.model_json_schema() + return { + "version": SCHEMA_VERSION, + "operation": OPERATION, + "intent_id": envelope_schema["properties"]["intent_id"], + "input": input_schema, + "supported_input_fields": list(SUPPORTED_INPUT_FIELDS), + "effects": deepcopy(STUDIO_IMAGE_DEFAULTS), + "inactive": list(INACTIVE_IMAGE_FIELDS), + "excluded": [ + "actor", + "client", + "permission", + "provenance", + "workspace inside input.params", + *INCOMPATIBLE_IMAGE_FIELDS, + "free-form JSON settings", + "filesystem paths", + ], + } + + +# Names used by adapters and discovery code in different slices of the shared +# command work. They are aliases, not additional contracts. +StudioImageGenerationInput = StudioImageInput +StudioImageGenerationParams = StudioImageParams +image_generation_schema_v2 = studio_image_schema + + +__all__ = [ + "FINGERPRINT_VERSION", + "INACTIVE_IMAGE_FIELDS", + "INCOMPATIBLE_IMAGE_FIELDS", + "ImageGenerationSpecError", + "ImageReference", + "OPERATION", + "SCHEMA_VERSION", + "STUDIO_IMAGE_DEFAULTS", + "SUPPORTED_INPUT_FIELDS", + "StudioImageCustomSettings", + "StudioImageGenerationInput", + "StudioImageGenerationParams", + "StudioImageInput", + "StudioImageParams", + "StudioImageSpecError", + "WangpProcessorSettings", + "freeze_studio_image_spec", + "image_generation_schema_v2", + "studio_image_schema", +] diff --git a/app/services/task_command_admission.py b/app/services/task_command_admission.py new file mode 100644 index 000000000..830d453b4 --- /dev/null +++ b/app/services/task_command_admission.py @@ -0,0 +1,158 @@ +"""Atomic command admission in the canonical TaskRegistry database. + +Receipts describe admission, not execution or published assets. The domain +executor still owns its worker and uses the existing resource scheduler. +""" +from __future__ import annotations + +import copy +import hashlib +import json + + +class TaskCommandConflict(ValueError): + """An intention already belongs to a different effective request.""" + + +def _json(value): + return json.dumps(value, ensure_ascii=False, sort_keys=True, allow_nan=False) + + +def _snapshot_digest(original, effective, receipt): + return hashlib.sha256(_json([original, effective, receipt]).encode("utf-8")).hexdigest() + + +def _valid_dispatch_owner(owner): + return owner is None or (isinstance(owner, str) and 1 <= len(owner) <= 160 and bool(owner.strip())) + + +def _valid_fingerprint(row, values): + receipt = values["receipt"] + if row["fingerprint_version"] == 1: + return "fingerprintVersion" not in receipt + return (row["fingerprint_version"] == 2 and receipt.get("fingerprintVersion") == 2 + and receipt.get("contentFingerprint") == row["digest"] + and receipt.get("commandVersion") == values["original"].get("version")) + + +def _decode_admission(row): + try: + values = {key: json.loads(row[key]) for key in ("original", "effective", "receipt")} + receipt = values["receipt"] + valid = (all(isinstance(value, dict) for value in values.values()) + and receipt["version"] == 1 and receipt["commandId"] == row["intent_id"] + and receipt["operation"] == row["operation"] and receipt["status"] == "queued" + and receipt["taskIds"] == [row["task_id"]] + and receipt["result"]["task_id"] == row["task_id"] + and _valid_fingerprint(row, values) + and _valid_dispatch_owner(row["dispatch_owner"]) + and row["snapshot_digest"] == _snapshot_digest(**values)) + if not valid: + raise ValueError("Invalid admission snapshot") + return {**dict(row), **values} + except (ValueError, KeyError, TypeError) as error: + raise OSError("Command admission storage is corrupt") from error + + +def _receipt(intent_id: str, operation: str, task: dict) -> dict: + return { + "version": 1, "commandId": intent_id, "operation": operation, "status": "queued", + "entities": [], "artifacts": [], "taskIds": [task["id"]], "pipelineIds": [], + "result": {"job_id": task["backend_job_id"], "task_id": task["id"], + "root_task_id": task["root_id"], "workspace": task["workspace"], "status": "queued"}, + } + + +class TaskCommandAdmission: + """Mixin using TaskRegistry's connection, snapshot, event and lock helpers.""" + + @staticmethod + def _initialize_command_admissions(connection) -> None: + connection.execute("""CREATE TABLE IF NOT EXISTS task_command_admissions ( + intent_id TEXT PRIMARY KEY, + operation TEXT NOT NULL, + fingerprint_version INTEGER NOT NULL, + digest TEXT NOT NULL, + task_id TEXT NOT NULL, + original TEXT NOT NULL, + effective TEXT NOT NULL, + receipt TEXT NOT NULL, + snapshot_digest TEXT NOT NULL, + dispatch_owner TEXT + )""") + + def command_admission(self, intent_id: str) -> dict | None: + with self._connect() as connection: + row = connection.execute("SELECT * FROM task_command_admissions WHERE intent_id = ?", (intent_id,)).fetchone() + if row is None: + return None + return _decode_admission(row) + + def claim_command_dispatch(self, intent_id: str, owner: str) -> bool: + """Claim initial dispatch once, after admission and before queue effects. + + A claimed command is never automatically stolen after a timeout. A + process crash requires the existing explicit queue recovery choice. + """ + if owner is None or not _valid_dispatch_owner(owner): + raise ValueError("A runtime dispatch owner is required") + with self._write_lock, self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") + changed = connection.execute("""UPDATE task_command_admissions SET dispatch_owner = ? + WHERE intent_id = ? AND dispatch_owner IS NULL AND task_id IN + (SELECT id FROM tasks WHERE status = 'queued')""", (owner, intent_id)).rowcount + connection.commit() + return bool(changed) + + def command_recovery_candidates(self) -> list[dict]: + """Project interrupted admissions whose runtime snapshot can be restored.""" + with self._connect() as connection: + rows = connection.execute("""SELECT c.intent_id FROM task_command_admissions c + JOIN tasks t ON t.id = c.task_id WHERE t.status = 'interrupted' + ORDER BY t.created_at""").fetchall() + return [self.command_admission(row["intent_id"]) for row in rows] + + def admit_command_task(self, *, intent_id: str, operation: str, digest: str, + original: dict, effective: dict, task_fields: dict, fingerprint_version: int = 1) -> dict: + """Commit one task/event and its recoverable receipt, or replay it. + + The caller validates the domain specification and supplies trusted task + fields. A new intention is independent of its content fingerprint. + No queue/worker is invoked while this transaction is open. + """ + if not all(isinstance(value, str) and value for value in (intent_id, operation, digest)): + raise ValueError("Command identity, operation and fingerprint are required") + if type(fingerprint_version) is not int or fingerprint_version not in (1, 2): + raise ValueError("Unsupported command fingerprint version") + # Validate serialization before opening a write transaction. Do not apply + # the public task metadata truncation rules to literal command inputs. + original_json, effective_json = _json(original), _json(effective) + task = self._build_task(task_fields) + if task["status"] != "queued" or not task["backend_job_id"]: + raise ValueError("Command admission requires a queued task and exact backend job ID") + receipt = _receipt(intent_id, operation, task) + if fingerprint_version == 2: + receipt.update(commandVersion=original["version"], contentFingerprint=digest, fingerprintVersion=2) + with self._write_lock, self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") + previous = connection.execute( + "SELECT * FROM task_command_admissions WHERE intent_id = ?", (intent_id,), + ).fetchone() + if previous is not None: + previous = _decode_admission(previous) + if (previous["operation"] != operation or previous["digest"] != digest + or previous["fingerprint_version"] != fingerprint_version): + raise TaskCommandConflict("intent_id was already used with different parameters or preconditions") + connection.rollback() + return {"receipt": previous["receipt"], "replayed": True} + # This uses the same task snapshot and event insertion as ordinary + # TaskRegistry.create. A collision never adopts another task. + self._insert_task(connection, task, {"metadata"}) + connection.execute("""INSERT INTO task_command_admissions + (intent_id, operation, fingerprint_version, digest, task_id, original, effective, receipt, snapshot_digest) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (intent_id, operation, fingerprint_version, digest, task["id"], original_json, effective_json, _json(receipt), + _snapshot_digest(original, effective, receipt))) + connection.commit() + self._after_task_created(task) + return {"receipt": copy.deepcopy(receipt), "replayed": False} diff --git a/app/services/task_manager.py b/app/services/task_manager.py index 8e36a39ab..242820d18 100644 --- a/app/services/task_manager.py +++ b/app/services/task_manager.py @@ -13,6 +13,7 @@ import json import logging import os +from pathlib import Path import re import sqlite3 import threading @@ -21,6 +22,8 @@ from typing import Any, Iterator, TypedDict from services.operation_logging import log_operation +from services.task_command_admission import TaskCommandAdmission +from services.workspace_store_lock import workspace_store_lock _LOGGER = logging.getLogger("loreframe.operations.tasks") @@ -33,7 +36,7 @@ DEFAULT_TASK_RETENTION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60 DEFAULT_TASK_RETENTION_MAX_TERMINAL_TASKS = 1_000 DEFAULT_TASK_RETENTION_MAX_EVENTS = 10_000 -TASK_SCHEMA_VERSION = 2 +TASK_SCHEMA_VERSION = 3 _PRUNED_THROUGH_META_KEY = "events_pruned_through" _SCHEMA_VERSION_META_KEY = "schema_version" ACTIVE_STATUSES = frozenset({"created", "queued", "waiting_resource", "running"}) @@ -301,7 +304,7 @@ def run_with_task_context(context: dict[str, Any], callback, *args, **kwargs): return callback(*args, **kwargs) -class TaskRegistry: +class TaskRegistry(TaskCommandAdmission): def __init__(self, workspace_dir: str, *, interrupt_stale: bool = True): self.workspace_dir = os.path.realpath(os.path.abspath(workspace_dir)) os.makedirs(self.workspace_dir, exist_ok=True) @@ -316,12 +319,20 @@ def _connect(self) -> sqlite3.Connection: connection = sqlite3.connect(self.path, timeout=15, isolation_level=None) connection.row_factory = sqlite3.Row connection.execute("PRAGMA busy_timeout=15000") - connection.execute("PRAGMA journal_mode=WAL") connection.execute("PRAGMA foreign_keys=ON") return connection def _initialize(self) -> None: + # A journal-mode transition can fail immediately under concurrent first + # connections, even with SQLite busy_timeout. Serialize bootstrap using + # the existing portable file lock; ordinary transactions remain SQLite. + with workspace_store_lock(Path(self.path)): + self._initialize_tables() + + def _initialize_tables(self) -> None: with self._connect() as connection: + if connection.execute("PRAGMA journal_mode").fetchone()[0] != "wal": + connection.execute("PRAGMA journal_mode=WAL") connection.executescript(""" CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, @@ -358,6 +369,7 @@ def _initialize(self) -> None: ); """) self._migrate_task_events_to_durable_log(connection) + self._initialize_command_admissions(connection) self._record_schema_version(connection) @staticmethod @@ -473,12 +485,8 @@ def _append_event( ) return int(cursor.lastrowid) - def create( - self, - *, - event_exclude_fields: set[str] | frozenset[str] | None = None, - **fields: Any, - ) -> dict: + @staticmethod + def _build_task(fields: dict[str, Any]) -> dict: now = _now() task_id = str(fields.get("id") or new_task_id(str(fields.get("kind") or "task")))[:200] root_id = str(fields.get("root_id") or task_id)[:200] @@ -531,31 +539,42 @@ def create( "result_refs": _bounded(fields.get("result_refs") or []), "metadata": _bounded(fields.get("metadata") or {}), } + return task + + def _insert_task(self, connection, task: dict, event_exclude_fields=None) -> None: + connection.execute( + """INSERT INTO tasks + (id, root_id, parent_id, workspace, status, kind, workflow, created_at, updated_at, snapshot) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (task["id"], task["root_id"], task["parent_id"], task["workspace"], task["status"], + task["kind"], task["workflow"], task["created_at"], task["updated_at"], _json(task)), + ) + excluded = set(event_exclude_fields or ()) + event_snapshot = {key: value for key, value in task.items() if key not in excluded} + self._append_event(connection, task, "task.created", event_snapshot) + + def _after_task_created(self, task: dict) -> None: + _update_cancellation_token(self.workspace_dir, task["id"], status=task["status"], phase=task["phase"]) + self._notify() + + def create( + self, + *, + event_exclude_fields: set[str] | frozenset[str] | None = None, + **fields: Any, + ) -> dict: + task = self._build_task(fields) with self._write_lock, self._connect() as connection: connection.execute("BEGIN IMMEDIATE") existing = self._decode(connection.execute( - "SELECT snapshot FROM tasks WHERE id = ?", (task_id,), + "SELECT snapshot FROM tasks WHERE id = ?", (task["id"],), ).fetchone()) if existing is not None: connection.rollback() return existing - connection.execute( - """INSERT INTO tasks - (id, root_id, parent_id, workspace, status, kind, workflow, created_at, updated_at, snapshot) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", - ( - task_id, root_id, parent_id, task["workspace"], status, task["kind"], - task["workflow"], task["created_at"], now, _json(task), - ), - ) - excluded = set(event_exclude_fields or ()) - event_snapshot = { - key: value for key, value in task.items() if key not in excluded - } - self._append_event(connection, task, "task.created", event_snapshot) + self._insert_task(connection, task, event_exclude_fields) connection.commit() - _update_cancellation_token(self.workspace_dir, task_id, status=status, phase=task["phase"]) - self._notify() + self._after_task_created(task) return copy.deepcopy(task) def get(self, task_id: str) -> dict | None: diff --git a/app/services/wangp_submission.py b/app/services/wangp_submission.py index 68f9861ae..895dae49a 100644 --- a/app/services/wangp_submission.py +++ b/app/services/wangp_submission.py @@ -20,7 +20,7 @@ async def json(self): return deepcopy(self.payload) -def prepare_generation_inputs(body, model_def, workspace, *, uploads_dir, workspace_dir): +def prepare_generation_inputs(body, model_def, workspace, *, uploads_dir, workspace_dir, prepared_images=False): """Validate processor options and resolve new-family media before admission.""" canonical_refs = body.pop('canonical_image_refs', False) if canonical_refs: @@ -39,9 +39,15 @@ def prepare_generation_inputs(body, model_def, workspace, *, uploads_dir, worksp body['wangp_processor_settings'] = validated_settings(body.get('spatial_upsampling', ''), body.get('wangp_processor_settings')) if not model_def.get('wangp_1272'): return + if prepared_images and (not model_def.get('image_outputs') or body.get('image_mode') != 1): + raise ValueError('Prepared image inputs require an image generation request') def resolve(value): return resolve_wangp_media(value, workspace, uploads_dir=uploads_dir, workspace_dir=workspace_dir) for field in ('video_guide', 'video_guide2', 'video_mask', 'audio_guide', 'audio_guide2', 'image_start', 'image_end', 'image_refs'): + if prepared_images and field in ('image_start', 'image_end', 'image_refs'): + # Only the in-process Studio command adapter supplies this flag. + # Those exact paths were resolved against each source workspace. + continue values = body.get(field) if values: body[field] = [resolve(value) for value in values] if isinstance(values, list) else resolve(values) diff --git a/docs/development/IMAGE_COMMANDS.md b/docs/development/IMAGE_COMMANDS.md new file mode 100644 index 000000000..e2e58fa3e --- /dev/null +++ b/docs/development/IMAGE_COMMANDS.md @@ -0,0 +1,184 @@ +# Shared image command admission + +Studio's image Generate button, Wizard `start_generation` in image mode, and +MCP `generation.image` share native preparation and durable command admission. +The browser builds version 2 from its complete assembled image parameters, +including references, LoRAs and advanced options. Version 1 remains available +for small text-to-image clients. Video, audio, Tools, editorial domains and +workflow execution are not covered by this slice. + +## Contract and discovery + +`GET /api/v1/generation/commands` describes the two executable operations. +The same entries generate MCP `generation.image` and `generation.receipt`. +The original ten MCP tools retain their existing names and behavior. +The Python schemas also generate `ui/src/api/imageCommandCatalog.json` through +`python scripts/export_image_command_catalog.py`; `--check` rejects stale +projections. Discovery preserves the correlation between each envelope version +and its input schema. It does not expose an arbitrary runtime parameter map. + +Submit to `POST /api/v1/generation/commands`: + +```json +{ + "version": 1, + "operation": "generation.image", + "intent_id": "an-intention-chosen-by-the-client", + "input": { + "workspace": "default", + "model_type": "an-exact-installed-image-model-id", + "prompt": "A literal prompt\nwith a second line", + "resolution": "512x512", + "num_inference_steps": 1, + "seed": 42, + "guidance_scale": 1.0 + } +} +``` + +MCP carries the operation in the tool name and omits `operation` from its +arguments. `negative_prompt` is optional. Unknown fields and authority claims +are rejected. The native image selectors, single-output counts and complete +multiline prompt policy are fixed by the contract; prompt enhancement is off. +Models must be installed and support image output without mandatory references. +Resolution uses explicit dimensions, each 64..4096 and divisible by eight. +No discovery call downloads a model. Native generation preparation remains the +authority for its model-specific rules and execution policy. + +Studio and advanced MCP clients use this version 2 envelope: + +```json +{ + "version": 2, + "operation": "generation.image", + "intent_id": "another-explicit-intention", + "input": { + "workspace": "default", + "params": { + "model_type": "an-exact-installed-image-model-id", + "prompt": "A literal prompt\nwith a second line", + "resolution": "512x512", + "num_inference_steps": 4, + "seed": 42, + "guidance_scale": 1.0, + "image_refs": ["/api/v1/file/reference.png?workspace=source"], + "video_prompt_type": "I", + "activated_loras": [], + "loras_multipliers": "", + "spatial_upsampling": "" + } + } +} +``` + +The selected model must support the supplied conditioning. Version 2 accepts +only the typed image fields declared in `studio_image_spec.py`. Optional native +sentinels and explicit null values remain in the snapshot. Active video/audio +inputs and unknown fields are rejected before admission. Repeat/batch and +multiline policies are explicit native parameters; they can produce several +images in one native job. Nested processor settings use a closed schema and +must match the installed image processor's capabilities. +Selected inputs must also match their native conditioning selectors (`I` for +references, `V` for a guide, `VA` for its mask, `S`/`E` for frames). Inconsistent +selectors and active frame lists with empty slots are rejected before admission. + +References use exact asset IDs or local API URLs. Workspace file URLs must +name their source workspace, which may differ from the output workspace. +An asset with multiple locations requires an exact URL. The read-only +`POST /api/v1/generation/commands/references` converts existing absolute paths +from older UI state to canonical URLs; it never searches by basename. Selected +images have their file structure verified and are hashed. LoRAs must exist unambiguously in the selected +model's search directories and pass its existing compatibility rule. This does +not add universal tensor compatibility validation for every model family. + +`input.workspace_collection_id` optionally names the target Workspace collection +and contributes to the fingerprint. The native preparation layer validates the +collection. It is separate from the physical output folder. UI surface and +workflow/run attribution travel in typed `X-Hocus-UI-Surface` and +`X-Hocus-UI-Context` headers; they grant no permissions. MCP supplies its own +external tool context. A retry returns the first admission's attribution. + +## Visible browser execution + +The complete native form is assembled once and detached before submission. +The shared client persists that exact command, then awaits the Studio panel's +correlated React acknowledgement before POST. The panel shows the literal +prompt, model, dimensions, workspace and reference/LoRA counts. It expands the +sidebar when needed; mobile Generate waits for admission before closing it. +The existing form remains available for manual editing. Changes to the form or +workspace during preparation cancel the pending submission before its effect. + +The Wizard uses the admission's exact task/job IDs for execution cards. A later +navigation failure retains the real receipt with a presentation warning. A +pending command appears in its original output workspace after reload; its +recovery button retries the stored intention and parameters, rather than the +currently edited form. Native task/gallery projections remain authoritative +for progress and finished assets. + +## Admission and progress + +The response is `{receipt, replayed}`. An immutable receipt with status `queued` +proves admission and contains exact native job/task IDs. It does not prove that +a worker is currently running, that inference completed, or that media passed +quality review. Read the current task through the canonical task API or through +`GET /api/v1/generation/commands/receipt?workspace=...&intent_id=...`, which +returns `{receipt, task}`. A retained receipt may outlive task retention. + +The intention namespace is the physical output workspace's TaskRegistry +database. It is independent of the installation-wide collection intentions. +Every request freezes its explicit output workspace; neither transport reads +the last browser's current selection. The field `workspace` here is an output +folder name, not a Workspace collection ID. A retry preserves that field and +the intention ID; another deliberate generation uses another intention. + +The content fingerprint excludes transport identity and includes all validated +effective inputs. TaskRegistry stores the original envelope, effective input +and the prepared native runtime snapshot separately from its bounded public +task metadata. The native snapshot also freezes the engine's base settings at +admission, including omitted settings outside the typed image input. These +defaults come from the engine's settings file, not a browser's current form. +Changing those defaults while a job waits cannot alter that admitted snapshot. +They are recorded in the runtime snapshot, outside the input fingerprint. +Version 2 receipts expose `commandVersion`, `fingerprintVersion` +and `contentFingerprint` together. The fingerprint versions cannot adopt each +other's intentions. A snapshot checksum detects corrupt admission storage and fails +closed. This is integrity checking, not protection against a malicious database +administrator. Server configuration and installed model bytes are external +dependencies; storing a request does not freeze a model installation. Resource +hashes record the files inspected before admission; this slice does not make +immutable copies of references or LoRAs during a queued job's lifetime. + +## Atomicity and recovery + +TaskRegistry schema version 3 adds command admissions to its existing SQLite +database. One transaction commits the canonical task, creation event and +receipt. This is not another task registry or scheduler. Older binaries reject +this schema rather than modifying tasks while ignoring their receipts. + +A separate atomic claim permits initial dispatch once. The existing native +FIFO and worker perform inference. The existing durable generation queue is a +recoverable projection of the stored runtime request; persistence must succeed +before dispatch. Transport retries cannot steal a claimed command after a +timeout. A failed response after admission must be recovered with the same ID. + +On restart, TaskRegistry marks interrupted work and the existing queue recovery +UI can restore its native request without automatically starting inference. +Explicit resume restarts the original inference from the beginning, according +to that existing policy; it is not a mid-diffusion checkpoint. Discard records +the terminal cancellation so a later recovery scan cannot resurrect it. +The deployment remains one native generation runtime; independent API worker +processes sharing a GPU still need the broader server fencing/lease work. + +The browser client persists a detached command before POST. A lost response, +invalid receipt or temporary outage preserves it across reload. A rejection +on an already uncertain retry does not delete the hint. A storage-cleanup +failure after receiving a valid receipt does not turn admission into failure. +JavaScript clients reject unsafe integer seeds instead of rounding a literal. + +## Validation boundary + +Provider-free service, HTTP/MCP, SQLite concurrency/failure and browser client +tests cover the command boundary. Runtime wiring tests preserve the existing +task projection and H3 preparation behavior. Real-media evidence is recorded +separately in the execution outputs and PR; unit tests do not certify GPU +generation, quality, Windows durability or every Studio parameter. diff --git a/scripts/export_image_command_catalog.py b/scripts/export_image_command_catalog.py new file mode 100644 index 000000000..cfa34e6f2 --- /dev/null +++ b/scripts/export_image_command_catalog.py @@ -0,0 +1,31 @@ +"""Export the executable image contracts consumed by the browser client.""" +from pathlib import Path +import argparse +import json +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "app")) +from routers.image_generation_commands import image_command_catalog +from services.studio_image_spec import studio_image_schema + + +def catalog(): + return {"version": 2, "operations": image_command_catalog(), "studio": studio_image_schema()} + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + path = ROOT / "ui/src/api/imageCommandCatalog.json" + expected = json.dumps(catalog(), indent=2, ensure_ascii=False) + "\n" + if args.check: + if not path.is_file() or path.read_text(encoding="utf-8") != expected: + raise SystemExit("Image command projection is stale; run scripts/export_image_command_catalog.py and review its diff") + else: + path.write_text(expected, encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/architecture_wire_inventory.json b/tests/fixtures/architecture_wire_inventory.json index e78f87080..676485cf7 100644 --- a/tests/fixtures/architecture_wire_inventory.json +++ b/tests/fixtures/architecture_wire_inventory.json @@ -163,6 +163,12 @@ "classification": "fragile_source", "reason": "Python inspects TypeScript source; splitting useStore requires converting or relocating this contract." }, + { + "file": "tests/test_image_generation_commands.py", + "target": "app/_launch_runtime.py", + "classification": "symbol_importable", + "reason": "Extracts selected launch symbols with AST/exec; migrate to direct imports when that domain moves." + }, { "file": "tests/test_job_lifecycle_wiring.py", "target": "app/_launch_runtime.py", @@ -619,6 +625,12 @@ "classification": "behavior", "reason": "Imports the public Zustand facade; it should survive slice extraction unchanged." }, + { + "file": "ui/tests/wizardImageReceiptResult.test.ts", + "target": "ui/src/stores/useStore.ts", + "classification": "behavior", + "reason": "Imports the public Zustand facade; it should survive slice extraction unchanged." + }, { "file": "ui/tests/wizardInteractionDom.test.tsx", "target": "ui/src/stores/useStore.ts", diff --git a/tests/fixtures/route_table.json b/tests/fixtures/route_table.json index 717edc9dc..afc1fc51b 100644 --- a/tests/fixtures/route_table.json +++ b/tests/fixtures/route_table.json @@ -3111,6 +3111,46 @@ "source": "app/_launch_runtime.py", "ordinal": 310 }, + { + "method": "GET", + "path": "/api/v1/generation/commands", + "endpoint": "catalog", + "status_code": null, + "response_model": null, + "include_in_schema": null, + "source": "app/routers/image_generation_commands.py", + "ordinal": 311 + }, + { + "method": "POST", + "path": "/api/v1/generation/commands", + "endpoint": "submit", + "status_code": null, + "response_model": null, + "include_in_schema": null, + "source": "app/routers/image_generation_commands.py", + "ordinal": 312 + }, + { + "method": "GET", + "path": "/api/v1/generation/commands/receipt", + "endpoint": "receipt", + "status_code": null, + "response_model": null, + "include_in_schema": null, + "source": "app/routers/image_generation_commands.py", + "ordinal": 313 + }, + { + "method": "POST", + "path": "/api/v1/generation/commands/references", + "endpoint": "references", + "status_code": null, + "response_model": null, + "include_in_schema": null, + "source": "app/routers/image_generation_commands.py", + "ordinal": 314 + }, { "method": "POST", "path": "/api/v1/wangp/mcp", @@ -3119,7 +3159,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/wangp_mcp.py", - "ordinal": 311 + "ordinal": 315 }, { "method": "GET", @@ -3129,7 +3169,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/wangp_mcp.py", - "ordinal": 312 + "ordinal": 316 }, { "method": "GET", @@ -3139,7 +3179,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 313 + "ordinal": 317 } ] } diff --git a/tests/fixtures/studio_image_native_request.json b/tests/fixtures/studio_image_native_request.json new file mode 100644 index 000000000..6f8525a44 --- /dev/null +++ b/tests/fixtures/studio_image_native_request.json @@ -0,0 +1,38 @@ +{ + "prompt": "Taller de luz ámbar — mañana\n第二行: café crème\nlinea final — conserva literalmente esta composición", + "model_type": "flux2_klein_9b", + "resolution": "672x672", + "video_length": 1, + "num_inference_steps": 1, + "guidance_scale": 1, + "seed": 424242, + "image_mode": 1, + "negative_prompt": "borroso\nнизкое качество — do not rewrite", + "repeat_generation": 1, + "activated_loras": [], + "loras_multipliers": "", + "settings_version": 2.52, + "flow_shift": 5, + "embedded_guidance_scale": 1, + "guidance_phases": 1, + "sliding_window_size": 80, + "sliding_window_overlap": 5, + "sliding_window_discard_last_frames": 0, + "minimax_h3_turbo_mode": false, + "generation_mode": "image", + "workspace": "studio-fixture-workspace", + "provenance": { + "actor": "user", + "tool": "studio", + "capability": "start_generation", + "command": { + "command_id": "captured-native-studio-intent" + } + }, + "image_refs": [ + "/api/v1/uploads/captured-reference-one.jpg", + "/api/v1/uploads/captured-reference-two.jpg" + ], + "remove_background_images_ref": 0, + "video_prompt_type": "KI" +} diff --git a/tests/test_image_command_restart_boundaries.py b/tests/test_image_command_restart_boundaries.py new file mode 100644 index 000000000..8a42a4a4a --- /dev/null +++ b/tests/test_image_command_restart_boundaries.py @@ -0,0 +1,98 @@ +"""Restart-boundary checks using the production TaskRegistry factory. + +These tests model the two points at which the native worker may be absent: +before the durable generation projection is written and after initial dispatch +has been claimed. They deliberately do not start a model worker. +""" + +from __future__ import annotations + +from copy import deepcopy +import hashlib +import json + +from services.task_manager import forget_task_registry, get_task_registry + + +def _digest(value: dict) -> str: + encoded = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def _admit(registry, intent_id: str) -> tuple[dict, str]: + original = { + "version": 1, + "operation": "generation.image", + "intent_id": intent_id, + "input": { + "workspace": "restart-boundary", + "model_type": "installed-image-model", + "prompt": "literal restart probe", + "resolution": "512x512", + "num_inference_steps": 1, + "seed": -1, + "guidance_scale": 1.0, + }, + } + effective = deepcopy(original) + task_id = f"task-{intent_id}" + registry.admit_command_task( + intent_id=intent_id, + operation="generation.image", + digest=_digest(effective), + original=original, + effective=effective, + task_fields={ + "id": task_id, + "root_id": f"root-{task_id}", + "kind": "image", + "workflow": "generation.image", + "title": "Restart boundary probe", + "status": "queued", + "phase": "queued", + "message": "Queued", + "workspace": "restart-boundary", + "backend_job_id": f"job-{intent_id}", + "current": 0, + "total": 1, + "recoverable": True, + "metadata": {"test": "restart-boundary"}, + }, + ) + return original, task_id + + +def test_factory_restart_recovers_admission_when_projection_was_never_persisted(tmp_path): + """A fresh production factory interrupts a committed queued admission.""" + first = get_task_registry(str(tmp_path)) + original, task_id = _admit(first, "before-persist") + assert first.get(task_id)["status"] == "queued" + + # No durable generation-queue projection is written: this is the crash + # between canonical admission and persist_recovery(). + forget_task_registry(str(tmp_path)) + restarted = get_task_registry(str(tmp_path)) # default interrupt_stale=True + + task = restarted.get(task_id) + assert task["status"] == "interrupted" + candidates = restarted.command_recovery_candidates() + assert [entry["intent_id"] for entry in candidates] == [original["intent_id"]] + + +def test_factory_restart_recovers_admission_after_dispatch_claim(tmp_path): + """A claimed queued admission is interrupted and remains recoverable.""" + first = get_task_registry(str(tmp_path)) + original, task_id = _admit(first, "after-claim") + assert first.claim_command_dispatch(original["intent_id"], "dispatch-owner") is True + assert first.get(task_id)["status"] == "queued" + assert first.command_admission(original["intent_id"])["dispatch_owner"] == "dispatch-owner" + + # The real factory's bootstrap calls interrupt_unfinished() before the + # recovery projection is queried. No second dispatch is performed here. + forget_task_registry(str(tmp_path)) + restarted = get_task_registry(str(tmp_path)) # default interrupt_stale=True + + assert restarted.get(task_id)["status"] == "interrupted" + entry = restarted.command_admission(original["intent_id"]) + assert entry["dispatch_owner"] == "dispatch-owner" + assert [candidate["intent_id"] for candidate in restarted.command_recovery_candidates()] == [original["intent_id"]] diff --git a/tests/test_image_generation_commands.py b/tests/test_image_generation_commands.py new file mode 100644 index 000000000..6fcc412e4 --- /dev/null +++ b/tests/test_image_generation_commands.py @@ -0,0 +1,816 @@ +"""Provider-free adversarial tests for shared image command admission. + +The fake native facade below deliberately calls the admission capability only +after it has validated the request. No test starts a model worker: these +checks exercise the durable task boundary, replay/claim semantics and the HTTP +and MCP projections around it. +""" + +from __future__ import annotations + +import asyncio +import ast +from concurrent.futures import ThreadPoolExecutor +from copy import deepcopy +from pathlib import Path +import sqlite3 +import threading + +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient + +from routers.image_generation_commands import ( + create_image_generation_commands_router, + image_command_catalog, + image_command_handlers, +) +from routers.wangp_mcp import create_wangp_mcp_router +from services.image_generation_commands import ( + ImageGenerationCommands, + validate_image_model, +) +from services.image_generation_runtime import create_image_generation_commands +from services.image_generation_spec import freeze_image_generation_spec +from services.task_manager import TaskRegistry + + +def _run(awaitable): + return asyncio.run(awaitable) + + +def _command(intent_id: str = "image-intent-1", **input_overrides) -> dict: + image_input = { + "workspace": "workspace-a", + "model_type": "pi_flux2", + "prompt": ' literal "mañana"\nsecond line ', + "negative_prompt": " avoid blur ", + "resolution": "512x512", + "num_inference_steps": 1, + "seed": -1, + "guidance_scale": 1.0, + } + image_input.update(input_overrides) + return { + "version": 1, + "operation": "generation.image", + "intent_id": intent_id, + "input": image_input, + } + + +def _task_fields(job: dict) -> dict: + task_id = f"task-{job['id']}" + return { + "id": task_id, + "root_id": f"root-{task_id}", + "kind": "image", + "workflow": "generation.image", + "title": "Image admission", + "status": "queued", + "phase": "queued", + "message": "Queued for image generation", + "workspace": job["workspace"], + "backend_job_id": job["id"], + "current": 0, + "total": 1, + "resource_requirements": ["local_gpu:0"], + "recoverable": True, + "metadata": {"command_scope": "test"}, + } + + +class FakeNative: + """Small native facade with no inference side effects.""" + + def __init__(self, root, *, interrupt_stale=False): + self.root = Path(root) + self.interrupt_stale = interrupt_stale + self._registries = {} + self._job_number = 0 + self._lock = threading.RLock() + self.prepare_calls = 0 + self.preflight_calls = 0 + self.native_validated = False + self.callback_after_validation = False + self.prepare_error = None + self.preflight_error = None + self.make_job_error = None + self.persist_error = None + self.dispatch_error = None + self.persist_calls = [] + self.dispatch_calls = [] + self.persisted = {} + self.active = set() + + def registry(self, workspace): + with self._lock: + if workspace not in self._registries: + self._registries[workspace] = TaskRegistry( + str(self.root / workspace), interrupt_stale=self.interrupt_stale, + ) + return self._registries[workspace] + + def preflight(self, _params): + self.preflight_calls += 1 + if self.preflight_error is not None: + raise self.preflight_error + + async def prepare(self, request): + self.prepare_calls += 1 + if self.prepare_error is not None: + raise self.prepare_error + body = await request.json() + # This is the ordering contract of the native facade: model/request + # validation happens before transferring the in-process capability. + self.native_validated = True + workspace = body.pop("workspace") + provenance = body.pop("provenance") + self.callback_after_validation = self.native_validated + return request.admit_generation_command(body, workspace, provenance) + + def make_job(self, body, workspace, *, job_id=None, created_at=None, + reserve_generation=False, publish_task=False, provenance=None): + del reserve_generation, publish_task + if self.make_job_error is not None: + raise self.make_job_error + with self._lock: + self._job_number += 1 + number = self._job_number + return { + "id": job_id or f"backend-image-{number}", + "status": "queued", + "created_at": created_at if created_at is not None else float(1000 + number), + "params": deepcopy(body), + "workspace": workspace, + "provenance": deepcopy(provenance or {}), + } + + @staticmethod + def task_fields(job): + return _task_fields(job) + + def persist_recovery(self, job): + if self.persist_error is not None: + raise self.persist_error + with self._lock: + self.persist_calls.append(deepcopy(job)) + self.persisted[job["id"]] = deepcopy(job) + + def dispatch(self, job): + # Record the attempted start before raising to model a lost response + # after a worker was handed the job. + with self._lock: + self.dispatch_calls.append(deepcopy(job)) + self.active.add(job["id"]) + if self.dispatch_error is not None: + raise self.dispatch_error + + def active_job_ids(self): + with self._lock: + return set(self.active) + + def service(self): + return ImageGenerationCommands( + registry=self.registry, + prepare=self.prepare, + preflight=self.preflight, + make_job=self.make_job, + task_fields=self.task_fields, + dispatch=self.dispatch, + persist_recovery=self.persist_recovery, + active_job_ids=self.active_job_ids, + ) + + +def _db_counts(registry: TaskRegistry) -> dict[str, int]: + with sqlite3.connect(registry.path) as connection: + return { + table: int(connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]) + for table in ("tasks", "task_events", "task_command_admissions") + } + + +def test_submit_uses_native_validation_then_one_durable_dispatch_and_preserves_literals(tmp_path): + native = FakeNative(tmp_path) + command = _command() + result = _run(native.service().submit(command)) + + assert result["replayed"] is False + assert result["receipt"]["status"] == "queued" + assert native.prepare_calls == 1 + assert native.native_validated is True + assert native.callback_after_validation is True + assert len(native.dispatch_calls) == 1 + + stored = native.registry("workspace-a").command_admission(command["intent_id"]) + assert stored["original"] == command + assert stored["original"]["input"]["prompt"] == ' literal "mañana"\nsecond line ' + assert stored["effective"]["input"]["prompt"] == stored["original"]["input"]["prompt"] + assert stored["effective"]["input"]["generation_mode"] == "image" + assert stored["effective"]["input"]["image_mode"] == 1 + assert stored["effective"]["runtime"]["workspace"] == "workspace-a" + assert _db_counts(native.registry("workspace-a")) == { + "tasks": 1, + "task_events": 1, + "task_command_admissions": 1, + } + + +def test_same_intent_replays_without_a_second_worker_but_changed_content_conflicts(tmp_path): + native = FakeNative(tmp_path) + service = native.service() + command = _command() + first = _run(service.submit(command)) + second = _run(service.submit(deepcopy(command))) + + assert second == {"receipt": first["receipt"], "replayed": True} + assert len(native.dispatch_calls) == 1 + assert _db_counts(native.registry("workspace-a"))["tasks"] == 1 + + changed = deepcopy(command) + changed["input"]["prompt"] = "different literal" + with pytest.raises(HTTPException) as error: + _run(service.submit(changed)) + assert error.value.status_code == 409 + assert error.value.detail["code"] == "intent_conflict" + assert len(native.dispatch_calls) == 1 + + +def _seed_undispatched(native: FakeNative, command: dict): + frozen = freeze_image_generation_spec(command) + params = deepcopy(frozen["effective"]["input"]) + workspace = params.pop("workspace") + provenance = { + "actor": "user", + "capability": "generation.image", + "command": {"command_id": command["intent_id"]}, + } + job = native.make_job(params, workspace, provenance=provenance, + reserve_generation=False, publish_task=False) + effective = deepcopy(frozen["effective"]) + effective["runtime"] = { + "params": deepcopy(job["params"]), + "workspace": workspace, + "provenance": deepcopy(job["provenance"]), + } + registry = native.registry(workspace) + registry.admit_command_task( + intent_id=command["intent_id"], operation="generation.image", + digest=frozen["fingerprint"], original=frozen["original"], + effective=effective, task_fields=native.task_fields(job), + ) + return native.service(), registry, registry.command_admission(command["intent_id"]) + + +def test_concurrent_dispatch_claim_starts_only_one_worker(tmp_path): + native = FakeNative(tmp_path) + service, registry, entry = _seed_undispatched(native, _command("claim-race")) + + with ThreadPoolExecutor(max_workers=2) as pool: + futures = [pool.submit(service._dispatch_admitted, registry, entry) for _ in range(2)] + [future.result() for future in futures] + + assert len(native.dispatch_calls) == 1 + assert registry.command_admission("claim-race")["dispatch_owner"] == service.owner + + +def test_native_failure_before_callback_has_no_admission_or_dispatch(tmp_path): + native = FakeNative(tmp_path) + native.prepare_error = HTTPException(422, {"code": "native_invalid", "message": "bad native input"}) + service = native.service() + + with pytest.raises(HTTPException) as error: + _run(service.submit(_command("before-callback"))) + + assert error.value.status_code == 422 + assert native.callback_after_validation is False + assert native.dispatch_calls == [] + assert _db_counts(native.registry("workspace-a")) == { + "tasks": 0, + "task_events": 0, + "task_command_admissions": 0, + } + + +def test_make_job_storage_failure_happens_before_admission(tmp_path): + native = FakeNative(tmp_path) + native.make_job_error = OSError("cannot allocate native job") + + with pytest.raises(HTTPException) as error: + _run(native.service().submit(_command("before-admission"))) + + assert error.value.status_code == 503 + assert error.value.detail["code"] == "storage_unavailable" + assert native.dispatch_calls == [] + assert _db_counts(native.registry("workspace-a"))["task_command_admissions"] == 0 + + +def test_post_commit_notification_failure_leaves_replayable_admission(tmp_path, monkeypatch): + native = FakeNative(tmp_path) + service = native.service() + registry = native.registry("workspace-a") + + def fail_after_commit(_task): + raise RuntimeError("notification lost after commit") + + original_hook = registry._after_task_created + monkeypatch.setattr(registry, "_after_task_created", fail_after_commit) + with pytest.raises(RuntimeError, match="after commit"): + _run(service.submit(_command("after-admission"))) + monkeypatch.setattr(registry, "_after_task_created", original_hook) + + assert len(native.dispatch_calls) == 0 + assert registry.command_admission("after-admission") is not None + retry = _run(service.submit(_command("after-admission"))) + assert retry["replayed"] is True + assert len(native.dispatch_calls) == 1 + assert _db_counts(registry)["tasks"] == 1 + + +def test_recovery_persistence_failure_is_retryable_without_new_task(tmp_path): + native = FakeNative(tmp_path) + service = native.service() + command = _command("persist-failure") + native.persist_error = OSError("durable queue unavailable") + + with pytest.raises(HTTPException) as error: + _run(service.submit(command)) + assert error.value.status_code == 503 + assert error.value.detail["code"] == "storage_unavailable" + assert native.dispatch_calls == [] + assert native.registry("workspace-a").command_admission(command["intent_id"])["dispatch_owner"] is None + + native.persist_error = None + retry = _run(service.submit(command)) + assert retry["replayed"] is True + assert len(native.dispatch_calls) == 1 + assert _db_counts(native.registry("workspace-a"))["tasks"] == 1 + + +def test_dispatch_started_then_raised_claims_once_and_never_retries_implicitly(tmp_path): + native = FakeNative(tmp_path) + service = native.service() + command = _command("dispatch-uncertain") + native.dispatch_error = RuntimeError("thread start response lost") + + with pytest.raises(HTTPException) as error: + _run(service.submit(command)) + assert error.value.status_code == 503 + assert error.value.detail["code"] == "dispatch_uncertain" + assert len(native.dispatch_calls) == 1 + + native.dispatch_error = None + retry = _run(service.submit(command)) + assert retry["replayed"] is True + assert len(native.dispatch_calls) == 1 + assert native.registry("workspace-a").command_admission(command["intent_id"])["dispatch_owner"] is not None + + +def test_native_thread_start_failure_removes_job_and_marks_task_interrupted(tmp_path, monkeypatch): + native = FakeNative(tmp_path) + registry = native.registry("workspace-a") + job = { + "id": "backend-thread-start-failure", + "task_id": "task-backend-thread-start-failure", + "workspace": "workspace-a", + "status": "queued", + "phase": "queued", + "message": "Queued", + "created_at": 1001.0, + } + registry.create(**_task_fields(job)) + registered = [] + cancelled_idle_release = [] + + class FailingThread: + ident = None + + def __init__(self, *_args, **_kwargs): + pass + + def start(self): + raise RuntimeError("thread start failed") + + monkeypatch.setattr("services.image_generation_runtime.threading.Thread", FailingThread) + service = create_image_generation_commands({ + "_durable_generation_queue": object(), + "_run_generation_with_preparation": lambda _job_id: None, + "_jobs": {}, + "register_generation_job": lambda _lock, current: registered.append(current), + "_gen_lock": object(), + "_cancel_h3_idle_release": lambda: cancelled_idle_release.append(True), + "_active_gen_states": {}, + "_task_registry": lambda _workspace: registry, + "generate": None, + "_new_generation_job": None, + "_generation_task_fields": None, + }) + + with pytest.raises(RuntimeError, match="thread start failed"): + service.dispatch(job) + + assert registered == [job] + assert cancelled_idle_release == [True] + assert service.active_job_ids() == set() + task = registry.get(job["task_id"]) + assert task["status"] == "interrupted" + assert task["phase"] == "dispatch_failed" + + +def test_restart_marks_queued_task_interrupted_and_projects_recovery_without_starting(tmp_path): + native = FakeNative(tmp_path) + command = _command("restart-recovery") + first = _run(native.service().submit(command)) + first_registry = native.registry("workspace-a") + task_id = first["receipt"]["taskIds"][0] + backend_id = first["receipt"]["result"]["job_id"] + assert first_registry.get(task_id)["status"] == "queued" + + restarted_native = FakeNative(tmp_path, interrupt_stale=True) + restarted_service = restarted_native.service() + restarted_registry = restarted_native.registry("workspace-a") + assert restarted_registry.get(task_id)["status"] == "interrupted" + + restarted_service.restore_recovery(["workspace-a"]) + + assert restarted_native.dispatch_calls == [] + assert restarted_native.persisted[backend_id]["status"] == "interrupted" + assert restarted_native.persisted[backend_id]["params"]["prompt"] == command["input"]["prompt"] + receipt = restarted_service.receipt("workspace-a", command["intent_id"]) + assert receipt["receipt"] == first["receipt"] + assert receipt["task"]["status"] == "interrupted" + + +@pytest.mark.parametrize("invalid", ["old copy", "backup.old", "café", "../outside"]) +def test_invalid_listed_workspace_does_not_block_recovery_restore(tmp_path, invalid): + native = FakeNative(tmp_path) + command = _command("listed-invalid-workspace") + first = _run(native.service().submit(command)) + backend_id = first["receipt"]["result"]["job_id"] + + restarted = FakeNative(tmp_path, interrupt_stale=True) + service = restarted.service() + service.restore_recovery([invalid, "workspace-a"]) + + assert restarted.dispatch_calls == [] + assert restarted.persisted[backend_id]["status"] == "interrupted" + assert service.filter_recovery([restarted.persisted[backend_id]]) == [restarted.persisted[backend_id]] + + +def _queue_record(job_id, workspace, *, capability, command_id=None, params=None): + provenance = {"capability": capability} + if command_id is not None: + provenance["command"] = {"command_id": command_id} + return { + "id": job_id, + "status": "interrupted", + "workspace": workspace, + "params": params or {"model_type": "pi_flux2", "prompt": job_id}, + "provenance": provenance, + } + + +def test_orphaned_image_leftover_does_not_block_other_recovery(tmp_path): + native = FakeNative(tmp_path) + command = _command("linked-recovery") + first = _run(native.service().submit(command)) + restarted = FakeNative(tmp_path, interrupt_stale=True) + service = restarted.service() + service.restore_recovery(["workspace-a"]) + linked = restarted.persisted[first["receipt"]["result"]["job_id"]] + video = _queue_record("video-leftover", "workspace-b", capability="generation.video") + orphan = _queue_record( + "orphan-image", "deleted-workspace", + capability="generation.image", command_id="missing-admission", + ) + drifted = _queue_record( + "drifted-job", "workspace-a", + capability="generation.image", command_id=command["intent_id"], + ) + invalid_workspace = _queue_record( + "bad-workspace", "../outside", + capability="generation.image", command_id="any-intent", + ) + + retained = service.filter_recovery([video, orphan, drifted, invalid_workspace, linked]) + + assert [record["id"] for record in retained] == ["video-leftover", linked["id"]] + service.discard_recovery([video, orphan, drifted, invalid_workspace, linked]) + assert restarted.registry("workspace-a").get(first["receipt"]["taskIds"][0])["status"] == "cancelled" + assert service.filter_recovery([video, orphan, linked]) == [video] + + +@pytest.mark.parametrize("provenance", ["corrupt", ["bad"], {"capability": []}, {"capability": "generation.image", "command": "bad"}, + {"capability": "generation.image", "command": {"command_id": ["bad"]}}]) +def test_malformed_leftover_metadata_does_not_block_valid_legacy_rows(tmp_path, provenance): + service = FakeNative(tmp_path).service() + malformed = _queue_record("malformed", "workspace-a", capability="generation.image") + malformed["provenance"] = provenance + legacy = _queue_record("legacy", "workspace-a", capability="generation.video") + assert service.filter_recovery([malformed, legacy]) == [legacy] + service.discard_recovery([malformed, legacy]) + + +def _recovery_http_app(service, queue, workspaces=None): + """Execute the actual route so storage failure cannot fall through to discard.""" + source = Path(__file__).resolve().parents[1] / "app" / "_launch_runtime.py" + tree = ast.parse(source.read_text(encoding="utf-8")) + names = {"_recovery_job_summary", "get_generation_queue_recovery", "discard_generation_queue"} + app = FastAPI() + listed = workspaces if workspaces is not None else [{"name": "workspace-a"}] + namespace = {"api": app, "_queue_recovery_lock": threading.Lock(), "_jobs": {}, + "_image_generation_commands": service, "_durable_generation_queue": queue, + "_list_workspaces": lambda: listed} + selected = ast.Module(body=[node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name in names], + type_ignores=[]) + exec(compile(selected, str(source), "exec"), namespace) + return app + + +@pytest.mark.parametrize("storage_error", [sqlite3.OperationalError("database is locked"), OSError("disk unavailable")]) +@pytest.mark.parametrize("failure_phase", ["restore", "link", "update"]) +def test_recovery_http_storage_failure_preserves_queue_and_interrupted_task(tmp_path, monkeypatch, storage_error, failure_phase): + from services.durable_generation_queue import DurableGenerationQueue + + native = FakeNative(tmp_path) + accepted = _run(native.service().submit(_command("storage-recovery"))) + restarted = FakeNative(tmp_path, interrupt_stale=True) + service = restarted.service() + service.restore_recovery(["workspace-a"]) + queue = DurableGenerationQueue(str(tmp_path / "queue.json")) + for record in restarted.persisted.values(): + queue.upsert(record) + before = queue.list() + registry = restarted.registry("workspace-a") + + def unavailable(*_args, **_kwargs): + raise storage_error + + monkeypatch.setattr(registry, "update" if failure_phase == "update" else "command_admission", unavailable) + if failure_phase == "link": + monkeypatch.setattr(service, "restore_recovery", lambda _workspaces: None) + with TestClient(_recovery_http_app(service, queue)) as client: + for method, path in (("get", "/api/v1/jobs/recovery"), ("post", "/api/v1/jobs/recovery/discard")): + response = getattr(client, method)(path) + if failure_phase == "update" and method == "get": + assert response.status_code == 200 + else: + assert response.status_code == 503 + assert response.json()["detail"]["code"] == "storage_unavailable" + assert queue.list() == before + assert registry.get(accepted["receipt"]["taskIds"][0])["status"] == "interrupted" + + +def test_recovery_http_skips_invalid_listed_workspaces_and_keeps_valid_leftovers(tmp_path): + from services.durable_generation_queue import DurableGenerationQueue + + native = FakeNative(tmp_path) + accepted = _run(native.service().submit(_command("listed-folder-recovery"))) + restarted = FakeNative(tmp_path, interrupt_stale=True) + service = restarted.service() + service.restore_recovery(["workspace-a"]) + queue = DurableGenerationQueue(str(tmp_path / "queue.json")) + for record in restarted.persisted.values(): + queue.upsert(record) + listed = [{"name": "old copy"}, {"name": "workspace-a"}, {"name": "backup.old"}] + with TestClient(_recovery_http_app(service, queue, listed)) as client: + response = client.get("/api/v1/jobs/recovery") + assert response.status_code == 200 + assert response.json()["jobs"][0]["job_id"] == accepted["receipt"]["result"]["job_id"] + discarded = client.post("/api/v1/jobs/recovery/discard") + assert discarded.status_code == 200 + assert discarded.json()["discarded"] + assert queue.list() == [] + assert restarted.registry("workspace-a").get(accepted["receipt"]["taskIds"][0])["status"] == "cancelled" + + +def test_queued_admission_is_not_a_recovery_candidate_while_dispatch_is_pending(tmp_path): + native = FakeNative(tmp_path) + service, registry, _entry = _seed_undispatched(native, _command("queued-not-recovery")) + + assert registry.command_recovery_candidates() == [] + service.restore_recovery(["workspace-a"]) + assert registry.command_recovery_candidates() == [] + assert native.persist_calls == [] + assert native.dispatch_calls == [] + + +def test_discarded_interrupted_admission_does_not_reappear_on_restore(tmp_path): + native = FakeNative(tmp_path) + command = _command("discard-recovery") + first = _run(native.service().submit(command)) + task_id = first["receipt"]["taskIds"][0] + + restarted_native = FakeNative(tmp_path, interrupt_stale=True) + restarted_service = restarted_native.service() + registry = restarted_native.registry("workspace-a") + restarted_service.restore_recovery(["workspace-a"]) + record = restarted_native.persisted[first["receipt"]["result"]["job_id"]] + assert restarted_service.filter_recovery([record]) == [record] + persist_count = len(restarted_native.persist_calls) + + restarted_service.discard_recovery([record]) + + assert registry.get(task_id)["status"] == "cancelled" + assert restarted_service.filter_recovery([record]) == [] + restarted_service.restore_recovery(["workspace-a"]) + assert len(restarted_native.persist_calls) == persist_count + + +def test_recovery_skips_an_interrupted_record_still_owned_by_an_active_job(tmp_path): + native = FakeNative(tmp_path) + command = _command("active-recovery-owner") + first = _run(native.service().submit(command)) + backend_id = first["receipt"]["result"]["job_id"] + + restarted_native = FakeNative(tmp_path, interrupt_stale=True) + restarted_native.active.add(backend_id) + restarted_native.service().restore_recovery(["workspace-a"]) + + assert restarted_native.persist_calls == [] + assert restarted_native.dispatch_calls == [] + + +def test_preflight_rejects_uninstalled_or_non_image_models_before_native_prepare(tmp_path): + native = FakeNative(tmp_path) + native.preflight_error = HTTPException( + 409, {"code": "model_unavailable", "message": "not downloaded", "retryable": False}, + ) + service = native.service() + with pytest.raises(HTTPException) as unavailable: + _run(service.submit(_command("missing-model"))) + assert unavailable.value.status_code == 409 + assert native.prepare_calls == 0 + + with pytest.raises(HTTPException) as wrong_model: + validate_image_model( + {"model_type": "audio-model", "resolution": "512x512"}, + model_definition=lambda _name: {"image_outputs": True, "returns_audio": True}, + model_downloaded=lambda _name: True, + ) + assert wrong_model.value.status_code == 422 + assert wrong_model.value.detail["code"] == "unsupported_model" + + +@pytest.mark.parametrize( + ("params", "code"), + [ + ({"model_type": "pi_flux2", "resolution": "513x512"}, "invalid_resolution"), + ({"model_type": "pi_flux2", "resolution": "64x4097"}, "invalid_resolution"), + ({"model_type": "pi_flux2", "resolution": "512x512"}, "model_unavailable"), + ], + ids=["bad-resolution", "out-of-range-resolution", "not-downloaded"], +) +def test_model_validation_rejects_bad_resolution_and_missing_files(tmp_path, params, code): + del tmp_path + definitions = {"pi_flux2": {"image_outputs": True, "returns_audio": False}} + downloaded = lambda _name: code != "model_unavailable" + with pytest.raises(HTTPException) as error: + validate_image_model( + params, + model_definition=lambda name: definitions.get(name), + model_downloaded=downloaded, + ) + assert error.value.detail["code"] == code + + +def test_service_rejects_unsupported_fields_and_workspace_paths_before_native_prepare(tmp_path): + native = FakeNative(tmp_path) + service = native.service() + unsupported = _command("unsupported-field") + unsupported["input"]["image_refs"] = [] + with pytest.raises(HTTPException) as extra: + _run(service.submit(unsupported)) + assert extra.value.status_code == 422 + assert native.prepare_calls == 0 + + invalid_workspace = _command("invalid-workspace") + invalid_workspace["input"]["workspace"] = "../outside" + with pytest.raises(HTTPException) as workspace: + _run(service.submit(invalid_workspace)) + assert workspace.value.status_code == 422 + assert workspace.value.detail["code"] == "invalid_command" + assert "workspace" in workspace.value.detail["message"] + assert native.prepare_calls == 0 + + +def _mcp_call(client, name, arguments, *, request_id=1, authorization="Bearer test-token"): + return client.post( + "/api/v1/wangp/mcp", + headers={"Authorization": authorization}, + json={"jsonrpc": "2.0", "id": request_id, "method": "tools/call", + "params": {"name": name, "arguments": arguments}}, + ) + + +def _command_app(native: FakeNative, tmp_path): + app = FastAPI() + service = native.service() + app.include_router(create_image_generation_commands_router(service)) + app.include_router(create_wangp_mcp_router( + handlers=image_command_handlers(service), + command_operations=image_command_catalog(), + journal_path=Path(tmp_path) / "mcp-journal.sqlite", + token_getter=lambda: "test-token", + )) + return TestClient(app) + + +def test_http_and_mcp_use_the_same_image_operation_and_receipt(tmp_path): + native = FakeNative(tmp_path) + client = _command_app(native, tmp_path) + command = _command("http-mcp-same") + + http = client.post("/api/v1/generation/commands", json=command) + assert http.status_code == 200 + http_body = http.json() + assert http_body["replayed"] is False + + listed = client.post( + "/api/v1/wangp/mcp", + headers={"Authorization": "Bearer test-token"}, + json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, + ).json()["result"]["tools"] + image_tool = next(tool for tool in listed if tool["name"] == "generation.image") + assert "operation" not in image_tool["inputSchema"]["properties"] + assert "operation" not in image_tool["inputSchema"]["required"] + + arguments = {key: value for key, value in command.items() if key != "operation"} + mcp = _mcp_call(client, "generation.image", arguments, request_id=3).json()["result"] + assert mcp["isError"] is False + assert mcp["structuredContent"] == {**http_body, "replayed": True} + assert len(native.dispatch_calls) == 1 + + receipt_http = client.get( + "/api/v1/generation/commands/receipt", + params={"workspace": "workspace-a", "intent_id": command["intent_id"]}, + ) + assert receipt_http.status_code == 200 + receipt_mcp = _mcp_call( + client, "generation.receipt", + {"version": 1, "input": {"workspace": "workspace-a", "intent_id": command["intent_id"]}}, + request_id=4, + ).json()["result"] + assert receipt_mcp["isError"] is False + assert receipt_mcp["structuredContent"] == receipt_http.json() + + assert _mcp_call(client, "generation.image", arguments, authorization="Bearer wrong").status_code == 401 + + +def test_mcp_image_handler_rejects_transport_operation_field_outside_declared_schema(tmp_path): + native = FakeNative(tmp_path) + client = _command_app(native, tmp_path) + arguments = {key: value for key, value in _command("mcp-operation-field").items() if key != "operation"} + arguments["operation"] = "generation.video" + + result = _mcp_call(client, "generation.image", arguments, request_id=11).json()["result"] + + assert result["isError"] is True + assert result["structuredContent"]["error"]["code"] == "invalid_command" + assert native.dispatch_calls == [] + + +def test_http_and_mcp_expose_policy_rejection_without_admitting(tmp_path): + native = FakeNative(tmp_path) + native.preflight_error = HTTPException( + 403, {"code": "policy_denied", "message": "workspace policy", "retryable": False}, + ) + client = _command_app(native, tmp_path) + command = _command("policy-denied") + + http = client.post("/api/v1/generation/commands", json=command) + assert http.status_code == 403 + assert http.json()["detail"]["code"] == "policy_denied" + + arguments = {key: value for key, value in command.items() if key != "operation"} + mcp = _mcp_call(client, "generation.image", arguments, request_id=8).json()["result"] + assert mcp["isError"] is True + assert mcp["structuredContent"]["status"] == "failed" + assert mcp["structuredContent"]["error"]["code"] == "policy_denied" + assert native.dispatch_calls == [] + + +@pytest.mark.parametrize( + ("column", "value"), + [("effective", "not-json"), ("receipt", "{}"), ("dispatch_owner", "")], + ids=["invalid-json-snapshot", "invalid-receipt-shape", "empty-dispatch-owner"], +) +def test_corrupt_admission_snapshots_fail_closed_without_replaying_or_dispatching(tmp_path, column, value): + native = FakeNative(tmp_path) + service = native.service() + command = _command(f"corrupt-{column}") + _run(service.submit(command)) + registry = native.registry("workspace-a") + with sqlite3.connect(registry.path) as connection: + connection.execute( + f"UPDATE task_command_admissions SET {column} = ? WHERE intent_id = ?", + (value, command["intent_id"]), + ) + + with pytest.raises(HTTPException) as error: + _run(service.submit(command)) + assert error.value.status_code == 503 + assert error.value.detail["code"] == "storage_unavailable" + assert len(native.dispatch_calls) == 1 + assert _db_counts(registry)["tasks"] == 1 diff --git a/tests/test_image_generation_spec.py b/tests/test_image_generation_spec.py new file mode 100644 index 000000000..8caa12a71 --- /dev/null +++ b/tests/test_image_generation_spec.py @@ -0,0 +1,263 @@ +"""Provider-free tests for the first shared text-to-image command.""" + +from copy import deepcopy +import json + +import pytest + +from services.image_generation_spec import ( + OPERATION, + ImageGenerationSpecError, + freeze_image_generation_spec, + image_generation_schema, +) + + +def command(*, intent_id="wizard-command-1", **input_overrides): + image_input = { + "workspace": "workspace_with_underscores", + "model_type": "pi_flux2", + "prompt": ' Keep this literal: "mañana"\nline two ', + "negative_prompt": " avoid blur ", + "resolution": "512x512", + "num_inference_steps": 4, + "seed": -1, + "guidance_scale": 1.0, + } + image_input.update(input_overrides) + return { + "version": 1, + "operation": OPERATION, + "intent_id": intent_id, + "input": image_input, + } + + +def test_freezes_original_and_adds_only_image_native_defaults(): + request = command() + before = deepcopy(request) + + frozen = freeze_image_generation_spec(request) + + assert request == before + assert frozen["original"] == before + assert frozen["original"] is not request + assert frozen["original"]["input"] is not request["input"] + effective = frozen["effective"] + assert effective["version"] == 1 + assert effective["operation"] == OPERATION + assert effective["intent_id"] == request["intent_id"] + assert effective["input"]["generation_mode"] == "image" + assert effective["input"]["image_mode"] == 1 + assert effective["input"]["video_length"] == 1 + assert effective["input"]["multi_prompts_gen_type"] == 2 + assert effective["input"]["repeat_generation"] == 1 + assert effective["input"]["batch_size"] == 1 + assert effective["input"]["prompt_enhancer"] == "" + assert effective["input"]["prompt"] == request["input"]["prompt"] + assert effective["input"]["negative_prompt"] == request["input"]["negative_prompt"] + assert effective["input"]["workspace"] == request["input"]["workspace"] + assert effective["input"]["model_type"] == request["input"]["model_type"] + + request["input"]["prompt"] = "changed after validation" + assert frozen["original"]["input"]["prompt"] == before["input"]["prompt"] + assert frozen["effective"]["input"]["prompt"] == before["input"]["prompt"] + + +def test_omitted_contract_owned_selectors_are_present_only_in_effective(): + request = command() + assert "image_mode" not in request["input"] + assert "video_length" not in request["input"] + + frozen = freeze_image_generation_spec(request) + + assert "image_mode" not in frozen["original"]["input"] + assert "video_length" not in frozen["original"]["input"] + assert frozen["effective"]["input"]["image_mode"] == 1 + assert frozen["effective"]["input"]["video_length"] == 1 + + +def test_explicit_native_selectors_must_be_the_image_values(): + assert freeze_image_generation_spec(command(image_mode=1, video_length=1))["effective"]["input"]["image_mode"] == 1 + for field, value in (("image_mode", 0), ("image_mode", 2), ("video_length", 0), ("video_length", 2)): + with pytest.raises(ImageGenerationSpecError, match=field): + freeze_image_generation_spec(command(**{field: value})) + + +def test_operation_is_the_explicit_mode_and_input_cannot_smuggle_another_mode(): + frozen = freeze_image_generation_spec(command()) + assert frozen["effective"]["input"]["generation_mode"] == "image" + + with pytest.raises(ImageGenerationSpecError, match="generation_mode"): + freeze_image_generation_spec(command(generation_mode="video")) + with pytest.raises(ImageGenerationSpecError, match="operation"): + freeze_image_generation_spec({**command(), "operation": "generation.video"}) + + +def test_fingerprint_excludes_transport_intent_id(): + first = freeze_image_generation_spec(command(intent_id="intent-one")) + second = freeze_image_generation_spec(command(intent_id="intent-two")) + + assert first["effective"]["intent_id"] == "intent-one" + assert second["effective"]["intent_id"] == "intent-two" + assert first["fingerprint_version"] == 1 + assert first["fingerprint"] == second["fingerprint"] + + +def test_intent_id_is_exact_but_cannot_be_blank(): + exact = freeze_image_generation_spec(command(intent_id=" exact id ")) + assert exact["original"]["intent_id"] == " exact id " + assert exact["effective"]["intent_id"] == " exact id " + with pytest.raises(ImageGenerationSpecError, match="intent_id"): + freeze_image_generation_spec(command(intent_id=" \n\t")) + + +@pytest.mark.parametrize("field", [ + "workspace", + "model_type", + "prompt", + "resolution", + "negative_prompt", +]) +@pytest.mark.parametrize("bad", [None, True, 1, [], {}]) +def test_text_fields_reject_null_boolean_and_non_string_types(field, bad): + with pytest.raises(ImageGenerationSpecError, match=field): + freeze_image_generation_spec(command(**{field: bad})) + + +@pytest.mark.parametrize("field", ["workspace", "model_type", "prompt", "resolution"]) +def test_required_text_fields_reject_empty_or_all_blank_values(field): + for value in ("", " \n\t"): + with pytest.raises(ImageGenerationSpecError, match=field): + freeze_image_generation_spec(command(**{field: value})) + + +@pytest.mark.parametrize("field", ["num_inference_steps", "seed"]) +@pytest.mark.parametrize("bad", [None, True, False, 1.0, "1", [], {}]) +def test_integer_fields_reject_null_boolean_float_and_coercible_types(field, bad): + with pytest.raises(ImageGenerationSpecError, match=field): + freeze_image_generation_spec(command(**{field: bad})) + + +@pytest.mark.parametrize("field", ["num_inference_steps", "image_mode", "video_length"]) +@pytest.mark.parametrize("bad", [0, -1, 1.5, "1", True, None]) +def test_positive_native_integer_fields_are_strict(field, bad): + with pytest.raises(ImageGenerationSpecError, match=field): + freeze_image_generation_spec(command(**{field: bad})) + + +@pytest.mark.parametrize("bad", [None, True, False, "1.0", [], {}, float("nan"), float("inf")]) +def test_guidance_scale_rejects_non_finite_or_non_numeric_values(bad): + with pytest.raises(ImageGenerationSpecError, match="guidance_scale"): + freeze_image_generation_spec(command(guidance_scale=bad)) + + +def test_seed_minus_one_is_preserved_as_native_random_seed(): + frozen = freeze_image_generation_spec(command(seed=-1)) + assert frozen["original"]["input"]["seed"] == -1 + assert frozen["effective"]["input"]["seed"] == -1 + + +@pytest.mark.parametrize("field", ["num_inference_steps", "seed", "guidance_scale"]) +def test_content_fingerprint_changes_when_effective_content_changes(field): + first = freeze_image_generation_spec(command()) + changed_value = { + "num_inference_steps": 5, + "seed": 42, + "guidance_scale": 2.0, + }[field] + second = freeze_image_generation_spec(command(**{field: changed_value})) + assert first["fingerprint"] != second["fingerprint"] + + +def test_fingerprint_is_stable_and_has_no_client_or_intent_fields(): + frozen = freeze_image_generation_spec(command()) + content = { + "version": 1, + "operation": OPERATION, + "input": frozen["effective"]["input"], + } + assert "intent_id" not in content + assert "client" not in content + assert len(frozen["fingerprint"]) == 64 + assert json.dumps(content, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +@pytest.mark.parametrize("extra", [ + {"client": "wizard"}, + {"actor": "wizard"}, + {"permission": "admin"}, + {"provenance": {"actor": "wizard"}}, + {"image_refs": ["asset-1"]}, + {"image_guide": "guide.png"}, + {"activated_loras": ["style.safetensors"]}, + {"loras_multipliers": "1.0"}, + {"repeat_generation": 1}, + {"output_count": 1}, +]) +def test_unsupported_or_attributed_input_is_rejected_before_effect(extra): + with pytest.raises(ImageGenerationSpecError, match="extra|Extra|input"): + freeze_image_generation_spec(command(**extra)) + + +@pytest.mark.parametrize("bad", [None, True, False, 1, "1", [], {}]) +def test_envelope_fields_are_strict(bad): + bad_fields = { + "version": bad, + "operation": bad, + "intent_id": bad, + "input": bad, + } + for field, value in bad_fields.items(): + request = command() + # Keep valid sentinels out of the field-specific checks below. The + # envelope validator still handles the shared type boundary. + if field == "version" and value == 1: + value = 2 + if field == "operation" and value == "1": + value = "generation.video" + if field == "intent_id" and value == "1": + value = "" + if field == "input" and value == {}: + value = [] + request[field] = value + with pytest.raises(ImageGenerationSpecError): + freeze_image_generation_spec(request) + + +def test_unknown_top_level_keys_are_rejected(): + request = command() + request["client"] = "mcp" + with pytest.raises(ImageGenerationSpecError, match="client"): + freeze_image_generation_spec(request) + + +def test_schema_publishes_only_the_implemented_image_scope(): + schema = image_generation_schema() + assert schema["version"] == 1 + assert schema["operation"] == OPERATION + assert schema["input"]["additionalProperties"] is False + assert set(schema["supported_input_fields"]) == { + "workspace", + "model_type", + "prompt", + "negative_prompt", + "resolution", + "num_inference_steps", + "seed", + "guidance_scale", + "image_mode", + "video_length", + } + assert "generation_mode" in schema["excluded"] + assert "image_refs" in schema["excluded"] + assert "activated_loras" in schema["excluded"] + assert schema["effects"] == { + "generation_mode": "image", + "image_mode": 1, + "video_length": 1, + "multi_prompts_gen_type": 2, + "repeat_generation": 1, + "batch_size": 1, + "prompt_enhancer": "", + } diff --git a/tests/test_studio_image_asset_ids.py b/tests/test_studio_image_asset_ids.py new file mode 100644 index 000000000..b4d3fe577 --- /dev/null +++ b/tests/test_studio_image_asset_ids.py @@ -0,0 +1,49 @@ +"""An asset identity must resolve to one exact image location, never a basename.""" +import pytest + +from services.asset_manifest import build_asset_manifest, write_asset_manifest +from tests.test_studio_image_resources import resources_fixture, write_image + + +def managed_image(path, identity): + write_image(path) + write_asset_manifest(path, build_asset_manifest(path, asset_id=identity, tool="studio-image")) + + +@pytest.mark.parametrize("reference", ["asset_exact", "/api/v1/assets/asset_exact"]) +def test_asset_id_resolves_real_manifest_in_source_workspace(resources_fixture, reference): + fixture = resources_fixture + source = fixture["source"] / "same name.png" + managed_image(source, "asset_exact") + managed_image(fixture["output"] / "same name.png", "asset_unrelated") + params, resources = fixture["service"].prepare_media({"workspace": "output", "image_refs": [reference]}) + assert params["image_refs"] == [str(source)] + assert resources[0]["workspace"] == "source" + assert resources[0]["url"] == reference + assert fixture["service"].canonicalize_legacy(reference) == reference + + +def test_ambiguous_asset_id_requires_exact_url(resources_fixture): + fixture = resources_fixture + managed_image(fixture["source"] / "reference.png", "asset_shared") + managed_image(fixture["other"] / "reference.png", "asset_shared") + with pytest.raises(ValueError, match="multiple locations"): + fixture["service"].prepare_media({"image_refs": ["asset_shared"]}) + params, _ = fixture["service"].prepare_media({"image_refs": ["/api/v1/file/reference.png?workspace=source"]}) + assert params["image_refs"] == [str(fixture["source"] / "reference.png")] + + +def test_unknown_identity_never_falls_back_to_filename(resources_fixture): + fixture = resources_fixture + managed_image(fixture["source"] / "asset_missing.png", "asset_actual") + with pytest.raises(ValueError, match="existing image asset"): + fixture["service"].prepare_media({"image_refs": ["asset_missing"]}) + + +def test_optional_native_frame_slots_keep_their_order(resources_fixture): + fixture = resources_fixture + source = fixture["source"] / "frame.png" + managed_image(source, "asset_frame") + params, resources = fixture["service"].prepare_media({"image_start": ["", "asset_frame", ""]}) + assert params["image_start"] == ["", str(source), ""] + assert [(item["role"], item["index"]) for item in resources] == [("image_start", 1)] diff --git a/tests/test_studio_image_commands.py b/tests/test_studio_image_commands.py new file mode 100644 index 000000000..0205e4eaf --- /dev/null +++ b/tests/test_studio_image_commands.py @@ -0,0 +1,176 @@ +"""Cross-transport and recovery checks for full Studio image admissions.""" +from copy import deepcopy +import json +from pathlib import Path +import sqlite3 + +import pytest +from fastapi import HTTPException + +from tests.test_image_generation_commands import FakeNative, _command, _command_app, _db_counts, _mcp_call, _run +from routers.image_generation_commands import image_command_catalog +from services.studio_image_spec import freeze_studio_image_spec, studio_image_schema + + +def studio_command(intent="studio-native-intent"): + return {"version": 2, "operation": "generation.image", "intent_id": intent, "input": { + "workspace": "workspace-a", "params": { + "model_type": "pi_flux2", "prompt": ' Literal "mañana"\nsecond line ', + "resolution": "512x512", "num_inference_steps": 4, "seed": 42, "guidance_scale": 1.0, + "image_refs": ["/api/v1/uploads/a.png", "/api/v1/file/b.png?workspace=source"], + "activated_loras": ["one.safetensors", "two.safetensors"], "loras_multipliers": "0.7;0.3 0.4;0.5", + "repeat_generation": 2, "batch_size": 1, "skip_steps_cache_type": "first_block", + "skip_steps_multiplier": 0.08, "skip_steps_start_step_perc": 25, + "spatial_upsampling": "lanczos*2", "film_grain_intensity": 0.2, + "settings_version": 2.52, + }, + }} + + +class StudioNative(FakeNative): + def studio_prepare(self, params): + self.preflight_calls += 1 + working = deepcopy(params) + working["image_refs"] = ["/resolved/a.png", "/resolved/b.png"] + return working, [{"role": "image_refs", "index": 0, "sha256": "a" * 64}] + + def service(self): + service = super().service() + service.prepare_studio = self.studio_prepare + return service + + +def test_full_studio_snapshot_and_fingerprint_survive_http_mcp_replay(tmp_path): + native = StudioNative(tmp_path) + client = _command_app(native, tmp_path) + command = studio_command() + before = deepcopy(command) + first = client.post("/api/v1/generation/commands", json=command) + assert first.status_code == 200, first.text + receipt = first.json()["receipt"] + assert receipt["commandVersion"] == receipt["fingerprintVersion"] == 2 + assert receipt["contentFingerprint"] == freeze_studio_image_spec(command)["fingerprint"] + arguments = {key: value for key, value in command.items() if key != "operation"} + replay = _mcp_call(client, "generation.image", arguments).json()["result"] + assert replay["isError"] is False + assert replay["structuredContent"] == {"receipt": receipt, "replayed": True} + assert len(native.dispatch_calls) == native.preflight_calls == 1 + entry = native.registry("workspace-a").command_admission(command["intent_id"]) + assert entry["fingerprint_version"] == 2 + assert entry["original"] == command == before + assert entry["effective"]["input"]["params"]["image_refs"] == command["input"]["params"]["image_refs"] + actual = entry["effective"]["runtime"]["params"] + assert actual["image_refs"] == ["/resolved/a.png", "/resolved/b.png"] + for key in ("prompt", "activated_loras", "loras_multipliers", "repeat_generation", "skip_steps_multiplier", "spatial_upsampling"): + assert actual[key] == command["input"]["params"][key] + assert entry["effective"]["resources"][0]["sha256"] == "a" * 64 + + +def test_new_version_cannot_adopt_a_legacy_intention(tmp_path): + native = StudioNative(tmp_path) + service = native.service() + _run(service.submit(_command("same-id"))) + with pytest.raises(HTTPException) as error: + _run(service.submit(studio_command("same-id"))) + assert error.value.status_code == 409 + assert len(native.dispatch_calls) == 1 + + +def test_invalid_studio_payload_rejects_before_preflight_and_task(tmp_path): + native = StudioNative(tmp_path) + command = studio_command() + command["input"]["params"]["actor"] = "wizard" + client = _command_app(native, tmp_path) + response = client.post("/api/v1/generation/commands", json=command) + assert response.status_code == 422 + assert response.json()["detail"]["code"] == "invalid_command" + assert native.preflight_calls == 0 + assert _db_counts(native.registry("workspace-a"))["tasks"] == 0 + + +def test_studio_requires_an_installed_runtime_adapter(tmp_path): + native = FakeNative(tmp_path) + with pytest.raises(HTTPException) as error: + _run(native.service().submit(studio_command())) + assert error.value.status_code == 422 + assert error.value.detail["code"] == "unsupported_version" + assert native.prepare_calls == 0 + + +def test_studio_receipt_detects_corrupt_fingerprint_metadata(tmp_path): + native = StudioNative(tmp_path) + service = native.service() + command = studio_command() + _run(service.submit(command)) + registry = native.registry("workspace-a") + with sqlite3.connect(registry.path) as connection: + connection.execute("UPDATE task_command_admissions SET fingerprint_version = 1") + with pytest.raises(HTTPException) as error: + service.receipt("workspace-a", command["intent_id"]) + assert error.value.status_code == 503 + + +def test_studio_preserves_collection_target_and_declared_workflow_context(tmp_path): + native = StudioNative(tmp_path) + client = _command_app(native, tmp_path) + command = studio_command() + command["input"]["workspace_collection_id"] = "collection-one" + response = client.post("/api/v1/generation/commands", json=command, headers={ + "X-Hocus-UI-Surface": "wizard", "X-Hocus-UI-Context": json.dumps({"workflowId": "wf-one", "runId": "run-one"}), + }) + assert response.status_code == 200, response.text + provenance = native.dispatch_calls[0]["provenance"] + assert provenance["actor"] == "wizard" + assert provenance["workspace_id"] == "collection-one" + assert provenance["command"] == {"command_id": command["intent_id"], "workflow_id": "wf-one", "run_id": "run-one"} + different = deepcopy(command) + different["input"]["workspace_collection_id"] = "collection-two" + assert client.post("/api/v1/generation/commands", json=different).status_code == 409 + # Transport metadata on a retry cannot rewrite the original attribution. + assert client.post("/api/v1/generation/commands", json=command).json()["replayed"] is True + assert len(native.dispatch_calls) == 1 + entry = native.registry("workspace-a").command_admission(command["intent_id"]) + assert entry["effective"]["runtime"]["provenance"] == provenance + + +@pytest.mark.parametrize("context", ["{", '[]', '{"actor":"admin"}', '{"workspace_id":"elsewhere"}', + '{"runId":false}', '{"workflowId":" "}', '{"runId":" padded "}']) +def test_invalid_ui_context_fails_before_any_admission(tmp_path, context): + native = StudioNative(tmp_path) + response = _command_app(native, tmp_path).post("/api/v1/generation/commands", json=studio_command(), + headers={"X-Hocus-UI-Context": context}) + assert response.status_code == 422 + assert response.json()["detail"]["code"] == "invalid_ui_context" + assert native.preflight_calls == native.prepare_calls == 0 + + +def test_reference_migration_is_read_only_and_does_not_guess_missing_names(tmp_path): + from fastapi import FastAPI + from fastapi.testclient import TestClient + from routers.image_generation_commands import create_image_generation_commands_router + + native = StudioNative(tmp_path) + service = native.service() + def exact_reference(value): + if value == "/known/reference.png": + return "/api/v1/file/reference.png?workspace=source" + raise ValueError("Unknown exact reference") + service.canonicalize_reference = exact_reference + app = FastAPI() + app.include_router(create_image_generation_commands_router(service)) + with TestClient(app) as client: + response = client.post("/api/v1/generation/commands/references", json={"references": ["/known/reference.png"]}) + assert response.status_code == 200 + assert response.json() == {"references": ["/api/v1/file/reference.png?workspace=source"]} + assert client.post("/api/v1/generation/commands/references", json={"references": ["reference.png"]}).status_code == 422 + assert native.preflight_calls == native.prepare_calls == 0 + + +def test_image_catalog_projection_is_current_and_keeps_version_correlation(): + root = Path(__file__).resolve().parents[1] + catalog = json.loads((root / "ui/src/api/imageCommandCatalog.json").read_text()) + assert catalog == {"version": 2, "operations": image_command_catalog(), "studio": studio_image_schema()} + schema = catalog["operations"][0]["inputSchema"] + assert schema["properties"]["version"]["enum"] == [1, 2] + assert [entry["properties"]["version"]["const"] for entry in schema["oneOf"]] == [1, 2] + assert schema["$defs"]["StudioCommandInput"]["additionalProperties"] is False diff --git a/tests/test_studio_image_native_boundary.py b/tests/test_studio_image_native_boundary.py new file mode 100644 index 000000000..8cce9f983 --- /dev/null +++ b/tests/test_studio_image_native_boundary.py @@ -0,0 +1,92 @@ +"""Verify prepared Studio images survive the real native media boundary.""" +from copy import deepcopy + +import pytest +from PIL import Image + +from services.studio_image_conditioning import validate_image_selectors +from services.studio_image_resources import StudioImageResources +from services.wangp_submission import prepare_generation_inputs +from tests.test_image_generation_commands import FakeNative, _command, _run + + +@pytest.mark.parametrize("new_family", [False, True]) +def test_source_workspace_image_survives_native_preparation(tmp_path, new_family): + folders = {name: tmp_path / name for name in ("source", "destination", "uploads")} + for folder in folders.values(): + folder.mkdir() + Image.new("RGB", (8, 8), "blue").save(folders["source"] / "same.png") + Image.new("RGB", (8, 8), "red").save(folders["destination"] / "same.png") + resources = StudioImageResources( + workspace_dir=lambda name: folders[name], uploads_dir=lambda: folders["uploads"], + list_workspaces=lambda: [{"name": name} for name in ("source", "destination")], + lora_search_dirs=lambda _: [], lora_compatible=lambda *_: True, + ) + params = {"image_mode": 1, "video_prompt_type": "I", "canonical_image_refs": True, + "image_refs": ["/api/v1/file/same.png?workspace=source"]} + prepared, identities = resources.prepare_media(params) + prepare_generation_inputs(prepared, {"image_outputs": True, "wangp_1272": new_family}, + "destination", uploads_dir=folders["uploads"], + workspace_dir=folders["destination"], prepared_images=True) + assert prepared["image_refs"] == [str(folders["source"] / "same.png")] + assert identities[0]["workspace"] == "source" + with Image.open(prepared["image_refs"][0]) as picture: + assert picture.getpixel((0, 0)) == (0, 0, 255) + assert params["canonical_image_refs"] is True + + +def test_json_cannot_claim_that_native_paths_are_already_resolved(tmp_path): + body = {"image_mode": 1, "image_refs": [str(tmp_path / "outside" / "secret.png")], + "prepared_images": True, "prepared_studio_images": True} + with pytest.raises(ValueError): + prepare_generation_inputs(body, {"image_outputs": True, "wangp_1272": True}, + "destination", uploads_dir=tmp_path / "uploads", + workspace_dir=tmp_path / "destination") + + +@pytest.mark.parametrize("field", ["image_refs", "image_guide", "image_mask", "image_start", "image_end"]) +def test_reference_without_consuming_selector_is_rejected(field): + with pytest.raises(ValueError, match="selector must be enabled"): + validate_image_selectors({field: ["/api/v1/uploads/image.png"]}, {}) + + +@pytest.mark.parametrize("params", [ + {"image_start": ["", "/api/v1/uploads/image.png"], "image_prompt_type": "S"}, + {"image_end": ["/api/v1/uploads/image.png", ""], "image_prompt_type": "E"}, +]) +def test_active_empty_frame_slots_are_rejected_before_native_validation(params): + with pytest.raises(ValueError, match="empty frame slots"): + validate_image_selectors(params, {"end_frames_always_enabled": True}) + + +def test_native_conditioning_pairs_are_preserved_and_missing_images_rejected(): + params = {field: ["/api/v1/uploads/image.png"] + for field in ("image_refs", "image_guide", "image_mask", "image_start", "image_end")} + params.update(image_prompt_type="SE", video_prompt_type="IVA") + before = deepcopy(params) + validate_image_selectors(params, {}) + assert params == before + with pytest.raises(ValueError, match="requires an image"): + validate_image_selectors({"video_prompt_type": "I"}, {}) + with pytest.raises(ValueError, match="selector must be enabled"): + validate_image_selectors({**params, "video_prompt_type": "IVAU"}, {}) + + +def test_admission_freezes_native_defaults_and_preserves_explicit_values(tmp_path): + native = FakeNative(tmp_path) + defaults = {"output_filename": "original_{seed}", "custom_guide": None, + "perturbation_layers": [9], "guidance_scale": 99} + service = native.service() + service.runtime_defaults = lambda: defaults + command = _command("freeze-native-settings") + first = _run(service.submit(command)) + defaults["output_filename"] = "changed_{seed}" + defaults["perturbation_layers"].append(15) + replay = _run(service.submit(command)) + assert replay["receipt"] == first["receipt"] + entry = native.registry("workspace-a").command_admission(command["intent_id"]) + snapshot = entry["effective"]["runtime"]["params"] + assert snapshot["output_filename"] == "original_{seed}" + assert snapshot["perturbation_layers"] == [9] + assert snapshot["guidance_scale"] == command["input"]["guidance_scale"] + assert len(native.dispatch_calls) == 1 diff --git a/tests/test_studio_image_preparation.py b/tests/test_studio_image_preparation.py new file mode 100644 index 000000000..9f34de611 --- /dev/null +++ b/tests/test_studio_image_preparation.py @@ -0,0 +1,359 @@ +"""Provider-free contract tests for the Studio image preparation boundary.""" + +from copy import deepcopy + +import pytest +from fastapi import HTTPException + +from services.studio_image_preparation import prepare_studio_image + + +BASE_DEFINITION = { + "image_outputs": True, + "returns_audio": False, + "guidance_max_phases": 1, +} + + +def base_params(**overrides): + params = { + "workspace": "studio-output", + "model_type": "model-image", + "prompt": ' literal "mañana"\nsecond line ', + "resolution": "512x512", + "negative_prompt": "", + "num_inference_steps": 4, + "guidance_scale": 1.0, + "guidance_phases": 1, + "activated_loras": [], + "loras_multipliers": "", + "spatial_upsampling": "", + "temporal_upsampling": "", + "wangp_processor_settings": {}, + } + params.update(overrides) + return params + + +class FakeResources: + def __init__(self, *, media_result=None, lora_result=None, media_error=None): + self.media_result = media_result if media_result is not None else ( + {"workspace": "studio-output", "prompt": "literal"}, + [{"role": "image_refs", "sha256": "media-hash"}], + ) + self.lora_result = lora_result if lora_result is not None else [] + self.media_error = media_error + self.media_calls = [] + self.lora_calls = [] + + def prepare_media(self, params): + self.media_calls.append(deepcopy(params)) + if self.media_error is not None: + raise self.media_error + return deepcopy(self.media_result) + + def prepare_loras(self, params, model_definition): + self.lora_calls.append((deepcopy(params), deepcopy(model_definition))) + return deepcopy(self.lora_result) + + +def invoke( + params, + *, + definition=None, + downloaded=True, + resources=None, + execution_policy=None, + processor_capabilities=None, + validate_processors=None, + processor_settings=None, +): + definition = deepcopy(definition or BASE_DEFINITION) + resources = resources or FakeResources() + policy_calls = [] + definition_calls = [] + download_calls = [] + + def model_definition(model_type): + definition_calls.append(model_type) + return deepcopy(definition) + + def model_downloaded(model_type): + download_calls.append(model_type) + return downloaded + + def policy(workspace): + policy_calls.append(workspace) + if execution_policy is not None: + return execution_policy(workspace) + return None + + capabilities = processor_capabilities or (lambda: []) + selection = validate_processors or (lambda _spatial, _temporal, _image: "") + settings = processor_settings or (lambda _method, values: deepcopy(values)) + result = prepare_studio_image( + params, + model_definition=model_definition, + model_downloaded=model_downloaded, + resources=resources, + execution_policy=policy, + processor_capabilities=capabilities, + validate_processors=selection, + processor_settings=settings, + ) + return result, { + "resources": resources, + "policy_calls": policy_calls, + "definition_calls": definition_calls, + "download_calls": download_calls, + } + + +def assert_http_error(error_info, *, status=422, code="invalid_studio_input"): + assert error_info.value.status_code == status + assert error_info.value.detail["code"] == code + return error_info.value.detail["message"] + + +@pytest.mark.parametrize("values,definition,field", [ + ({"num_inference_steps": 1}, {"inference_steps_min": 4}, "num_inference_steps"), + ({"num_inference_steps": 60}, {"inference_steps_max": 50}, "num_inference_steps"), + ({"sample_solver": "unavailable"}, {"sample_solvers": [("Euler", "euler")]}, "sample_solver"), + ({"skip_steps_cache_type": "first_block"}, {"first_block_cache": False}, "skip_steps_cache_type"), +]) +def test_declared_model_option_limits_fail_before_resource_preparation(values, definition, field): + resources = FakeResources() + with pytest.raises(HTTPException) as error: + invoke(base_params(**values), definition={**BASE_DEFINITION, **definition}, resources=resources) + assert field in assert_http_error(error) + assert resources.media_calls == resources.lora_calls == [] + + +def test_declared_model_options_preserve_valid_sampling_and_cache_parameters(): + params = base_params(sample_solver="euler", skip_steps_cache_type="first_block", skip_steps_multiplier=0.08) + resources = FakeResources(media_result=(deepcopy(params), [])) + (native, _), _ = invoke(params, definition={**BASE_DEFINITION, "sample_solvers": [("Euler", "euler")], + "first_block_cache": True}, resources=resources) + assert native == params + + +def test_empty_optional_frame_slots_do_not_request_model_conditioning(): + params = base_params(image_start=[""], image_end=["", ""], image_mask=[""]) + resources = FakeResources(media_result=(deepcopy(params), [])) + (native, _), _ = invoke(params, resources=resources) + assert native == params + + +def test_installed_still_image_model_without_audio_returns_native_snapshot_and_resources(): + resources = FakeResources( + media_result=( + {"workspace": "studio-output", "prompt": ' literal "mañana"\nsecond line ', "nested": {"keep": [1]}}, + [{"role": "image_refs", "sha256": "media-hash"}], + ), + lora_result=[{"role": "lora", "name": "style.safetensors", "sha256": "lora-hash"}], + ) + params = base_params() + before = deepcopy(params) + + (native, resources_out), calls = invoke(params, resources=resources) + + assert params == before + assert native == resources.media_result[0] + assert native is not resources.media_result[0] + assert resources_out == [ + {"role": "image_refs", "sha256": "media-hash"}, + {"role": "lora", "name": "style.safetensors", "sha256": "lora-hash"}, + ] + assert calls["policy_calls"] == ["studio-output"] + assert calls["definition_calls"] == ["model-image"] + assert calls["download_calls"] == ["model-image"] + assert calls["resources"].media_calls == [before] + assert calls["resources"].lora_calls[0][0] == before + native["nested"]["keep"].append(2) + assert resources.media_result[0]["nested"] == {"keep": [1]} + assert params == before + + +@pytest.mark.parametrize( + ("definition", "downloaded", "status", "code"), + [ + ({"image_outputs": False, "returns_audio": False}, True, 422, "unsupported_model"), + ({"image_outputs": True, "returns_audio": True}, True, 422, "unsupported_model"), + ({"image_outputs": True, "returns_audio": False}, False, 409, "model_unavailable"), + ], +) +def test_model_must_be_installed_image_only_and_non_audio(definition, downloaded, status, code): + resources = FakeResources() + with pytest.raises(HTTPException) as error: + invoke(base_params(), definition=definition, downloaded=downloaded, resources=resources) + + assert_http_error(error, status=status, code=code) + assert resources.media_calls == [] + assert resources.lora_calls == [] + + +def test_unsupported_image_references_are_rejected_before_resource_lookup(): + resources = FakeResources() + with pytest.raises(HTTPException) as error: + invoke( + base_params(image_refs=["/api/v1/uploads/ref.png"]), + resources=resources, + ) + + assert "image_refs" in assert_http_error(error) + assert resources.media_calls == [] + + +def test_model_requiring_a_reference_rejects_missing_reference_at_model_boundary(): + resources = FakeResources() + with pytest.raises(HTTPException) as error: + invoke( + base_params(), + definition={**BASE_DEFINITION, "at_least_one_image_ref_needed": True}, + resources=resources, + ) + + assert_http_error(error, code="reference_required") + assert resources.media_calls == [] + + +def test_supported_reference_reaches_resource_preparation(): + resources = FakeResources( + media_result=( + {"workspace": "studio-output", "image_refs": ["/tmp/ref.png"]}, + [{"role": "image_refs", "sha256": "ref-hash"}], + ), + ) + params = base_params(image_refs=["/api/v1/uploads/ref.png"], video_prompt_type="I") + definition = { + **BASE_DEFINITION, + "image_ref_choices": {"choices": [("Reference", "I")]}, + } + + (native, _resource_ids), _calls = invoke(params, definition=definition, resources=resources) + + assert native["image_refs"] == ["/tmp/ref.png"] + assert resources.media_calls[0]["image_refs"] == ["/api/v1/uploads/ref.png"] + + +def test_model_without_negative_prompt_rejects_nonempty_negative_input(): + resources = FakeResources() + with pytest.raises(HTTPException) as error: + invoke( + base_params(negative_prompt="must be rejected"), + definition={**BASE_DEFINITION, "no_negative_prompt": True}, + resources=resources, + ) + + assert "negative_prompt" in assert_http_error(error) + assert resources.media_calls == [] + + +def test_guidance_phases_cannot_exceed_the_model_capability(): + resources = FakeResources() + with pytest.raises(HTTPException) as error: + invoke( + base_params(guidance_phases=3), + definition={**BASE_DEFINITION, "guidance_max_phases": 2}, + resources=resources, + ) + + assert "guidance_phases" in assert_http_error(error) + assert resources.media_calls == [] + + +def test_supported_guidance_phases_are_preserved_for_native_preparation(): + params = base_params( + guidance_phases=2, + activated_loras=["style.safetensors"], + loras_multipliers="1;0", + ) + (native, _resource_ids), _calls = invoke( + params, + definition={**BASE_DEFINITION, "guidance_max_phases": 2}, + ) + + assert native == {"workspace": "studio-output", "prompt": "literal"} + + +def test_unknown_processor_settings_are_rejected_instead_of_silently_dropped(): + resources = FakeResources() + params = base_params( + spatial_upsampling="image-refiner", + wangp_processor_settings={"unknown": 7}, + ) + capabilities = lambda: [{ + "value": "image-refiner", + "kind": "spatial", + "enabled": True, + "media": ["image"], + }] + seen = [] + + def settings(method, values): + seen.append((method, deepcopy(values))) + return {} + + with pytest.raises(HTTPException) as error: + invoke( + params, + resources=resources, + processor_capabilities=capabilities, + processor_settings=settings, + ) + + assert "not supported" in assert_http_error(error) + assert seen == [("image-refiner", {"unknown": 7})] + assert resources.media_calls == [] + + +@pytest.mark.parametrize( + "capability", + [ + {"value": "video-only", "kind": "spatial", "enabled": True, "media": ["video"]}, + {"value": "disabled-refiner", "kind": "spatial", "enabled": False, "media": ["image"]}, + ], +) +def test_processor_with_bad_media_or_disabled_status_is_rejected(capability): + resources = FakeResources() + with pytest.raises(HTTPException) as error: + invoke( + base_params(spatial_upsampling=capability["value"]), + resources=resources, + processor_capabilities=lambda: [capability], + ) + + assert "installed image processor" in assert_http_error(error) + assert resources.media_calls == [] + + +def test_processor_selection_error_is_propagated_as_invalid_input(): + resources = FakeResources() + params = base_params(spatial_upsampling="image-refiner") + capabilities = lambda: [{ + "value": "image-refiner", + "kind": "spatial", + "enabled": True, + "media": ["image"], + }] + + with pytest.raises(HTTPException) as error: + invoke( + params, + resources=resources, + processor_capabilities=capabilities, + validate_processors=lambda _spatial, _temporal, _image: "bad media selection", + ) + + assert "bad media selection" in assert_http_error(error) + assert resources.media_calls == [] + + +def test_resource_media_failure_is_wrapped_before_loras_are_looked_up(): + resources = FakeResources(media_error=ValueError("bad media reference")) + with pytest.raises(HTTPException) as error: + invoke(base_params(), resources=resources) + + assert "bad media reference" in assert_http_error(error) + assert resources.media_calls == [base_params()] + assert resources.lora_calls == [] diff --git a/tests/test_studio_image_resources.py b/tests/test_studio_image_resources.py new file mode 100644 index 000000000..d547a5841 --- /dev/null +++ b/tests/test_studio_image_resources.py @@ -0,0 +1,389 @@ +"""Provider-free adversarial checks for Studio image and LoRA resources. + +The fixture uses only temporary files and a small Pillow image. It models the +Studio output workspace separately from source workspaces so a same-named file +cannot silently satisfy a reference from the wrong root. +""" + +from copy import deepcopy +import hashlib +from pathlib import Path + +import pytest +from PIL import Image, UnidentifiedImageError + +from services.studio_image_resources import ( + StudioImageResources, + validate_lora_multipliers, +) + + +@pytest.fixture +def resources_fixture(tmp_path): + uploads = tmp_path / "uploads" + output = tmp_path / "output-workspace" + source = tmp_path / "source-workspace" + other = tmp_path / "other-workspace" + for root in (uploads, output, source, other): + root.mkdir() + + workspaces = { + "output": output, + "source": source, + "other": other, + } + lora_roots = {} + + def workspace_dir(name): + return str(workspaces.get(name, tmp_path / name)) + + def uploads_dir(): + return str(uploads) + + def list_workspaces(): + return [{"name": name} for name in workspaces] + + def lora_search_dirs(model_type): + return [str(root) for root in lora_roots.get(model_type, [])] + + compatible_calls = [] + + def lora_compatible(model_definition, path): + compatible_calls.append((model_definition, path)) + return model_definition.get("compatible", True) + + service = StudioImageResources( + workspace_dir=workspace_dir, + uploads_dir=uploads_dir, + list_workspaces=list_workspaces, + lora_search_dirs=lora_search_dirs, + lora_compatible=lora_compatible, + ) + return { + "service": service, + "uploads": uploads, + "output": output, + "source": source, + "other": other, + "workspaces": workspaces, + "lora_roots": lora_roots, + "compatible_calls": compatible_calls, + } + + +def write_image(path: Path, color=(40, 80, 120)): + Image.new("RGB", (17, 11), color).save(path) + + +def canonical_upload(name): + return f"/api/v1/uploads/{name}" + + +def canonical_file(name, workspace="source"): + return f"/api/v1/file/{name}?workspace={workspace}" + + +def test_media_keeps_upload_and_declared_source_workspace_separate(resources_fixture): + fixture = resources_fixture + upload = fixture["uploads"] / "reference.png" + source = fixture["source"] / "reference.png" + output_same_name = fixture["output"] / "reference.png" + write_image(upload, (1, 2, 3)) + write_image(source, (4, 5, 6)) + write_image(output_same_name, (7, 8, 9)) + + params = { + "workspace": "output", + "image_refs": [canonical_upload("reference.png"), canonical_file("reference.png")], + "image_start": canonical_file("reference.png"), + "image_end": canonical_upload("reference.png"), + "canonical_image_refs": True, + } + working, resources = fixture["service"].prepare_media(params) + + assert working["workspace"] == "output" + assert working["image_refs"] == [str(upload.resolve()), str(source.resolve())] + assert working["image_start"] == str(source.resolve()) + assert working["image_end"] == str(upload.resolve()) + assert [item["role"] for item in resources] == [ + "image_refs", "image_refs", "image_start", "image_end", + ] + assert [item["index"] for item in resources] == [0, 1, 0, 0] + assert [item["workspace"] for item in resources] == [ + "__uploads__", "source", "source", "__uploads__", + ] + assert resources[0]["sha256"] == hashlib.sha256(upload.read_bytes()).hexdigest() + assert resources[1]["sha256"] == hashlib.sha256(source.read_bytes()).hexdigest() + assert working["image_refs"][1] != str(output_same_name.resolve()) + assert "canonical_image_refs" not in working + + +@pytest.mark.parametrize( + "reference", + [ + "/api/v1/file/reference.png?workspace=source&workspace=source", + "/api/v1/file/reference.png?workspace=unknown", + "/api/v1/file/reference.png", + "https://example.invalid/reference.png?workspace=source", + "/api/v1/file/../outside.png?workspace=source", + "/api/v1/uploads/../outside.png", + ], +) +def test_media_rejects_duplicate_unknown_http_and_traversal_references(resources_fixture, reference): + fixture = resources_fixture + write_image(fixture["source"] / "reference.png") + (fixture["other"] / "outside.png").write_bytes(b"outside") + + with pytest.raises(ValueError): + fixture["service"].prepare_media({"workspace": "output", "image_refs": [reference]}) + + +def test_media_rejects_a_symlink_that_resolves_outside_declared_root(resources_fixture, tmp_path): + fixture = resources_fixture + outside = tmp_path / "outside.png" + write_image(outside) + link = fixture["source"] / "escape.png" + try: + link.symlink_to(outside) + except OSError: + pytest.skip("file symlinks are unavailable on this platform") + + with pytest.raises(ValueError): + fixture["service"].prepare_media({ + "workspace": "output", + "image_refs": [canonical_file("escape.png")], + }) + + +def test_canonicalize_legacy_uses_exact_contained_absolute_path_and_does_not_adopt_homonyms( + resources_fixture, +): + fixture = resources_fixture + upload = fixture["uploads"] / "same.png" + source = fixture["source"] / "same.png" + output = fixture["output"] / "same.png" + for path in (upload, source, output): + write_image(path) + + service = fixture["service"] + assert service.canonicalize_legacy(str(upload)) == "/api/v1/uploads/same.png" + assert service.canonicalize_legacy(str(source)) == "/api/v1/file/same.png?workspace=source" + assert service.canonicalize_legacy(str(output)) == "/api/v1/file/same.png?workspace=output" + with pytest.raises(ValueError): + service.canonicalize_legacy("same.png") + outside = fixture["uploads"].parent / "outside.png" + write_image(outside) + with pytest.raises(ValueError): + service.canonicalize_legacy(str(outside)) + + +def test_canonicalize_legacy_rejects_symlink_outside_known_media_roots(resources_fixture, tmp_path): + fixture = resources_fixture + outside = tmp_path / "not-managed.png" + write_image(outside) + link = fixture["source"] / "legacy-link.png" + try: + link.symlink_to(outside) + except OSError: + pytest.skip("file symlinks are unavailable on this platform") + + with pytest.raises(ValueError): + fixture["service"].canonicalize_legacy(str(link)) + + +def test_nested_named_workspace_wins_over_parent_default_root_and_default_url_is_rejected(tmp_path): + uploads = tmp_path / "uploads" + outputs = tmp_path / "outputs" + named = outputs / "named-workspace" + uploads.mkdir() + named.mkdir(parents=True) + image = named / "nested.png" + write_image(image) + workspaces = {"default": outputs, "named": named} + + service = StudioImageResources( + workspace_dir=lambda name: str(workspaces[name]), + uploads_dir=lambda: str(uploads), + list_workspaces=lambda: [{"name": name} for name in workspaces], + lora_search_dirs=lambda _model_type: [], + lora_compatible=lambda _definition, _path: True, + ) + + assert service.canonicalize_legacy(str(image)) == "/api/v1/file/nested.png?workspace=named" + assert service._media("/api/v1/file/nested.png?workspace=named")[0] == str(image.resolve()) + with pytest.raises(ValueError, match="actual source workspace"): + service._media("/api/v1/file/named-workspace/nested.png?workspace=default") + + +def test_invalid_pillow_input_is_rejected_before_prepared_media_is_returned(resources_fixture): + fixture = resources_fixture + invalid = fixture["uploads"] / "not-an-image.png" + invalid.write_bytes(b"this is not an image") + + with pytest.raises(UnidentifiedImageError): + fixture["service"].prepare_media({ + "workspace": "output", + "image_refs": [canonical_upload("not-an-image.png")], + }) + + +def test_media_snapshot_is_detached_and_input_order_literals_and_sha_are_preserved(resources_fixture): + fixture = resources_fixture + first = fixture["uploads"] / "first.png" + second = fixture["source"] / "second.png" + write_image(first, (10, 20, 30)) + write_image(second, (30, 20, 10)) + params = { + "workspace": "output", + "prompt": ' literal "mañana"\nsecond line ', + "image_refs": [canonical_file("second.png"), canonical_upload("first.png")], + "nested": {"keep": [1, 2, 3]}, + } + before = deepcopy(params) + + working, resources = fixture["service"].prepare_media(params) + + assert params == before + assert working is not params + assert working["nested"] is not params["nested"] + assert working["prompt"] == before["prompt"] + assert [item["url"] for item in resources] == params["image_refs"] + assert [item["sha256"] for item in resources] == [ + hashlib.sha256(second.read_bytes()).hexdigest(), + hashlib.sha256(first.read_bytes()).hexdigest(), + ] + params["image_refs"].reverse() + params["nested"]["keep"].append(4) + assert working["image_refs"] == [str(second.resolve()), str(first.resolve())] + assert working["nested"] == {"keep": [1, 2, 3]} + + +def test_lora_lookup_is_model_specific_and_records_identity_without_mutating_params(resources_fixture): + fixture = resources_fixture + model_a_root = fixture["uploads"].parent / "loras-a" + model_b_root = fixture["uploads"].parent / "loras-b" + model_a_root.mkdir() + model_b_root.mkdir() + a = model_a_root / "style.safetensors" + b = model_b_root / "style.safetensors" + a.write_bytes(b"model-a-lora") + b.write_bytes(b"model-b-lora") + fixture["lora_roots"]["model-a"] = [model_a_root] + fixture["lora_roots"]["model-b"] = [model_b_root] + params = {"model_type": "model-a", "activated_loras": ["style.safetensors"]} + before = deepcopy(params) + + resources = fixture["service"].prepare_loras(params, {"compatible": True}) + + assert params == before + assert resources == [{ + "role": "lora", + "name": "style.safetensors", + "sha256": hashlib.sha256(a.read_bytes()).hexdigest(), + "size_bytes": a.stat().st_size, + }] + assert fixture["compatible_calls"] == [({"compatible": True}, str(a.resolve()))] + + +def test_lora_missing_ambiguous_incompatible_and_path_escape_are_rejected(resources_fixture): + fixture = resources_fixture + missing_root = fixture["uploads"].parent / "loras-missing" + missing_root.mkdir() + fixture["lora_roots"]["missing"] = [missing_root] + with pytest.raises(ValueError, match="missing or ambiguous"): + fixture["service"].prepare_loras( + {"model_type": "missing", "activated_loras": ["missing.safetensors"]}, + {}, + ) + + first_root = fixture["uploads"].parent / "loras-first" + second_root = fixture["uploads"].parent / "loras-second" + first_root.mkdir() + second_root.mkdir() + (first_root / "same.safetensors").write_bytes(b"first") + (second_root / "same.safetensors").write_bytes(b"second") + fixture["lora_roots"]["ambiguous"] = [first_root, second_root] + with pytest.raises(ValueError, match="missing or ambiguous"): + fixture["service"].prepare_loras( + {"model_type": "ambiguous", "activated_loras": ["same.safetensors"]}, + {}, + ) + + compatible_root = fixture["uploads"].parent / "loras-incompatible" + compatible_root.mkdir() + (compatible_root / "incompatible.safetensors").write_bytes(b"incompatible") + fixture["lora_roots"]["incompatible"] = [compatible_root] + with pytest.raises(ValueError, match="incompatible"): + fixture["service"].prepare_loras( + {"model_type": "incompatible", "activated_loras": ["incompatible.safetensors"]}, + {"compatible": False}, + ) + + fixture["lora_roots"]["escape"] = [first_root] + with pytest.raises(ValueError, match="exact LoRA"): + fixture["service"].prepare_loras( + {"model_type": "escape", "activated_loras": ["../same.safetensors"]}, + {}, + ) + + +def test_lora_lookup_rejects_a_symlink_that_resolves_outside_model_roots(resources_fixture, tmp_path): + fixture = resources_fixture + model_root = fixture["uploads"].parent / "loras-symlink" + model_root.mkdir() + outside = tmp_path / "outside.safetensors" + outside.write_bytes(b"outside model adapter") + link = model_root / "escape.safetensors" + try: + link.symlink_to(outside) + except OSError: + pytest.skip("file symlinks are unavailable on this platform") + fixture["lora_roots"]["symlink"] = [model_root] + + with pytest.raises(ValueError, match="outside its model"): + fixture["service"].prepare_loras( + {"model_type": "symlink", "activated_loras": ["escape.safetensors"]}, + {}, + ) + + +def test_lora_multiplier_count_and_nonfinite_values_are_rejected(): + too_many = { + "activated_loras": ["one.safetensors", "two.safetensors"], + "loras_multipliers": "1 0.5 0.25", + "num_inference_steps": 4, + } + with pytest.raises(ValueError, match="exceeds"): + validate_lora_multipliers(too_many, maximum_phases=2) + + for value in ("nan", "inf", "-inf", "1;nan"): + with pytest.raises(ValueError, match="finite"): + validate_lora_multipliers( + { + "activated_loras": ["one.safetensors"], + "loras_multipliers": value, + "num_inference_steps": 4, + }, + maximum_phases=2, + ) + + +def test_lora_multiplier_phases_are_validated_without_normalizing_or_mutating_input(): + params = { + "activated_loras": ["one.safetensors", "two.safetensors"], + "loras_multipliers": "1;0 0;1", + "num_inference_steps": 4, + "model_switch_phase": 1, + } + before = deepcopy(params) + + assert validate_lora_multipliers(params, maximum_phases=2) is None + assert params == before + with pytest.raises(ValueError, match="at most 2 phases"): + validate_lora_multipliers( + { + **params, + "loras_multipliers": "1;0;0 0;1;0", + }, + maximum_phases=2, + ) diff --git a/tests/test_studio_image_spec.py b/tests/test_studio_image_spec.py new file mode 100644 index 000000000..fcc5778eb --- /dev/null +++ b/tests/test_studio_image_spec.py @@ -0,0 +1,490 @@ +"""Pure contract tests for the complete Studio image command (v2).""" + +from copy import deepcopy +import json +from pathlib import Path + +import pytest + +from services.studio_image_spec import ( + FINGERPRINT_VERSION, + INCOMPATIBLE_IMAGE_FIELDS, + OPERATION, + SCHEMA_VERSION, + StudioImageSpecError, + freeze_studio_image_spec, + studio_image_schema, +) +from services.image_generation_spec import ImageGenerationSpecError + + +def command(*, intent_id="studio-image-1", **param_overrides): + params = { + "prompt": ' Keep this literal: "mañana"\nline two ', + "model_type": "pi_flux2", + "resolution": "512x512", + "num_inference_steps": 4, + "guidance_scale": 1.0, + "seed": -1, + } + params.update(param_overrides) + return { + "version": 2, + "operation": OPERATION, + "intent_id": intent_id, + "input": {"workspace": "workspace_with_underscores", "params": params}, + } + + +def test_v2_error_keeps_v1_error_compatibility_for_shared_adapters(): + assert issubclass(StudioImageSpecError, ImageGenerationSpecError) + + +def test_intercepted_native_ui_payload_keeps_all_image_fields_after_envelope_adaptation(): + native = json.loads((Path(__file__).parent / "fixtures/studio_image_native_request.json").read_text()) + workspace = native.pop("workspace") + attribution = native.pop("provenance") + request = {"version": 2, "operation": OPERATION, "intent_id": attribution["command"]["command_id"], + "input": {"workspace": workspace, "params": native}} + frozen = freeze_studio_image_spec(request) + assert frozen["original"]["input"]["params"] == native + effective = frozen["effective"]["input"]["params"] + assert all(effective[key] == value for key, value in native.items()) + + +@pytest.mark.parametrize("value", [True, 0, 1, "false", "", [], {}]) +def test_restored_h3_flag_only_accepts_inactive_boolean_or_null(value): + with pytest.raises(StudioImageSpecError): + freeze_studio_image_spec(command(minimax_h3_turbo_mode=value)) + + +def test_freezes_full_envelope_without_effects_or_mutations(): + request = command() + before = deepcopy(request) + + frozen = freeze_studio_image_spec(request) + + assert request == before + assert frozen["original"] == before + assert frozen["original"] is not request + assert frozen["original"]["input"] is not request["input"] + assert frozen["original"]["input"]["params"] is not request["input"]["params"] + assert frozen["effective"]["version"] == SCHEMA_VERSION + assert frozen["effective"]["operation"] == OPERATION + assert frozen["effective"]["intent_id"] == request["intent_id"] + assert frozen["effective"]["input"]["workspace"] == request["input"]["workspace"] + assert frozen["effective"]["input"]["params"]["prompt"] == request["input"]["params"]["prompt"] + assert frozen["effective"]["input"]["params"]["generation_mode"] == "image" + assert frozen["effective"]["input"]["params"]["image_mode"] == 1 + assert frozen["effective"]["input"]["params"]["video_length"] == 1 + assert frozen["effective"]["input"]["params"]["multi_prompts_gen_type"] == 2 + assert frozen["effective"]["input"]["params"]["repeat_generation"] == 1 + assert frozen["effective"]["input"]["params"]["batch_size"] == 1 + assert frozen["effective"]["input"]["params"]["prompt_enhancer"] == "" + assert frozen["effective"]["input"]["params"]["activated_loras"] == [] + assert frozen["effective"]["input"]["params"]["loras_multipliers"] == "" + assert frozen["effective"]["input"]["params"]["canonical_image_refs"] is False + + request["input"]["params"]["prompt"] = "changed after validation" + assert frozen["original"]["input"]["params"]["prompt"] == before["input"]["params"]["prompt"] + assert frozen["effective"]["input"]["params"]["prompt"] == before["input"]["params"]["prompt"] + + +def test_explicit_native_and_advanced_fields_are_retained_verbatim(): + params = { + "alt_prompt": "style and composition cues", + "negative_prompt": " avoid blur ", + "generation_mode": "image", + "image_mode": 1, + "video_length": 1, + "repeat_generation": 2, + "batch_size": 3, + "activated_loras": ["style-a.safetensors", "character.sft"], + "loras_multipliers": "1;0.5 0.25", + "image_refs": ["asset_ref_01", "/api/v1/uploads/reference.png"], + "image_start": "/api/v1/file/start.png?workspace=default", + "image_end": ["asset_end_01"], + "image_guide": "/api/v1/uploads/guide.png", + "image_mask": "/api/v1/file/mask.png?workspace=workspace_with_underscores", + "image_prompt_type": "KI", + "video_prompt_type": "I", + "frames_positions": "0,12", + "canonical_image_refs": True, + "multi_prompts_gen_type": 2, + "image_fit_mode": "contain", + "input_video_strength": 0.75, + "denoising_strength": 0.8, + "masking_strength": 0.9, + "video_guide_outpainting": "2", + "control_net_weight": 0.5, + "control_net_weight2": 0.25, + "control_net_weight_alt": 0.125, + "motion_amplitude": 1.1, + "mask_expand": 16, + "image_refs_relative_size": 80, + "remove_background_images_ref": 0, + "model_mode": 2, + "flow_shift": 3, + "sample_solver": "default", + "embedded_guidance_scale": 1.2, + "guidance2_scale": 1.3, + "guidance3_scale": 1.4, + "switch_threshold": 0.8, + "switch_threshold2": 0.75, + "guidance_phases": 1, + "model_switch_phase": 1, + "alt_guidance_scale": 1.5, + "alt_scale": 0.25, + "audio_guidance_scale": 0.0, + "audio_scale": 0.0, + "injection_strength": 0.7, + "identity_guidance_scale": 3.0, + "skip_steps_cache_type": "first_block", + "skip_steps_multiplier": 0.08, + "skip_steps_start_step_perc": 25, + "settings_version": 2.52, + "prompt_enhancer": "", + "spatial_upsampling": "lanczos2", + "film_grain_intensity": 0.2, + "film_grain_saturation": 0.6, + "progressive_pipeline": True, + "single_stage_pipeline": False, + "reference_pipeline": False, + "progressive_stage1_image_weight": 0.7, + "progressive_stage2_steps": 5, + "progressive_stage2_sigma": 0.85, + "progressive_stage3_steps": 3, + "progressive_stage3_sigma": 0.85, + "progressive_stage3_image_weight": 0.7, + "custom_settings": {"sensenova_kv_cache": "Enabled", "noise_clip_std": 2.5}, + "wangp_processor_settings": { + "spatial_upsampler_strength": 0.5, + "spatial_upsampler_face_count": 0, + "spatial_upsampler_h3_strength": 0.75, + "spatial_upsampler_prompt": "restore face detail", + "spatial_upsampler_reference_images": ["asset_face_01"], + "spatial_upsampler_dlss_strength": 1.0, + }, + } + + frozen = freeze_studio_image_spec(command(**params)) + effective = frozen["effective"]["input"]["params"] + for key, value in params.items(): + assert effective[key] == value, key + + +def test_omitted_optional_fields_stay_omitted_in_original_and_defaults_are_effective(): + request = command() + frozen = freeze_studio_image_spec(request) + original_params = frozen["original"]["input"]["params"] + effective_params = frozen["effective"]["input"]["params"] + + for field in ("generation_mode", "image_mode", "video_length", "activated_loras", "loras_multipliers", "canonical_image_refs"): + assert field not in original_params + assert field in effective_params + assert "settings_version" not in original_params + assert "settings_version" not in effective_params + + +def test_optional_collection_identity_is_preserved_outside_native_params(): + request = command() + request["input"]["workspace_collection_id"] = "collection-nightwatch" + + frozen = freeze_studio_image_spec(request) + + assert frozen["original"]["input"]["workspace_collection_id"] == "collection-nightwatch" + assert frozen["effective"]["input"]["workspace_collection_id"] == "collection-nightwatch" + assert "workspace_collection_id" not in frozen["effective"]["input"]["params"] + + request["input"]["workspace_collection_id"] = "changed-after-freeze" + assert frozen["effective"]["input"]["workspace_collection_id"] == "collection-nightwatch" + + explicit_null = command() + explicit_null["input"]["workspace_collection_id"] = None + null_frozen = freeze_studio_image_spec(explicit_null) + assert "workspace_collection_id" in null_frozen["effective"]["input"] + assert null_frozen["effective"]["input"]["workspace_collection_id"] is None + + for value in ("", " \n\t", True, 1, [], {}): + invalid = command() + invalid["input"]["workspace_collection_id"] = value + with pytest.raises(StudioImageSpecError, match="workspace_collection_id"): + freeze_studio_image_spec(invalid) + + +@pytest.mark.parametrize("field,value", [ + ("generation_mode", "video"), + ("image_mode", 0), + ("image_mode", 2), + ("video_length", 0), + ("video_length", 2), +]) +def test_image_selectors_cannot_smuggle_another_mode(field, value): + with pytest.raises(StudioImageSpecError, match=field): + freeze_studio_image_spec(command(**{field: value})) + + +@pytest.mark.parametrize("field", ["workspace", "prompt", "model_type", "resolution"]) +def test_required_text_fields_reject_blank_and_non_text_values(field): + for value in ("", " \n\t", None, True, 1, [], {}): + overrides = {field: value} + if field == "workspace": + request = command() + request["input"]["workspace"] = value + else: + request = command(**overrides) + with pytest.raises(StudioImageSpecError, match=field): + freeze_studio_image_spec(request) + + +@pytest.mark.parametrize("field", ["num_inference_steps", "seed"]) +@pytest.mark.parametrize("bad", [None, True, False, 1.0, "1", [], {}]) +def test_integer_fields_reject_coercion(field, bad): + with pytest.raises(StudioImageSpecError, match=field): + freeze_studio_image_spec(command(**{field: bad})) + + +@pytest.mark.parametrize("field", [ + "guidance_scale", "flow_shift", "denoising_strength", "masking_strength", + "film_grain_intensity", "film_grain_saturation", "skip_steps_multiplier", +]) +@pytest.mark.parametrize("bad", [True, False, "1.0", [], {}, float("nan"), float("inf")]) +def test_numeric_fields_reject_non_finite_or_coercible_values(field, bad): + with pytest.raises(StudioImageSpecError, match=field): + freeze_studio_image_spec(command(**{field: bad})) + + +@pytest.mark.parametrize("reference", [ + "/api/v1/uploads/reference.png", + "/api/v1/file/reference.png?workspace=default", + "/api/v1/assets/asset_reference_1", + "asset_reference_1", +]) +def test_references_accept_only_canonical_local_forms(reference): + frozen = freeze_studio_image_spec(command(image_refs=[reference], canonical_image_refs=True)) + assert frozen["effective"]["input"]["params"]["image_refs"] == [reference] + + +@pytest.mark.parametrize("reference", [ + "reference.png", + "/tmp/reference.png", + "C:\\reference.png", + "../reference.png", + "/api/v1/file/../reference.png?workspace=default", + "/api/v1/file/reference.png", + "/api/v1/file/reference.png?workspace=default&workspace=other", + "/api/v1/uploads/reference.png?workspace=default", + "https://example.invalid/reference.png", + "/api/v1/assets/not-an-asset-id", +]) +def test_references_reject_basenames_host_paths_and_ambiguous_urls(reference): + with pytest.raises(StudioImageSpecError, match="reference|workspace|asset"): + freeze_studio_image_spec(command(image_refs=[reference])) + + +def test_canonical_refs_requires_a_nonempty_reference_list(): + with pytest.raises(StudioImageSpecError, match="canonical_image_refs"): + freeze_studio_image_spec(command(canonical_image_refs=True)) + + +@pytest.mark.parametrize("extra", [ + {"actor": "wizard"}, + {"client": "mcp"}, + {"provenance": {"actor": "wizard"}}, + {"workspace": "default"}, + {"unknown_native_field": 1}, +]) +def test_extra_params_and_authority_fields_are_rejected(extra): + request = command() + request["input"]["params"].update(extra) + with pytest.raises(StudioImageSpecError, match="extra|Extra|workspace|unknown"): + freeze_studio_image_spec(request) + + +@pytest.mark.parametrize("extra", [ + {"actor": "wizard"}, + {"client": "mcp"}, + {"provenance": {"source": "browser"}}, +]) +def test_extra_envelope_and_input_fields_are_rejected(extra): + request = command() + request.update(extra) + with pytest.raises(StudioImageSpecError, match="extra|Extra|actor|client|provenance"): + freeze_studio_image_spec(request) + + request = command() + request["input"].update(extra) + with pytest.raises(StudioImageSpecError, match="extra|Extra|actor|client|provenance"): + freeze_studio_image_spec(request) + + +@pytest.mark.parametrize("field", ["prompt", "model_type", "resolution"]) +def test_explicit_null_is_rejected_for_required_fields(field): + with pytest.raises(StudioImageSpecError, match=field): + freeze_studio_image_spec(command(**{field: None})) + + +def test_known_nullable_native_fields_are_preserved_instead_of_dropped(): + request = command( + image_refs=None, + image_start=None, + image_end=None, + image_guide=None, + image_mask=None, + custom_settings=None, + wangp_processor_settings=None, + flow_shift=None, + model_mode=None, + ) + frozen = freeze_studio_image_spec(request) + effective = frozen["effective"]["input"]["params"] + for field in ( + "image_refs", "image_start", "image_end", "image_guide", "image_mask", + "custom_settings", "wangp_processor_settings", "flow_shift", "model_mode", + ): + assert field in effective + assert effective[field] is None + + +def test_nested_settings_are_typed_and_closed(): + with pytest.raises(StudioImageSpecError, match="custom_settings"): + freeze_studio_image_spec(command(custom_settings={"future_setting": 1})) + with pytest.raises(StudioImageSpecError, match="wangp_processor_settings"): + freeze_studio_image_spec(command(wangp_processor_settings={"future_setting": 1})) + with pytest.raises(StudioImageSpecError, match="spatial_upsampler_face_count"): + freeze_studio_image_spec(command(wangp_processor_settings={"spatial_upsampler_face_count": 1.5})) + with pytest.raises(StudioImageSpecError, match="sensenova_kv_cache"): + freeze_studio_image_spec(command(custom_settings={"sensenova_kv_cache": True})) + + +def test_lora_names_are_exact_catalog_names_without_paths(): + frozen = freeze_studio_image_spec(command(activated_loras=["style.safetensors"])) + assert frozen["effective"]["input"]["params"]["activated_loras"] == ["style.safetensors"] + for value in ("../style.safetensors", "/tmp/style.safetensors", "", " "): + with pytest.raises(StudioImageSpecError, match="activated_loras"): + freeze_studio_image_spec(command(activated_loras=[value])) + + +def test_fingerprint_excludes_intent_but_covers_workspace_and_every_effective_param(): + first = freeze_studio_image_spec(command(intent_id="one", settings_version=2.52)) + second = freeze_studio_image_spec(command(intent_id="two", settings_version=2.52)) + changed = freeze_studio_image_spec(command(intent_id="three", settings_version=2.53)) + + assert first["fingerprint_version"] == FINGERPRINT_VERSION == 2 + assert first["fingerprint"] == second["fingerprint"] + assert first["fingerprint"] != changed["fingerprint"] + assert len(first["fingerprint"]) == 64 + content = { + "version": 2, + "operation": OPERATION, + "input": first["effective"]["input"], + } + encoded = json.dumps(content, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + assert encoded + + other_workspace = command() + other_workspace["input"]["workspace"] = "another_workspace" + assert first["fingerprint"] != freeze_studio_image_spec(other_workspace)["fingerprint"] + + collection = command(intent_id="one") + collection["input"]["workspace_collection_id"] = "collection-a" + other_collection = command(intent_id="two") + other_collection["input"]["workspace_collection_id"] = "collection-b" + assert freeze_studio_image_spec(collection)["fingerprint"] != freeze_studio_image_spec(other_collection)["fingerprint"] + + +def test_schema_is_closed_and_documents_incompatible_native_families(): + schema = studio_image_schema() + assert schema["version"] == 2 + assert schema["operation"] == OPERATION + assert schema["input"]["additionalProperties"] is False + params_schema = schema["input"]["$defs"]["StudioImageParams"] + assert params_schema["additionalProperties"] is False + assert set(schema["supported_input_fields"]) == set(params_schema["properties"]) | {"workspace", "workspace_collection_id"} + assert "settings_version" in schema["supported_input_fields"] + assert "generation_mode" in schema["supported_input_fields"] + assert "canonical_image_refs" in schema["supported_input_fields"] + assert "video_source" in schema["inactive"] + assert "audio_guide" in schema["inactive"] + assert set(INCOMPATIBLE_IMAGE_FIELDS).issubset(schema["excluded"]) + assert schema["effects"]["generation_mode"] == "image" + assert schema["effects"]["image_mode"] == 1 + assert schema["effects"]["video_length"] == 1 + + +def test_invalid_envelope_shapes_are_reported_as_contract_errors(): + for value in (None, [], "text", 2, True): + with pytest.raises(StudioImageSpecError): + freeze_studio_image_spec(value) + for field, value in (("version", 1), ("operation", "generation.video"), ("intent_id", ""), ("input", None)): + request = command() + request[field] = value + with pytest.raises(StudioImageSpecError, match=field): + freeze_studio_image_spec(request) + + +def test_restored_mode_inactive_sentinels_are_retained_but_nonempty_values_fail(): + frozen = freeze_studio_image_spec(command( + minimax_h3_turbo_mode=False, + image_start="", + image_end="", + image_guide="", + image_mask="", + audio_guide="", + audio_guide2=None, + video_guide="", + video_mask=None, + video_source="", + temporal_upsampling="", + audio_prompt_type="", + force_fps="", + sliding_window_size=129, + sliding_window_overlap=9, + sliding_window_memory_override=False, + sliding_window_discard_last_frames=0, + h3_ref_videos=[], + h3_ref_audios=None, + minimax_h3_references=[], + MMAudio_setting=0, + MMAudio_prompt="", + MMAudio_neg_prompt=None, + )) + effective = frozen["effective"]["input"]["params"] + assert effective["minimax_h3_turbo_mode"] is False + assert effective["audio_guide"] == "" + assert effective["image_start"] == "" + assert effective["image_end"] == "" + assert effective["image_guide"] == "" + assert effective["image_mask"] == "" + assert effective["audio_guide2"] is None + assert effective["sliding_window_size"] == 129 + assert effective["sliding_window_overlap"] == 9 + assert effective["h3_ref_videos"] == [] + assert effective["MMAudio_setting"] == 0 + for field, value in ( + ("MMAudio_setting", 1), + ("audio_guide", "/api/v1/uploads/voice.wav"), + ("video_guide", "/api/v1/uploads/guide.mp4"), + ("video_source", "/api/v1/uploads/source.mp4"), + ("temporal_upsampling", "dlssg*2"), + ("audio_prompt_type", "A"), + ("keep_frames_video_source", "0,12"), + ("force_fps", "control"), + ): + with pytest.raises(StudioImageSpecError, match=field): + freeze_studio_image_spec(command(**{field: value})) + + for field in ("h3_ref_videos", "h3_ref_audios", "minimax_h3_references"): + with pytest.raises(StudioImageSpecError, match=field): + freeze_studio_image_spec(command(**{field: ["/api/v1/uploads/reference.png"]})) + + +@pytest.mark.parametrize("field", [ + "h3_ref_image_size", "h3_reference_mode", "h3_model_profile", "stage2_steps", + "perturbation_switch", "stg_scale", "keyframe_conditioning_mode", + "h3_window_plan", "preserve_source_style", + "duration_seconds", "voice_reference", +]) +def test_active_non_image_native_fields_are_explicitly_rejected(field): + with pytest.raises(StudioImageSpecError, match=field): + freeze_studio_image_spec(command(**{field: 1})) diff --git a/tests/test_task_adapter_helpers.py b/tests/test_task_adapter_helpers.py index 5ef237c63..983c193e8 100644 --- a/tests/test_task_adapter_helpers.py +++ b/tests/test_task_adapter_helpers.py @@ -113,9 +113,10 @@ def test_canonical_legacy_progress_prioritizes_current_total_and_clamps(): "adapter", ["_publish_generation_task", "_publish_generic_legacy_task"], ) def test_legacy_adapters_use_the_canonical_progress_helper(adapter): + function = "_generation_task_fields" if adapter == "_publish_generation_task" else adapter calls = { node.func.id - for node in ast.walk(_function(adapter)) + for node in ast.walk(_function(function)) if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) } assert "_canonical_legacy_progress" in calls @@ -144,8 +145,8 @@ def upsert(workspace, task_id, **fields): "_local_gpu_lane": SimpleNamespace(key="local_gpu:0"), "_upsert_canonical_task": upsert, } - node = _function("_publish_generation_task") - exec(compile(ast.Module(body=[node], type_ignores=[]), str(LAUNCH_PATH), "exec"), namespace) + nodes = [_function("_generation_task_fields"), _function("_publish_generation_task")] + exec(compile(ast.Module(body=nodes, type_ignores=[]), str(LAUNCH_PATH), "exec"), namespace) namespace["_publish_generation_task"]({ "id": "song-job", @@ -181,6 +182,8 @@ def _load_publisher(name: str): _function(helper) for helper in ("_task_legacy_id", "_task_status", "_task_timestamp", name) ] + if name == "_publish_generation_task": + selected.insert(0, _function("_generation_task_fields")) module = ast.Module(body=selected, type_ignores=[]) ast.fix_missing_locations(module) captured = {} diff --git a/tests/test_task_command_admission.py b/tests/test_task_command_admission.py new file mode 100644 index 000000000..0d4e15cea --- /dev/null +++ b/tests/test_task_command_admission.py @@ -0,0 +1,516 @@ +"""Contract tests for atomic TaskRegistry command admission. + +These tests exercise the durable admission boundary only. Admission creates +one queued task, its creation event and a replayable receipt; it does not start +the worker or prove that a provider has executed the task. +""" + +from __future__ import annotations + +from concurrent.futures import ProcessPoolExecutor +import copy +import hashlib +import json +import multiprocessing +import sqlite3 + +import pytest + +from services.task_command_admission import TaskCommandConflict +from services.task_manager import TaskRegistry + + +def _command_input(*, workspace: str = "workspace-a", prompt: str = "literal prompt") -> dict: + return { + "workspace": workspace, + "model_type": "pi_flux2", + "prompt": prompt, + "negative_prompt": "avoid blur", + "resolution": "512x512", + "num_inference_steps": 1, + "seed": -1, + "guidance_scale": 1.0, + "generation_mode": "image", + "image_mode": 1, + "video_length": 1, + } + + +def _original_and_effective(intent_id: str, *, prompt: str = "literal prompt") -> tuple[dict, dict]: + original = { + "version": 1, + "operation": "generation.image", + "intent_id": intent_id, + "input": _command_input(prompt=prompt), + } + effective = copy.deepcopy(original) + return original, effective + + +def _digest(effective: dict) -> str: + payload = json.dumps(effective, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _task_fields( + task_id: str, + *, + backend_job_id: str | None = None, + workspace: str = "workspace-a", + status: str = "queued", +) -> dict: + return { + "id": task_id, + "root_id": f"root-{task_id}", + "parent_id": None, + "kind": "image", + "workflow": "generation.image", + "title": "Image admission", + "status": status, + "phase": "queued", + "message": "Queued for image generation", + "workspace": workspace, + "backend_job_id": backend_job_id if backend_job_id is not None else f"backend-{task_id}", + "current": 0, + "total": 1, + "resource_requirements": ["local_gpu:0"], + "recoverable": True, + "metadata": {"command_scope": "test"}, + } + + +def _admit( + registry: TaskRegistry, + intent_id: str, + *, + operation: str = "generation.image", + digest: str | None = None, + original: dict | None = None, + effective: dict | None = None, + task_id: str | None = None, + workspace: str = "workspace-a", + status: str = "queued", + backend_job_id: str | None = None, +) -> dict: + if original is None or effective is None: + original, effective = _original_and_effective(intent_id) + return registry.admit_command_task( + intent_id=intent_id, + operation=operation, + digest=digest or _digest(effective), + original=original, + effective=effective, + task_fields=_task_fields( + task_id or f"task-{intent_id}", + backend_job_id=backend_job_id, + workspace=workspace, + status=status, + ), + ) + + +def _db_counts(registry: TaskRegistry) -> dict[str, int]: + with sqlite3.connect(registry.path) as connection: + return { + table: int(connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]) + for table in ("tasks", "task_events", "task_command_admissions") + } + + +def test_admission_commits_task_event_and_receipt_as_one_canonical_unit(tmp_path): + registry = TaskRegistry(str(tmp_path), interrupt_stale=False) + original, effective = _original_and_effective("intent-atomic") + + result = _admit( + registry, + "intent-atomic", + original=original, + effective=effective, + task_id="task-atomic", + ) + + assert result["replayed"] is False + receipt = result["receipt"] + assert receipt["status"] == "queued" + assert receipt["taskIds"] == ["task-atomic"] + assert receipt["result"] == { + "job_id": "backend-task-atomic", + "task_id": "task-atomic", + "root_task_id": "root-task-atomic", + "workspace": "workspace-a", + "status": "queued", + } + assert registry.get("task-atomic")["status"] == "queued" + assert [event["type"] for event in registry.events("task-atomic")] == ["task.created"] + + stored = registry.command_admission("intent-atomic") + assert stored is not None + assert stored["operation"] == "generation.image" + assert stored["digest"] == _digest(effective) + assert stored["task_id"] == "task-atomic" + assert stored["original"] == original + assert stored["effective"] == effective + assert stored["receipt"] == receipt + assert _db_counts(registry) == { + "tasks": 1, + "task_events": 1, + "task_command_admissions": 1, + } + + +def test_same_intent_and_digest_replays_without_second_task_or_event(tmp_path): + registry = TaskRegistry(str(tmp_path), interrupt_stale=False) + original, effective = _original_and_effective("intent-replay") + first = _admit( + registry, + "intent-replay", + original=original, + effective=effective, + task_id="task-first", + ) + + # A retry may reconstruct a different local task candidate. The durable + # intent receipt remains authoritative and must be returned unchanged. + retry_original, retry_effective = _original_and_effective( + "intent-replay", prompt="candidate that must not replace the first request" + ) + second = _admit( + registry, + "intent-replay", + original=retry_original, + effective=retry_effective, + task_id="task-retry", + digest=_digest(effective), + ) + + assert first["replayed"] is False + assert second == {"receipt": first["receipt"], "replayed": True} + assert registry.get("task-first") is not None + assert registry.get("task-retry") is None + assert registry.command_admission("intent-replay")["original"] == original + assert _db_counts(registry) == { + "tasks": 1, + "task_events": 1, + "task_command_admissions": 1, + } + + +@pytest.mark.parametrize( + ("operation", "digest"), + [ + ("generation.video", None), + ("generation.image", "different-digest"), + ], + ids=["operation-conflict", "digest-conflict"], +) +def test_same_intent_with_changed_operation_or_digest_is_rejected_atomically( + tmp_path, + operation, + digest, +): + registry = TaskRegistry(str(tmp_path), interrupt_stale=False) + original, effective = _original_and_effective("intent-conflict") + first_digest = _digest(effective) + _admit( + registry, + "intent-conflict", + original=original, + effective=effective, + digest=first_digest, + task_id="task-conflict", + ) + + with pytest.raises(TaskCommandConflict, match="different parameters"): + _admit( + registry, + "intent-conflict", + operation=operation, + digest=digest or first_digest, + task_id="task-conflicting-retry", + ) + + assert registry.get("task-conflicting-retry") is None + assert registry.command_admission("intent-conflict")["digest"] == first_digest + assert _db_counts(registry) == { + "tasks": 1, + "task_events": 1, + "task_command_admissions": 1, + } + + +def test_distinct_intents_with_identical_content_are_independent(tmp_path): + registry = TaskRegistry(str(tmp_path), interrupt_stale=False) + first_original, first_effective = _original_and_effective("intent-one") + second_original, second_effective = _original_and_effective("intent-two") + digest = _digest(first_effective) + + first = _admit( + registry, + "intent-one", + original=first_original, + effective=first_effective, + digest=digest, + task_id="task-one", + ) + second = _admit( + registry, + "intent-two", + original=second_original, + effective=second_effective, + digest=digest, + task_id="task-two", + ) + + assert first["replayed"] is False + assert second["replayed"] is False + assert first["receipt"]["taskIds"] != second["receipt"]["taskIds"] + assert {task["id"] for task in registry.list()} == {"task-one", "task-two"} + assert _db_counts(registry) == { + "tasks": 2, + "task_events": 2, + "task_command_admissions": 2, + } + + +def test_long_original_and_effective_literals_are_snapshotted_without_mutation(tmp_path): + registry = TaskRegistry(str(tmp_path), interrupt_stale=False) + literal = (' literal ñ line\nwith spaces and "quotes" ' * 1_200) + original, effective = _original_and_effective("intent-snapshot", prompt=literal) + original_before = copy.deepcopy(original) + effective_before = copy.deepcopy(effective) + task_fields = _task_fields("task-snapshot") + task_fields_before = copy.deepcopy(task_fields) + + result = registry.admit_command_task( + intent_id="intent-snapshot", + operation="generation.image", + digest=_digest(effective), + original=original, + effective=effective, + task_fields=task_fields, + ) + + original["input"]["prompt"] = "caller mutation" + effective["input"]["prompt"] = "caller mutation" + task_fields["metadata"]["command_scope"] = "caller mutation" + result["receipt"]["result"]["status"] = "caller mutation" + + stored = registry.command_admission("intent-snapshot") + assert stored["original"] == original_before + assert stored["effective"] == effective_before + assert registry.get("task-snapshot")["metadata"] == task_fields_before["metadata"] + assert stored["receipt"]["status"] == "queued" + assert stored["original"]["input"]["prompt"] == literal + assert len(stored["original"]["input"]["prompt"]) == len(literal) + + +@pytest.mark.parametrize( + ("status", "backend_job_id"), + [("running", "backend-running"), ("queued", "")], + ids=["non-queued-status", "missing-backend-id"], +) +def test_admission_requires_queued_task_with_backend_id(tmp_path, status, backend_job_id): + registry = TaskRegistry(str(tmp_path), interrupt_stale=False) + original, effective = _original_and_effective("intent-invalid-task") + + with pytest.raises(ValueError, match="queued task and exact backend job ID"): + _admit( + registry, + "intent-invalid-task", + original=original, + effective=effective, + task_id="task-invalid-task", + status=status, + backend_job_id=backend_job_id, + ) + + assert registry.command_admission("intent-invalid-task") is None + assert registry.get("task-invalid-task") is None + assert registry.events("task-invalid-task") == [] + assert _db_counts(registry) == { + "tasks": 0, + "task_events": 0, + "task_command_admissions": 0, + } + + +def test_failure_before_commit_rolls_back_task_event_and_receipt(tmp_path, monkeypatch): + registry = TaskRegistry(str(tmp_path), interrupt_stale=False) + original, effective = _original_and_effective("intent-before-commit") + + def fail_before_commit(*_args, **_kwargs): + raise RuntimeError("injected insert failure") + + monkeypatch.setattr(registry, "_insert_task", fail_before_commit) + with pytest.raises(RuntimeError, match="injected insert failure"): + _admit( + registry, + "intent-before-commit", + original=original, + effective=effective, + task_id="task-before-commit", + ) + + assert registry.get("task-before-commit") is None + assert registry.events("task-before-commit") == [] + assert registry.command_admission("intent-before-commit") is None + assert _db_counts(registry) == { + "tasks": 0, + "task_events": 0, + "task_command_admissions": 0, + } + + +def test_failure_after_commit_is_recoverable_as_replay(tmp_path, monkeypatch): + registry = TaskRegistry(str(tmp_path), interrupt_stale=False) + original, effective = _original_and_effective("intent-after-commit") + digest = _digest(effective) + + def fail_after_commit(_task): + raise RuntimeError("injected post-commit notification failure") + + monkeypatch.setattr(registry, "_after_task_created", fail_after_commit) + with pytest.raises(RuntimeError, match="post-commit"): + _admit( + registry, + "intent-after-commit", + original=original, + effective=effective, + digest=digest, + task_id="task-after-commit", + ) + + # A fresh registry models process restart and supplies the unpatched + # post-admission hook for the replay. The committed receipt is durable. + restarted = TaskRegistry(str(tmp_path), interrupt_stale=False) + replay = _admit( + restarted, + "intent-after-commit", + original=original, + effective=effective, + digest=digest, + task_id="task-after-retry", + ) + + assert replay["replayed"] is True + assert replay["receipt"]["taskIds"] == ["task-after-commit"] + assert restarted.get("task-after-commit")["status"] == "queued" + assert restarted.get("task-after-retry") is None + assert _db_counts(restarted) == { + "tasks": 1, + "task_events": 1, + "task_command_admissions": 1, + } + + +def test_restart_rehydrates_admission_and_replay_without_dispatch(tmp_path): + first = TaskRegistry(str(tmp_path), interrupt_stale=False) + original, effective = _original_and_effective("intent-restart") + admitted = _admit( + first, + "intent-restart", + original=original, + effective=effective, + task_id="task-restart", + ) + + restarted = TaskRegistry(str(tmp_path), interrupt_stale=False) + stored = restarted.command_admission("intent-restart") + replay = _admit( + restarted, + "intent-restart", + original=original, + effective=effective, + digest=_digest(effective), + task_id="task-restart-retry", + ) + + assert stored["receipt"] == admitted["receipt"] + assert replay == {"receipt": admitted["receipt"], "replayed": True} + assert restarted.get("task-restart")["status"] == "queued" + assert restarted.get("task-restart-retry") is None + assert _db_counts(restarted) == { + "tasks": 1, + "task_events": 1, + "task_command_admissions": 1, + } + + +def _admit_from_process(arguments): + workspace, intent_id, digest, original, effective, task_fields = arguments + registry = TaskRegistry(workspace, interrupt_stale=False) + result = registry.admit_command_task( + intent_id=intent_id, + operation="generation.image", + digest=digest, + original=original, + effective=effective, + task_fields=task_fields, + ) + return result["replayed"], result["receipt"] + + +def test_independent_processes_serialize_one_intent_without_duplicates(tmp_path): + workspace = str(tmp_path) + intent_id = "intent-processes" + original, effective = _original_and_effective(intent_id) + digest = _digest(effective) + task_fields = _task_fields("task-processes") + arguments = (workspace, intent_id, digest, original, effective, task_fields) + context = multiprocessing.get_context("spawn") + + with ProcessPoolExecutor(max_workers=4, mp_context=context) as pool: + results = list(pool.map(_admit_from_process, [arguments] * 6)) + + assert [replayed for replayed, _receipt in results].count(False) == 1 + assert [replayed for replayed, _receipt in results].count(True) == 5 + assert all(receipt == results[0][1] for _replayed, receipt in results) + + registry = TaskRegistry(workspace, interrupt_stale=False) + assert registry.get("task-processes")["status"] == "queued" + assert registry.command_admission(intent_id)["receipt"] == results[0][1] + assert _db_counts(registry) == { + "tasks": 1, + "task_events": 1, + "task_command_admissions": 1, + } + + +@pytest.mark.parametrize("delete_terminal", [False, True], ids=["retained", "deleted"]) +def test_receipt_survives_terminal_retention_or_deletion(tmp_path, delete_terminal): + registry = TaskRegistry(str(tmp_path), interrupt_stale=False) + original, effective = _original_and_effective("intent-terminal") + admitted = _admit( + registry, + "intent-terminal", + original=original, + effective=effective, + task_id="task-terminal", + ) + registry.update( + "task-terminal", + status="completed", + phase="completed", + force=True, + event_type="task.finished", + ) + if delete_terminal: + assert registry.delete("task-terminal") is True + assert registry.get("task-terminal") is None + assert registry.events("task-terminal")[-1]["type"] == "task.deleted" + else: + assert registry.get("task-terminal")["status"] == "completed" + + replay = _admit( + registry, + "intent-terminal", + original=original, + effective=effective, + digest=_digest(effective), + task_id="task-terminal-retry", + ) + + assert replay == {"receipt": admitted["receipt"], "replayed": True} + assert registry.command_admission("intent-terminal")["receipt"] == admitted["receipt"] + assert registry.get("task-terminal-retry") is None + assert _db_counts(registry)["task_command_admissions"] == 1 diff --git a/ui/src/api/imageCommandCatalog.json b/ui/src/api/imageCommandCatalog.json new file mode 100644 index 000000000..4c763ce0f --- /dev/null +++ b/ui/src/api/imageCommandCatalog.json @@ -0,0 +1,3429 @@ +{ + "version": 2, + "operations": [ + { + "name": "generation.image", + "version": 2, + "supportedVersions": [ + 1, + 2 + ], + "domain": "studio", + "mutation": true, + "description": "Admit an image job with an installed model and explicit output workspace. Version 1 is a single text-to-image request; version 2 accepts the complete typed Studio image parameters, canonical references, LoRAs and image processors. Preserve literal prompts and reuse intent_id only for retries. The receipt proves admission; inspect its task for completion.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "version": { + "type": "integer", + "enum": [ + 1, + 2 + ] + }, + "operation": { + "const": "generation.image" + }, + "intent_id": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "input": { + "type": "object" + } + }, + "required": [ + "version", + "operation", + "intent_id", + "input" + ], + "$defs": { + "StudioImageCustomSettings": { + "additionalProperties": false, + "description": "Known model-specific image settings, with no untyped JSON map.", + "properties": { + "sensenova_kv_cache": { + "anyOf": [ + { + "enum": [ + "Disabled", + "Enabled" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sensenova Kv Cache" + }, + "noise_scale_start": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Noise Scale Start" + }, + "noise_scale_end": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Noise Scale End" + }, + "noise_clip_std": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Noise Clip Std" + } + }, + "title": "StudioImageCustomSettings", + "type": "object" + }, + "StudioImageParams": { + "additionalProperties": false, + "description": "The closed native parameter family for one Studio image job.", + "properties": { + "prompt": { + "maxLength": 200000, + "minLength": 1, + "title": "Prompt", + "type": "string" + }, + "alt_prompt": { + "default": "", + "maxLength": 200000, + "title": "Alt Prompt", + "type": "string" + }, + "model_type": { + "maxLength": 240, + "minLength": 1, + "title": "Model Type", + "type": "string" + }, + "resolution": { + "maxLength": 8192, + "minLength": 1, + "title": "Resolution", + "type": "string" + }, + "video_length": { + "default": 1, + "maximum": 1, + "minimum": 1, + "title": "Video Length", + "type": "integer" + }, + "num_inference_steps": { + "maximum": 1000, + "minimum": 1, + "title": "Num Inference Steps", + "type": "integer" + }, + "guidance_scale": { + "title": "Guidance Scale", + "type": "number" + }, + "seed": { + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "title": "Seed", + "type": "integer" + }, + "image_mode": { + "default": 1, + "maximum": 1, + "minimum": 1, + "title": "Image Mode", + "type": "integer" + }, + "generation_mode": { + "const": "image", + "default": "image", + "title": "Generation Mode", + "type": "string" + }, + "negative_prompt": { + "default": "", + "maxLength": 200000, + "title": "Negative Prompt", + "type": "string" + }, + "repeat_generation": { + "default": 1, + "maximum": 100, + "minimum": 1, + "title": "Repeat Generation", + "type": "integer" + }, + "batch_size": { + "default": 1, + "maximum": 100, + "minimum": 1, + "title": "Batch Size", + "type": "integer" + }, + "activated_loras": { + "items": { + "maxLength": 8192, + "minLength": 1, + "type": "string" + }, + "maxItems": 64, + "title": "Activated Loras", + "type": "array" + }, + "loras_multipliers": { + "default": "", + "maxLength": 8192, + "title": "Loras Multipliers", + "type": "string" + }, + "image_start": { + "anyOf": [ + { + "maxLength": 8192, + "type": "string" + }, + { + "items": { + "maxLength": 8192, + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Image Start" + }, + "image_end": { + "anyOf": [ + { + "maxLength": 8192, + "type": "string" + }, + { + "items": { + "maxLength": 8192, + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Image End" + }, + "image_refs": { + "anyOf": [ + { + "items": { + "maxLength": 8192, + "minLength": 1, + "type": "string" + }, + "maxItems": 64, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Image Refs" + }, + "image_guide": { + "anyOf": [ + { + "maxLength": 8192, + "type": "string" + }, + { + "items": { + "maxLength": 8192, + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Image Guide" + }, + "image_mask": { + "anyOf": [ + { + "maxLength": 8192, + "type": "string" + }, + { + "items": { + "maxLength": 8192, + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Image Mask" + }, + "image_prompt_type": { + "default": "", + "maxLength": 8192, + "title": "Image Prompt Type", + "type": "string" + }, + "video_prompt_type": { + "default": "", + "maxLength": 8192, + "title": "Video Prompt Type", + "type": "string" + }, + "frames_positions": { + "default": "", + "maxLength": 8192, + "title": "Frames Positions", + "type": "string" + }, + "canonical_image_refs": { + "default": false, + "title": "Canonical Image Refs", + "type": "boolean" + }, + "multi_prompts_gen_type": { + "default": 2, + "maximum": 100000, + "minimum": 0, + "title": "Multi Prompts Gen Type", + "type": "integer" + }, + "image_fit_mode": { + "default": "", + "enum": [ + "", + "contain", + "source", + "crop" + ], + "title": "Image Fit Mode", + "type": "string" + }, + "input_video_strength": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Input Video Strength" + }, + "denoising_strength": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Denoising Strength" + }, + "masking_strength": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Masking Strength" + }, + "video_guide_outpainting": { + "default": "", + "maxLength": 8192, + "minLength": 1, + "title": "Video Guide Outpainting", + "type": "string" + }, + "control_net_weight": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Control Net Weight" + }, + "control_net_weight2": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Control Net Weight2" + }, + "control_net_weight_alt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Control Net Weight Alt" + }, + "motion_amplitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Motion Amplitude" + }, + "mask_expand": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mask Expand" + }, + "image_refs_relative_size": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Image Refs Relative Size" + }, + "remove_background_images_ref": { + "default": 0, + "maximum": 1, + "minimum": 0, + "title": "Remove Background Images Ref", + "type": "integer" + }, + "model_mode": { + "anyOf": [ + { + "maximum": 100000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 0, + "title": "Model Mode" + }, + "temporal_upsampling": { + "default": "", + "enum": [ + "", + null + ], + "title": "Temporal Upsampling" + }, + "audio_prompt_type": { + "default": "", + "enum": [ + "", + null + ], + "title": "Audio Prompt Type" + }, + "video_guide": { + "default": null, + "enum": [ + "", + null + ], + "title": "Video Guide" + }, + "video_mask": { + "default": null, + "enum": [ + "", + null + ], + "title": "Video Mask" + }, + "video_source": { + "default": null, + "enum": [ + "", + null + ], + "title": "Video Source" + }, + "audio_guide": { + "default": null, + "enum": [ + "", + null + ], + "title": "Audio Guide" + }, + "audio_guide2": { + "default": null, + "enum": [ + "", + null + ], + "title": "Audio Guide2" + }, + "audio_guide3": { + "default": null, + "enum": [ + "", + null + ], + "title": "Audio Guide3" + }, + "audio_guide4": { + "default": null, + "enum": [ + "", + null + ], + "title": "Audio Guide4" + }, + "audio_guide5": { + "default": null, + "enum": [ + "", + null + ], + "title": "Audio Guide5" + }, + "audio_guide6": { + "default": null, + "enum": [ + "", + null + ], + "title": "Audio Guide6" + }, + "audio_source": { + "default": null, + "enum": [ + "", + null + ], + "title": "Audio Source" + }, + "MMAudio_setting": { + "anyOf": [ + { + "maximum": 0, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mmaudio Setting" + }, + "MMAudio_prompt": { + "default": null, + "enum": [ + "", + null + ], + "title": "Mmaudio Prompt" + }, + "MMAudio_neg_prompt": { + "default": null, + "enum": [ + "", + null + ], + "title": "Mmaudio Neg Prompt" + }, + "h3_ref_videos": { + "anyOf": [ + { + "items": { + "maxLength": 8192, + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "H3 Ref Videos" + }, + "h3_ref_audios": { + "anyOf": [ + { + "items": { + "maxLength": 8192, + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "H3 Ref Audios" + }, + "minimax_h3_references": { + "anyOf": [ + { + "items": { + "maxLength": 8192, + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Minimax H3 References" + }, + "minimax_h3_turbo_mode": { + "anyOf": [ + { + "const": false, + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Minimax H3 Turbo Mode" + }, + "sliding_window_size": { + "anyOf": [ + { + "maximum": 100000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sliding Window Size" + }, + "sliding_window_overlap": { + "anyOf": [ + { + "maximum": 100000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sliding Window Overlap" + }, + "sliding_window_memory_override": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sliding Window Memory Override" + }, + "sliding_window_discard_last_frames": { + "anyOf": [ + { + "maximum": 100000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sliding Window Discard Last Frames" + }, + "sliding_window_color_correction_strength": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sliding Window Color Correction Strength" + }, + "sliding_window_overlap_noise": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sliding Window Overlap Noise" + }, + "keep_frames_video_source": { + "default": null, + "enum": [ + "", + null + ], + "title": "Keep Frames Video Source" + }, + "keep_frames_video_guide": { + "default": null, + "enum": [ + "", + null + ], + "title": "Keep Frames Video Guide" + }, + "force_fps": { + "default": "", + "enum": [ + "", + null + ], + "title": "Force Fps" + }, + "flow_shift": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Flow Shift" + }, + "sample_solver": { + "default": "", + "maxLength": 8192, + "title": "Sample Solver", + "type": "string" + }, + "embedded_guidance_scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Embedded Guidance Scale" + }, + "guidance2_scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Guidance2 Scale" + }, + "guidance3_scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Guidance3 Scale" + }, + "switch_threshold": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Switch Threshold" + }, + "switch_threshold2": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Switch Threshold2" + }, + "guidance_phases": { + "default": 1, + "maximum": 16, + "minimum": 1, + "title": "Guidance Phases", + "type": "integer" + }, + "model_switch_phase": { + "default": 1, + "maximum": 16, + "minimum": 1, + "title": "Model Switch Phase", + "type": "integer" + }, + "alt_guidance_scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Alt Guidance Scale" + }, + "alt_scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Alt Scale" + }, + "audio_guidance_scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Audio Guidance Scale" + }, + "audio_scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Audio Scale" + }, + "NAG_scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Nag Scale" + }, + "NAG_tau": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Nag Tau" + }, + "NAG_alpha": { + "anyOf": [ + { + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Nag Alpha" + }, + "RIFLEx_setting": { + "anyOf": [ + { + "maximum": 100000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Riflex Setting" + }, + "injection_strength": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Injection Strength" + }, + "identity_guidance_scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Identity Guidance Scale" + }, + "skip_steps_cache_type": { + "default": "", + "enum": [ + "", + "first_block" + ], + "title": "Skip Steps Cache Type", + "type": "string" + }, + "skip_steps_multiplier": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Skip Steps Multiplier" + }, + "skip_steps_start_step_perc": { + "anyOf": [ + { + "maximum": 100.0, + "minimum": 0.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Skip Steps Start Step Perc" + }, + "settings_version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Settings Version" + }, + "prompt_enhancer": { + "default": "", + "maxLength": 8192, + "title": "Prompt Enhancer", + "type": "string" + }, + "spatial_upsampling": { + "default": "", + "maxLength": 8192, + "title": "Spatial Upsampling", + "type": "string" + }, + "film_grain_intensity": { + "anyOf": [ + { + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Film Grain Intensity" + }, + "film_grain_saturation": { + "anyOf": [ + { + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Film Grain Saturation" + }, + "progressive_pipeline": { + "default": false, + "title": "Progressive Pipeline", + "type": "boolean" + }, + "single_stage_pipeline": { + "default": false, + "title": "Single Stage Pipeline", + "type": "boolean" + }, + "reference_pipeline": { + "default": false, + "title": "Reference Pipeline", + "type": "boolean" + }, + "progressive_stage1_image_weight": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Progressive Stage1 Image Weight" + }, + "progressive_stage2_steps": { + "anyOf": [ + { + "maximum": 100000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Progressive Stage2 Steps" + }, + "progressive_stage2_sigma": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Progressive Stage2 Sigma" + }, + "progressive_stage3_steps": { + "anyOf": [ + { + "maximum": 100000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Progressive Stage3 Steps" + }, + "progressive_stage3_sigma": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Progressive Stage3 Sigma" + }, + "progressive_stage3_image_weight": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Progressive Stage3 Image Weight" + }, + "override_profile": { + "anyOf": [ + { + "maxLength": 8192, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Override Profile" + }, + "override_attention": { + "anyOf": [ + { + "maxLength": 8192, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Override Attention" + }, + "temperature": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Temperature" + }, + "top_p": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Top P" + }, + "top_k": { + "anyOf": [ + { + "maximum": 100000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Top K" + }, + "self_refiner_setting": { + "anyOf": [ + { + "maximum": 100000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Self Refiner Setting" + }, + "self_refiner_plan": { + "anyOf": [ + { + "maxLength": 200000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Self Refiner Plan" + }, + "self_refiner_f_uncertainty": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Self Refiner F Uncertainty" + }, + "self_refiner_certain_percentage": { + "anyOf": [ + { + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Self Refiner Certain Percentage" + }, + "cfg_rescale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cfg Rescale" + }, + "modality_scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Modality Scale" + }, + "use_gradient_estimation": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Use Gradient Estimation" + }, + "ge_gamma": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ge Gamma" + }, + "ge_alpha": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ge Alpha" + }, + "outpaint_lora_strength": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outpaint Lora Strength" + }, + "outpaint_mask_preserve": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outpaint Mask Preserve" + }, + "outpaint_official_stack": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outpaint Official Stack" + }, + "custom_settings": { + "anyOf": [ + { + "$ref": "#/$defs/StudioImageCustomSettings" + }, + { + "type": "null" + } + ], + "default": null + }, + "wangp_processor_settings": { + "anyOf": [ + { + "$ref": "#/$defs/WangpProcessorSettings" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "prompt", + "model_type", + "resolution", + "num_inference_steps", + "guidance_scale", + "seed" + ], + "title": "StudioImageParams", + "type": "object" + }, + "WangpProcessorSettings": { + "additionalProperties": false, + "description": "Typed settings currently declared by image-capable processors.", + "properties": { + "spatial_upsampler_strength": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Spatial Upsampler Strength" + }, + "spatial_upsampler_face_count": { + "anyOf": [ + { + "maximum": 5, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Spatial Upsampler Face Count" + }, + "spatial_upsampler_h3_strength": { + "anyOf": [ + { + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Spatial Upsampler H3 Strength" + }, + "spatial_upsampler_prompt": { + "anyOf": [ + { + "maxLength": 200000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Spatial Upsampler Prompt" + }, + "spatial_upsampler_reference_images": { + "anyOf": [ + { + "items": { + "maxLength": 8192, + "minLength": 1, + "type": "string" + }, + "maxItems": 64, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Spatial Upsampler Reference Images" + }, + "spatial_upsampler_dlss_strength": { + "anyOf": [ + { + "maximum": 2.0, + "minimum": 0.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Spatial Upsampler Dlss Strength" + } + }, + "title": "WangpProcessorSettings", + "type": "object" + }, + "StudioCommandInput": { + "additionalProperties": false, + "properties": { + "workspace": { + "maxLength": 240, + "minLength": 1, + "pattern": "^(?:default|[A-Za-z0-9][A-Za-z0-9_-]*)$", + "title": "Workspace", + "type": "string" + }, + "workspace_collection_id": { + "anyOf": [ + { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Workspace Collection Id" + }, + "params": { + "$ref": "#/$defs/StudioImageParams" + } + }, + "required": [ + "workspace", + "params" + ], + "title": "StudioImageInput", + "type": "object" + } + }, + "oneOf": [ + { + "properties": { + "version": { + "const": 1 + }, + "input": { + "additionalProperties": false, + "description": "Strict input fields supported by ``generation.image``.\n\nDefaults on ``negative_prompt``, ``image_mode`` and ``video_length`` are\ncontract-owned. ``exclude_unset=True`` is used for the original input so\nomitted fields remain omitted there, while the effective native map has\nall three deterministic defaults.", + "properties": { + "workspace": { + "maxLength": 240, + "minLength": 1, + "pattern": "^(?:default|[A-Za-z0-9][A-Za-z0-9_-]*)$", + "title": "Workspace", + "type": "string" + }, + "model_type": { + "maxLength": 240, + "minLength": 1, + "title": "Model Type", + "type": "string" + }, + "prompt": { + "maxLength": 200000, + "minLength": 1, + "title": "Prompt", + "type": "string" + }, + "negative_prompt": { + "default": "", + "maxLength": 200000, + "title": "Negative Prompt", + "type": "string" + }, + "resolution": { + "maxLength": 128, + "minLength": 1, + "title": "Resolution", + "type": "string" + }, + "num_inference_steps": { + "maximum": 1000, + "minimum": 1, + "title": "Num Inference Steps", + "type": "integer" + }, + "seed": { + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "title": "Seed", + "type": "integer" + }, + "guidance_scale": { + "maximum": 1000.0, + "minimum": 0.0, + "title": "Guidance Scale", + "type": "number" + }, + "image_mode": { + "default": 1, + "maximum": 1, + "minimum": 1, + "title": "Image Mode", + "type": "integer" + }, + "video_length": { + "default": 1, + "maximum": 1, + "minimum": 1, + "title": "Video Length", + "type": "integer" + } + }, + "required": [ + "workspace", + "model_type", + "prompt", + "resolution", + "num_inference_steps", + "seed", + "guidance_scale" + ], + "title": "ImageGenerationInput", + "type": "object" + } + } + }, + { + "properties": { + "version": { + "const": 2 + }, + "input": { + "$ref": "#/$defs/StudioCommandInput" + } + } + } + ] + } + }, + { + "name": "generation.receipt", + "version": 1, + "domain": "studio", + "mutation": false, + "description": "Read an immutable image admission and its current canonical task in the exact original output workspace.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "version": { + "type": "integer", + "const": 1 + }, + "operation": { + "const": "generation.receipt" + }, + "input": { + "type": "object", + "additionalProperties": false, + "properties": { + "workspace": { + "maxLength": 240, + "minLength": 1, + "pattern": "^(?:default|[A-Za-z0-9][A-Za-z0-9_-]*)$", + "title": "Workspace", + "type": "string" + }, + "intent_id": { + "type": "string", + "minLength": 1, + "maxLength": 160 + } + }, + "required": [ + "workspace", + "intent_id" + ] + } + }, + "required": [ + "version", + "operation", + "input" + ] + } + } + ], + "studio": { + "version": 2, + "operation": "generation.image", + "intent_id": { + "maxLength": 160, + "minLength": 1, + "title": "Intent Id", + "type": "string" + }, + "input": { + "$defs": { + "StudioImageCustomSettings": { + "additionalProperties": false, + "description": "Known model-specific image settings, with no untyped JSON map.", + "properties": { + "sensenova_kv_cache": { + "anyOf": [ + { + "enum": [ + "Disabled", + "Enabled" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sensenova Kv Cache" + }, + "noise_scale_start": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Noise Scale Start" + }, + "noise_scale_end": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Noise Scale End" + }, + "noise_clip_std": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Noise Clip Std" + } + }, + "title": "StudioImageCustomSettings", + "type": "object" + }, + "StudioImageParams": { + "additionalProperties": false, + "description": "The closed native parameter family for one Studio image job.", + "properties": { + "prompt": { + "maxLength": 200000, + "minLength": 1, + "title": "Prompt", + "type": "string" + }, + "alt_prompt": { + "default": "", + "maxLength": 200000, + "title": "Alt Prompt", + "type": "string" + }, + "model_type": { + "maxLength": 240, + "minLength": 1, + "title": "Model Type", + "type": "string" + }, + "resolution": { + "maxLength": 8192, + "minLength": 1, + "title": "Resolution", + "type": "string" + }, + "video_length": { + "default": 1, + "maximum": 1, + "minimum": 1, + "title": "Video Length", + "type": "integer" + }, + "num_inference_steps": { + "maximum": 1000, + "minimum": 1, + "title": "Num Inference Steps", + "type": "integer" + }, + "guidance_scale": { + "title": "Guidance Scale", + "type": "number" + }, + "seed": { + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "title": "Seed", + "type": "integer" + }, + "image_mode": { + "default": 1, + "maximum": 1, + "minimum": 1, + "title": "Image Mode", + "type": "integer" + }, + "generation_mode": { + "const": "image", + "default": "image", + "title": "Generation Mode", + "type": "string" + }, + "negative_prompt": { + "default": "", + "maxLength": 200000, + "title": "Negative Prompt", + "type": "string" + }, + "repeat_generation": { + "default": 1, + "maximum": 100, + "minimum": 1, + "title": "Repeat Generation", + "type": "integer" + }, + "batch_size": { + "default": 1, + "maximum": 100, + "minimum": 1, + "title": "Batch Size", + "type": "integer" + }, + "activated_loras": { + "items": { + "maxLength": 8192, + "minLength": 1, + "type": "string" + }, + "maxItems": 64, + "title": "Activated Loras", + "type": "array" + }, + "loras_multipliers": { + "default": "", + "maxLength": 8192, + "title": "Loras Multipliers", + "type": "string" + }, + "image_start": { + "anyOf": [ + { + "maxLength": 8192, + "type": "string" + }, + { + "items": { + "maxLength": 8192, + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Image Start" + }, + "image_end": { + "anyOf": [ + { + "maxLength": 8192, + "type": "string" + }, + { + "items": { + "maxLength": 8192, + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Image End" + }, + "image_refs": { + "anyOf": [ + { + "items": { + "maxLength": 8192, + "minLength": 1, + "type": "string" + }, + "maxItems": 64, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Image Refs" + }, + "image_guide": { + "anyOf": [ + { + "maxLength": 8192, + "type": "string" + }, + { + "items": { + "maxLength": 8192, + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Image Guide" + }, + "image_mask": { + "anyOf": [ + { + "maxLength": 8192, + "type": "string" + }, + { + "items": { + "maxLength": 8192, + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Image Mask" + }, + "image_prompt_type": { + "default": "", + "maxLength": 8192, + "title": "Image Prompt Type", + "type": "string" + }, + "video_prompt_type": { + "default": "", + "maxLength": 8192, + "title": "Video Prompt Type", + "type": "string" + }, + "frames_positions": { + "default": "", + "maxLength": 8192, + "title": "Frames Positions", + "type": "string" + }, + "canonical_image_refs": { + "default": false, + "title": "Canonical Image Refs", + "type": "boolean" + }, + "multi_prompts_gen_type": { + "default": 2, + "maximum": 100000, + "minimum": 0, + "title": "Multi Prompts Gen Type", + "type": "integer" + }, + "image_fit_mode": { + "default": "", + "enum": [ + "", + "contain", + "source", + "crop" + ], + "title": "Image Fit Mode", + "type": "string" + }, + "input_video_strength": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Input Video Strength" + }, + "denoising_strength": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Denoising Strength" + }, + "masking_strength": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Masking Strength" + }, + "video_guide_outpainting": { + "default": "", + "maxLength": 8192, + "minLength": 1, + "title": "Video Guide Outpainting", + "type": "string" + }, + "control_net_weight": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Control Net Weight" + }, + "control_net_weight2": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Control Net Weight2" + }, + "control_net_weight_alt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Control Net Weight Alt" + }, + "motion_amplitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Motion Amplitude" + }, + "mask_expand": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mask Expand" + }, + "image_refs_relative_size": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Image Refs Relative Size" + }, + "remove_background_images_ref": { + "default": 0, + "maximum": 1, + "minimum": 0, + "title": "Remove Background Images Ref", + "type": "integer" + }, + "model_mode": { + "anyOf": [ + { + "maximum": 100000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 0, + "title": "Model Mode" + }, + "temporal_upsampling": { + "default": "", + "enum": [ + "", + null + ], + "title": "Temporal Upsampling" + }, + "audio_prompt_type": { + "default": "", + "enum": [ + "", + null + ], + "title": "Audio Prompt Type" + }, + "video_guide": { + "default": null, + "enum": [ + "", + null + ], + "title": "Video Guide" + }, + "video_mask": { + "default": null, + "enum": [ + "", + null + ], + "title": "Video Mask" + }, + "video_source": { + "default": null, + "enum": [ + "", + null + ], + "title": "Video Source" + }, + "audio_guide": { + "default": null, + "enum": [ + "", + null + ], + "title": "Audio Guide" + }, + "audio_guide2": { + "default": null, + "enum": [ + "", + null + ], + "title": "Audio Guide2" + }, + "audio_guide3": { + "default": null, + "enum": [ + "", + null + ], + "title": "Audio Guide3" + }, + "audio_guide4": { + "default": null, + "enum": [ + "", + null + ], + "title": "Audio Guide4" + }, + "audio_guide5": { + "default": null, + "enum": [ + "", + null + ], + "title": "Audio Guide5" + }, + "audio_guide6": { + "default": null, + "enum": [ + "", + null + ], + "title": "Audio Guide6" + }, + "audio_source": { + "default": null, + "enum": [ + "", + null + ], + "title": "Audio Source" + }, + "MMAudio_setting": { + "anyOf": [ + { + "maximum": 0, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mmaudio Setting" + }, + "MMAudio_prompt": { + "default": null, + "enum": [ + "", + null + ], + "title": "Mmaudio Prompt" + }, + "MMAudio_neg_prompt": { + "default": null, + "enum": [ + "", + null + ], + "title": "Mmaudio Neg Prompt" + }, + "h3_ref_videos": { + "anyOf": [ + { + "items": { + "maxLength": 8192, + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "H3 Ref Videos" + }, + "h3_ref_audios": { + "anyOf": [ + { + "items": { + "maxLength": 8192, + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "H3 Ref Audios" + }, + "minimax_h3_references": { + "anyOf": [ + { + "items": { + "maxLength": 8192, + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Minimax H3 References" + }, + "minimax_h3_turbo_mode": { + "anyOf": [ + { + "const": false, + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Minimax H3 Turbo Mode" + }, + "sliding_window_size": { + "anyOf": [ + { + "maximum": 100000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sliding Window Size" + }, + "sliding_window_overlap": { + "anyOf": [ + { + "maximum": 100000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sliding Window Overlap" + }, + "sliding_window_memory_override": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sliding Window Memory Override" + }, + "sliding_window_discard_last_frames": { + "anyOf": [ + { + "maximum": 100000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sliding Window Discard Last Frames" + }, + "sliding_window_color_correction_strength": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sliding Window Color Correction Strength" + }, + "sliding_window_overlap_noise": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sliding Window Overlap Noise" + }, + "keep_frames_video_source": { + "default": null, + "enum": [ + "", + null + ], + "title": "Keep Frames Video Source" + }, + "keep_frames_video_guide": { + "default": null, + "enum": [ + "", + null + ], + "title": "Keep Frames Video Guide" + }, + "force_fps": { + "default": "", + "enum": [ + "", + null + ], + "title": "Force Fps" + }, + "flow_shift": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Flow Shift" + }, + "sample_solver": { + "default": "", + "maxLength": 8192, + "title": "Sample Solver", + "type": "string" + }, + "embedded_guidance_scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Embedded Guidance Scale" + }, + "guidance2_scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Guidance2 Scale" + }, + "guidance3_scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Guidance3 Scale" + }, + "switch_threshold": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Switch Threshold" + }, + "switch_threshold2": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Switch Threshold2" + }, + "guidance_phases": { + "default": 1, + "maximum": 16, + "minimum": 1, + "title": "Guidance Phases", + "type": "integer" + }, + "model_switch_phase": { + "default": 1, + "maximum": 16, + "minimum": 1, + "title": "Model Switch Phase", + "type": "integer" + }, + "alt_guidance_scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Alt Guidance Scale" + }, + "alt_scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Alt Scale" + }, + "audio_guidance_scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Audio Guidance Scale" + }, + "audio_scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Audio Scale" + }, + "NAG_scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Nag Scale" + }, + "NAG_tau": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Nag Tau" + }, + "NAG_alpha": { + "anyOf": [ + { + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Nag Alpha" + }, + "RIFLEx_setting": { + "anyOf": [ + { + "maximum": 100000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Riflex Setting" + }, + "injection_strength": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Injection Strength" + }, + "identity_guidance_scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Identity Guidance Scale" + }, + "skip_steps_cache_type": { + "default": "", + "enum": [ + "", + "first_block" + ], + "title": "Skip Steps Cache Type", + "type": "string" + }, + "skip_steps_multiplier": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Skip Steps Multiplier" + }, + "skip_steps_start_step_perc": { + "anyOf": [ + { + "maximum": 100.0, + "minimum": 0.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Skip Steps Start Step Perc" + }, + "settings_version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Settings Version" + }, + "prompt_enhancer": { + "default": "", + "maxLength": 8192, + "title": "Prompt Enhancer", + "type": "string" + }, + "spatial_upsampling": { + "default": "", + "maxLength": 8192, + "title": "Spatial Upsampling", + "type": "string" + }, + "film_grain_intensity": { + "anyOf": [ + { + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Film Grain Intensity" + }, + "film_grain_saturation": { + "anyOf": [ + { + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Film Grain Saturation" + }, + "progressive_pipeline": { + "default": false, + "title": "Progressive Pipeline", + "type": "boolean" + }, + "single_stage_pipeline": { + "default": false, + "title": "Single Stage Pipeline", + "type": "boolean" + }, + "reference_pipeline": { + "default": false, + "title": "Reference Pipeline", + "type": "boolean" + }, + "progressive_stage1_image_weight": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Progressive Stage1 Image Weight" + }, + "progressive_stage2_steps": { + "anyOf": [ + { + "maximum": 100000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Progressive Stage2 Steps" + }, + "progressive_stage2_sigma": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Progressive Stage2 Sigma" + }, + "progressive_stage3_steps": { + "anyOf": [ + { + "maximum": 100000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Progressive Stage3 Steps" + }, + "progressive_stage3_sigma": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Progressive Stage3 Sigma" + }, + "progressive_stage3_image_weight": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Progressive Stage3 Image Weight" + }, + "override_profile": { + "anyOf": [ + { + "maxLength": 8192, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Override Profile" + }, + "override_attention": { + "anyOf": [ + { + "maxLength": 8192, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Override Attention" + }, + "temperature": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Temperature" + }, + "top_p": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Top P" + }, + "top_k": { + "anyOf": [ + { + "maximum": 100000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Top K" + }, + "self_refiner_setting": { + "anyOf": [ + { + "maximum": 100000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Self Refiner Setting" + }, + "self_refiner_plan": { + "anyOf": [ + { + "maxLength": 200000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Self Refiner Plan" + }, + "self_refiner_f_uncertainty": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Self Refiner F Uncertainty" + }, + "self_refiner_certain_percentage": { + "anyOf": [ + { + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Self Refiner Certain Percentage" + }, + "cfg_rescale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cfg Rescale" + }, + "modality_scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Modality Scale" + }, + "use_gradient_estimation": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Use Gradient Estimation" + }, + "ge_gamma": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ge Gamma" + }, + "ge_alpha": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ge Alpha" + }, + "outpaint_lora_strength": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outpaint Lora Strength" + }, + "outpaint_mask_preserve": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outpaint Mask Preserve" + }, + "outpaint_official_stack": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outpaint Official Stack" + }, + "custom_settings": { + "anyOf": [ + { + "$ref": "#/$defs/StudioImageCustomSettings" + }, + { + "type": "null" + } + ], + "default": null + }, + "wangp_processor_settings": { + "anyOf": [ + { + "$ref": "#/$defs/WangpProcessorSettings" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "prompt", + "model_type", + "resolution", + "num_inference_steps", + "guidance_scale", + "seed" + ], + "title": "StudioImageParams", + "type": "object" + }, + "WangpProcessorSettings": { + "additionalProperties": false, + "description": "Typed settings currently declared by image-capable processors.", + "properties": { + "spatial_upsampler_strength": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Spatial Upsampler Strength" + }, + "spatial_upsampler_face_count": { + "anyOf": [ + { + "maximum": 5, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Spatial Upsampler Face Count" + }, + "spatial_upsampler_h3_strength": { + "anyOf": [ + { + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Spatial Upsampler H3 Strength" + }, + "spatial_upsampler_prompt": { + "anyOf": [ + { + "maxLength": 200000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Spatial Upsampler Prompt" + }, + "spatial_upsampler_reference_images": { + "anyOf": [ + { + "items": { + "maxLength": 8192, + "minLength": 1, + "type": "string" + }, + "maxItems": 64, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Spatial Upsampler Reference Images" + }, + "spatial_upsampler_dlss_strength": { + "anyOf": [ + { + "maximum": 2.0, + "minimum": 0.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Spatial Upsampler Dlss Strength" + } + }, + "title": "WangpProcessorSettings", + "type": "object" + } + }, + "additionalProperties": false, + "properties": { + "workspace": { + "maxLength": 240, + "minLength": 1, + "pattern": "^(?:default|[A-Za-z0-9][A-Za-z0-9_-]*)$", + "title": "Workspace", + "type": "string" + }, + "workspace_collection_id": { + "anyOf": [ + { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Workspace Collection Id" + }, + "params": { + "$ref": "#/$defs/StudioImageParams" + } + }, + "required": [ + "workspace", + "params" + ], + "title": "StudioImageInput", + "type": "object" + }, + "supported_input_fields": [ + "minimax_h3_turbo_mode", + "workspace", + "workspace_collection_id", + "prompt", + "alt_prompt", + "model_type", + "resolution", + "video_length", + "num_inference_steps", + "guidance_scale", + "seed", + "image_mode", + "generation_mode", + "negative_prompt", + "repeat_generation", + "batch_size", + "activated_loras", + "loras_multipliers", + "image_start", + "image_end", + "image_refs", + "image_guide", + "image_mask", + "video_guide", + "video_mask", + "video_source", + "audio_guide", + "audio_guide2", + "audio_guide3", + "audio_guide4", + "audio_guide5", + "audio_guide6", + "audio_source", + "MMAudio_setting", + "MMAudio_prompt", + "MMAudio_neg_prompt", + "h3_ref_videos", + "h3_ref_audios", + "minimax_h3_references", + "image_prompt_type", + "video_prompt_type", + "frames_positions", + "canonical_image_refs", + "multi_prompts_gen_type", + "image_fit_mode", + "input_video_strength", + "denoising_strength", + "masking_strength", + "video_guide_outpainting", + "control_net_weight", + "control_net_weight2", + "control_net_weight_alt", + "motion_amplitude", + "mask_expand", + "image_refs_relative_size", + "remove_background_images_ref", + "model_mode", + "temporal_upsampling", + "audio_prompt_type", + "sliding_window_size", + "sliding_window_overlap", + "sliding_window_memory_override", + "sliding_window_discard_last_frames", + "sliding_window_color_correction_strength", + "sliding_window_overlap_noise", + "keep_frames_video_source", + "keep_frames_video_guide", + "force_fps", + "flow_shift", + "sample_solver", + "embedded_guidance_scale", + "guidance2_scale", + "guidance3_scale", + "switch_threshold", + "switch_threshold2", + "guidance_phases", + "model_switch_phase", + "alt_guidance_scale", + "alt_scale", + "audio_guidance_scale", + "audio_scale", + "NAG_scale", + "NAG_tau", + "NAG_alpha", + "RIFLEx_setting", + "injection_strength", + "identity_guidance_scale", + "skip_steps_cache_type", + "skip_steps_multiplier", + "skip_steps_start_step_perc", + "settings_version", + "prompt_enhancer", + "spatial_upsampling", + "film_grain_intensity", + "film_grain_saturation", + "progressive_pipeline", + "single_stage_pipeline", + "reference_pipeline", + "progressive_stage1_image_weight", + "progressive_stage2_steps", + "progressive_stage2_sigma", + "progressive_stage3_steps", + "progressive_stage3_sigma", + "progressive_stage3_image_weight", + "override_profile", + "override_attention", + "temperature", + "top_p", + "top_k", + "self_refiner_setting", + "self_refiner_plan", + "self_refiner_f_uncertainty", + "self_refiner_certain_percentage", + "cfg_rescale", + "modality_scale", + "use_gradient_estimation", + "ge_gamma", + "ge_alpha", + "outpaint_lora_strength", + "outpaint_mask_preserve", + "outpaint_official_stack", + "custom_settings", + "wangp_processor_settings" + ], + "effects": { + "generation_mode": "image", + "image_mode": 1, + "video_length": 1, + "multi_prompts_gen_type": 2, + "repeat_generation": 1, + "batch_size": 1, + "prompt_enhancer": "", + "activated_loras": [], + "loras_multipliers": "", + "canonical_image_refs": false + }, + "inactive": [ + "minimax_h3_turbo_mode", + "audio_guide", + "audio_guide2", + "audio_guide3", + "audio_guide4", + "audio_guide5", + "audio_guide6", + "audio_source", + "video_source", + "video_guide", + "video_mask", + "temporal_upsampling", + "audio_prompt_type", + "sliding_window_size", + "sliding_window_overlap", + "sliding_window_memory_override", + "sliding_window_discard_last_frames", + "sliding_window_color_correction_strength", + "sliding_window_overlap_noise", + "keep_frames_video_source", + "keep_frames_video_guide", + "force_fps", + "h3_ref_videos", + "h3_ref_audios", + "minimax_h3_references", + "MMAudio_setting", + "MMAudio_prompt", + "MMAudio_neg_prompt" + ], + "excluded": [ + "actor", + "client", + "permission", + "provenance", + "workspace inside input.params", + "viggle_audio_mode", + "preserve_source_style", + "stage2_steps", + "per_clip_frames", + "per_clip_keyframes", + "perturbation_switch", + "perturbation_layers", + "perturbation_start_perc", + "perturbation_end_perc", + "stg_scale", + "keyframe_conditioning_mode", + "keyframe_inject_mode", + "h3_audio_shift", + "h3_audio_prompt", + "h3_ref_image_size", + "h3_reference_mode", + "h3_model_profile", + "h3_reference_context", + "h3_window_prompts", + "h3_window_plan_signature", + "h3_window_plan", + "minimax_h3_reference_detail", + "minimax_h3_text_encoder", + "minimax_h3_turbo_preset", + "minimax_h3_planning_style", + "minimax_h3_audio_policy", + "minimax_h3_reference_sequence", + "minimax_h3_semantic_bridge_alpha", + "minimax_h3_semantic_bridge_magnitude", + "minimax_h3_multi_window", + "minimax_h3_window_storyboard", + "continue_video", + "voice_reference", + "voice_clone_enabled", + "voice_clone_mode", + "voice_clone_refs", + "tts_dynaudnorm", + "tts_comp_threshold", + "tts_comp_attack", + "tts_comp_release", + "tts_comp_makeup", + "tts_voice_count", + "duration_seconds", + "pause_seconds", + "_audio_sub_mode", + "_music_description", + "_music_instrumental", + "_tts_original_prompt", + "_tts_speaker_name1", + "_tts_speaker_name2", + "sfx_mode", + "free-form JSON settings", + "filesystem paths" + ] + } +} diff --git a/ui/src/api/imageGenerationCommands.ts b/ui/src/api/imageGenerationCommands.ts new file mode 100644 index 000000000..03f112a80 --- /dev/null +++ b/ui/src/api/imageGenerationCommands.ts @@ -0,0 +1,906 @@ +import { BASE } from './http' +import { stableSerialize } from '../lib/commandContract' +import type { GenerationSubmissionContext } from '../features/studio/generationProvenance' +import { + assertStudioImageGenerationCommand, + detachedStudioImageGenerationCommand, + type StudioImageGenerationCommand, +} from '../features/studio/generationSpec' + +export { + buildStudioImageGenerationCommand, + createStudioImageGenerationCommand, +} from '../features/studio/generationSpec' +export type { + StudioImageGenerationCommand, + StudioImageGenerationFullParams, + StudioImageGenerationInput, + StudioImageParams, +} from '../features/studio/generationSpec' + +/** The first shared generation vertical deliberately exposes image only. */ +export const IMAGE_GENERATION_OPERATION = 'generation.image' as const +export const IMAGE_GENERATION_SCHEMA_VERSION = 1 as const + +export interface ImageGenerationInput { + workspace: string + model_type: string + prompt: string + negative_prompt?: string + resolution: string + num_inference_steps: number + seed: number + guidance_scale: number + image_mode?: 1 + video_length?: 1 +} + +export interface ImageGenerationCommandV1 { + version: typeof IMAGE_GENERATION_SCHEMA_VERSION + operation: typeof IMAGE_GENERATION_OPERATION + intent_id: string + input: ImageGenerationInput +} + +export type ImageGenerationCommandV2 = StudioImageGenerationCommand +export type ImageGenerationCommand = ImageGenerationCommandV1 | ImageGenerationCommandV2 + +export interface ImageGenerationTaskResult { + job_id: string + task_id: string + workspace: string + status: 'queued' + root_task_id?: string | null +} + +export interface ImageGenerationReceipt { + version: typeof IMAGE_GENERATION_SCHEMA_VERSION + commandId: string + operation: typeof IMAGE_GENERATION_OPERATION + status: 'queued' + entities: unknown[] + artifacts: unknown[] + taskIds: string[] + pipelineIds: string[] + result: ImageGenerationTaskResult + replayed?: boolean + commandVersion?: 2 + contentFingerprint?: string + fingerprintVersion?: 2 +} + +/** + * Recovery metadata is deliberately smaller than the caller context. It is + * stored beside the immutable command hint and only drives declared UI + * attribution headers; it is never part of the command or its fingerprint. + */ +type StoredSubmissionContext = Pick + +export class ImageGenerationCommandError extends Error { + readonly intentId: string + readonly workspace: string + readonly status?: number + readonly uncertain: boolean + readonly code: string + + constructor( + message: string, + intentId: string, + workspace: string, + options: { status?: number; uncertain?: boolean; code?: string } = {}, + ) { + super(message) + this.name = 'ImageGenerationCommandError' + this.intentId = intentId + this.workspace = workspace + this.status = options.status + this.uncertain = options.uncertain ?? false + this.code = options.code ?? 'image_generation_command_failed' + } +} + +const PENDING_KEY_PREFIX = 'hocuspocus.generation.image-commands.v1:' +const PENDING_CONTEXT_KEY_PREFIX = 'hocuspocus.generation.image-command-context.v1:' +const PENDING_CHANGED_EVENT = 'hocuspocus:generation-image-commands-changed' +const MAX_INTENT_LENGTH = 160 +const MAX_ID_LENGTH = 240 +const MAX_PROMPT_LENGTH = 200_000 +const MAX_RESOLUTION_LENGTH = 128 +const MAX_SUBMISSION_CONTEXT_ID_LENGTH = 200 +const SUBMISSION_ACTORS = new Set(['user', 'wizard', 'system', 'unknown']) + +const INPUT_FIELDS = new Set([ + 'workspace', + 'model_type', + 'prompt', + 'negative_prompt', + 'resolution', + 'num_inference_steps', + 'seed', + 'guidance_scale', + 'image_mode', + 'video_length', +]) + +const COMMAND_FIELDS = new Set(['version', 'operation', 'intent_id', 'input']) + +function isRecord(value: unknown): value is Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +function requiredText(value: unknown, field: string, maximum: number): string { + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`${field} must be a non-blank string`) + } + if (value.length > maximum) throw new Error(`${field} is too long`) + return value +} + +function strictInteger(value: unknown, field: string, minimum: number, maximum: number): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum || value > maximum) { + throw new Error(`${field} must be an integer in range`) + } + return value +} + +function strictFiniteNumber(value: unknown, field: string, minimum: number, maximum: number): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value < minimum || value > maximum) { + throw new Error(`${field} must be a finite number in range`) + } + return value +} + +function assertImageSelectors(value: Record): void { + for (const selector of ['image_mode', 'video_length'] as const) { + if (selector in value && value[selector] !== 1) { + throw new Error(`input.${selector} must be the image value 1`) + } + } +} + +function assertImageGenerationInput(value: unknown): asserts value is ImageGenerationInput { + if (!isRecord(value)) throw new Error('input must be an object') + for (const key of Object.keys(value)) { + if (!INPUT_FIELDS.has(key)) throw new Error(`input.${key} is not supported by generation.image`) + } + const workspace = requiredText(value.workspace, 'input.workspace', MAX_ID_LENGTH) + if (!/^(?:default|[A-Za-z0-9][A-Za-z0-9_-]*)$/.test(workspace)) { + throw new Error('input.workspace must be an exact output workspace name') + } + requiredText(value.model_type, 'input.model_type', MAX_ID_LENGTH) + requiredText(value.prompt, 'input.prompt', MAX_PROMPT_LENGTH) + if ('negative_prompt' in value && typeof value.negative_prompt !== 'string') { + throw new Error('input.negative_prompt must be a string') + } + if (typeof value.negative_prompt === 'string' && value.negative_prompt.length > MAX_PROMPT_LENGTH) { + throw new Error('input.negative_prompt is too long') + } + requiredText(value.resolution, 'input.resolution', MAX_RESOLUTION_LENGTH) + strictInteger(value.num_inference_steps, 'input.num_inference_steps', 1, 1000) + strictInteger(value.seed, 'input.seed', -(2 ** 63), 2 ** 63 - 1) + strictFiniteNumber(value.guidance_scale, 'input.guidance_scale', 0, 1000) + assertImageSelectors(value) +} + +export function assertImageGenerationCommandV1(value: unknown): asserts value is ImageGenerationCommandV1 { + if (!isRecord(value)) throw new Error('image generation command must be an object') + for (const key of Object.keys(value)) { + if (!COMMAND_FIELDS.has(key)) throw new Error(`command.${key} is not supported by generation.image`) + } + if (value.version !== IMAGE_GENERATION_SCHEMA_VERSION) throw new Error('version must be the integer 1') + if (value.operation !== IMAGE_GENERATION_OPERATION) throw new Error('operation must be generation.image') + requiredText(value.intent_id, 'intent_id', MAX_INTENT_LENGTH) + assertImageGenerationInput(value.input) +} + +function detachedCommand(value: unknown): ImageGenerationCommand { + if (isRecord(value) && value.version === 2) { + assertStudioImageGenerationCommand(value) + return detachedStudioImageGenerationCommand(value) + } + assertImageGenerationCommandV1(value) + // stableSerialize validates the JSON boundary and gives the retry an + // immutable value-level snapshot. It never adds native defaults to input. + return JSON.parse(stableSerialize(value)) as ImageGenerationCommand +} + +function storage(): Storage { + if (typeof globalThis.localStorage === 'undefined') { + throw new Error('localStorage is unavailable; command admission cannot be made safely') + } + return globalThis.localStorage +} + +function notifyPendingChanged(): void { + if (typeof window === 'undefined' || typeof Event === 'undefined') return + window.dispatchEvent(new Event(PENDING_CHANGED_EVENT)) +} + +function pendingKey(intentId: string): string { + return PENDING_KEY_PREFIX + intentId +} + +function invalidStoredCommand(intentId: string): ImageGenerationCommandError { + return new ImageGenerationCommandError( + `Stored image generation command ${intentId} is invalid`, + intentId, + '', + { code: 'invalid_pending_command' }, + ) +} + +function readPending(intentId: string): ImageGenerationCommand | null { + const raw = storage().getItem(pendingKey(intentId)) + if (raw == null) return null + try { + const value: unknown = JSON.parse(raw) + const command = detachedCommand(value) + if (command.intent_id !== intentId) throw new Error('intent_id does not match storage key') + return command + } catch { + throw invalidStoredCommand(intentId) + } +} + +const STORED_CONTEXT_ENVELOPE_FIELDS = new Set(['version', 'intent_id', 'workspace', 'context']) +const STORED_CONTEXT_FIELDS = new Set(['actor', 'workflowId', 'runId']) + +function pendingContextKey(intentId: string): string { + return PENDING_CONTEXT_KEY_PREFIX + intentId +} + +function invalidStoredContext(intentId: string): ImageGenerationCommandError { + return new ImageGenerationCommandError( + `Stored image generation context ${intentId} is invalid`, + intentId, + '', + { code: 'invalid_pending_context' }, + ) +} + +function normalizeStoredSubmissionContext(value: unknown): StoredSubmissionContext { + if (!isRecord(value)) throw new Error('context must be an object') + for (const key of Object.keys(value)) { + if (!STORED_CONTEXT_FIELDS.has(key)) throw new Error('context contains an unsupported field') + } + if (typeof value.actor !== 'string' || !SUBMISSION_ACTORS.has(value.actor)) { + throw new Error('context.actor is invalid') + } + const workflowId = submissionContextPart(value.workflowId, 'context.workflowId') + const runId = submissionContextPart(value.runId, 'context.runId') + return { + actor: value.actor as StoredSubmissionContext['actor'], + ...(workflowId !== undefined ? { workflowId } : {}), + ...(runId !== undefined ? { runId } : {}), + } +} + +function readPendingContext(command: ImageGenerationCommand): StoredSubmissionContext | null { + const raw = storage().getItem(pendingContextKey(command.intent_id)) + if (raw == null) return null + try { + const value: unknown = JSON.parse(raw) + if (!isRecord(value) + || Object.keys(value).some(key => !STORED_CONTEXT_ENVELOPE_FIELDS.has(key)) + || value.version !== 1 + || value.intent_id !== command.intent_id + || value.workspace !== command.input.workspace) { + throw new Error('context envelope does not match its command') + } + return normalizeStoredSubmissionContext(value.context) + } catch { + throw invalidStoredContext(command.intent_id) + } +} + +function persistPendingContext( + command: ImageGenerationCommand, + context: StoredSubmissionContext | undefined, +): void { + const key = pendingContextKey(command.intent_id) + if (!context) { + // Avoid turning a no-op cleanup into a storage failure. This also keeps + // the legacy command-only path compatible with callers whose storage + // implementation rejects removeItem even when the key is absent. + if (storage().getItem(key) != null) storage().removeItem(key) + return + } + storage().setItem(key, stableSerialize({ + version: 1, + intent_id: command.intent_id, + workspace: command.input.workspace, + context, + })) +} + +function sameSubmissionContext( + left: StoredSubmissionContext, + right: StoredSubmissionContext, +): boolean { + return stableSerialize(left) === stableSerialize(right) +} + +function sameCommand(left: ImageGenerationCommand, right: ImageGenerationCommand): boolean { + return stableSerialize(left) === stableSerialize(right) +} + +function retainPending(command: ImageGenerationCommand, done = false): boolean { + const existing = readPending(command.intent_id) + if (existing && !sameCommand(existing, command)) { + throw new ImageGenerationCommandError( + `intent_id ${command.intent_id} is already pending with a different command`, + command.intent_id, + command.input.workspace, + { code: 'intent_conflict' }, + ) + } + const key = pendingKey(command.intent_id) + if (done) { + // A different tab may have replaced the value. Never erase that command + // while cleaning up a receipt for this one. + const current = readPending(command.intent_id) + if (current && sameCommand(current, command)) { + // Clear the sidecar first. If storage cleanup fails, retain the command + // hint so a confirmed result remains recoverable as before this sidecar + // existed. + persistPendingContext(command, undefined) + storage().removeItem(key) + } else if (!current) { + // The command may already have been removed by another tab after its + // receipt was confirmed. Its context key is still scoped by intent and + // can be cleaned without touching a replacement command. + persistPendingContext(command, undefined) + } + } else { + storage().setItem(key, stableSerialize(command)) + } + notifyPendingChanged() + return existing != null +} + +function forgetPending(command: ImageGenerationCommand): void { + try { retainPending(command, true) } catch { /* A cleanup failure must not hide a confirmed server result. */ } +} + +export function newImageGenerationIntentId(): string { + return globalThis.crypto?.randomUUID?.() + || `image-${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +/** Require the caller to choose the intention; this function never invents one. */ +export function createImageGenerationCommand( + intentId: string, + input: ImageGenerationInput, +): ImageGenerationCommandV1 { + return detachedCommand({ + version: IMAGE_GENERATION_SCHEMA_VERSION, + operation: IMAGE_GENERATION_OPERATION, + intent_id: intentId, + input, + }) as ImageGenerationCommandV1 +} + +export function pendingImageGenerationCommands(workspace?: string): ImageGenerationCommand[] { + const pending: ImageGenerationCommand[] = [] + const area = storage() + for (let index = 0; index < area.length; index += 1) { + const key = area.key(index) + if (!key?.startsWith(PENDING_KEY_PREFIX)) continue + const intentId = key.slice(PENDING_KEY_PREFIX.length) + const command = readPending(intentId) + if (!command || (workspace !== undefined && command.input.workspace !== workspace)) continue + pending.push(command) + } + return pending.sort((left, right) => left.intent_id.localeCompare(right.intent_id)) +} + +export function pendingImageGenerationCommand( + intentId: string, + workspace?: string, +): ImageGenerationCommand | null { + const command = readPending(intentId) + if (!command || (workspace !== undefined && command.input.workspace !== workspace)) return null + return command +} + +function errorDetail(value: unknown): string | undefined { + if (typeof value === 'string' && value.trim()) return value + if (!isRecord(value)) return undefined + const detail = value.detail + if (typeof detail === 'string' && detail.trim()) return detail + if (isRecord(detail) && typeof detail.message === 'string' && detail.message.trim()) return detail.message + if (typeof value.message === 'string' && value.message.trim()) return value.message + return undefined +} + +async function responseError( + response: Response, + intentId: string, + workspace: string, +): Promise { + const payload = await response.json().catch(() => undefined) + return new ImageGenerationCommandError( + errorDetail(payload) || `Image generation command failed (${response.status})`, + intentId, + workspace, + { status: response.status, uncertain: response.status >= 500, code: 'http_error' }, + ) +} + +interface ReceiptEnvelope { + receipt: unknown + replayed?: boolean + malformed?: boolean +} + +function unwrapReceipt(value: unknown): ReceiptEnvelope { + if (isRecord(value) && 'receipt' in value) { + if ('replayed' in value && typeof value.replayed !== 'boolean') { + return { receipt: value.receipt, malformed: true } + } + return { + receipt: value.receipt, + replayed: typeof value.replayed === 'boolean' ? value.replayed : undefined, + } + } + return { receipt: value } +} + +type ReceiptContext = Pick & { + input: Pick +} + +function invalidReceipt(command: ReceiptContext, message = 'Receipt could not be verified'): ImageGenerationCommandError { + return new ImageGenerationCommandError( + `${message} for ${command.intent_id}`, + command.intent_id, + command.input.workspace, + { status: 200, uncertain: true, code: 'invalid_receipt' }, + ) +} + +function validTaskResult(value: unknown, workspace: string, taskIds: unknown[]): value is ImageGenerationTaskResult { + return isRecord(value) + && typeof value.job_id === 'string' && value.job_id.length > 0 + && typeof value.task_id === 'string' && value.task_id.length > 0 + && taskIds.length === 1 && taskIds[0] === value.task_id + && value.workspace === workspace && value.status === 'queued' +} + +function receiptReplay(value: Record, command: ReceiptContext, fallback?: boolean): boolean | undefined { + if ('replayed' in value && typeof value.replayed !== 'boolean') throw invalidReceipt(command) + return typeof value.replayed === 'boolean' ? value.replayed : fallback +} + +function receiptV2Metadata(value: Record, command: ReceiptContext): { + commandVersion?: 2 + contentFingerprint?: string + fingerprintVersion?: 2 +} { + const metadataFields = ['commandVersion', 'contentFingerprint', 'fingerprintVersion'] as const + const present = metadataFields.filter(field => field in value) + // A receipt is either the complete v1 shape or the complete v2 shape. A + // half-present fingerprint must never be treated as a legacy receipt. + if (present.length !== 0 && present.length !== metadataFields.length) { + throw invalidReceipt(command, 'Receipt fingerprint metadata is incomplete') + } + if (present.length === 0) { + if (command.version === 2) { + throw invalidReceipt(command, 'The v2 receipt is missing its content fingerprint') + } + return {} + } + if (value.commandVersion !== 2 || value.fingerprintVersion !== 2 + || typeof value.contentFingerprint !== 'string' + || !/^[a-f0-9]{64}$/.test(value.contentFingerprint)) { + throw invalidReceipt(command, 'Receipt fingerprint metadata is invalid') + } + return { + commandVersion: 2, + contentFingerprint: value.contentFingerprint, + fingerprintVersion: 2, + } +} + +function validateReceipt( + value: unknown, + command: ReceiptContext, + replayed?: boolean, +): ImageGenerationReceipt { + if (!isRecord(value)) throw invalidReceipt(command) + const outerReplayed = receiptReplay(value, command, replayed) + const v2Metadata = receiptV2Metadata(value, command) + const result = value.result + const taskIds = value.taskIds + if (value.version !== IMAGE_GENERATION_SCHEMA_VERSION + || value.commandId !== command.intent_id + || value.operation !== command.operation + || value.status !== 'queued' + || !Array.isArray(value.entities) + || !Array.isArray(value.artifacts) + || !Array.isArray(taskIds) + || !Array.isArray(value.pipelineIds) + || !validTaskResult(result, command.input.workspace, taskIds)) { + throw invalidReceipt(command) + } + const receipt: ImageGenerationReceipt = { + version: IMAGE_GENERATION_SCHEMA_VERSION, + commandId: command.intent_id, + operation: IMAGE_GENERATION_OPERATION, + status: 'queued', + entities: JSON.parse(stableSerialize(value.entities)) as unknown[], + artifacts: JSON.parse(stableSerialize(value.artifacts)) as unknown[], + taskIds: [...taskIds] as string[], + pipelineIds: [...value.pipelineIds] as string[], + result: JSON.parse(stableSerialize(result)) as ImageGenerationTaskResult, + ...v2Metadata, + } + if (outerReplayed !== undefined) receipt.replayed = outerReplayed + return receipt +} + +function submissionContextPart(value: unknown, field: string): string | undefined { + if (value === undefined || value === null || value === '') return undefined + if (typeof value !== 'string' || !value.trim() || value.trim() !== value + || value.length > MAX_SUBMISSION_CONTEXT_ID_LENGTH) { + throw new Error(field + ' must be an exact non-blank string of at most 200 characters') + } + return value +} + +function validateSubmissionContext( + context: GenerationSubmissionContext | undefined, +): StoredSubmissionContext | undefined { + if (!context) return undefined + if (!SUBMISSION_ACTORS.has(context.actor)) { + throw new Error('submissionContext.actor must be a known actor') + } + const workflowId = submissionContextPart(context.workflowId, 'submissionContext.workflowId') + const runId = submissionContextPart(context.runId, 'submissionContext.runId') + return { + actor: context.actor, + ...(workflowId !== undefined ? { workflowId } : {}), + ...(runId !== undefined ? { runId } : {}), + } +} + +async function postCommand( + command: ImageGenerationCommand, + submissionContext?: StoredSubmissionContext, +): Promise { + let response: Response + try { + const headers: Record = { 'Content-Type': 'application/json' } + if (submissionContext) { + headers['X-Hocus-UI-Surface'] = submissionContext.actor === 'wizard' ? 'wizard' : 'studio' + const context: Record = {} + const workflowId = submissionContextPart(submissionContext.workflowId, 'submissionContext.workflowId') + const runId = submissionContextPart(submissionContext.runId, 'submissionContext.runId') + if (workflowId !== undefined) context.workflowId = workflowId + if (runId !== undefined) context.runId = runId + if (Object.keys(context).length > 0) headers['X-Hocus-UI-Context'] = JSON.stringify(context) + } + response = await fetch(`${BASE}/api/v1/generation/commands`, { + method: 'POST', + headers, + body: JSON.stringify(command), + }) + } catch (error) { + throw new ImageGenerationCommandError( + error instanceof Error ? error.message : 'Image generation request failed', + command.intent_id, + command.input.workspace, + { uncertain: true, code: 'transport_uncertain' }, + ) + } + if (!response.ok) { + throw await responseError(response, command.intent_id, command.input.workspace) + } + try { + return await response.json() + } catch { + throw new ImageGenerationCommandError( + `Image generation response could not be decoded for ${command.intent_id}`, + command.intent_id, + command.input.workspace, + { status: response.status, uncertain: true, code: 'invalid_response' }, + ) + } +} + +export interface SubmitImageGenerationCommandOptions { + /** + * A declared UI surface is transport metadata, never part of the command + * snapshot and never an authorization decision. + */ + submissionContext?: GenerationSubmissionContext + /** + * Runs after the pending hint is durable and before POST. The callback gets + * a detached copy so it cannot mutate the retry or transport snapshot. + */ + onSnapshotReady?: (snapshot: ImageGenerationCommand) => void | Promise +} + +interface PreparedSubmission { + recovering: boolean + submissionContext?: StoredSubmissionContext +} + +function requestedContext( + snapshot: ImageGenerationCommand, + context: GenerationSubmissionContext | undefined, +): StoredSubmissionContext | undefined { + try { + return validateSubmissionContext(context) + } catch (error) { + throw new ImageGenerationCommandError( + error instanceof Error ? error.message : 'The submission context is invalid', + snapshot.intent_id, + snapshot.input.workspace, + { code: 'invalid_submission_context' }, + ) + } +} + +function assertContextMatches( + snapshot: ImageGenerationCommand, + stored: StoredSubmissionContext | null, + requested: StoredSubmissionContext | undefined, +): void { + if (stored && requested && !sameSubmissionContext(stored, requested)) { + throw new ImageGenerationCommandError( + `intent_id ${snapshot.intent_id} is already attributed to a different UI context`, + snapshot.intent_id, + snapshot.input.workspace, + { code: 'submission_context_conflict', uncertain: true }, + ) + } +} + +function assertPendingCommandMatches( + snapshot: ImageGenerationCommand, + existing: ImageGenerationCommand | null, +): void { + if (existing && !sameCommand(existing, snapshot)) { + throw new ImageGenerationCommandError( + `intent_id ${snapshot.intent_id} is already pending with a different command`, + snapshot.intent_id, + snapshot.input.workspace, + { code: 'intent_conflict' }, + ) + } +} + +function prepareNewPendingContext( + snapshot: ImageGenerationCommand, + requested: StoredSubmissionContext | undefined, +): void { + // A sidecar is written before its command hint. If this write fails, no + // recoverable command is left behind to suggest an admitted request. + // Clearing an orphan is strict for the same reason: a stale attribution + // must not be paired with a newly written command. + persistPendingContext(snapshot, requested) +} + +function persistMissingRecoveryContext( + snapshot: ImageGenerationCommand, + recovering: boolean, + stored: StoredSubmissionContext | null, + requested: StoredSubmissionContext | undefined, +): StoredSubmissionContext | undefined { + const submissionContext = stored || requested + if (recovering && !stored && requested) { + persistPendingContext(snapshot, requested) + } + return submissionContext +} + +function preparePendingSubmission( + snapshot: ImageGenerationCommand, + requested: StoredSubmissionContext | undefined, +): PreparedSubmission { + let recovering = false + try { + const existing = readPending(snapshot.intent_id) + assertPendingCommandMatches(snapshot, existing) + recovering = existing !== null + if (!recovering) prepareNewPendingContext(snapshot, requested) + recovering = retainPending(snapshot) + const stored = recovering ? readPendingContext(snapshot) : null + assertContextMatches(snapshot, stored, requested) + return { + recovering, + submissionContext: persistMissingRecoveryContext(snapshot, recovering, stored, requested), + } + } catch (error) { + if (error instanceof ImageGenerationCommandError) throw error + throw new ImageGenerationCommandError( + error instanceof Error ? error.message : 'Could not persist image generation command', + snapshot.intent_id, + snapshot.input.workspace, + { code: 'pending_storage_failed', uncertain: recovering }, + ) + } +} + +async function presentSnapshot( + snapshot: ImageGenerationCommand, + recovering: boolean, + hook: SubmitImageGenerationCommandOptions['onSnapshotReady'], +): Promise { + if (!hook) return + try { + await hook(detachedCommand(snapshot)) + } catch (error) { + // A hook failure happens before network admission and is therefore + // certain. Preserve an older recovery hint because it may represent a + // previously admitted request whose response was lost. + if (!recovering) forgetPending(snapshot) + throw new ImageGenerationCommandError( + error instanceof Error ? error.message : 'The image command snapshot could not be presented', + snapshot.intent_id, + snapshot.input.workspace, + { code: 'snapshot_hook_failed' }, + ) + } +} + +async function admitImageCommand( + snapshot: ImageGenerationCommand, + submissionContext: StoredSubmissionContext | undefined, +): Promise { + const envelope = unwrapReceipt(await postCommand(snapshot, submissionContext)) + if (envelope.malformed) throw invalidReceipt(snapshot) + const receipt = validateReceipt(envelope.receipt, snapshot, envelope.replayed) + // A committed receipt is returned even if best-effort local cleanup fails. + forgetPending(snapshot) + return receipt +} + +function isDefinitiveClientError(error: ImageGenerationCommandError): boolean { + return error.status !== undefined && error.status >= 400 && error.status < 500 +} + +function normalizeSubmissionFailure( + error: unknown, + snapshot: ImageGenerationCommand, + recovering: boolean, +): ImageGenerationCommandError { + const commandError = error instanceof ImageGenerationCommandError + ? error + : new ImageGenerationCommandError( + error instanceof Error ? error.message : 'Image generation command failed', + snapshot.intent_id, + snapshot.input.workspace, + { uncertain: true, code: 'unknown_failure' }, + ) + // A first, explicit 4xx response is definitive before admission. Once a + // pending hint exists, a later rejection may follow an admitted request; + // preserve it, including a 401 after a timeout or lost response. + if (!recovering && isDefinitiveClientError(commandError)) forgetPending(snapshot) + return new ImageGenerationCommandError( + commandError.message, + snapshot.intent_id, + snapshot.input.workspace, + { + status: commandError.status, + uncertain: commandError.uncertain || recovering, + code: commandError.code, + }, + ) +} + +/** Submit or explicitly retry the same detached envelope and intention. */ +export async function submitImageGenerationCommand( + command: ImageGenerationCommand, + options: SubmitImageGenerationCommandOptions = {}, +): Promise { + const snapshot = detachedCommand(command) + const requestedSubmissionContext = requestedContext(snapshot, options.submissionContext) + const prepared = preparePendingSubmission(snapshot, requestedSubmissionContext) + + await presentSnapshot(snapshot, prepared.recovering, options.onSnapshotReady) + try { + return await admitImageCommand(snapshot, prepared.submissionContext) + } catch (error) { + throw normalizeSubmissionFailure(error, snapshot, prepared.recovering) + } +} + +function receiptCommandContext(intentId: string, workspace: string): ReceiptContext { + const fallback: ReceiptContext = { + version: 1, + intent_id: intentId, + operation: IMAGE_GENERATION_OPERATION, + input: { workspace }, + } + try { + const pending = readPending(intentId) + return pending && pending.input.workspace === workspace ? pending : fallback + } catch { + // A receipt read must remain available when local recovery storage is damaged. + return fallback + } +} + +async function requestReceipt(workspace: string, intentId: string): Promise { + try { + const response = await fetch( + `${BASE}/api/v1/generation/commands/receipt?workspace=${encodeURIComponent(workspace)}&intent_id=${encodeURIComponent(intentId)}`, + ) + if (!response.ok) throw await responseError(response, intentId, workspace) + return response + } catch (error) { + if (error instanceof ImageGenerationCommandError) throw error + throw new ImageGenerationCommandError( + error instanceof Error ? error.message : 'Receipt request failed', + intentId, + workspace, + { uncertain: true, code: 'transport_uncertain' }, + ) + } +} + +async function decodeReceiptResponse( + response: Response, + command: ReceiptContext, +): Promise { + try { + const payload = unwrapReceipt(await response.json()) + if (payload.malformed) throw invalidReceipt(command) + return validateReceipt(payload.receipt, command, payload.replayed) + } catch (error) { + if (error instanceof ImageGenerationCommandError) throw error + throw new ImageGenerationCommandError( + error instanceof Error ? error.message : `Receipt could not be verified for ${command.intent_id}`, + command.intent_id, + command.input.workspace, + { status: 200, uncertain: true, code: 'invalid_receipt' }, + ) + } +} + +function clearReceiptPending(intentId: string, workspace: string): void { + try { + const pending = readPending(intentId) + if (pending && pending.input.workspace === workspace) clearPendingReceipt(pending) + } catch { /* Keep the valid receipt even if local recovery storage is corrupt. */ } +} + +function clearPendingReceipt(command: ImageGenerationCommand): void { + try { retainPending(command, true) } catch { /* Keep the receipt visible if storage cleanup is unavailable. */ } +} + +/** Query a durable receipt after a lost response; this never invents a new ID. */ +export async function fetchImageGenerationCommandReceipt( + workspace: string, + intentId: string, +): Promise { + requiredText(workspace, 'workspace', MAX_ID_LENGTH) + requiredText(intentId, 'intent_id', MAX_INTENT_LENGTH) + // A receipt query has no command envelope of its own. When the durable hint + // is present, use its version so a lost v2 response cannot be accepted as a + // legacy v1 receipt. If the hint is unavailable, the endpoint remains a + // legacy-compatible read and the server's receipt metadata is still + // validated when it is present. + const receiptCommand = receiptCommandContext(intentId, workspace) + const response = await requestReceipt(workspace, intentId) + const receipt = await decodeReceiptResponse(response, receiptCommand) + // Receipt validation is authoritative. Storage read/removal is only a + // recovery hint and must never turn a valid GET into an apparent failure. + clearReceiptPending(intentId, workspace) + return receipt +} + +export const getImageGenerationCommandReceipt = fetchImageGenerationCommandReceipt + +export function subscribeImageGenerationCommands(callback: () => void): () => void { + window.addEventListener(PENDING_CHANGED_EVENT, callback) + window.addEventListener('storage', callback) + return () => { + window.removeEventListener(PENDING_CHANGED_EVENT, callback) + window.removeEventListener('storage', callback) + } +} diff --git a/ui/src/components/Sidebar/GenerateButton.tsx b/ui/src/components/Sidebar/GenerateButton.tsx index a0c3ac907..2a9dee07a 100644 --- a/ui/src/components/Sidebar/GenerateButton.tsx +++ b/ui/src/components/Sidebar/GenerateButton.tsx @@ -60,7 +60,8 @@ export function GenerateButton() { const handleClick = async () => { if (blocked || !await checkBeforeGenerate()) return setCooldown(true) - startGeneration(undefined, newUserGenerationContext()) + const submitted = startGeneration(undefined, newUserGenerationContext()) + if (generationMode === 'image') await submitted setSidebarOpen(false) } diff --git a/ui/src/components/Sidebar/Sidebar.tsx b/ui/src/components/Sidebar/Sidebar.tsx index d9631a7eb..8ae61ab6a 100644 --- a/ui/src/components/Sidebar/Sidebar.tsx +++ b/ui/src/components/Sidebar/Sidebar.tsx @@ -39,6 +39,7 @@ import { DirectorChat } from './DirectorChat' import { useUiTranslation } from '../../i18n' const ViggleControls = lazy(() => import('./ViggleControls').then(module => ({ default: module.ViggleControls }))) +const StudioImageCommandPanel = lazy(() => import('../../features/studio/StudioImageCommandPanel').then(module => ({ default: module.StudioImageCommandPanel }))) export function Sidebar() { const { t } = useUiTranslation('navigation') @@ -57,6 +58,8 @@ export function Sidebar() { const setDashboardOpen = useStore(s => s.setDashboardOpen) const editSubMode = useStore(s => s.editSubMode) const modelType = useStore(s => s.params.model_type) + const workspace = useStore(s => s.activeWorkspace) + const studioUnobscured = useStore(s => !s.settingsOpen && !s.dashboardOpen) const openLoraBrowser = useStore(s => s.setLoraBrowserOpen) const isMobile = useIsMobile() @@ -95,6 +98,16 @@ export function Sidebar() { window.localStorage.setItem('hocuspocus-tools-sidebar-collapsed', String(collapsed)) } + useEffect(() => { + const openImageSubmission = () => { + setToolsCollapsed(false) + window.localStorage.setItem('hocuspocus-tools-sidebar-collapsed', 'false') + setSidebarOpen(true) + } + window.addEventListener('hocuspocus:studio-image-open', openImageSubmission) + return () => window.removeEventListener('hocuspocus:studio-image-open', openImageSubmission) + }, [setSidebarOpen]) + useEffect(() => { const openStudio = () => { setSidebarMode('studio') @@ -235,6 +248,13 @@ export function Sidebar() { {/* Prompt area (non-edit modes, skip for SFX/Mixer/Music which have their own UI) */} {!isEdit && !(isAudio && (audioSubMode === 'sfx' || audioSubMode === 'mixer' || audioSubMode === 'music')) && (isMultiClip ? : )} + {isImage && { + await useStore.getState().reconnectJobs() + if (useStore.getState().activeWorkspace === receipt.result.workspace) { + await useStore.getState().maybeRefreshGallery() + } + }} />} {/* Video: reference images below prompt. In Frames mode the InputsPanel renders them as ordered tiles instead. */} diff --git a/ui/src/features/agent/applicationAdapters.ts b/ui/src/features/agent/applicationAdapters.ts index d8b791d33..54c19606e 100644 --- a/ui/src/features/agent/applicationAdapters.ts +++ b/ui/src/features/agent/applicationAdapters.ts @@ -1,4 +1,5 @@ import { useStore } from '../../stores/useStore' +import i18n from '../../i18n' import type { CommandResult } from '../../lib/commandContract' import { rememberedCharacterKitLibrary } from '../characters/session' import type { SeriesAssemblyJob } from '../series/assemblyContract' @@ -361,7 +362,7 @@ export function createDefaultApplicationAdapters(): WizardApplicationAdapters { const result = await startGeneration(context || { actor: 'wizard', capability: action.type, }) - const presented = await presentStudioSliceResult(result, 'Studio generation') + const presented = await presentAdmittedStudioResult(result) const taskId = result.taskIds[0] return { ...presented, @@ -370,10 +371,11 @@ export function createDefaultApplicationAdapters(): WizardApplicationAdapters { state: 'queued', message: presented.message, target: presented.target, + metadata: presented.metadata, taskId, recoverable: true, executionKey: executionKey({ - workspace: useStore.getState().activeWorkspace || 'default', + workspace: result.entities[0]?.workspaceId || useStore.getState().activeWorkspace || 'default', type: action.type, params: action, }), @@ -1017,6 +1019,22 @@ async function presentQueueSliceResult(result: CommandResult): Promise { + const receipt = result.artifacts[0]?.metadata?.receipt + if (result.status !== 'queued' || !receipt) return presentStudioSliceResult(result, 'Studio generation') + const metadata = { commandId: result.commandId, receipt } + try { + return { ...await presentStudioSliceResult(result, 'Studio generation'), metadata } + } catch { + const message = i18n.t('studio:commands.admittedNotVisible', { id: result.taskIds[0] }) + return { + message, taskId: result.taskIds[0], + target: { kind: 'generation_task', id: result.taskIds[0], title: 'Studio generation' }, + metadata: { ...metadata, presentationWarning: message }, + } + } +} + async function presentStudioSliceResult(result: CommandResult, fallbackTitle: string): Promise { await navigate('studio') const summary = typeof result.artifacts[0]?.metadata?.summary === 'string' diff --git a/ui/src/features/agent/studioCapabilities.ts b/ui/src/features/agent/studioCapabilities.ts index 7a2869dc8..1b981f411 100644 --- a/ui/src/features/agent/studioCapabilities.ts +++ b/ui/src/features/agent/studioCapabilities.ts @@ -31,6 +31,10 @@ function text(value: unknown, maxLength: number): string { return typeof value === 'string' ? value.trim().slice(0, maxLength) : '' } +function literalText(value: unknown, maxLength: number): string | undefined { + return typeof value === 'string' && value.length <= maxLength ? value : undefined +} + function number( value: unknown, minimum: number, @@ -90,8 +94,9 @@ function videoAction(raw: Record): AgentPrepareVideoAction | nu } function imageAction(raw: Record): AgentPrepareImageAction | null { - const prompt = text(raw.prompt, 8_000) - if (!prompt) return null + const prompt = literalText(raw.prompt, 200_000) + const negativePrompt = literalText(raw.negative_prompt, 200_000) + if (!prompt?.trim() || (raw.negative_prompt !== undefined && negativePrompt === undefined)) return null const resolutionPreset = text(raw.resolution_preset, 12) as ResolutionPreset const aspectRatio = text(raw.aspect_ratio, 12) as AspectRatio const resolution = text(raw.resolution, 20) @@ -102,10 +107,12 @@ function imageAction(raw: Record): AgentPrepareImageAction | nu resolutionPreset: RESOLUTION_PRESETS.has(resolutionPreset) ? resolutionPreset : undefined, resolution: /^\d{2,4}x\d{2,4}$/.test(resolution) ? resolution : undefined, aspectRatio: ASPECT_RATIOS.has(aspectRatio) ? aspectRatio : undefined, - negativePrompt: text(raw.negative_prompt, 2_000) || undefined, + negativePrompt, seed: number(raw.seed, -1, 2_147_483_647, true), inferenceSteps: number(raw.inference_steps, 1, 100, true), - guidanceScale: number(raw.guidance_scale, 0, 30), + // The Wizard response schema uses -1 for an unspecified CFG value. + guidanceScale: typeof raw.guidance_scale === 'number' && raw.guidance_scale >= 0 + ? number(raw.guidance_scale, 0, 30) : undefined, outputCount: number(raw.output_count, 1, 8, true), } } @@ -231,11 +238,13 @@ export function registerStudioCapabilities(register: typeof defineCapability): v description: 'Open Studio → Image and fill a validated text-to-image form.', useWhen: 'The user asks to prepare, show or fill an image generation form.', parameters: ['prompt', 'model_type', 'resolution_preset', 'resolution', 'aspect_ratio', 'negative_prompt', 'seed', 'inference_steps', 'guidance_scale', 'output_count'], - inputSchema: { type: 'object', additionalProperties: false, properties: { type: { const: 'prepare_image' }, prompt: { type: 'string', minLength: 1 }, model_type: { type: 'string' }, resolution_preset: { type: 'string', enum: [...RESOLUTION_PRESETS] }, resolution: { type: 'string' }, aspect_ratio: { type: 'string', enum: [...ASPECT_RATIOS] }, negative_prompt: { type: 'string' }, seed: { type: 'integer' }, inference_steps: { type: 'integer' }, guidance_scale: { type: 'number' }, output_count: { type: 'integer' } }, required: ['type', 'prompt'] }, + inputSchema: { type: 'object', additionalProperties: false, properties: { type: { const: 'prepare_image' }, prompt: { type: 'string', minLength: 1, maxLength: 200_000 }, model_type: { type: 'string' }, resolution_preset: { type: 'string', enum: [...RESOLUTION_PRESETS] }, resolution: { type: 'string' }, aspect_ratio: { type: 'string', enum: [...ASPECT_RATIOS] }, negative_prompt: { type: 'string', maxLength: 200_000 }, seed: { type: 'integer' }, inference_steps: { type: 'integer' }, guidance_scale: { type: 'number' }, output_count: { type: 'integer' } }, required: ['type', 'prompt'] }, risk: 'edit', confirmation: 'none', progress: 'Rellenando Studio → Image…', resolve: imageAction, validate(action) { return action.prompt ? validType('prepare_image', action) : ['prompt is required'] }, - async prepare(action) { return compilePromptAction(action, 'image') }, + // Language intent remains on the action/workflow. The visible image form + // and durable command must contain the authored prompt character-for-character. + async prepare(action) { return action }, async execute(action, context) { return context.adapters.studio.prepareImage(action) }, correlate(_action, outcome) { return outcome.target }, async track(_action, outcome) { return outcome }, report: { targetKind: 'studio_form', successState: 'prepared' }, summarize(_action, outcome) { return outcome.message }, diff --git a/ui/src/features/studio/StudioImageCommandPanel.tsx b/ui/src/features/studio/StudioImageCommandPanel.tsx new file mode 100644 index 000000000..7e9d6a805 --- /dev/null +++ b/ui/src/features/studio/StudioImageCommandPanel.tsx @@ -0,0 +1,145 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { ImageGenerationCommand, ImageGenerationReceipt } from '../../api/imageGenerationCommands' +import { + pendingImageGenerationCommands, submitImageGenerationCommand, subscribeImageGenerationCommands, +} from '../../api/imageGenerationCommands' +import i18n, { useUiTranslation } from '../../i18n' + +import { IMAGE_PRESENTATION_EVENT, IMAGE_RESULT_EVENT, type ImagePresentation } from './imageCommandPresentation' + +function parameters(command: ImageGenerationCommand): Record { + return command.version === 2 ? command.input.params : { ...command.input } +} + +function RequestSummary({ command }: { command: ImageGenerationCommand }) { + const { t } = useUiTranslation('studio') + const params = parameters(command) + return
+
{String(params.model_type)} · {String(params.resolution)} · {command.input.workspace}
+

{String(params.prompt)}

+
{t('commands.resources', { + references: Array.isArray(params.image_refs) ? params.image_refs.length : 0, + loras: Array.isArray(params.activated_loras) ? params.activated_loras.length : 0, + })}
+
+} + +interface Props { + workspace: string + model: string + visible: boolean + onRecovered: (receipt: ImageGenerationReceipt) => Promise +} + +export function StudioImageCommandPanel({ workspace, model, visible, onRecovered }: Props) { + const { t } = useUiTranslation('studio') + const [shown, setShown] = useState(null) + const [pending, setPending] = useState([]) + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + const [receipt, setReceipt] = useState(null) + const current = useRef({ workspace, model, visible }) + const root = useRef(null) + const waiting = useRef(null) + useLayoutEffect(() => { current.current = { workspace, model, visible } }, [workspace, model, visible]) + + useEffect(() => { + const refresh = () => { + try { setPending(pendingImageGenerationCommands(workspace)) } + catch { setError(i18n.t('studio:commands.pendingInvalid')) } + } + refresh() + return subscribeImageGenerationCommands(refresh) + }, [workspace]) + + useLayoutEffect(() => { + const element = root.current + const receive = (event: Event) => { + const request = (event as CustomEvent).detail + const view = current.current + if (!view.visible || request.command.input.workspace !== view.workspace + || parameters(request.command).model_type !== view.model || waiting.current?.active) { + request.respond(i18n.t('studio:commands.contextChanged')) + return + } + waiting.current = request + setShown(request.command) + setBusy(true) + setReceipt(null) + setError('') + } + window.addEventListener(IMAGE_PRESENTATION_EVENT, receive) + if (element) element.dataset.studioImageListening = 'true' + return () => { + window.removeEventListener(IMAGE_PRESENTATION_EVENT, receive) + if (element) element.dataset.studioImageListening = 'false' + const request = waiting.current + // Suspense temporarily disconnects layout effects while retaining the + // DOM. Keep the same request until reveal reconnects its ACK effect. + // An actual navigation/unmount removes the element and cancels it. + queueMicrotask(() => { + if (!element?.isConnected) request?.respond(i18n.t('studio:commands.panelUnavailable')) + }) + } + }, []) + + useEffect(() => { + const complete = (event: Event) => { + const result = (event as CustomEvent<{ intentId: string; receipt?: ImageGenerationReceipt; error?: string }>).detail + if (result.intentId !== shown?.intent_id) return + setBusy(false) + setReceipt(result.receipt || null) + setError(result.error || '') + } + window.addEventListener(IMAGE_RESULT_EVENT, complete) + return () => window.removeEventListener(IMAGE_RESULT_EVENT, complete) + }, [shown]) + + useLayoutEffect(() => { + const request = waiting.current + if (!request?.active || request.command !== shown || !root.current) return + root.current.dataset.studioImageCommand = request.command.intent_id + root.current.scrollIntoView?.({ block: 'nearest' }) + // Let the rendered values reach the screen before admitting compute. + let second = 0 + const first = requestAnimationFrame(() => { + second = requestAnimationFrame(() => { + const view = current.current + const valid = root.current?.isConnected && view.visible && view.workspace === request.command.input.workspace + && view.model === parameters(request.command).model_type + request.respond(valid ? undefined : i18n.t('studio:commands.contextChanged')) + waiting.current = null + }) + }) + return () => { cancelAnimationFrame(first); cancelAnimationFrame(second) } + }, [shown]) + + const recover = async (command: ImageGenerationCommand) => { + setBusy(true) + setError('') + setShown(command) + try { + // Retrying the saved intention can safely complete an admitted dispatch. + // It never builds a new request from the currently edited form. + const admitted = await submitImageGenerationCommand(command) + setReceipt(admitted) + await onRecovered(admitted) + } catch (failure) { + setError(failure instanceof Error ? failure.message : String(failure)) + } finally { setBusy(false) } + } + + return
+ {shown &&
+ {receipt ? t('commands.admitted', { id: receipt.result.job_id }) : t('commands.prepared')} + +
} + {pending.filter(command => !busy || command.intent_id !== shown?.intent_id).map(command =>
+ {t('commands.pending')} + + +
)} + {error &&

{error}

} +
+} diff --git a/ui/src/features/studio/actions.ts b/ui/src/features/studio/actions.ts index b215c180a..bac8f9d15 100644 --- a/ui/src/features/studio/actions.ts +++ b/ui/src/features/studio/actions.ts @@ -1,4 +1,6 @@ import * as api from '../../api/client' +import type { ImageGenerationReceipt } from '../../api/imageGenerationCommands' +import i18n from '../../i18n' import { commandResultFromSlice, type CommandResult } from '../../lib/commandContract' import { getFamiliesForMode, getModelsForFamily, useStore } from '../../stores/useStore' import type { ModelDef } from '../../types' @@ -353,6 +355,17 @@ export async function prepareAudio(action: PrepareAudioCommand): Promise { const state = useStore.getState() if (state.generationMode === 'model3d') { @@ -376,7 +389,8 @@ export async function startPreparedGeneration(context?: GenerationSubmissionCont } const before = useStore.getState().jobs const knownJobs = new Set(before) - await useStore.getState().startGeneration(undefined, context) + const admitted = await useStore.getState().startGeneration(undefined, context) + if (admitted) return studioAdmissionResult(admitted) const created = useStore.getState().jobs.find(job => !knownJobs.has(job)) if (!created) throw new Error('HocusPocus no creó una tarea; revisa los requisitos del modelo y los campos visibles.') if (created.status === 'failed') throw new Error(created.error || created.message || 'La generación no pudo entrar en cola.') diff --git a/ui/src/features/studio/generationSpec.ts b/ui/src/features/studio/generationSpec.ts new file mode 100644 index 000000000..ee9cee362 --- /dev/null +++ b/ui/src/features/studio/generationSpec.ts @@ -0,0 +1,679 @@ +import { stableSerialize } from '../../lib/commandContract' +import imageCommandCatalog from '../../api/imageCommandCatalog.json' + +/** + * The v2 Studio image input is deliberately a closed map. The values are + * validated again by the server; this catalog only prevents a UI caller from + * silently adding a transport, authority, or unrelated form field. + * + * Keep this list in step with the native image parameter schema. It includes + * optional native settings which can be present in a persisted Studio form, + * even when a particular model does not consume them. + */ +export const STUDIO_IMAGE_SCHEMA_VERSION = 2 as const +export const STUDIO_IMAGE_OPERATION = 'generation.image' as const + +export const STUDIO_IMAGE_PARAM_KEYS = [ + 'minimax_h3_turbo_mode', + 'prompt', + 'alt_prompt', + 'model_type', + 'resolution', + 'video_length', + 'num_inference_steps', + 'guidance_scale', + 'seed', + 'image_mode', + 'generation_mode', + 'negative_prompt', + 'repeat_generation', + 'batch_size', + 'activated_loras', + 'loras_multipliers', + 'image_start', + 'image_end', + 'image_refs', + 'image_guide', + 'image_mask', + 'video_guide', + 'video_mask', + 'video_source', + 'audio_guide', + 'audio_guide2', + 'audio_guide3', + 'audio_guide4', + 'audio_guide5', + 'audio_guide6', + 'audio_source', + 'MMAudio_setting', + 'MMAudio_prompt', + 'MMAudio_neg_prompt', + 'h3_ref_videos', + 'h3_ref_audios', + 'minimax_h3_references', + 'image_prompt_type', + 'video_prompt_type', + 'frames_positions', + 'canonical_image_refs', + 'multi_prompts_gen_type', + 'image_fit_mode', + 'input_video_strength', + 'denoising_strength', + 'masking_strength', + 'video_guide_outpainting', + 'control_net_weight', + 'control_net_weight2', + 'control_net_weight_alt', + 'motion_amplitude', + 'mask_expand', + 'image_refs_relative_size', + 'remove_background_images_ref', + 'model_mode', + 'temporal_upsampling', + 'audio_prompt_type', + 'sliding_window_size', + 'sliding_window_overlap', + 'sliding_window_memory_override', + 'sliding_window_discard_last_frames', + 'sliding_window_color_correction_strength', + 'sliding_window_overlap_noise', + 'keep_frames_video_source', + 'keep_frames_video_guide', + 'force_fps', + 'flow_shift', + 'sample_solver', + 'embedded_guidance_scale', + 'guidance2_scale', + 'guidance3_scale', + 'switch_threshold', + 'switch_threshold2', + 'guidance_phases', + 'model_switch_phase', + 'alt_guidance_scale', + 'alt_scale', + 'audio_guidance_scale', + 'audio_scale', + 'NAG_scale', + 'NAG_tau', + 'NAG_alpha', + 'RIFLEx_setting', + 'injection_strength', + 'identity_guidance_scale', + 'skip_steps_cache_type', + 'skip_steps_multiplier', + 'skip_steps_start_step_perc', + 'settings_version', + 'prompt_enhancer', + 'spatial_upsampling', + 'film_grain_intensity', + 'film_grain_saturation', + 'progressive_pipeline', + 'single_stage_pipeline', + 'reference_pipeline', + 'progressive_stage1_image_weight', + 'progressive_stage2_steps', + 'progressive_stage2_sigma', + 'progressive_stage3_steps', + 'progressive_stage3_sigma', + 'progressive_stage3_image_weight', + 'override_profile', + 'override_attention', + 'temperature', + 'top_p', + 'top_k', + 'self_refiner_setting', + 'self_refiner_plan', + 'self_refiner_f_uncertainty', + 'self_refiner_certain_percentage', + 'cfg_rescale', + 'modality_scale', + 'use_gradient_estimation', + 'ge_gamma', + 'ge_alpha', + 'outpaint_lora_strength', + 'outpaint_mask_preserve', + 'outpaint_official_stack', + 'custom_settings', + 'wangp_processor_settings', +] as const + +const GENERATED_STUDIO_IMAGE_PARAM_KEYS = new Set( + imageCommandCatalog.studio.supported_input_fields.filter(key => key !== 'workspace' && key !== 'workspace_collection_id'), +) + +/* Keep the type-level tuple and the generated runtime catalog synchronized. */ +if (STUDIO_IMAGE_PARAM_KEYS.some(key => !GENERATED_STUDIO_IMAGE_PARAM_KEYS.has(key)) + || GENERATED_STUDIO_IMAGE_PARAM_KEYS.size !== STUDIO_IMAGE_PARAM_KEYS.length) { + throw new Error('The generated Studio image parameter catalog is stale') +} + +export type StudioImageParamKey = typeof STUDIO_IMAGE_PARAM_KEYS[number] +export type StudioImageParams = Partial> +export type StudioImageGenerationFullParams = StudioImageParams & { + workspace: string +} + +export interface StudioImageGenerationInput { + workspace: string + workspace_collection_id?: string | null + params: StudioImageParams + /** + * Type-only compatibility for code which reads the common v1/v2 input + * shape. The property is never emitted and is rejected on a v2 envelope. + */ + prompt?: never +} + +export interface StudioImageGenerationCommand { + version: typeof STUDIO_IMAGE_SCHEMA_VERSION + operation: typeof STUDIO_IMAGE_OPERATION + intent_id: string + input: StudioImageGenerationInput +} + +export const STUDIO_IMAGE_PARAM_CATALOG: ReadonlySet = GENERATED_STUDIO_IMAGE_PARAM_KEYS + +const COMMAND_FIELDS = new Set(['version', 'operation', 'intent_id', 'input']) +const INPUT_FIELDS = new Set(['workspace', 'workspace_collection_id', 'params']) +const MAX_INTENT_LENGTH = 160 +const MAX_WORKSPACE_LENGTH = 240 +const MAX_WORKSPACE_COLLECTION_LENGTH = 200 +const MAX_PROMPT_LENGTH = 200_000 +const MAX_RESOLUTION_LENGTH = 8192 + +// These values are declarations supplied by the UI/runtime boundary, not +// generation inputs. The builder drops them because Studio already has a +// separate typed submissionContext/header for surface attribution. +const DECLARED_METADATA_FIELDS = new Set([ + 'provenance', + 'runtime', + 'client', + 'actor', + 'permission', + 'workspace_id', + 'workspaceId', +]) + +const ENVELOPE_INJECTION_FIELDS = new Set([ + 'version', + 'operation', + 'intent_id', + 'input', + 'params', + 'workspace_collection_id', + 'command', + 'command_id', + 'commandId', +]) + +/* + * `useStore.params` is a shared native form bag rather than a Studio image + * object. Load Settings and reroll can therefore leave fields from the + * video/audio/avatar families (and a few old primary-settings keys) beside + * the image fields. Keep this projection explicit: a new typo or an + * unreviewed native field must still fail at this boundary instead of being + * silently ignored. + * + * The generated `excluded` entries are the field-name portion of the server's + * image contract. The two descriptive entries in that list are filtered out; + * the additional names are emitted by the existing WangP restore path or + * primary-settings snapshot but are not part of the image catalog. + */ +const STUDIO_IMAGE_FORM_RESIDUAL_FIELDS = new Set([ + ...imageCommandCatalog.studio.excluded.filter((field: string) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(field)), + 'attention_sparsity', + 'video_guide2', + 'speakers_locations', + 'apg_switch', + 'cfg_star_switch', + 'cfg_zero_step', + 'custom_guide', + 'matanyone_version', + 'min_frames_if_references', + 'multi_images_gen_type', + 'output_filename', +]) + +const STUDIO_IMAGE_ADVANCED_RESIDUAL_FIELDS = new Set([ + 'perturbation_switch', + 'perturbation_layers', + 'perturbation_start_perc', + 'perturbation_end_perc', + 'stg_scale', + 'apg_switch', + 'cfg_star_switch', + 'cfg_zero_step', +]) + +// These controls are excluded from the v2 image contract, but some native +// video-capable handlers can use them. A stale sidecar may contain their +// disabled defaults; silently dropping an active value would change a user's +// request, so active controls remain fail-closed at this boundary. +function hasResidualValue(value: unknown): boolean { + return Array.isArray(value) ? value.length > 0 : value !== undefined && value !== null && value !== 0 +} + +function isActiveAdvancedResidual( + key: string, + value: unknown, + fullParams: Record, +): boolean { + if (!STUDIO_IMAGE_ADVANCED_RESIDUAL_FIELDS.has(key)) return false + if (key === 'perturbation_switch') return hasResidualValue(value) + if (key === 'cfg_zero_step') return value !== undefined && value !== null && value !== -1 + if (key === 'apg_switch' || key === 'cfg_star_switch') return hasResidualValue(value) + return fullParams.perturbation_switch !== 0 && hasResidualValue(value) +} + +const SINGLE_REFERENCE_FIELDS = new Set([ + 'image_start', + 'image_end', + 'image_guide', + 'image_mask', +]) + +const MULTI_REFERENCE_FIELDS = new Set([ + 'image_start', + 'image_end', + 'image_guide', + 'image_mask', +]) + +const ARRAY_REFERENCE_FIELDS = new Set([ + 'image_refs', +]) + +// These lists belong to the video/audio command families. Studio may leave +// them in a restored image form, but a non-empty value would silently switch +// modes or be ignored by the native image contract, so fail closed here. +const INACTIVE_REFERENCE_LIST_FIELDS = new Set([ + 'h3_ref_videos', + 'h3_ref_audios', + 'minimax_h3_references', +]) + +const INACTIVE_IMAGE_FIELDS = new Set([ + 'video_guide', + 'video_mask', + 'video_source', + 'audio_guide', + 'audio_guide2', + 'audio_guide3', + 'audio_guide4', + 'audio_guide5', + 'audio_guide6', + 'audio_source', + 'temporal_upsampling', + 'audio_prompt_type', + 'MMAudio_prompt', + 'MMAudio_neg_prompt', + 'keep_frames_video_source', + 'keep_frames_video_guide', + 'force_fps', +]) + +const CUSTOM_SETTING_FIELDS = new Set([ + 'sensenova_kv_cache', + 'noise_scale_start', + 'noise_scale_end', + 'noise_clip_std', +]) + +const PROCESSOR_SETTING_FIELDS = new Set([ + 'spatial_upsampler_strength', + 'spatial_upsampler_face_count', + 'spatial_upsampler_h3_strength', + 'spatial_upsampler_prompt', + 'spatial_upsampler_reference_images', + 'spatial_upsampler_dlss_strength', +]) + +function isRecord(value: unknown): value is Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +function requiredText(value: unknown, field: string, maximum: number): string { + if (typeof value !== 'string' || !value.trim()) { + throw new Error(field + ' must be a non-blank string') + } + if (value.length > maximum) throw new Error(field + ' is too long') + return value +} + +function strictInteger(value: unknown, field: string, minimum: number, maximum: number): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum || value > maximum) { + throw new Error(field + ' must be an integer in range') + } + return value +} + +function strictFiniteNumber(value: unknown, field: string, minimum: number, maximum: number): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value < minimum || value > maximum) { + throw new Error(field + ' must be a finite number in range') + } + return value +} + +function assertCanonicalMediaUrl(value: unknown, field: string): void { + if (typeof value !== 'string' || !value || value.trim() !== value) { + throw new Error(field + ' must be a canonical media URL') + } + if (/^asset(?:[_:-])[A-Za-z0-9][A-Za-z0-9._:-]{0,238}$/.test(value)) return + if (!value.startsWith('/api/v1/')) { + throw new Error(field + ' must be a canonical media URL') + } + // URL() normalizes dot segments before exposing pathname. Inspect the raw + // path first so `/file/a/../b` cannot become an apparently safe `/file/b` + // in the browser while the server correctly rejects the traversal. + const rawPathEnd = value.search(/[?#]/) + const rawPath = rawPathEnd < 0 ? value : value.slice(0, rawPathEnd) + let decodedRawPath: string + try { + decodedRawPath = decodeURIComponent(rawPath) + } catch { + throw new Error(field + ' must be a canonical media URL') + } + if (!safeReferencePath(decodedRawPath.slice('/api/v1/'.length))) { + throw new Error(field + ' must be a canonical media URL') + } + let parsed: URL + try { + parsed = new URL(value, 'http://hocuspocus.invalid') + } catch { + throw new Error(field + ' must be a canonical media URL') + } + if (parsed.origin !== 'http://hocuspocus.invalid' + || parsed.hash + || !(/^\/api\/v1\/(?:uploads|file|assets)\//).test(parsed.pathname)) { + throw new Error(field + ' must be a canonical media URL') + } + let suffix: string + try { + suffix = decodeURIComponent(parsed.pathname.slice(parsed.pathname.indexOf('/api/v1/') + '/api/v1/'.length)) + } catch { + throw new Error(field + ' must be a canonical media URL') + } + if (parsed.pathname.startsWith('/api/v1/assets/')) { + if (parsed.search || !/^assets\/asset(?:[_:-])[A-Za-z0-9][A-Za-z0-9._:-]{0,238}$/.test(suffix)) { + throw new Error(field + ' must be a canonical media URL') + } + } else if (parsed.pathname.startsWith('/api/v1/uploads/')) { + if (parsed.search || !safeReferencePath(suffix.slice('uploads/'.length))) { + throw new Error(field + ' must be a canonical media URL') + } + } else { + const query = new URLSearchParams(parsed.search) + if (query.size !== 1 || query.getAll('workspace').length !== 1 + || !/^(?:default|[A-Za-z0-9][A-Za-z0-9_-]*)$/.test(query.get('workspace') || '')) { + throw new Error(field + ' must be a canonical media URL') + } + if (!safeReferencePath(suffix.slice('file/'.length))) { + throw new Error(field + ' must be a canonical media URL') + } + } +} + +function safeReferencePath(value: string): boolean { + return Boolean(value) + && !value.includes('\\') + && !value.includes('\u0000') + && value.split('/').every(part => Boolean(part) && part !== '.' && part !== '..') +} + +function assertReferenceValue(value: unknown, field: string): void { + if (value == null || value === '') return + assertCanonicalMediaUrl(value, field) +} + +function assertOptionalReferenceItem(value: unknown, field: string): void { + if (typeof value !== 'string') { + throw new Error(field + ' must be a canonical media URL') + } + // The native optional start/end/guide/mask fields use an empty string as a + // persisted "no reference" sentinel, including when the UI restored a + // list-shaped value. It is different from an image_refs entry, which must + // always identify a real asset. + if (value === '') return + assertCanonicalMediaUrl(value, field) +} + +function assertReferences(params: Record): void { + for (const field of SINGLE_REFERENCE_FIELDS) { + if (!(field in params)) continue + const value = params[field] + if (MULTI_REFERENCE_FIELDS.has(field) && Array.isArray(value)) { + value.forEach((item, index) => assertOptionalReferenceItem( + item, + 'input.params.' + field + '[' + index + ']', + )) + } else { + assertReferenceValue(value, 'input.params.' + field) + } + } + for (const field of ARRAY_REFERENCE_FIELDS) { + if (!(field in params) || params[field] == null) continue + if (!Array.isArray(params[field])) { + throw new Error('input.params.' + field + ' must be an ordered URL list') + } + params[field].forEach((value, index) => { + assertCanonicalMediaUrl(value, 'input.params.' + field + '[' + index + ']') + }) + } + for (const field of INACTIVE_REFERENCE_LIST_FIELDS) { + if (!(field in params) || params[field] == null) continue + if (!Array.isArray(params[field])) { + throw new Error('input.params.' + field + ' must be an empty image-mode list') + } + if (params[field].length > 0) { + throw new Error('input.params.' + field + ' must be empty in image mode') + } + } + for (const field of INACTIVE_IMAGE_FIELDS) { + if (field in params && params[field] !== null && params[field] !== '') { + throw new Error('input.params.' + field + ' must be the inactive image value') + } + } + if ('custom_settings' in params && params.custom_settings != null) { + assertClosedNestedObject(params.custom_settings, CUSTOM_SETTING_FIELDS, 'custom_settings') + } + if ('wangp_processor_settings' in params && params.wangp_processor_settings != null) { + const settings = assertClosedNestedObject( + params.wangp_processor_settings, + PROCESSOR_SETTING_FIELDS, + 'wangp_processor_settings', + ) + if ('spatial_upsampler_reference_images' in settings + && settings.spatial_upsampler_reference_images != null) { + const refs = settings.spatial_upsampler_reference_images + if (!Array.isArray(refs)) throw new Error('input.params.wangp_processor_settings.spatial_upsampler_reference_images must be a URL list') + refs.forEach((value, index) => assertCanonicalMediaUrl( + value, + 'input.params.wangp_processor_settings.spatial_upsampler_reference_images[' + index + ']', + )) + } + } +} + +function assertClosedNestedObject( + value: unknown, + fields: ReadonlySet, + name: string, +): Record { + if (!isRecord(value)) throw new Error('input.params.' + name + ' must be an object or null') + for (const key of Object.keys(value)) { + if (!fields.has(key)) throw new Error('input.params.' + name + '.' + key + ' is not supported') + } + return value +} + +function assertRequiredImageParams(value: Record): void { + requiredText(value.model_type, 'input.params.model_type', MAX_WORKSPACE_LENGTH) + requiredText(value.prompt, 'input.params.prompt', MAX_PROMPT_LENGTH) + requiredText(value.resolution, 'input.params.resolution', MAX_RESOLUTION_LENGTH) + strictInteger(value.num_inference_steps, 'input.params.num_inference_steps', 1, 1000) + strictInteger(value.seed, 'input.params.seed', -(2 ** 63), 2 ** 63 - 1) + strictFiniteNumber(value.guidance_scale, 'input.params.guidance_scale', 0, 1000) + if ('negative_prompt' in value && typeof value.negative_prompt !== 'string') { + throw new Error('input.params.negative_prompt must be a string') + } + if (typeof value.negative_prompt === 'string' && value.negative_prompt.length > MAX_PROMPT_LENGTH) { + throw new Error('input.params.negative_prompt is too long') + } +} + +function assertImageModeParams(value: Record): void { + if ('image_mode' in value && value.image_mode !== 1) { + throw new Error('input.params.image_mode must be the image value 1') + } + if ('video_length' in value && value.video_length !== 1) { + throw new Error('input.params.video_length must be the image value 1') + } + if ('generation_mode' in value && value.generation_mode !== 'image') { + throw new Error('input.params.generation_mode must be image') + } + if ('minimax_h3_turbo_mode' in value + && value.minimax_h3_turbo_mode !== false + && value.minimax_h3_turbo_mode !== null) { + throw new Error('input.params.minimax_h3_turbo_mode must be false or null in image mode') + } + if ('MMAudio_setting' in value + && value.MMAudio_setting !== 0 + && value.MMAudio_setting !== null) { + throw new Error('input.params.MMAudio_setting must be 0 or null in image mode') + } +} + +function assertImageOptionParams(value: Record): void { + if ('image_fit_mode' in value + && !['', 'contain', 'source', 'crop'].includes(String(value.image_fit_mode))) { + throw new Error('input.params.image_fit_mode is not supported') + } + if ('skip_steps_cache_type' in value + && !['', 'first_block'].includes(String(value.skip_steps_cache_type))) { + throw new Error('input.params.skip_steps_cache_type is not supported') + } + if ('canonical_image_refs' in value && value.canonical_image_refs !== false + && value.canonical_image_refs !== true) { + throw new Error('input.params.canonical_image_refs must be boolean') + } + if (value.canonical_image_refs === true + && (!Array.isArray(value.image_refs) || value.image_refs.length === 0)) { + throw new Error('input.params.canonical_image_refs requires image_refs') + } +} + +function assertStudioImageParams(value: unknown): asserts value is StudioImageParams { + if (!isRecord(value)) throw new Error('input.params must be an object') + for (const key of Object.keys(value)) { + if (!STUDIO_IMAGE_PARAM_CATALOG.has(key)) { + throw new Error('input.params.' + key + ' is not supported by generation.image') + } + } + assertRequiredImageParams(value) + assertImageModeParams(value) + assertImageOptionParams(value) + assertReferences(value) + // This is also the JSON-safety check. It rejects cycles, BigInt, + // functions, symbols, class instances, non-finite numbers, and excessive + // nesting before anything can reach localStorage or fetch. + stableSerialize(value) +} + +export function assertStudioImageGenerationCommand( + value: unknown, +): asserts value is StudioImageGenerationCommand { + if (!isRecord(value)) throw new Error('Studio image generation command must be an object') + for (const key of Object.keys(value)) { + if (!COMMAND_FIELDS.has(key)) throw new Error('command.' + key + ' is not supported by generation.image') + } + if (value.version !== STUDIO_IMAGE_SCHEMA_VERSION) throw new Error('version must be the integer 2') + if (value.operation !== STUDIO_IMAGE_OPERATION) throw new Error('operation must be generation.image') + requiredText(value.intent_id, 'intent_id', MAX_INTENT_LENGTH) + if (!isRecord(value.input)) throw new Error('input must be an object') + for (const key of Object.keys(value.input)) { + if (!INPUT_FIELDS.has(key)) throw new Error('input.' + key + ' is not supported by generation.image') + } + const workspace = requiredText(value.input.workspace, 'input.workspace', MAX_WORKSPACE_LENGTH) + if (!/^(?:default|[A-Za-z0-9][A-Za-z0-9_-]*)$/.test(workspace)) { + throw new Error('input.workspace must be an exact output workspace name') + } + if ('workspace_collection_id' in value.input + && value.input.workspace_collection_id !== null) { + requiredText(value.input.workspace_collection_id, 'input.workspace_collection_id', MAX_WORKSPACE_COLLECTION_LENGTH) + } + assertStudioImageParams(value.input.params) +} + +export function detachedStudioImageGenerationCommand( + value: unknown, +): StudioImageGenerationCommand { + assertStudioImageGenerationCommand(value) + return JSON.parse(stableSerialize(value)) as StudioImageGenerationCommand +} + +function takeWorkspace(value: Record): string { + const workspace = requiredText(value.workspace, 'workspace', MAX_WORKSPACE_LENGTH) + if (!/^(?:default|[A-Za-z0-9][A-Za-z0-9_-]*)$/.test(workspace)) { + throw new Error('workspace must be an exact output workspace name') + } + return workspace +} + +function takeWorkspaceCollectionId(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined + if (!isRecord(value)) throw new Error('provenance must be an object when supplied') + if (value.workspace_id === undefined || value.workspace_id === null) return undefined + return requiredText(value.workspace_id, 'provenance.workspace_id', MAX_WORKSPACE_COLLECTION_LENGTH) +} + +/** + * Build the detached v2 envelope from the complete flat Studio form. + * + * Workspace is moved to input.workspace. Declared UI metadata is omitted, + * while every catalogued native parameter is copied at its value level. + * Shared-form leftovers from Load Settings, reroll sidecars or native + * primary settings (H3 policy, perturbation, duration_seconds, …) are + * dropped instead of failing the whole image submission. Envelope + * injection and invalid catalogued values still fail closed. + * Legacy filesystem references are intentionally rejected here; callers must + * resolve them through the read-only references endpoint first. + */ +export function createStudioImageGenerationCommand( + fullParams: Record, + intentId: string, +): StudioImageGenerationCommand { + if (!isRecord(fullParams)) throw new Error('Studio image parameters must be an object') + const workspace = takeWorkspace(fullParams) + const workspaceCollectionId = takeWorkspaceCollectionId(fullParams.provenance) + const params: Record = {} + for (const [key, value] of Object.entries(fullParams)) { + if (key === 'workspace' || DECLARED_METADATA_FIELDS.has(key)) continue + if (ENVELOPE_INJECTION_FIELDS.has(key)) { + throw new Error('workspace parameters cannot contain envelope field ' + key) + } + if (!STUDIO_IMAGE_PARAM_CATALOG.has(key) && !STUDIO_IMAGE_FORM_RESIDUAL_FIELDS.has(key)) { + throw new Error('input.params.' + key + ' is not supported by generation.image') + } + if (value === undefined) continue + if (isActiveAdvancedResidual(key, value, fullParams)) { + throw new Error('input.params.' + key + ' is active and incompatible with generation.image') + } + if (!STUDIO_IMAGE_PARAM_CATALOG.has(key)) continue + params[key] = value + } + const command: StudioImageGenerationCommand = { + version: STUDIO_IMAGE_SCHEMA_VERSION, + operation: STUDIO_IMAGE_OPERATION, + intent_id: intentId, + input: { + workspace, + ...(workspaceCollectionId !== undefined ? { workspace_collection_id: workspaceCollectionId } : {}), + params: params as StudioImageParams, + }, + } + return detachedStudioImageGenerationCommand(command) +} + +export const buildStudioImageGenerationCommand = createStudioImageGenerationCommand diff --git a/ui/src/features/studio/imageCommandPresentation.ts b/ui/src/features/studio/imageCommandPresentation.ts new file mode 100644 index 000000000..3949fb088 --- /dev/null +++ b/ui/src/features/studio/imageCommandPresentation.ts @@ -0,0 +1,56 @@ +import type { ImageGenerationCommand, ImageGenerationReceipt } from '../../api/imageGenerationCommands' +import i18n from '../../i18n' + +export const IMAGE_PRESENTATION_EVENT = 'hocuspocus:studio-image-presentation' +export const IMAGE_RESULT_EVENT = 'hocuspocus:studio-image-result' +export interface ImagePresentation { + command: ImageGenerationCommand + active: boolean + respond: (error?: string) => void +} + +export function finishStudioImageCommand(intentId: string, receipt?: ImageGenerationReceipt, error?: string): void { + window.dispatchEvent(new CustomEvent(IMAGE_RESULT_EVENT, { detail: { intentId, receipt, error } })) +} + +async function mountedPanel(): Promise { + window.dispatchEvent(new Event('hocuspocus:studio-image-open')) + const find = () => document.querySelector('[data-studio-image-ready="true"][data-studio-image-listening="true"]') + const current = find() + if (current) return current + return new Promise((resolve, reject) => { + const timer = window.setTimeout(() => { observer.disconnect(); reject(new Error(i18n.t('studio:commands.panelUnavailable'))) }, 8000) + const observer = new MutationObserver(() => { + const panel = find() + if (panel) { observer.disconnect(); clearTimeout(timer); resolve(panel) } + }) + observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-studio-image-ready', 'data-studio-image-listening'] }) + }) +} + +/** Wait for a visible React commit of this exact request, before any POST. */ +export async function presentStudioImageCommand(command: ImageGenerationCommand): Promise { + const root = await mountedPanel() + await new Promise((resolve, reject) => { + const request: ImagePresentation = { + command: structuredClone(command), active: true, + respond: error => { + if (!request.active) return + request.active = false + clearTimeout(timer) + observer.disconnect() + if (error) reject(new Error(error)) + else resolve() + }, + } + const timer = window.setTimeout(() => request.respond(i18n.t('studio:commands.panelUnavailable')), 8000) + const observer = new MutationObserver(() => { + if (!root.isConnected) request.respond(i18n.t('studio:commands.panelUnavailable')) + }) + observer.observe(document.body, { childList: true, subtree: true }) + window.dispatchEvent(new CustomEvent(IMAGE_PRESENTATION_EVENT, { detail: request })) + }) + if (!root.isConnected || root.dataset.studioImageCommand !== command.intent_id) { + throw new Error(i18n.t('studio:commands.contextChanged')) + } +} diff --git a/ui/src/features/studio/imageCommandSubmission.ts b/ui/src/features/studio/imageCommandSubmission.ts new file mode 100644 index 000000000..8b81f8f54 --- /dev/null +++ b/ui/src/features/studio/imageCommandSubmission.ts @@ -0,0 +1,111 @@ +import * as api from '../../api/client' +import { BASE } from '../../api/http' +import { stableSerialize } from '../../lib/commandContract' +import type { AppState } from '../../stores/useStore' +import type { GenerationSubmissionContext } from './generationProvenance' +import type { ImageGenerationReceipt } from '../../api/imageGenerationCommands' +import { finishStudioImageCommand, presentStudioImageCommand } from './imageCommandPresentation' +import i18n from '../../i18n' + +type StudioState = AppState +type NativeReceipt = Awaited> +interface Submission { + params: Record + receipt?: ImageGenerationReceipt + submit: () => Promise +} + +const MEDIA_FIELDS = ['image_refs', 'image_start', 'image_end', 'image_guide', 'image_mask'] as const +const FORM_FIELDS = ['params', 'activeWorkspace', 'generationMode', 'imageRefs', 'imageRefType', + 'removeBackgroundRefs', 'loraWeights', 'spatialUpsampling', 'filmGrainIntensity', 'filmGrainSaturation', + 'startImage', 'endImage', 'settingsOpen', 'dashboardOpen', 'sidebarMode'] as const + +function assertSameForm(before: StudioState, current: StudioState): void { + if (FORM_FIELDS.some(field => before[field] !== current[field])) { + throw new Error(i18n.t('studio:commands.contextChanged')) + } +} + +async function canonicalReferences(params: Record): Promise { + const fields = MEDIA_FIELDS.filter(field => params[field]) + const references = fields.flatMap(field => Array.isArray(params[field]) ? params[field] as unknown[] : [params[field]]) + .filter(value => value !== '') + if (!references.length) return + const response = await fetch(`${BASE}/api/v1/generation/commands/references`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ references }), + }) + if (!response.ok) { + const body = await response.json().catch(() => ({})) + throw new Error(body.detail?.message || i18n.t('studio:commands.referenceFailed')) + } + const result = await response.json() as { references?: unknown } + if (!Array.isArray(result.references) || result.references.length !== references.length + || result.references.some(value => typeof value !== 'string')) { + throw new Error(i18n.t('studio:commands.referenceFailed')) + } + const resolved = result.references as string[] + let cursor = 0 + const replace = (value: unknown) => value === '' ? '' : resolved[cursor++] + for (const field of fields) { + const original = params[field] + params[field] = Array.isArray(original) ? original.map(replace) : replace(original) + } +} + +/** Bind legacy form field names without discarding conflicting image inputs. */ +export function translateLegacyImageGuides(params: Record): void { + for (const [legacy, canonical] of [['video_guide', 'image_guide'], ['video_mask', 'image_mask']]) { + const value = params[legacy] + if (!value) continue + if (params[canonical] && stableSerialize(params[canonical]) !== stableSerialize(value)) { + throw new Error(i18n.t('studio:commands.conflictingGuides')) + } + params[canonical] = value + delete params[legacy] + } +} + +/** Called after the complete native form builder; never reduces its parameters. */ +export async function prepareStudioSubmission( + params: Record, before: StudioState, current: () => StudioState, + context?: GenerationSubmissionContext, referenceErrors: string[] = [], +): Promise { + if (before.generationMode !== 'image') return { params, submit: () => api.submitGeneration(params) } + let snapshot = params + try { + const { newImageGenerationIntentId, submitImageGenerationCommand, createStudioImageGenerationCommand } = + await import('../../api/imageGenerationCommands') + if (referenceErrors.length) throw new Error(i18n.t('studio:commands.referenceFailed')) + snapshot = JSON.parse(stableSerialize(params)) as Record + translateLegacyImageGuides(snapshot) + assertSameForm(before, current()) + await canonicalReferences(snapshot) + assertSameForm(before, current()) + const command = createStudioImageGenerationCommand(snapshot, context?.commandId || newImageGenerationIntentId()) + const submission: Submission = { + params: { ...command.input.params, workspace: command.input.workspace }, + submit: async () => { + try { + const receipt = await submitImageGenerationCommand(command, { + submissionContext: context, + onSnapshotReady: async frozen => { + assertSameForm(before, current()) + await presentStudioImageCommand(frozen) + assertSameForm(before, current()) + }, + }) + submission.receipt = receipt + finishStudioImageCommand(command.intent_id, receipt) + return { ...receipt.result, status: receipt.status } + } catch (error) { + finishStudioImageCommand(command.intent_id, undefined, error instanceof Error ? error.message : String(error)) + throw error + } + }, + } + return submission + } catch (error) { + // Let the existing submission error tile report preparation failures too. + return { params: snapshot, submit: () => Promise.reject(error) } + } +} diff --git a/ui/src/features/studio/studioSubmission.ts b/ui/src/features/studio/studioSubmission.ts new file mode 100644 index 000000000..489a6a95d --- /dev/null +++ b/ui/src/features/studio/studioSubmission.ts @@ -0,0 +1,25 @@ +import * as api from '../../api/client' + +type Preparation = typeof import('./imageCommandSubmission').prepareStudioSubmission +type Inputs = Parameters +type Loader = () => Promise<{ prepareStudioSubmission: Preparation }> + +/** Preserve the legacy path and surface image chunk failures in the job tile. */ +export async function prepareStudioSubmission( + params: Inputs[0], before: Inputs[1], current: Inputs[2], + context?: Inputs[3], referenceErrors?: Inputs[4], + load: Loader = () => import('./imageCommandSubmission'), +): ReturnType { + if (before.generationMode !== 'image') return { params, submit: () => api.submitGeneration(params) } + try { + const implementation = await load() + return await implementation.prepareStudioSubmission(params, before, current, context, referenceErrors) + } catch (error) { + return { params, submit: () => Promise.reject(error) } + } +} + +/** Image references travel as canonical URLs; other native modes retain paths. */ +export function studioUploadReference(upload: { path: string; url: string }, mode: string): string { + return mode === 'image' ? upload.url : upload.path +} diff --git a/ui/src/i18n/locales/en/studio.json b/ui/src/i18n/locales/en/studio.json index 3e6bfd077..2dee93e4d 100644 --- a/ui/src/i18n/locales/en/studio.json +++ b/ui/src/i18n/locales/en/studio.json @@ -925,5 +925,18 @@ "exampleTitle": "Example prompt", "example": "Replace the character in image 1 with the person in image 2. Keep the pose, background, camera, framing and aspect ratio of image 1." } + }, + "commands": { + "prepared": "Image request ready", + "admitted": "Image queued · {{id}}", + "admittedNotVisible": "Image task {{id}} was admitted. Reopen Studio or Activity to follow it.", + "pending": "A previous submission needs recovery", + "recover": "Recover this submission", + "resources": "{{references}} references · {{loras}} LoRAs", + "contextChanged": "The image form changed before submission. Review the current settings and try again.", + "panelUnavailable": "The image request could not be shown. Open Studio and try again.", + "pendingInvalid": "The saved submission could not be read. It has been preserved for recovery.", + "referenceFailed": "A selected reference could not be prepared. No image was submitted.", + "conflictingGuides": "Conflicting image guides are selected. Choose one control image and mask before generating." } } diff --git a/ui/src/i18n/locales/es/studio.json b/ui/src/i18n/locales/es/studio.json index 546e6931a..c6b128802 100644 --- a/ui/src/i18n/locales/es/studio.json +++ b/ui/src/i18n/locales/es/studio.json @@ -925,5 +925,18 @@ "exampleTitle": "Ejemplo de instrucción", "example": "Sustituye al personaje de la imagen 1 por la persona de la imagen 2. Conserva la pose, el fondo, la cámara, el encuadre y las proporciones de la imagen 1." } + }, + "commands": { + "prepared": "Solicitud de imagen preparada", + "admitted": "Imagen en cola · {{id}}", + "admittedNotVisible": "La tarea de imagen {{id}} está admitida. Abre Studio o Actividad para seguirla.", + "pending": "Una solicitud anterior necesita recuperación", + "recover": "Recuperar esta solicitud", + "resources": "{{references}} referencias · {{loras}} LoRAs", + "contextChanged": "El formulario de imagen cambió antes del envío. Revisa los ajustes actuales y vuelve a intentarlo.", + "panelUnavailable": "No se pudo mostrar la solicitud de imagen. Abre Studio y vuelve a intentarlo.", + "pendingInvalid": "No se pudo leer la solicitud guardada. Se ha conservado para recuperarla.", + "referenceFailed": "No se pudo preparar una referencia seleccionada. No se ha enviado ninguna imagen.", + "conflictingGuides": "Hay guías de imagen distintas seleccionadas. Elige una imagen de control y una máscara antes de generar." } } diff --git a/ui/src/stores/useStore.ts b/ui/src/stores/useStore.ts index 9ce6de82d..df598bf2f 100644 --- a/ui/src/stores/useStore.ts +++ b/ui/src/stores/useStore.ts @@ -36,6 +36,8 @@ import { type GenerationSubmissionContext, } from '../features/studio/generationProvenance' import { storyDirectorSubmissionProvenance } from '../features/stories/provenance' +import type { ImageGenerationReceipt } from '../api/imageGenerationCommands' +import { prepareStudioSubmission, studioUploadReference } from '../features/studio/studioSubmission' const DASHBOARD_PIPELINE_PAGE_SIZE = 8 const CIVIT_DOWNLOAD_POLL_MS = 2000 @@ -1518,7 +1520,7 @@ export interface AppState extends LlmSlice, StudioConfigurationSlice { startGeneration: ( scheduledPrompt?: ScheduledPromptSubmission, submissionContext?: GenerationSubmissionContext, - ) => Promise + ) => Promise stopGeneration: (jobId?: string) => void dismissJob: (jobId: string) => void reconnectJobs: () => Promise @@ -4723,6 +4725,7 @@ export const useStore = create((set, get) => { } const params: Record = { ...state.params, ...viggleEditingParameters(state), generation_mode: state.generationMode, workspace: state.activeWorkspace } + const referenceUploadErrors: string[] = [] const provenance = generationProvenancePayload(submissionContext) if (provenance) params.provenance = provenance if (scheduledPrompt) { @@ -4999,10 +5002,6 @@ export const useStore = create((set, get) => { if (state.generationMode === 'image') { params.video_length = 1 params.image_mode = 1 - // WanGP expects control input in image_guide (not video_guide) for image mode - if (params.video_guide && !params.image_guide) { - params.image_guide = params.video_guide - } } // Audio mode: branch by sub-mode (Speech/Music vs SFX) @@ -5257,10 +5256,11 @@ export const useStore = create((set, get) => { if (!isOmniReference && state.endImage) { try { const result = await api.uploadImage(state.endImage) - params.image_end = result.path + params.image_end = studioUploadReference(result, state.generationMode) const ipt = (params.image_prompt_type as string) || '' if (!ipt.includes('E')) params.image_prompt_type = ipt + 'E' } catch (e) { + referenceUploadErrors.push(String(e)) console.error('Failed to upload end image:', e) } } else if (params.image_end) { @@ -5334,8 +5334,9 @@ export const useStore = create((set, get) => { for (const file of state.imageRefs) { try { const result = await api.uploadImage(file) - refPaths.push(result.path) + refPaths.push(studioUploadReference(result, state.generationMode)) } catch (e) { + referenceUploadErrors.push(String(e)) console.error('Failed to upload reference image:', e) } } @@ -5452,6 +5453,7 @@ export const useStore = create((set, get) => { delete params.h3_window_plan } + const submission = await prepareStudioSubmission(params, state, get, submissionContext, referenceUploadErrors) const newJob: GenerationJob = { id: '', status: 'queued', @@ -5466,7 +5468,7 @@ export const useStore = create((set, get) => { error: null, createdAt: Date.now(), oomInfo: null, - generationDetails: _generationDetailsFromParams(params, state.models), + generationDetails: _generationDetailsFromParams(submission.params, state.models), } set(s => { @@ -5474,7 +5476,7 @@ export const useStore = create((set, get) => { }) try { - const { job_id, task_id, root_task_id, h3_window_plan } = await api.submitGeneration(params) + const { job_id, task_id, root_task_id, h3_window_plan } = await submission.submit() if (h3_window_plan) { const planFps = state.modelOptions?.fps ?? 24 @@ -5553,7 +5555,7 @@ export const useStore = create((set, get) => { console.error('Status poll error:', e) } }, 2000) - + return submission.receipt } catch (e) { const msg = e instanceof Error ? e.message : 'Generation failed' // Submit itself failed (pre-queue). Convert the placeholder to a failed diff --git a/ui/tests/imageGenerationCommands.test.ts b/ui/tests/imageGenerationCommands.test.ts new file mode 100644 index 000000000..d53ced0fe --- /dev/null +++ b/ui/tests/imageGenerationCommands.test.ts @@ -0,0 +1,335 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { JSDOM } from 'jsdom' + +const dom = new JSDOM('', { url: 'http://localhost/' }) +Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + Event: dom.window.Event, + localStorage: dom.window.localStorage, +}) + +const { + createImageGenerationCommand, + fetchImageGenerationCommandReceipt, + newImageGenerationIntentId, + pendingImageGenerationCommand, + pendingImageGenerationCommands, + submitImageGenerationCommand, + ImageGenerationCommandError, +} = await import('../src/api/imageGenerationCommands.ts') + +const originalFetch = globalThis.fetch + +function response(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +function command( + intentId = 'intent-image-1', + workspace = 'workspace-a', + overrides: Record = {}, +) { + const input = { + workspace, + model_type: 'pi_flux2', + prompt: ' literal prompt: mañana\nkeep spaces ', + resolution: '512x512', + num_inference_steps: 1, + seed: -1, + guidance_scale: 1.0, + } + Object.assign(input, overrides) + return createImageGenerationCommand(intentId, input) +} + +function receipt(value: ReturnType, overrides: Record = {}) { + return { + version: 1, + commandId: value.intent_id, + operation: value.operation, + status: 'queued', + entities: [], + artifacts: [], + taskIds: [`task-${value.intent_id}`], + pipelineIds: [], + result: { + job_id: `job-${value.intent_id}`, + task_id: `task-${value.intent_id}`, + root_task_id: `root-${value.intent_id}`, + workspace: value.input.workspace, + status: 'queued', + }, + ...overrides, + } +} + +function bodyOf(init: RequestInit | undefined): Record { + assert.ok(init?.body) + return JSON.parse(String(init.body)) as Record +} + +test.afterEach(() => { + dom.window.localStorage.clear() + globalThis.fetch = originalFetch +}) + +test('creation requires an explicit intention and detaches the exact image envelope', { concurrency: false }, () => { + const input = { + workspace: 'workspace-a', + model_type: 'pi_flux2', + prompt: ' keep literal\nmañana ', + negative_prompt: ' do not rewrite ', + resolution: '512x512', + num_inference_steps: 1, + seed: -1, + guidance_scale: 1.0, + } + const value = createImageGenerationCommand(' exact-intent ', input) + + input.prompt = 'changed after creation' + assert.equal(value.intent_id, ' exact-intent ') + assert.equal(value.input.prompt, ' keep literal\nmañana ') + assert.equal(value.input.negative_prompt, ' do not rewrite ') + assert.equal('generation_mode' in value.input, false) + assert.equal('image_mode' in value.input, false) + assert.equal('video_length' in value.input, false) +}) + +test('integer fields reject JavaScript values that cannot preserve the native literal', { concurrency: false }, () => { + assert.throws( + () => command('intent-unsafe-seed', 'workspace-a', { seed: Number.MAX_SAFE_INTEGER + 1 }), + /input\.seed/, + ) +}) + +test('unsupported mode and client metadata cannot enter the image envelope', { concurrency: false }, async () => { + assert.throws( + () => command('intent-extra-input', 'workspace-a', { generation_mode: 'image' }), + /generation_mode/, + ) + const withClient = { ...command('intent-extra-envelope'), client: 'wizard' } + await assert.rejects(submitImageGenerationCommand(withClient), /command\.client/) +}) + +test('the exact envelope is persisted before POST and a valid queued receipt clears it', { concurrency: false }, async () => { + const value = command('intent-before-post') + let requestUrl = '' + let bodyAtTransport: Record | undefined + globalThis.fetch = (async (input, init) => { + requestUrl = String(input) + bodyAtTransport = bodyOf(init) + assert.deepEqual(pendingImageGenerationCommands(), [value]) + return response({ receipt: receipt(value), replayed: false }) + }) as typeof fetch + + const result = await submitImageGenerationCommand(value) + + assert.equal(requestUrl, '/api/v1/generation/commands') + assert.deepEqual(bodyAtTransport, value) + assert.equal(result.commandId, value.intent_id) + assert.equal(result.operation, 'generation.image') + assert.equal(result.status, 'queued') + assert.equal(result.replayed, false) + assert.deepEqual(result.taskIds, [`task-${value.intent_id}`]) + assert.deepEqual(result.result, receipt(value).result) + assert.deepEqual(pendingImageGenerationCommands(), []) +}) + +test('pending getter is storage-backed, workspace-scoped, and returns detached snapshots', { concurrency: false }, async () => { + const first = command('intent-pending-a', 'workspace-a') + const second = command('intent-pending-b', 'workspace-b') + globalThis.fetch = (async () => { throw new Error('connection lost') }) as typeof fetch + + await assert.rejects(submitImageGenerationCommand(first), error => error instanceof ImageGenerationCommandError && error.uncertain) + await assert.rejects(submitImageGenerationCommand(second), error => error instanceof ImageGenerationCommandError && error.uncertain) + + const recoveredView = pendingImageGenerationCommands() + assert.deepEqual(recoveredView.map(item => item.intent_id), [first.intent_id, second.intent_id]) + assert.deepEqual(pendingImageGenerationCommands('workspace-a'), [first]) + assert.deepEqual(pendingImageGenerationCommands('workspace-b'), [second]) + assert.equal(pendingImageGenerationCommand(first.intent_id, 'workspace-b'), null) + + recoveredView[0].input.prompt = 'mutated view' + assert.equal(pendingImageGenerationCommand(first.intent_id)?.input.prompt, first.input.prompt) +}) + +test('a lost response retries the same JSON and intention without inventing a fresh ID', { concurrency: false }, async () => { + const value = command('intent-lost-response') + const bodies: Record[] = [] + let attempt = 0 + globalThis.fetch = (async (_input, init) => { + bodies.push(bodyOf(init)) + attempt += 1 + if (attempt === 1) throw new Error('socket closed after admission') + return response({ receipt: receipt(value), replayed: true }) + }) as typeof fetch + + await assert.rejects( + submitImageGenerationCommand(value), + error => error instanceof ImageGenerationCommandError + && error.uncertain && /socket closed after admission/.test(error.message), + ) + assert.deepEqual(pendingImageGenerationCommands(), [value]) + + const replay = await submitImageGenerationCommand(value) + + assert.equal(replay.replayed, true) + assert.equal(bodies.length, 2) + assert.deepEqual(bodies[1], bodies[0]) + assert.equal((bodies[1].intent_id as string), value.intent_id) + assert.deepEqual(pendingImageGenerationCommands(), []) +}) + +test('an initial definitive 4xx may clear its pending hint', { concurrency: false }, async () => { + const value = command('intent-invalid-before-admission') + let calls = 0 + globalThis.fetch = (async () => { + calls += 1 + return response({ detail: { code: 'invalid_resolution', message: 'bad resolution' } }, 422) + }) as typeof fetch + + await assert.rejects( + submitImageGenerationCommand(value), + error => error instanceof ImageGenerationCommandError && error.status === 422 && !error.uncertain, + ) + assert.equal(calls, 1) + assert.deepEqual(pendingImageGenerationCommands(), []) +}) + +test('a later 401 does not erase a hint left by an uncertain prior attempt', { concurrency: false }, async () => { + const value = command('intent-uncertain-then-auth') + let attempt = 0 + globalThis.fetch = (async () => { + attempt += 1 + if (attempt === 1) throw new Error('response lost after commit') + if (attempt === 2) return response({ detail: 'authentication expired' }, 401) + return response({ receipt: receipt(value), replayed: true }) + }) as typeof fetch + + await assert.rejects(submitImageGenerationCommand(value), error => error instanceof ImageGenerationCommandError && error.uncertain) + await assert.rejects( + submitImageGenerationCommand(value), + error => error instanceof ImageGenerationCommandError && error.status === 401 && error.uncertain, + ) + assert.deepEqual(pendingImageGenerationCommands(), [value]) + + const replay = await submitImageGenerationCommand(value) + assert.equal(replay.replayed, true) + assert.deepEqual(pendingImageGenerationCommands(), []) +}) + +test('same intent cannot be retried with a changed workspace or literal', { concurrency: false }, async () => { + const original = command('intent-workspace-conflict', 'workspace-a') + const changed = command('intent-workspace-conflict', 'workspace-b', { prompt: 'changed literal' }) + let calls = 0 + globalThis.fetch = (async () => { + calls += 1 + throw new Error('uncertain transport') + }) as typeof fetch + + await assert.rejects(submitImageGenerationCommand(original)) + await assert.rejects( + submitImageGenerationCommand(changed), + error => error instanceof ImageGenerationCommandError && error.code === 'intent_conflict', + ) + assert.equal(calls, 1) + assert.deepEqual(pendingImageGenerationCommands(), [original]) +}) + +test('a false 200 with uncorrelated or non-queued data is rejected and remains recoverable', { concurrency: false }, async () => { + const value = command('intent-false-200') + let attempt = 0 + globalThis.fetch = (async () => { + attempt += 1 + if (attempt === 1) return response({ receipt: receipt(value, { commandId: 'other-intent' }), replayed: false }) + return response({ receipt: receipt(value), replayed: true }) + }) as typeof fetch + + await assert.rejects( + submitImageGenerationCommand(value), + error => error instanceof ImageGenerationCommandError + && error.code === 'invalid_receipt' && error.status === 200 && error.uncertain, + ) + assert.deepEqual(pendingImageGenerationCommands(), [value]) + + const valid = receipt(value, { status: 'completed' }) + globalThis.fetch = (async () => response({ receipt: valid, replayed: true })) as typeof fetch + await assert.rejects(submitImageGenerationCommand(value), /Receipt could not be verified/) + assert.deepEqual(pendingImageGenerationCommands(), [value]) +}) + +test('a valid committed receipt is returned even when local cleanup fails', { concurrency: false }, async () => { + const value = command('intent-cleanup-failure') + globalThis.fetch = (async () => response({ receipt: receipt(value), replayed: false })) as typeof fetch + const storagePrototype = Object.getPrototypeOf(dom.window.localStorage) as Storage + const originalRemoveItem = storagePrototype.removeItem + Object.defineProperty(storagePrototype, 'removeItem', { + configurable: true, + value: () => { throw new Error('storage cleanup unavailable') }, + }) + try { + const result = await submitImageGenerationCommand(value) + assert.equal(result.commandId, value.intent_id) + assert.deepEqual(pendingImageGenerationCommands(), [value]) + } finally { + Object.defineProperty(storagePrototype, 'removeItem', { + configurable: true, + value: originalRemoveItem, + }) + } +}) + +test('receipt query uses exact workspace and intention and recovers the POST wrapper', { concurrency: false }, async () => { + const value = command('intent / recovery', 'workspace_A') + globalThis.fetch = (async () => { throw new Error('lost POST response') }) as typeof fetch + await assert.rejects(submitImageGenerationCommand(value)) + + let requestUrl = '' + globalThis.fetch = (async input => { + requestUrl = String(input) + return response({ receipt: receipt(value), task: { id: `task-${value.intent_id}`, status: 'queued' } }) + }) as typeof fetch + + const recovered = await fetchImageGenerationCommandReceipt(value.input.workspace, value.intent_id) + + assert.equal( + requestUrl, + '/api/v1/generation/commands/receipt?workspace=workspace_A&intent_id=intent%20%2F%20recovery', + ) + assert.equal(recovered.commandId, value.intent_id) + assert.equal(recovered.result.workspace, value.input.workspace) + assert.deepEqual(pendingImageGenerationCommands(), []) +}) + +test('an invalid receipt from the recovery GET does not clear the pending command', { concurrency: false }, async () => { + const value = command('intent-invalid-get') + globalThis.fetch = (async () => { throw new Error('lost POST response') }) as typeof fetch + await assert.rejects(submitImageGenerationCommand(value)) + + globalThis.fetch = (async () => response({ receipt: { version: 1, status: 'queued' } })) as typeof fetch + await assert.rejects( + fetchImageGenerationCommandReceipt(value.input.workspace, value.intent_id), + error => error instanceof ImageGenerationCommandError && error.code === 'invalid_receipt', + ) + assert.deepEqual(pendingImageGenerationCommands(), [value]) +}) + +test('generated IDs are only a creation helper; submit never changes an explicit ID', { concurrency: false }, async () => { + const generated = newImageGenerationIntentId() + const value = command(generated) + const sentIds: unknown[] = [] + globalThis.fetch = (async (_input, init) => { + sentIds.push(bodyOf(init).intent_id) + return response({ receipt: receipt(value), replayed: false }) + }) as typeof fetch + + await submitImageGenerationCommand(value) + + assert.equal(sentIds.length, 1) + assert.equal(sentIds[0], generated) +}) diff --git a/ui/tests/studioCapabilities.test.mjs b/ui/tests/studioCapabilities.test.mjs index 908c392ea..40c733363 100644 --- a/ui/tests/studioCapabilities.test.mjs +++ b/ui/tests/studioCapabilities.test.mjs @@ -114,3 +114,34 @@ test('keeps compute confirmation and exact reference/LoRA semantics', async () = assert.deepEqual(definitions.get('configure_studio_loras').validate(clearLoras), []) assert.equal(definitions.get('configure_studio_loras').resolve({ type: 'configure_studio_loras', loras: [] }), null) }) + +test('image preparation preserves literal prompts and language metadata without appending instructions', async () => { + const definitions = await registeredStudioCapabilities() + const definition = definitions.get('prepare_image') + const prompt = ' A blue origami octopus.\nNo text or watermark. ' + const negative = ' blur\nextra letters ' + const languageIntent = { contentLanguage: 'en', technicalPromptLanguage: 'en', + verbatimSegments: [{ kind: 'visible_text', text: prompt, language: 'en' }] } + const parsed = definition.resolve({ type: 'prepare_image', prompt, negative_prompt: negative, + model_type: 'flux2_klein_9b', guidance_scale: -1 }) + assert.equal(parsed.prompt, prompt) + assert.equal(parsed.negativePrompt, negative) + assert.equal(parsed.guidanceScale, undefined, 'unspecified CFG keeps the selected model default') + const prepared = await definition.prepare({ ...parsed, languageIntent }) + let received + await definition.execute(prepared, { adapters: { studio: { async prepareImage(action) { + received = action + return { message: 'Prepared' } + } } } }) + assert.equal(received.prompt, prompt) + assert.equal(received.negativePrompt, negative) + assert.deepEqual(received.languageIntent, languageIntent) +}) + +test('image prompts are accepted intact or rejected rather than silently truncated', async () => { + const definition = (await registeredStudioCapabilities()).get('prepare_image') + const prompt = 'x'.repeat(10_000) + '\nfin' + assert.equal(definition.resolve({ type: 'prepare_image', prompt }).prompt, prompt) + assert.equal(definition.resolve({ type: 'prepare_image', prompt: 'x'.repeat(200_001) }), null) + assert.equal(definition.resolve({ type: 'prepare_image', prompt, negative_prompt: 'x'.repeat(200_001) }), null) +}) diff --git a/ui/tests/studioImageCommandPresentation.test.tsx b/ui/tests/studioImageCommandPresentation.test.tsx new file mode 100644 index 000000000..af5a3b5dd --- /dev/null +++ b/ui/tests/studioImageCommandPresentation.test.tsx @@ -0,0 +1,411 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import React from 'react' +import { JSDOM } from 'jsdom' + +const dom = new JSDOM('', { url: 'http://localhost/' }) +let nextFrameId = 1 +const frameCallbacks = new Map() +const requestAnimationFrame = (callback: FrameRequestCallback): number => { + const id = nextFrameId++ + frameCallbacks.set(id, callback) + return id +} +const cancelAnimationFrame = (id: number): void => { frameCallbacks.delete(id) } + +Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + Event: dom.window.Event, + CustomEvent: dom.window.CustomEvent, + MutationObserver: dom.window.MutationObserver, + localStorage: dom.window.localStorage, + React, + requestAnimationFrame, + cancelAnimationFrame, + IS_REACT_ACT_ENVIRONMENT: true, +}) +Object.defineProperty(dom.window, 'requestAnimationFrame', { configurable: true, value: requestAnimationFrame }) +Object.defineProperty(dom.window, 'cancelAnimationFrame', { configurable: true, value: cancelAnimationFrame }) +// The production bus clears a `window.setTimeout` with the browser-global +// `clearTimeout`. Use the same Node timer realm in this JSDOM harness so a +// completed acknowledgement does not keep the test process alive for 8s. +Object.defineProperty(dom.window, 'setTimeout', { configurable: true, value: globalThis.setTimeout }) +Object.defineProperty(dom.window, 'clearTimeout', { configurable: true, value: globalThis.clearTimeout }) +Object.defineProperty(dom.window.HTMLElement.prototype, 'scrollIntoView', { configurable: true, value: () => undefined }) +Object.defineProperty(globalThis, 'navigator', { configurable: true, value: dom.window.navigator }) + +const { setUiLanguage } = await import('../src/i18n/index.ts') +await setUiLanguage('en') +const { createStudioImageGenerationCommand, pendingImageGenerationCommands, submitImageGenerationCommand } = + await import('../src/api/imageGenerationCommands.ts') +const { StudioImageCommandPanel } = await import('../src/features/studio/StudioImageCommandPanel.tsx') +const { prepareStudioSubmission } = await import('../src/features/studio/imageCommandSubmission.ts') + +const originalFetch = globalThis.fetch + +async function flushAnimationFrames(): Promise { + // The panel deliberately waits for two browser frames. Keep those frames + // controllable so a test can change context or unmount before acknowledgement. + for (let pass = 0; pass < 8 && frameCallbacks.size > 0; pass += 1) { + const pending = [...frameCallbacks.entries()] + frameCallbacks.clear() + for (const [, callback] of pending) callback(0) + await Promise.resolve() + } +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +function baseParams(intent: string): Record { + return { + workspace: 'studio-ack-workspace', + prompt: `Literal prompt for ${intent}\nsecond line stays exact`, + model_type: 'pi_flux2', + resolution: '512x512', + num_inference_steps: 4, + guidance_scale: 1, + seed: -1, + image_mode: 1, + video_length: 1, + generation_mode: 'image', + negative_prompt: '', + repeat_generation: 1, + batch_size: 1, + activated_loras: ['style.safetensors'], + loras_multipliers: '0.7', + image_refs: [`asset_subject_${intent}`], + canonical_image_refs: false, + } +} + +function command(intent: string) { + return createStudioImageGenerationCommand(baseParams(intent), intent) +} + +function queuedResponse(body: Record): Response { + const input = body.input as Record + const workspace = String(input.workspace) + const intent = String(body.intent_id) + const taskId = `task-${intent}` + return jsonResponse({ + receipt: { + version: 1, + commandId: intent, + operation: 'generation.image', + status: 'queued', + entities: [], + artifacts: [], + taskIds: [taskId], + pipelineIds: [], + result: { job_id: `job-${intent}`, task_id: taskId, workspace, status: 'queued' }, + commandVersion: 2, + fingerprintVersion: 2, + contentFingerprint: 'a'.repeat(64), + }, + replayed: false, + }) +} + +function formState(params: Record) { + return { + params, + activeWorkspace: params.workspace, + generationMode: 'image', + imageRefs: [], + imageRefType: '', + removeBackgroundRefs: false, + loraWeights: {}, + spatialUpsampling: '', + filmGrainIntensity: 0, + filmGrainSaturation: 0.5, + startImage: null, + endImage: null, + } as Parameters[1] +} + +function setSubmissionFetch(options: { failGeneration?: number } = {}) { + const calls: Array<{ url: string; body?: Record }> = [] + let generationCalls = 0 + let generationDom = '' + let generationCommandAttribute: string | null = null + globalThis.fetch = (async (input, init) => { + const url = String(input) + const body = init?.body ? JSON.parse(String(init.body)) as Record : undefined + calls.push({ url, body }) + if (url.includes('/api/v1/generation/commands/references')) { + const references = body?.references + return jsonResponse({ references: Array.isArray(references) ? references : [] }) + } + if (url.includes('/api/v1/generation/commands')) { + generationCalls += 1 + generationDom = document.body.textContent || '' + generationCommandAttribute = document.querySelector('[data-studio-image-command]')?.getAttribute('data-studio-image-command') || null + if (options.failGeneration === generationCalls) return jsonResponse({ detail: 'upstream unavailable' }, 503) + return queuedResponse(body || {}) + } + throw new Error(`Unexpected fetch in Studio image command test: ${url}`) + }) as typeof fetch + return { + calls, + generationCalls: () => generationCalls, + generationDom: () => generationDom, + generationCommandAttribute: () => generationCommandAttribute, + } +} + +test.afterEach(() => { + frameCallbacks.clear() + dom.window.localStorage.clear() + globalThis.fetch = originalFetch + document.body.replaceChildren() +}) + +test('renders the literal Studio snapshot and intent before the generation POST', { concurrency: false }, async () => { + const { render, screen, waitFor, cleanup, act } = await import('@testing-library/react') + const params = baseParams('ack-before-post') + const state = formState(params) + const transport = setSubmissionFetch() + render( undefined} />) + + try { + const prepared = await prepareStudioSubmission(params, state, () => state, { + actor: 'wizard', + commandId: 'ack-before-post', + }) + let submission!: ReturnType + await act(async () => { + submission = prepared.submit() + await Promise.resolve() + }) + + await waitFor(() => { + assert.equal( + document.querySelector('[data-studio-image-command]')?.getAttribute('data-studio-image-command'), + 'ack-before-post', + ) + assert.match(screen.getByRole('status').textContent || '', /Literal prompt for ack-before-post/) + assert.match(screen.getByRole('status').textContent || '', /1 references · 1 LoRAs/) + }) + assert.equal(transport.generationCalls(), 0, 'generation must wait for visible acknowledgement') + + await act(async () => { await flushAnimationFrames() }) + await submission + + assert.equal(transport.generationCalls(), 1) + assert.match(transport.generationDom(), /Literal prompt for ack-before-post/) + assert.doesNotMatch(transport.generationDom(), /previous submission needs recovery/i) + assert.equal(transport.generationCommandAttribute(), 'ack-before-post') + const generationBody = transport.calls.find(call => call.url.includes('/api/v1/generation/commands') && !call.url.includes('/references'))?.body + assert.deepEqual( + ((generationBody?.input as Record).params as Record).image_refs, + ['asset_subject_ack-before-post'], + ) + } finally { + cleanup() + } +}) + +test('a context change before the acknowledgement frame aborts without posting or retaining a hint', { concurrency: false }, async () => { + const { render, screen, waitFor, cleanup, act } = await import('@testing-library/react') + const params = baseParams('context-change') + const state = formState(params) + const transport = setSubmissionFetch() + const view = render( undefined} />) + + try { + const prepared = await prepareStudioSubmission(params, state, () => state, { actor: 'wizard', commandId: 'context-change' }) + let submission!: ReturnType + await act(async () => { + submission = prepared.submit() + await Promise.resolve() + }) + const rejected = assert.rejects(submission, error => { + assert.equal((error as { code?: string }).code, 'snapshot_hook_failed') + return true + }) + await waitFor(() => assert.match(screen.getByRole('status').textContent || '', /Literal prompt for context-change/)) + + view.rerender( undefined} />) + await act(async () => { await flushAnimationFrames() }) + + await rejected + assert.equal(transport.generationCalls(), 0) + assert.deepEqual(pendingImageGenerationCommands(), []) + } finally { + cleanup() + } +}) + +test('unmounting while the snapshot is waiting cancels before admission', { concurrency: false }, async () => { + const { render, screen, waitFor, cleanup, act } = await import('@testing-library/react') + const params = baseParams('unmount-before-post') + const state = formState(params) + const transport = setSubmissionFetch() + const view = render( undefined} />) + + try { + const prepared = await prepareStudioSubmission(params, state, () => state, { actor: 'wizard', commandId: 'unmount-before-post' }) + let submission!: ReturnType + await act(async () => { + submission = prepared.submit() + await Promise.resolve() + }) + const rejected = assert.rejects(submission, error => { + assert.equal((error as { code?: string }).code, 'snapshot_hook_failed') + return true + }) + await waitFor(() => assert.match(screen.getByRole('status').textContent || '', /Literal prompt for unmount-before-post/)) + view.unmount() + + await rejected + assert.equal(transport.generationCalls(), 0) + assert.deepEqual(pendingImageGenerationCommands(), []) + } finally { + cleanup() + } +}) + +test('a transient Suspense hide preserves the waiting request until the panel is visible again', { concurrency: false }, async () => { + const { render, waitFor, cleanup, act } = await import('@testing-library/react') + const never = new Promise(() => undefined) + function Sibling({ suspended }: { suspended: boolean }) { + if (suspended) throw never + return null + } + const tree = (suspended: boolean) => Loading

}> + undefined} /> + +
+ const params = baseParams('suspense-before-post') + const state = formState(params) + const transport = setSubmissionFetch() + const view = render(tree(false)) + try { + const prepared = await prepareStudioSubmission(params, state, () => state, { actor: 'wizard', commandId: 'suspense-before-post' }) + let submission!: ReturnType + let settled = false + await act(async () => { + submission = prepared.submit() + void submission.then(() => { settled = true }, () => { settled = true }) + }) + await waitFor(() => assert.equal(document.querySelector('[data-studio-image-command]')?.getAttribute('data-studio-image-command'), 'suspense-before-post')) + view.rerender(tree(true)) + await act(async () => { await flushAnimationFrames() }) + assert.equal(settled, false, 'a temporary hidden tree is not a cancelled request') + assert.equal(transport.generationCalls(), 0) + view.rerender(tree(false)) + await act(async () => { await flushAnimationFrames() }) + await submission + assert.equal(transport.generationCalls(), 1) + } finally { cleanup() } +}) + +test('a request started in a hidden Suspense tree waits for the receiver to reconnect', { concurrency: false }, async () => { + const { render, waitFor, cleanup, act } = await import('@testing-library/react') + const never = new Promise(() => undefined) + function Sibling({ suspended }: { suspended: boolean }) { + if (suspended) throw never + return null + } + const tree = (suspended: boolean) => Loading

}> + undefined} /> + +
+ const params = baseParams('start-while-hidden') + const state = formState(params) + const transport = setSubmissionFetch() + const view = render(tree(false)) + try { + view.rerender(tree(true)) + const prepared = await prepareStudioSubmission(params, state, () => state, { actor: 'wizard', commandId: 'start-while-hidden' }) + let submission!: ReturnType + await act(async () => { submission = prepared.submit() }) + assert.equal(transport.generationCalls(), 0) + view.rerender(tree(false)) + await waitFor(() => assert.equal(document.querySelector('[data-studio-image-command]')?.getAttribute('data-studio-image-command'), 'start-while-hidden')) + await act(async () => { await flushAnimationFrames() }) + await submission + assert.equal(transport.generationCalls(), 1) + } finally { cleanup() } +}) + +test('removing an already hidden Suspense tree cancels its pending acknowledgement', { concurrency: false }, async () => { + const { render, waitFor, cleanup, act } = await import('@testing-library/react') + const never = new Promise(() => undefined) + function Sibling({ suspended }: { suspended: boolean }) { + if (suspended) throw never + return null + } + const tree = (suspended: boolean) => Loading

}> + undefined} /> + +
+ const params = baseParams('unmount-after-hide') + const state = formState(params) + const transport = setSubmissionFetch() + const view = render(tree(false)) + try { + const prepared = await prepareStudioSubmission(params, state, () => state, { actor: 'wizard', commandId: 'unmount-after-hide' }) + let submission!: ReturnType + await act(async () => { submission = prepared.submit() }) + const rejected = assert.rejects(submission, error => (error as { code?: string }).code === 'snapshot_hook_failed') + await waitFor(() => assert.equal(document.querySelector('[data-studio-image-command]')?.getAttribute('data-studio-image-command'), 'unmount-after-hide')) + view.rerender(tree(true)) + view.unmount() + await rejected + assert.equal(transport.generationCalls(), 0) + assert.deepEqual(pendingImageGenerationCommands(), []) + } finally { cleanup() } +}) + +test('recovery reload reuses the exact intention after 503 and keeps its receipt if reconnect fails', { concurrency: false }, async () => { + const { render, screen, fireEvent, waitFor, cleanup } = await import('@testing-library/react') + const saved = command('recovery-reload') + let calls = 0 + const bodies: Record[] = [] + globalThis.fetch = (async (_input, init) => { + calls += 1 + const body = JSON.parse(String(init?.body)) as Record + bodies.push(body) + if (calls < 3) return jsonResponse({ detail: 'upstream unavailable' }, 503) + return queuedResponse(body) + }) as typeof fetch + + try { + await assert.rejects(submitImageGenerationCommand(saved), error => { + assert.equal((error as { uncertain?: boolean }).uncertain, true) + return true + }) + assert.deepEqual(pendingImageGenerationCommands(), [saved]) + + const first = render( undefined} />) + await waitFor(() => screen.getByRole('button', { name: 'Recover this submission' })) + fireEvent.click(screen.getByRole('button', { name: 'Recover this submission' })) + await waitFor(() => assert.match(screen.getByRole('alert').textContent || '', /upstream unavailable/)) + assert.equal(calls, 2) + assert.deepEqual(bodies[1], saved) + assert.deepEqual(pendingImageGenerationCommands(), [saved]) + + // A remount models a reload: the durable hint, rather than current form + // state, is the only source for the next explicit recovery. + first.unmount() + render( { throw new Error('reconnect failed') }} />) + await waitFor(() => screen.getByRole('button', { name: 'Recover this submission' })) + fireEvent.click(screen.getByRole('button', { name: 'Recover this submission' })) + await waitFor(() => assert.match(screen.getByRole('alert').textContent || '', /reconnect failed/)) + + assert.equal(calls, 3) + assert.deepEqual(bodies[2], saved) + assert.equal(pendingImageGenerationCommands().length, 0) + assert.match(screen.getByRole('status').textContent || '', /Image queued · job-recovery-reload/) + } finally { + cleanup() + } +}) diff --git a/ui/tests/studioImageGenerationCommands.test.ts b/ui/tests/studioImageGenerationCommands.test.ts new file mode 100644 index 000000000..c9a3f7c70 --- /dev/null +++ b/ui/tests/studioImageGenerationCommands.test.ts @@ -0,0 +1,614 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { JSDOM } from 'jsdom' + +const dom = new JSDOM('', { url: 'http://localhost/' }) +Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + Event: dom.window.Event, + localStorage: dom.window.localStorage, +}) + +const { + ImageGenerationCommandError, + createStudioImageGenerationCommand, + fetchImageGenerationCommandReceipt, + pendingImageGenerationCommands, + submitImageGenerationCommand, +} = await import('../src/api/imageGenerationCommands.ts') +const { STUDIO_IMAGE_PARAM_KEYS } = await import('../src/features/studio/generationSpec.ts') + +const originalFetch = globalThis.fetch + +function response(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +function baseParams(intent = 'studio-v2-intent'): Record { + return { + workspace: 'workspace_v2', + prompt: 'literal line one\nliteral line two', + model_type: 'pi_flux2', + resolution: '512x512', + num_inference_steps: 4, + guidance_scale: 1, + seed: -1, + image_mode: 1, + video_length: 1, + generation_mode: 'image', + negative_prompt: '', + repeat_generation: 1, + batch_size: 1, + activated_loras: [], + loras_multipliers: '', + minimax_h3_turbo_mode: false, + image_refs: ['asset_subject_' + intent], + image_start: ['/api/v1/uploads/start.png'], + image_end: '/api/v1/file/end.png?workspace=workspace_v2', + image_guide: 'asset_guide_' + intent, + image_mask: null, + canonical_image_refs: false, + audio_prompt_type: '', + temporal_upsampling: '', + video_guide: null, + video_mask: null, + force_fps: '', + custom_settings: { + noise_scale_start: 0.1, + noise_scale_end: 0.2, + noise_clip_std: null, + }, + wangp_processor_settings: { + spatial_upsampler_strength: 0.5, + spatial_upsampler_reference_images: ['asset_processor_' + intent], + }, + } +} + +function queuedReceipt(command: { intent_id: string; operation: string; input: { workspace: string } }) { + return { + receipt: { + version: 1, + commandId: command.intent_id, + operation: command.operation, + status: 'queued', + entities: [], + artifacts: [], + taskIds: ['task-' + command.intent_id], + pipelineIds: [], + result: { + job_id: 'job-' + command.intent_id, + task_id: 'task-' + command.intent_id, + workspace: command.input.workspace, + status: 'queued', + }, + commandVersion: 2, + fingerprintVersion: 2, + contentFingerprint: 'a'.repeat(64), + }, + replayed: false, + } +} + +function bodyOf(init: RequestInit | undefined): Record { + assert.ok(init?.body) + return JSON.parse(String(init.body)) as Record +} + +test.afterEach(() => { + dom.window.localStorage.clear() + globalThis.fetch = originalFetch +}) + +test('v2 builder preserves the complete typed native map and detaches metadata', { concurrency: false }, () => { + const source = baseParams() + source.provenance = { + actor: 'wizard', + workspace_id: 'collection_v2', + command: { command_id: 'spoofed' }, + } + source.runtime = { workspace: 'other-workspace', params: { prompt: 'spoofed' } } + source.client = { actor: 'external_agent' } + const command = createStudioImageGenerationCommand(source, 'studio-v2-intent') + + assert.equal(command.version, 2) + assert.equal(command.operation, 'generation.image') + assert.equal(command.intent_id, 'studio-v2-intent') + assert.equal(command.input.workspace, 'workspace_v2') + assert.equal(command.input.workspace_collection_id, 'collection_v2') + assert.equal('workspace' in command.input.params, false) + assert.equal('provenance' in command.input.params, false) + assert.equal('runtime' in command.input.params, false) + assert.equal('client' in command.input.params, false) + assert.equal(command.input.params.prompt, 'literal line one\nliteral line two') + assert.equal(command.input.params.image_end, '/api/v1/file/end.png?workspace=workspace_v2') + assert.equal(command.input.params.minimax_h3_turbo_mode, false) + assert.deepEqual(command.input.params.custom_settings, source.custom_settings) + assert.deepEqual(command.input.params.wangp_processor_settings, source.wangp_processor_settings) + + source.prompt = 'mutated after build' + ;(source.custom_settings as Record).noise_scale_start = 0.9 + assert.equal(command.input.params.prompt, 'literal line one\nliteral line two') + assert.equal( + (command.input.params.custom_settings as Record).noise_scale_start, + 0.1, + ) + assert.equal(STUDIO_IMAGE_PARAM_KEYS.includes('custom_settings'), true) + assert.equal(STUDIO_IMAGE_PARAM_KEYS.includes('wangp_processor_settings'), true) + assert.equal(STUDIO_IMAGE_PARAM_KEYS.includes('minimax_h3_turbo_mode'), true) +}) + +test('the captured native Studio request fixture remains a valid v2 snapshot', { concurrency: false }, () => { + const fixture = JSON.parse(readFileSync( + new URL('../../tests/fixtures/studio_image_native_request.json', import.meta.url), + 'utf8', + )) as Record + const command = createStudioImageGenerationCommand(fixture, 'fixture-v2-intent') + + assert.equal(command.input.workspace, 'studio-fixture-workspace') + assert.equal(command.input.params.minimax_h3_turbo_mode, false) + assert.equal(command.input.params.prompt, fixture.prompt) + assert.equal('provenance' in command.input.params, false) +}) + +test('v2 builder omits Load Settings and primary-settings leftovers instead of blocking Generate', { concurrency: false }, () => { + const source = baseParams('reroll-leftovers') + // Small sidecar-shaped fixture from the real native params path. Keep only + // field names and scalar/list values here; no machine-local media paths. + Object.assign(source, { + apg_switch: 0, + cfg_star_switch: 0, + cfg_zero_step: -1, + custom_guide: null, + matanyone_version: 'v1', + min_frames_if_references: 1, + multi_images_gen_type: 0, + output_filename: '', + perturbation_switch: 0, + }) + source.minimax_h3_planning_style = 'faithful' + source.minimax_h3_audio_policy = 'native' + source.minimax_h3_reference_sequence = false + source.minimax_h3_turbo_preset = 'standard' + source.duration_seconds = 0 + source.pause_seconds = 0 + source.perturbation_switch = 0 + source.perturbation_layers = [9] + source.stg_scale = 1 + source.keyframe_conditioning_mode = 'replace' + source.keyframe_inject_mode = 'additive' + source.speakers_locations = '0:45 55:100' + source.viggle_audio_mode = '' + source.attention_sparsity = 0 + source.video_guide2 = '' + source.voice_clone_enabled = true + source.voice_clone_mode = 'in_place' + source.voice_clone_refs = ['/tmp/voice.wav'] + const command = createStudioImageGenerationCommand(source, 'reroll-leftovers') + + assert.equal(command.input.params.prompt, source.prompt) + assert.equal(command.input.params.model_type, source.model_type) + assert.equal(command.input.workspace, source.workspace) + assert.equal('minimax_h3_planning_style' in command.input.params, false) + assert.equal('duration_seconds' in command.input.params, false) + assert.equal('perturbation_layers' in command.input.params, false) + assert.equal('voice_clone_enabled' in command.input.params, false) + assert.equal('speakers_locations' in command.input.params, false) + assert.equal('viggle_audio_mode' in command.input.params, false) + assert.equal('apg_switch' in command.input.params, false) + assert.equal('cfg_zero_step' in command.input.params, false) + assert.equal('output_filename' in command.input.params, false) +}) + +test('v2 builder requires canonical references and rejects envelope injection', { concurrency: false }, () => { + const legacyPath = baseParams('legacy') + legacyPath.image_refs = ['/tmp/legacy.png'] + assert.throws(() => createStudioImageGenerationCommand(legacyPath, 'legacy-ref'), /canonical media URL/) + + const traversalPath = baseParams('traversal') + traversalPath.image_refs = ['/api/v1/file/a/../secret.png?workspace=workspace_v2'] + assert.throws(() => createStudioImageGenerationCommand(traversalPath, 'traversal-ref'), /canonical media URL/) + + const duplicateWorkspace = baseParams('duplicate-workspace') + duplicateWorkspace.image_refs = ['/api/v1/file/ref.png?workspace=workspace_v2&workspace=workspace_v2'] + assert.throws(() => createStudioImageGenerationCommand(duplicateWorkspace, 'duplicate-workspace-ref'), /canonical media URL/) + + const injected = baseParams('injected') + injected.params = { prompt: 'nested envelope' } + assert.throws(() => createStudioImageGenerationCommand(injected, 'injected'), /envelope field params/) + + const flatCollection = baseParams('flat-collection') + flatCollection.workspace_collection_id = 'collection-v2' + assert.throws( + () => createStudioImageGenerationCommand(flatCollection, 'flat-collection'), + /envelope field workspace_collection_id/, + ) + + const wrongMode = baseParams('wrong-mode') + wrongMode.generation_mode = 'video' + assert.throws(() => createStudioImageGenerationCommand(wrongMode, 'wrong-mode'), /generation_mode/) + + const activeTurbo = baseParams('active-turbo') + activeTurbo.minimax_h3_turbo_mode = true + assert.throws(() => createStudioImageGenerationCommand(activeTurbo, 'active-turbo'), /minimax_h3_turbo_mode/) + + const activeAudio = baseParams('active-audio') + activeAudio.MMAudio_setting = 1 + assert.throws(() => createStudioImageGenerationCommand(activeAudio, 'active-audio'), /MMAudio_setting/) +}) + +test('v2 builder rejects unknown fields, including undefined typos, instead of dropping them', { concurrency: false }, () => { + const unknown = baseParams('unknown-field') + unknown.guidance_scal = 1 + assert.throws( + () => createStudioImageGenerationCommand(unknown, 'unknown-field'), + /input\.params\.guidance_scal is not supported/, + ) + + const undefinedTypo = baseParams('undefined-typo') + undefinedTypo.gudance_scale = undefined + assert.throws( + () => createStudioImageGenerationCommand(undefinedTypo, 'undefined-typo'), + /input\.params\.gudance_scale is not supported/, + ) +}) + +test('v2 builder rejects every envelope-shaped flat field before undefined omission', { concurrency: false }, () => { + for (const field of [ + 'version', + 'operation', + 'intent_id', + 'input', + 'params', + 'workspace_collection_id', + 'command', + 'command_id', + 'commandId', + ]) { + const source = baseParams('envelope-' + field) + source[field] = undefined + assert.throws( + () => createStudioImageGenerationCommand(source, 'envelope-' + field), + /workspace parameters cannot contain envelope field/, + field, + ) + } +}) + +test('v2 builder rejects active advanced leftovers instead of silently losing image controls', { concurrency: false }, () => { + const perturbation = baseParams('active-perturbation') + perturbation.perturbation_switch = 2 + perturbation.perturbation_layers = [9] + assert.throws( + () => createStudioImageGenerationCommand(perturbation, 'active-perturbation'), + /input\.params\.perturbation_switch is active and incompatible/, + ) + + const stgWithoutSwitch = baseParams('active-stg') + stgWithoutSwitch.stg_scale = 1 + assert.throws( + () => createStudioImageGenerationCommand(stgWithoutSwitch, 'active-stg'), + /input\.params\.stg_scale is active and incompatible/, + ) + + const projectedGuidance = baseParams('active-guidance') + projectedGuidance.apg_switch = 1 + assert.throws( + () => createStudioImageGenerationCommand(projectedGuidance, 'active-guidance'), + /input\.params\.apg_switch is active and incompatible/, + ) + + const cfgStar = baseParams('active-cfg-star') + cfgStar.cfg_star_switch = 1 + assert.throws( + () => createStudioImageGenerationCommand(cfgStar, 'active-cfg-star'), + /input\.params\.cfg_star_switch is active and incompatible/, + ) + + const cfgZero = baseParams('active-cfg-zero') + cfgZero.cfg_zero_step = 0 + assert.throws( + () => createStudioImageGenerationCommand(cfgZero, 'active-cfg-zero'), + /input\.params\.cfg_zero_step is active and incompatible/, + ) +}) + +test('v2 submit posts the exact detached envelope, declares the UI surface, and keeps v1 receipt shape', { concurrency: false }, async () => { + const command = createStudioImageGenerationCommand(baseParams('submit'), 'submit-v2') + let requestHeaders: Headers | Record | undefined + let requestBody: Record | undefined + globalThis.fetch = (async (_input, init) => { + requestHeaders = init?.headers as Record + requestBody = bodyOf(init) + assert.deepEqual(pendingImageGenerationCommands(), [command]) + return response(queuedReceipt(command)) + }) as typeof fetch + + const receipt = await submitImageGenerationCommand(command, { + submissionContext: { + actor: 'wizard', + commandId: command.intent_id, + workflowId: 'workflow-42', + runId: 'run-7', + }, + }) + + assert.deepEqual(requestBody, command) + assert.equal((requestHeaders as Record)['X-Hocus-UI-Surface'], 'wizard') + assert.equal( + (requestHeaders as Record)['X-Hocus-UI-Context'], + JSON.stringify({ workflowId: 'workflow-42', runId: 'run-7' }), + ) + assert.equal(receipt.version, 1) + assert.equal(receipt.commandVersion, 2) + assert.equal(receipt.fingerprintVersion, 2) + assert.equal(receipt.contentFingerprint, 'a'.repeat(64)) + assert.deepEqual(pendingImageGenerationCommands(), []) +}) + +test('a v2 command cannot accept a legacy or partial fingerprint receipt', { concurrency: false }, async () => { + const command = createStudioImageGenerationCommand(baseParams('v2-receipt'), 'v2-receipt') + let calls = 0 + globalThis.fetch = (async () => { + calls += 1 + const legacy = queuedReceipt(command) + delete (legacy.receipt as Record).commandVersion + return response(legacy) + }) as typeof fetch + + await assert.rejects( + submitImageGenerationCommand(command), + error => error instanceof ImageGenerationCommandError + && error.code === 'invalid_receipt' + && error.uncertain, + ) + assert.equal(calls, 1) + assert.deepEqual(pendingImageGenerationCommands(), [command]) + + const partial = createStudioImageGenerationCommand(baseParams('v2-partial'), 'v2-partial') + globalThis.fetch = (async () => { + const receipt = queuedReceipt(partial) + delete (receipt.receipt as Record).fingerprintVersion + return response(receipt) + }) as typeof fetch + await assert.rejects(submitImageGenerationCommand(partial), error => + error instanceof ImageGenerationCommandError && error.code === 'invalid_receipt') + assert.deepEqual(pendingImageGenerationCommands(), [partial, command]) +}) + +test('invalid UI context fails before the pending hint or POST and never truncates IDs', { concurrency: false }, async () => { + const command = createStudioImageGenerationCommand(baseParams('context-invalid'), 'context-invalid') + let calls = 0 + globalThis.fetch = (async () => { + calls += 1 + return response(queuedReceipt(command)) + }) as typeof fetch + + await assert.rejects( + submitImageGenerationCommand(command, { + submissionContext: { actor: 'wizard', workflowId: 'x'.repeat(201) }, + }), + error => error instanceof ImageGenerationCommandError + && error.code === 'invalid_submission_context' + && !error.uncertain, + ) + assert.equal(calls, 0) + assert.deepEqual(pendingImageGenerationCommands(), []) +}) + +test('a context snapshot write failure leaves no admission hint or POST', { concurrency: false }, async () => { + const command = createStudioImageGenerationCommand(baseParams('context-storage-failure'), 'context-storage-failure') + let calls = 0 + globalThis.fetch = (async () => { + calls += 1 + return response(queuedReceipt(command)) + }) as typeof fetch + const storagePrototype = Object.getPrototypeOf(dom.window.localStorage) as Storage + const originalSetItem = storagePrototype.setItem + Object.defineProperty(storagePrototype, 'setItem', { + configurable: true, + value: (key: string, value: string) => { + if (key.includes('image-command-context')) throw new Error('context quota exhausted') + return originalSetItem.call(dom.window.localStorage, key, value) + }, + }) + try { + await assert.rejects( + submitImageGenerationCommand(command, { submissionContext: { actor: 'wizard' } }), + error => error instanceof ImageGenerationCommandError + && error.code === 'pending_storage_failed' + && !error.uncertain, + ) + assert.equal(calls, 0) + assert.deepEqual(pendingImageGenerationCommands(), []) + assert.equal(localStorage.length, 0) + } finally { + Object.defineProperty(storagePrototype, 'setItem', { + configurable: true, + value: originalSetItem, + }) + } +}) + +test('an uncertain retry reuses the persisted UI context after the caller reloads', { concurrency: false }, async () => { + const command = createStudioImageGenerationCommand(baseParams('context-recovery'), 'context-recovery') + let calls = 0 + let retryHeaders: Record | undefined + globalThis.fetch = (async (_input, init) => { + calls += 1 + if (calls === 2) retryHeaders = init?.headers as Record + if (calls === 1) throw new Error('response lost after admission') + return response(queuedReceipt(command)) + }) as typeof fetch + + await assert.rejects(submitImageGenerationCommand(command, { + submissionContext: { + actor: 'wizard', workflowId: 'workflow-reload', runId: 'run-reload', + }, + })) + // A real reload reconstructs the command from storage and has no in-memory + // context object. The stored attribution must still be sent on the retry. + await submitImageGenerationCommand(command) + assert.equal(retryHeaders?.['X-Hocus-UI-Surface'], 'wizard') + assert.equal( + retryHeaders?.['X-Hocus-UI-Context'], + JSON.stringify({ workflowId: 'workflow-reload', runId: 'run-reload' }), + ) + assert.equal(localStorage.length, 0) +}) + +test('a recovery cannot overwrite its original UI attribution', { concurrency: false }, async () => { + const command = createStudioImageGenerationCommand(baseParams('context-conflict'), 'context-conflict') + let calls = 0 + globalThis.fetch = (async () => { + calls += 1 + throw new Error('response lost after admission') + }) as typeof fetch + await assert.rejects(submitImageGenerationCommand(command, { + submissionContext: { actor: 'wizard', workflowId: 'workflow-original' }, + })) + + await assert.rejects( + submitImageGenerationCommand(command, { + submissionContext: { actor: 'user', workflowId: 'workflow-replacement' }, + }), + error => error instanceof ImageGenerationCommandError + && error.code === 'submission_context_conflict' + && error.uncertain, + ) + assert.equal(calls, 1) + assert.deepEqual(pendingImageGenerationCommands(), [command]) + assert.equal(localStorage.length, 2) +}) + +test('receipt recovery uses the stored v2 version when validating a GET', { concurrency: false }, async () => { + const command = createStudioImageGenerationCommand(baseParams('v2-get'), 'v2-get') + globalThis.fetch = (async () => { throw new Error('response lost') }) as typeof fetch + await assert.rejects(submitImageGenerationCommand(command)) + assert.deepEqual(pendingImageGenerationCommands(), [command]) + + globalThis.fetch = (async input => { + assert.match(String(input), /\/receipt\?workspace=workspace_v2&intent_id=v2-get$/) + const legacy = queuedReceipt(command) + delete (legacy.receipt as Record).commandVersion + return response(legacy) + }) as typeof fetch + await assert.rejects( + fetchImageGenerationCommandReceipt('workspace_v2', 'v2-get'), + error => error instanceof ImageGenerationCommandError + && error.code === 'invalid_receipt' + && error.uncertain, + ) + assert.deepEqual(pendingImageGenerationCommands(), [command]) +}) + +test('snapshot hook sees durable pending state but cannot mutate the posted copy', { concurrency: false }, async () => { + const command = createStudioImageGenerationCommand(baseParams('hook'), 'hook-v2') + let requestBody: Record | undefined + let hookSawPending = false + globalThis.fetch = (async (_input, init) => { + requestBody = bodyOf(init) + return response(queuedReceipt(command)) + }) as typeof fetch + + await submitImageGenerationCommand(command, { + onSnapshotReady: async snapshot => { + hookSawPending = pendingImageGenerationCommands().length === 1 + snapshot.input.params.prompt = 'hook mutation must not escape' + await Promise.resolve() + }, + }) + + assert.equal(hookSawPending, true) + assert.equal((requestBody?.input as Record).params + && ((requestBody?.input as Record).params as Record).prompt, + 'literal line one\nliteral line two') + assert.deepEqual(pendingImageGenerationCommands(), []) +}) + +test('a new snapshot hook failure is certain, makes no POST, and removes its new hint', { concurrency: false }, async () => { + const command = createStudioImageGenerationCommand(baseParams('hook-failure'), 'hook-failure-v2') + let calls = 0 + globalThis.fetch = (async () => { + calls += 1 + return response(queuedReceipt(command)) + }) as typeof fetch + + await assert.rejects( + submitImageGenerationCommand(command, { + onSnapshotReady: () => { throw new Error('panel closed') }, + }), + error => error instanceof ImageGenerationCommandError + && error.code === 'snapshot_hook_failed' + && !error.uncertain, + ) + assert.equal(calls, 0) + assert.deepEqual(pendingImageGenerationCommands(), []) +}) + +test('a recovery hook failure preserves the uncertain hint until the same intention succeeds', { concurrency: false }, async () => { + const command = createStudioImageGenerationCommand(baseParams('recovery-hook'), 'recovery-hook-v2') + let calls = 0 + globalThis.fetch = (async () => { + calls += 1 + if (calls === 1) throw new Error('response lost after admission') + return response(queuedReceipt(command)) + }) as typeof fetch + + await assert.rejects(submitImageGenerationCommand(command), error => error instanceof ImageGenerationCommandError && error.uncertain) + await assert.rejects( + submitImageGenerationCommand(command, { onSnapshotReady: () => { throw new Error('panel unavailable') } }), + error => error instanceof ImageGenerationCommandError + && error.code === 'snapshot_hook_failed' + && !error.uncertain, + ) + assert.equal(calls, 1) + assert.deepEqual(pendingImageGenerationCommands(), [command]) + + const receipt = await submitImageGenerationCommand(command) + assert.equal(receipt.replayed, false) + assert.equal(calls, 2) + assert.deepEqual(pendingImageGenerationCommands(), []) +}) + +test('a changed v2 command cannot reuse an uncertain intention', { concurrency: false }, async () => { + const original = createStudioImageGenerationCommand(baseParams('conflict'), 'same-v2-intent') + const changedParams = baseParams('conflict') + changedParams.prompt = 'changed literal' + const changed = createStudioImageGenerationCommand(changedParams, 'same-v2-intent') + let calls = 0 + globalThis.fetch = (async () => { + calls += 1 + throw new Error('transport lost') + }) as typeof fetch + + await assert.rejects(submitImageGenerationCommand(original)) + await assert.rejects( + submitImageGenerationCommand(changed), + error => error instanceof ImageGenerationCommandError && error.code === 'intent_conflict', + ) + assert.equal(calls, 1) + assert.deepEqual(pendingImageGenerationCommands(), [original]) +}) + +test('unsafe JSON and unsafe nested settings never cross the v2 boundary', { concurrency: false }, () => { + const nonFinite = baseParams('unsafe-number') + nonFinite.top_p = Number.NaN + assert.throws(() => createStudioImageGenerationCommand(nonFinite, 'unsafe-number'), /finito|finite|JSON|number/i) + + const cyclicSettings = baseParams('unsafe-cycle') + const settings = cyclicSettings.custom_settings as Record + const cycle: Record = {} + cycle.self = cycle + settings.noise_scale_start = cycle + assert.throws(() => createStudioImageGenerationCommand(cyclicSettings, 'unsafe-cycle'), /circular|cycle|JSON|nested/i) + + const unknownSettings = baseParams('unsafe-settings') + unknownSettings.custom_settings = { provider_payload: 'must reject' } + assert.throws(() => createStudioImageGenerationCommand(unknownSettings, 'unsafe-settings'), /not supported/) +}) diff --git a/ui/tests/studioSubmissionFailure.test.ts b/ui/tests/studioSubmissionFailure.test.ts new file mode 100644 index 000000000..98dd84afe --- /dev/null +++ b/ui/tests/studioSubmissionFailure.test.ts @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { prepareStudioSubmission } from '../src/features/studio/studioSubmission.ts' +import { prepareStudioSubmission as prepareImage, translateLegacyImageGuides } from '../src/features/studio/imageCommandSubmission.ts' + +const originalFetch = globalThis.fetch +test.afterEach(() => { globalThis.fetch = originalFetch }) + +function state(mode: string, params: Record) { + return { generationMode: mode, params, activeWorkspace: 'command-qa', imageRefs: [] } as Parameters[1] +} + +test('a missing image chunk produces a failed submission for the existing job error path', async () => { + const params = { prompt: 'Keep my request', workspace: 'command-qa' } + const before = state('image', params) + const failure = new Error('Failed to fetch dynamically imported module') + let requests = 0 + globalThis.fetch = async () => { requests += 1; throw new Error('Unexpected POST') } + const submission = await prepareStudioSubmission(params, before, () => before, undefined, [], async () => { throw failure }) + assert.deepEqual(submission.params, params) + await assert.rejects(submission.submit, error => error === failure) + assert.equal(requests, 0) +}) + +for (const mode of ['audio', 'video']) { + test(`${mode} submits through its native API without loading image code`, async () => { + const params = { prompt: 'literal\nsecond line', generation_mode: mode, workspace: 'command-qa' } + const before = state(mode, params) + let loads = 0 + let sent: unknown + globalThis.fetch = async (_url, options) => { + sent = JSON.parse(String(options?.body)) + return new Response(JSON.stringify({ job_id: 'native-job', status: 'queued' }), { status: 200 }) + } + const submission = await prepareStudioSubmission(params, before, () => before, undefined, [], async () => { + loads += 1 + throw new Error('Image chunk unavailable') + }) + const result = await submission.submit() + assert.equal(result.job_id, 'native-job') + assert.equal(loads, 0) + assert.deepEqual(sent, params) + }) +} + +test('legacy image control and mask fields are translated in the detached V2 snapshot', async () => { + const params = { workspace: 'command-qa', prompt: 'Use this control image literally', model_type: 'pi_flux2', + resolution: '512x512', num_inference_steps: 4, seed: 42, guidance_scale: 1, + video_guide: '/api/v1/uploads/control.png', video_mask: '/api/v1/uploads/mask.png', video_prompt_type: 'VA' } + const before = state('image', params) + let resolutions = 0 + globalThis.fetch = async (url, options) => { + assert.match(String(url), /generation\/commands\/references$/) + resolutions += 1 + const body = JSON.parse(String(options?.body)) + return new Response(JSON.stringify({ references: body.references }), { status: 200 }) + } + const submission = await prepareImage(params, before, () => before) + assert.equal(submission.params.image_guide, params.video_guide) + assert.equal(submission.params.image_mask, params.video_mask) + assert.equal(submission.params.video_guide, undefined) + assert.equal(submission.params.video_mask, undefined) + assert.equal(params.video_guide, '/api/v1/uploads/control.png') + assert.equal(resolutions, 1) +}) + +test('Load Settings leftovers do not block image command preparation', async () => { + const params = { + workspace: 'command-qa', prompt: 'Reroll this image literally', model_type: 'pi_flux2', + resolution: '512x512', num_inference_steps: 4, seed: 42, guidance_scale: 1, + image_mode: 1, video_length: 1, generation_mode: 'image', + minimax_h3_planning_style: 'faithful', minimax_h3_audio_policy: 'native', + duration_seconds: 0, perturbation_switch: 0, perturbation_layers: [9], stg_scale: 1, + speakers_locations: '0:45 55:100', voice_clone_enabled: true, + } + const before = state('image', params) + const submission = await prepareImage(params, before, () => before) + assert.equal(submission.params.prompt, params.prompt) + assert.equal(submission.params.workspace, params.workspace) + assert.equal(submission.params.minimax_h3_planning_style, undefined) + assert.equal(submission.params.duration_seconds, undefined) + assert.equal(submission.params.voice_clone_enabled, undefined) +}) + +test('an unreviewed native field still fails image preparation instead of being silently omitted', async () => { + const params = { + workspace: 'command-qa', prompt: 'Reject a typo literally', model_type: 'pi_flux2', + resolution: '512x512', num_inference_steps: 4, seed: 42, guidance_scale: 1, + image_mode: 1, video_length: 1, generation_mode: 'image', guidance_scal: 1, + } + const before = state('image', params) + const submission = await prepareImage(params, before, () => before) + await assert.rejects(submission.submit, /input\.params\.guidance_scal is not supported/) +}) + +test('two different legacy and image guides fail instead of silently discarding one', () => { + const params = { video_guide: '/api/v1/uploads/one.png', image_guide: '/api/v1/uploads/two.png' } + assert.throws(() => translateLegacyImageGuides(params)) + assert.equal(params.video_guide, '/api/v1/uploads/one.png') + assert.equal(params.image_guide, '/api/v1/uploads/two.png') +}) diff --git a/ui/tests/wizardImageReceiptResult.test.ts b/ui/tests/wizardImageReceiptResult.test.ts new file mode 100644 index 000000000..b376b6661 --- /dev/null +++ b/ui/tests/wizardImageReceiptResult.test.ts @@ -0,0 +1,144 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { JSDOM } from 'jsdom' + +// Import the store and application adapters only after a browser-shaped global +// exists. This keeps the test on the same navigation/store seams used by the +// Wizard without rendering the whole application or contacting a provider. +const dom = new JSDOM('', { url: 'http://localhost/' }) +Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + Event: dom.window.Event, + CustomEvent: dom.window.CustomEvent, + localStorage: dom.window.localStorage, +}) +window.matchMedia = () => ({ matches: false }) as MediaQueryList + +const { useStore } = await import('../src/stores/useStore.ts') +const { createDefaultApplicationAdapters } = await import('../src/features/agent/applicationAdapters.ts') +const { resolveAndRunRegisteredCapability } = await import('../src/features/agent/capabilityRunner.ts') + +const WORKSPACE = 'wizard-image-receipt-result-test' + +function queuedReceipt(commandId: string, taskId: string) { + return { + version: 1 as const, + commandId, + operation: 'generation.image' as const, + status: 'queued' as const, + entities: [], + artifacts: [], + taskIds: [taskId], + pipelineIds: [], + result: { + job_id: `job-${taskId}`, + task_id: taskId, + workspace: WORKSPACE, + status: 'queued' as const, + }, + commandVersion: 2 as const, + fingerprintVersion: 2 as const, + contentFingerprint: 'b'.repeat(64), + } +} + +function availability() { + return { + location: { tab: 'studio' }, + labs: { + story: { project_id: '' }, + series: { series_id: '', episode_id: '', shots: 0, approved: 0 }, + }, + } +} + +function snapshotStore() { + const state = useStore.getState() + return { + generationMode: state.generationMode, + activeWorkspace: state.activeWorkspace, + sidebarMode: state.sidebarMode, + sidebarOpen: state.sidebarOpen, + settingsOpen: state.settingsOpen, + dashboardOpen: state.dashboardOpen, + startGeneration: state.startGeneration, + setSidebarOpen: state.setSidebarOpen, + } +} + +function installAdmittedGeneration( + receipt: ReturnType, + options: { navigationVisible: boolean }, +): { calls: () => number; restore: () => void } { + const before = snapshotStore() + let calls = 0 + useStore.setState({ + generationMode: 'image', + activeWorkspace: WORKSPACE, + sidebarMode: 'studio', + sidebarOpen: options.navigationVisible, + settingsOpen: false, + dashboardOpen: false, + startGeneration: async () => { + calls += 1 + return receipt + }, + ...(options.navigationVisible ? {} : { setSidebarOpen: () => undefined }), + }) + return { + calls: () => calls, + restore: () => { useStore.setState(before) }, + } +} + +async function runStartGeneration() { + return resolveAndRunRegisteredCapability('start_generation', { + type: 'start_generation', confirm: true, + }, { + workspace: WORKSPACE, + adapters: createDefaultApplicationAdapters(), + availability: availability(), + }) +} + +test.afterEach(() => { + document.body.replaceChildren() +}) + +test('Wizard result keeps the admitted V2 receipt through the real Studio adapter and runner', { concurrency: false }, async () => { + const receipt = queuedReceipt('receipt-visible', 'task-receipt-visible') + const generation = installAdmittedGeneration(receipt, { navigationVisible: true }) + try { + const result = await runStartGeneration() + assert.ok(result) + assert.equal(generation.calls(), 1) + assert.equal(result.commandResult?.status, 'queued') + assert.equal(result.commandResult?.taskIds[0], receipt.result.task_id) + assert.equal(result.report?.metadata?.commandId, receipt.commandId) + assert.deepEqual(result.report?.metadata?.receipt, receipt) + assert.equal(result.report?.metadata?.presentationWarning, undefined) + } finally { + generation.restore() + } +}) + +test('Wizard result keeps the receipt and presentation warning when Studio cannot show the admitted task', { concurrency: false }, async () => { + const receipt = queuedReceipt('receipt-warning', 'task-receipt-warning') + const generation = installAdmittedGeneration(receipt, { navigationVisible: false }) + try { + const result = await runStartGeneration() + assert.ok(result) + assert.equal(generation.calls(), 1) + assert.equal(result.commandResult?.status, 'queued') + assert.deepEqual(result.report?.metadata?.receipt, receipt) + assert.equal(result.report?.metadata?.commandId, receipt.commandId) + const warning = result.report?.metadata?.presentationWarning + assert.equal(typeof warning, 'string') + assert.ok(warning) + assert.equal(result.report?.message, warning) + } finally { + generation.restore() + } +})