Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion decart/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
QueueResultError,
TokenCreateError,
)
from .models import models, ModelDefinition, VideoRestyleInput
from .models import models, ModelDefinition, CustomModelDefinition, VideoRestyleInput
from .types import FileInput, ModelState, Prompt
from .queue import (
QueueClient,
Expand Down Expand Up @@ -69,6 +69,7 @@
"QueueResultError",
"models",
"ModelDefinition",
"CustomModelDefinition",
"VideoRestyleInput",
"FileInput",
"ModelState",
Expand Down
67 changes: 33 additions & 34 deletions decart/client.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import os
from types import TracebackType
from typing import Any, Optional
import aiohttp
from pydantic import ValidationError
from .errors import InvalidAPIKeyError, InvalidBaseURLError, InvalidInputError
from .models import ImageModelDefinition, _MODELS
from .models import ModelDefinition
from .process.request import send_request
from .queue.client import QueueClient
from .tokens.client import TokensClient
Expand Down Expand Up @@ -77,8 +78,7 @@ def __init__(
@property
def queue(self) -> QueueClient:
"""
Queue client for async video editing jobs.
Only video models support the queue API.
Queue client for async jobs.

Example:
```python
Expand Down Expand Up @@ -128,47 +128,43 @@ async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()

async def __aenter__(self):
async def __aenter__(self) -> "DecartClient":
"""Async context manager entry."""
return self

async def __aexit__(self, exc_type, exc_val, exc_tb):
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
"""Async context manager exit."""
await self.close()

async def process(self, options: dict[str, Any]) -> bytes:
"""
Process image editing synchronously.
Only image models support the process API.
Process synchronously using the model definition's configured endpoint.

For video editing, use the queue API instead:
result = await client.queue.submit_and_poll({...})

Args:
options: Processing options including model and inputs
- model: ImageModelDefinition from models.image()
- model: ModelDefinition from models.image() or constructed directly
- prompt: Text instructions describing the requested edit
- Additional model-specific inputs

Returns:
Generated/transformed image as bytes

Raises:
InvalidInputError: If inputs are invalid or model is not an image model
InvalidInputError: If inputs are invalid
ProcessingError: If processing fails
"""
if "model" not in options:
raise InvalidInputError("model is required")

model: ImageModelDefinition = options["model"]

# Validate that this is an image model (check against registry)
if model.name not in _MODELS["image"]:
raise InvalidInputError(
f"Model '{model.name}' is not supported by process(). "
f"Only image models support sync processing. "
f"For video models, use client.queue.submit_and_poll() instead."
)
model: ModelDefinition[str] = options["model"]

cancel_token = options.get("cancel_token")

Expand All @@ -181,22 +177,25 @@ async def process(self, options: dict[str, Any]) -> bytes:
file_inputs = {k: v for k, v in inputs.items() if k in FILE_FIELDS}
non_file_inputs = {k: v for k, v in inputs.items() if k not in FILE_FIELDS}

# Validate non-file inputs and create placeholder for file fields
validation_inputs = {
**non_file_inputs,
**{k: b"" for k in file_inputs.keys()}, # Placeholder bytes for validation
}

try:
validated_inputs = model.input_schema(**validation_inputs)
except ValidationError as e:
raise InvalidInputError(f"Invalid inputs for {model.name}: {str(e)}") from e

# Build final inputs: validated non-file inputs + original file inputs
processed_inputs = {
**validated_inputs.model_dump(exclude_none=True),
**file_inputs, # Override placeholders with actual file data
}
if model.input_schema is None:
processed_inputs = {k: v for k, v in inputs.items() if v is not None}
else:
# Validate non-file inputs and create placeholder for file fields
validation_inputs = {
**non_file_inputs,
**{k: b"" for k in file_inputs.keys()}, # Placeholder bytes for validation
}

try:
validated_inputs = model.input_schema(**validation_inputs)
except ValidationError as e:
raise InvalidInputError(f"Invalid inputs for {model.name}: {str(e)}") from e

# Build final inputs: validated non-file inputs + original file inputs
processed_inputs = {
**validated_inputs.model_dump(exclude_none=True),
**file_inputs, # Override placeholders with actual file data
}

session = await self._get_session()
response = await send_request(
Expand Down
19 changes: 4 additions & 15 deletions decart/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from .errors import ModelNotFoundError
from .types import FileInput, MotionTrajectoryInput


RealTimeModels = Literal[
# Canonical names
"lucy",
Expand Down Expand Up @@ -92,7 +91,7 @@ class ModelDefinition(DecartBaseModel, Generic[ModelT]):
fps: int = Field(ge=1)
width: int = Field(ge=1)
height: int = Field(ge=1)
input_schema: type[BaseModel]
input_schema: Optional[type[BaseModel]] = None


# Type aliases for model definitions that support specific APIs
Expand All @@ -105,6 +104,9 @@ class ModelDefinition(DecartBaseModel, Generic[ModelT]):
RealTimeModelDefinition = ModelDefinition[RealTimeModels]
"""Type alias for model definitions that support realtime streaming."""

CustomModelDefinition = ModelDefinition[str]
"""Type alias for model definitions with arbitrary (non-registry) model names."""


class VideoToVideoInput(DecartBaseModel):
prompt: str = Field(
Expand Down Expand Up @@ -193,47 +195,41 @@ class ImageToImageInput(DecartBaseModel):
fps=25,
width=1280,
height=704,
input_schema=BaseModel,
),
"lucy-2.1": ModelDefinition(
name="lucy-2.1",
url_path="/v1/stream",
fps=20,
width=1088,
height=624,
input_schema=BaseModel,
),
"lucy-2.1-vton": ModelDefinition(
name="lucy-2.1-vton",
url_path="/v1/stream",
fps=20,
width=1088,
height=624,
input_schema=BaseModel,
),
"lucy-restyle": ModelDefinition(
name="lucy-restyle",
url_path="/v1/stream",
fps=25,
width=1280,
height=704,
input_schema=BaseModel,
),
"lucy-restyle-2": ModelDefinition(
name="lucy-restyle-2",
url_path="/v1/stream",
fps=22,
width=1280,
height=704,
input_schema=BaseModel,
),
"live-avatar": ModelDefinition(
name="live-avatar",
url_path="/v1/stream",
fps=25,
width=1280,
height=720,
input_schema=BaseModel,
),
# Latest aliases (server-side resolution)
"lucy-latest": ModelDefinition(
Expand All @@ -242,23 +238,20 @@ class ImageToImageInput(DecartBaseModel):
fps=20,
width=1088,
height=624,
input_schema=BaseModel,
),
"lucy-vton-latest": ModelDefinition(
name="lucy-vton-latest",
url_path="/v1/stream",
fps=20,
width=1088,
height=624,
input_schema=BaseModel,
),
"lucy-restyle-latest": ModelDefinition(
name="lucy-restyle-latest",
url_path="/v1/stream",
fps=22,
width=1280,
height=704,
input_schema=BaseModel,
),
# Deprecated names
"mirage": ModelDefinition(
Expand All @@ -267,31 +260,27 @@ class ImageToImageInput(DecartBaseModel):
fps=25,
width=1280,
height=704,
input_schema=BaseModel,
),
"mirage_v2": ModelDefinition(
name="mirage_v2",
url_path="/v1/stream",
fps=22,
width=1280,
height=704,
input_schema=BaseModel,
),
"lucy_v2v_720p_rt": ModelDefinition(
name="lucy_v2v_720p_rt",
url_path="/v1/stream",
fps=25,
width=1280,
height=704,
input_schema=BaseModel,
),
"live_avatar": ModelDefinition(
name="live_avatar",
url_path="/v1/stream",
fps=25,
width=1280,
height=720,
input_schema=BaseModel,
),
},
"video": {
Expand Down
2 changes: 1 addition & 1 deletion decart/process/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ async def send_request(
session: aiohttp.ClientSession,
base_url: str,
api_key: str,
model: ModelDefinition,
model: ModelDefinition[str],
inputs: dict[str, Any],
cancel_token: Optional[asyncio.Event] = None,
integration: Optional[str] = None,
Expand Down
57 changes: 25 additions & 32 deletions decart/queue/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import aiohttp
from pydantic import ValidationError

from ..models import VideoModelDefinition, _MODELS
from ..models import ModelDefinition
from ..errors import InvalidInputError
from .request import submit_job, get_job_status, get_job_content
from .types import (
Expand All @@ -25,8 +25,7 @@

class QueueClient:
"""
Queue client for async job-based video editing.
Only video models support the queue API.
Queue client for async jobs.

Jobs are submitted and processed asynchronously, allowing you to
poll for status and retrieve results when ready.
Expand Down Expand Up @@ -62,35 +61,26 @@ async def _get_session(self) -> aiohttp.ClientSession:

async def submit(self, options: dict[str, Any]) -> JobSubmitResponse:
"""
Submit a video editing job to the queue for async processing.
Only video models are supported.
Submit an async queue job.
Returns immediately with job_id and initial status.

Args:
options: Submit options including model and inputs
- model: VideoModelDefinition from models.video()
- model: VideoModelDefinition from models.video(), or a custom ModelDefinition
- prompt: Text instructions describing the requested edit
- Additional model-specific inputs

Returns:
JobSubmitResponse with job_id and status

Raises:
InvalidInputError: If inputs are invalid or model is not a video model
InvalidInputError: If inputs are invalid
QueueSubmitError: If submission fails
"""
if "model" not in options:
raise InvalidInputError("model is required")

model: VideoModelDefinition = options["model"]

# Validate that this is a video model (check against registry)
if model.name not in _MODELS["video"]:
raise InvalidInputError(
f"Model '{model.name}' is not supported by queue API. "
f"Only video models support async queue processing. "
f"For image models, use client.process() instead."
)
model: ModelDefinition[str] = options["model"]

inputs = {k: v for k, v in options.items() if k not in ("model", "cancel_token")}

Expand All @@ -101,22 +91,25 @@ async def submit(self, options: dict[str, Any]) -> JobSubmitResponse:
file_inputs = {k: v for k, v in inputs.items() if k in FILE_FIELDS}
non_file_inputs = {k: v for k, v in inputs.items() if k not in FILE_FIELDS}

# Validate non-file inputs
validation_inputs = {
**non_file_inputs,
**{k: b"" for k in file_inputs.keys()},
}

try:
validated_inputs = model.input_schema(**validation_inputs)
except ValidationError as e:
raise InvalidInputError(f"Invalid inputs for {model.name}: {str(e)}") from e

# Build final inputs
processed_inputs = {
**validated_inputs.model_dump(exclude_none=True),
**file_inputs,
}
if model.input_schema is None:
processed_inputs = {k: v for k, v in inputs.items() if v is not None}
else:
# Validate non-file inputs
validation_inputs = {
**non_file_inputs,
**{k: b"" for k in file_inputs.keys()},
}

try:
validated_inputs = model.input_schema(**validation_inputs)
except ValidationError as e:
raise InvalidInputError(f"Invalid inputs for {model.name}: {str(e)}") from e

# Build final inputs
processed_inputs = {
**validated_inputs.model_dump(exclude_none=True),
**file_inputs,
}

session = await self._get_session()
return await submit_job(
Expand Down
Loading
Loading