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 {String(params.prompt)} {error}