Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ LTX Desktop collects minimal, anonymous usage analytics (app version, platform,
- [`INSTALLER.md`](docs/INSTALLER.md) — building installers
- [`TELEMETRY.md`](docs/TELEMETRY.md) — telemetry and privacy
- [`backend/architecture.md`](backend/architecture.md) — backend architecture
- [`docs/REMOTE_BACKEND.md`](docs/REMOTE_BACKEND.md) — experimental remote/headless backend setup

## Contributing

Expand Down
8 changes: 7 additions & 1 deletion backend/_routes/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,13 @@ def _shutdown_process() -> None:


@router.post("/api/system/shutdown")
def route_shutdown(background_tasks: BackgroundTasks, request: Request) -> dict[str, str]:
def route_shutdown(
background_tasks: BackgroundTasks,
request: Request,
handler: AppHandler = Depends(get_state_service),
) -> dict[str, str]:
if handler.config.deployment_mode == "standalone":
raise HTTPException(status_code=403, detail="Shutdown is disabled in standalone mode")
client_host = request.client.host if request.client else None
if client_host not in {"127.0.0.1", "::1", "localhost"}:
raise HTTPException(status_code=403, detail="Forbidden")
Expand Down
59 changes: 59 additions & 0 deletions backend/_routes/media.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Opaque media upload and generated artifact routes."""

from __future__ import annotations

from typing import Annotated

from fastapi import APIRouter, Depends, File, Form, UploadFile
from fastapi.responses import FileResponse

from api_types import MediaRef, MediaType, StatusResponse
from app_handler import AppHandler
from state import get_state_service

router = APIRouter(prefix="/api", tags=["media"])


@router.post("/media", response_model=MediaRef)
def route_upload_media(
file: Annotated[UploadFile, File(...)],
media_type: Annotated[MediaType, Form(...)],
handler: AppHandler = Depends(get_state_service),
) -> MediaRef:
return handler.media.upload(
file.file,
filename=file.filename or "media",
media_type=media_type,
)


@router.delete("/media/{media_id}", response_model=StatusResponse)
def route_delete_media(
media_id: str,
handler: AppHandler = Depends(get_state_service),
) -> StatusResponse:
handler.media.delete_upload(media_id)
return StatusResponse(status="ok")


@router.get("/artifacts/{artifact_id}", response_class=FileResponse)
def route_download_artifact(
artifact_id: str,
handler: AppHandler = Depends(get_state_service),
) -> FileResponse:
artifact = handler.media.resolve_artifact(artifact_id)
return FileResponse(
path=artifact.path,
media_type=artifact.content_type,
filename=artifact.filename,
headers={"X-Content-Type-Options": "nosniff"},
)


@router.delete("/artifacts/{artifact_id}", response_model=StatusResponse)
def route_delete_artifact(
artifact_id: str,
handler: AppHandler = Depends(get_state_service),
) -> StatusResponse:
handler.media.delete_artifact(artifact_id)
return StatusResponse(status="ok")
23 changes: 23 additions & 0 deletions backend/_routes/server_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Remote-backend capability handshake."""

from __future__ import annotations

from fastapi import APIRouter, Depends

from api_types import ServerCapabilities, ServerInfoResponse
from app_handler import AppHandler
from state import get_state_service

router = APIRouter(prefix="/api", tags=["server"])


@router.get("/server-info", response_model=ServerInfoResponse)
def route_server_info(handler: AppHandler = Depends(get_state_service)) -> ServerInfoResponse:
config = handler.config
return ServerInfoResponse(
deployment_mode=config.deployment_mode,
capabilities=ServerCapabilities(
legacy_path_inputs=config.allow_legacy_path_inputs,
models_dir_editable=config.models_dir_editable,
),
)
10 changes: 6 additions & 4 deletions backend/api_model_specs.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def _resolution_spec(
(
"fast",
LTXVideoGenerationSpec(
display_name="LTX-2.3 Fast (API)",
display_name="LTX-2.3 Fast (LTX Cloud)",
supported_resolutions_durations={
"1080p": _resolution_spec(
fps_to_durations={
Expand Down Expand Up @@ -73,7 +73,7 @@ def _resolution_spec(
(
"pro",
LTXVideoGenerationSpec(
display_name="LTX-2.3 Pro (API)",
display_name="LTX-2.3 Pro (LTX Cloud)",
supported_resolutions_durations={
"1080p": _resolution_spec(
fps_to_durations={
Expand Down Expand Up @@ -169,15 +169,17 @@ def validate_generate_video_request(
items = get_api_video_generation_model_specs() if use_api_specs else get_local_video_generation_model_specs()
item = next((candidate for candidate in items if candidate.pipeline == req.model), None)
generation_backend = "api" if use_api_specs else "local"
generation_mode = "audio-to-video" if req.audioPath is not None else "image-to-video" if req.imagePath is not None else "text-to-video"
has_audio = req.audioPath is not None or req.audioMediaId is not None
has_image = req.imagePath is not None or req.imageMediaId is not None
generation_mode = "audio-to-video" if has_audio else "image-to-video" if has_image else "text-to-video"

if item is None:
return (
f"Unsupported {generation_backend} video generation pipeline: {req.model}. "
f"Supported pipelines: {', '.join(candidate.pipeline for candidate in items)}"
)

resolution_spec = _get_resolution_spec(item, resolution=req.resolution, is_a2v=req.audioPath is not None)
resolution_spec = _get_resolution_spec(item, resolution=req.resolution, is_a2v=has_audio)
if resolution_spec is None:
return (
f"Unsupported {generation_backend} {generation_mode} resolution '{req.resolution}' "
Expand Down
102 changes: 97 additions & 5 deletions backend/api_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from typing import Annotated
from datetime import datetime
from typing import Literal, NamedTuple, TypeAlias

from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator
Expand Down Expand Up @@ -30,6 +31,7 @@ class ImageConditioningInput(NamedTuple):


JsonObject: TypeAlias = dict[str, object]
MediaType: TypeAlias = Literal["image", "audio", "video"]
VideoCameraMotion = Literal[
"none",
"dolly_in",
Expand Down Expand Up @@ -83,12 +85,48 @@ class RuntimePolicyResponse(BaseModel):
force_api_generations: bool


class ServerCapabilities(BaseModel):
media_ids: Literal[True] = True
artifact_downloads: Literal[True] = True
legacy_path_inputs: bool
models_dir_editable: bool


class ServerInfoResponse(BaseModel):
api_version: Literal[2] = 2
deployment_mode: Literal["managed_local", "standalone"]
capabilities: ServerCapabilities


class MediaRef(BaseModel):
media_id: str
media_type: MediaType
filename: str
content_type: str
size_bytes: int
sha256: str
expires_at: datetime


class ArtifactRef(BaseModel):
artifact_id: str
media_type: MediaType
filename: str
content_type: str
size_bytes: int
sha256: str
expires_at: datetime


class GenerationProgressResponse(BaseModel):
status: Literal["idle", "running", "complete", "cancelled", "error"]
phase: str
progress: int
currentStep: int | None
totalSteps: int | None
generationId: str | None = None
artifacts: list[ArtifactRef] = Field(default_factory=lambda: list[ArtifactRef]())
error: str | None = None


class DownloadProgressRunningResponse(BaseModel):
Expand Down Expand Up @@ -126,6 +164,7 @@ class SuggestGapPromptResponse(BaseModel):
class GenerateVideoCompleteResponse(BaseModel):
status: Literal["complete"]
video_path: str
artifact: ArtifactRef


class GenerateVideoCancelledResponse(BaseModel):
Expand All @@ -138,6 +177,7 @@ class GenerateVideoCancelledResponse(BaseModel):
class GenerateImageCompleteResponse(BaseModel):
status: Literal["complete"]
image_paths: list[str]
artifacts: list[ArtifactRef]


class GenerateImageCancelledResponse(BaseModel):
Expand All @@ -162,6 +202,7 @@ class CancelNoActiveGenerationResponse(BaseModel):
class RetakeVideoResponse(BaseModel):
status: Literal["complete"]
video_path: str
artifact: ArtifactRef


class RetakePayloadResponse(BaseModel):
Expand All @@ -186,6 +227,7 @@ class IcLoraExtractResponse(BaseModel):
class IcLoraGenerateCompleteResponse(BaseModel):
status: Literal["complete"]
video_path: str
artifact: ArtifactRef


class IcLoraGenerateCancelledResponse(BaseModel):
Expand Down Expand Up @@ -308,6 +350,7 @@ class GenerateVideoRequest(BaseModel):
model_config = ConfigDict(strict=True)

prompt: NonEmptyPrompt
generationId: str | None = Field(default=None, min_length=8, max_length=64, pattern=r"^[A-Za-z0-9_-]+$")
resolution: LTXVideoGenResolution = "1080p"
model: LTXVideoGenPipeline = "fast"
cameraMotion: VideoCameraMotion = "none"
Expand All @@ -317,13 +360,24 @@ class GenerateVideoRequest(BaseModel):
audio: bool = False
imagePath: str | None = None
audioPath: str | None = None
imageMediaId: str | None = None
audioMediaId: str | None = None
aspectRatio: Literal["16:9", "9:16"] = "16:9"

@model_validator(mode="after")
def _validate_media_refs(self) -> "GenerateVideoRequest":
if self.imagePath and self.imageMediaId:
raise ValueError("imagePath and imageMediaId are mutually exclusive")
if self.audioPath and self.audioMediaId:
raise ValueError("audioPath and audioMediaId are mutually exclusive")
return self


class GenerateImageRequest(BaseModel):
model_config = ConfigDict(strict=True)

prompt: NonEmptyPrompt
generationId: str | None = Field(default=None, min_length=8, max_length=64, pattern=r"^[A-Za-z0-9_-]+$")
width: int = Field(default=1024, ge=16)
height: int = Field(default=1024, ge=16)
numSteps: int = Field(default=4, ge=1)
Expand Down Expand Up @@ -364,14 +418,24 @@ class SuggestGapPromptRequest(BaseModel):
afterPrompt: str = ""
beforeFrame: str | None = None
afterFrame: str | None = None
beforeFrameMediaId: str | None = None
afterFrameMediaId: str | None = None
gapDuration: float = 5
mode: GapPromptMode = "text-to-video"
inputImage: str | None = None
inputImageMediaId: str | None = None

@model_validator(mode="after")
def _validate_input_image_mode(self) -> "SuggestGapPromptRequest":
if self.inputImage is not None and self.mode != "image-to-video":
if (self.inputImage is not None or self.inputImageMediaId is not None) and self.mode != "image-to-video":
raise ValueError("inputImage is only valid for image-to-video mode")
for path_value, id_value, name in (
(self.beforeFrame, self.beforeFrameMediaId, "beforeFrame"),
(self.afterFrame, self.afterFrameMediaId, "afterFrame"),
(self.inputImage, self.inputImageMediaId, "inputImage"),
):
if path_value and id_value:
raise ValueError(f"{name} path and media ID are mutually exclusive")
return self


Expand All @@ -381,43 +445,71 @@ def _validate_input_image_mode(self) -> "SuggestGapPromptRequest":
class RetakeRequest(BaseModel):
model_config = ConfigDict(strict=True)

video_path: str
video_path: str | None = None
video_media_id: str | None = None
start_time: float
duration: float
prompt: str = ""
mode: RetakeMode = "replace_audio_and_video"

@model_validator(mode="after")
def _validate_video_ref(self) -> "RetakeRequest":
if bool(self.video_path) == bool(self.video_media_id):
raise ValueError("exactly one of video_path or video_media_id is required")
return self


ConditioningType: TypeAlias = Literal["canny", "depth"]


class IcLoraExtractRequest(BaseModel):
model_config = ConfigDict(strict=True)

video_path: str
video_path: str | None = None
video_media_id: str | None = None
conditioning_type: ConditioningType = "canny"
frame_time: float = 0

@model_validator(mode="after")
def _validate_video_ref(self) -> "IcLoraExtractRequest":
if bool(self.video_path) == bool(self.video_media_id):
raise ValueError("exactly one of video_path or video_media_id is required")
return self


class IcLoraImageInput(BaseModel):
model_config = ConfigDict(strict=True)

path: str
path: str | None = None
media_id: str | None = None
frame: int = 0
strength: float = 1.0

@model_validator(mode="after")
def _validate_image_ref(self) -> "IcLoraImageInput":
if bool(self.path) == bool(self.media_id):
raise ValueError("exactly one of path or media_id is required")
return self


def _default_ic_lora_images() -> list[IcLoraImageInput]:
return []


class IcLoraGenerateRequest(BaseModel):
model_config = ConfigDict(strict=True)
video_path: str
video_path: str | None = None
video_media_id: str | None = None
conditioning_type: ConditioningType
prompt: NonEmptyPrompt
conditioning_strength: float = 1.0
num_inference_steps: int = 30
cfg_guidance_scale: float = 1.0
negative_prompt: str = ""
images: list[IcLoraImageInput] = Field(default_factory=_default_ic_lora_images)

@model_validator(mode="after")
def _validate_video_ref(self) -> "IcLoraGenerateRequest":
if bool(self.video_path) == bool(self.video_media_id):
raise ValueError("exactly one of video_path or video_media_id is required")
return self
Loading