From 229084db34a70fdbeaa9b15a1ffa90ff70e4e28b Mon Sep 17 00:00:00 2001 From: luisnomad Date: Mon, 20 Jul 2026 14:19:18 +0200 Subject: [PATCH 1/7] feat: add optional remote backend mode --- README.md | 1 + backend/_routes/health.py | 8 +- backend/_routes/media.py | 59 ++ backend/_routes/server_info.py | 23 + backend/_routes/settings.py | 3 + backend/api_model_specs.py | 6 +- backend/api_types.py | 103 ++- backend/app_factory.py | 21 +- backend/app_handler.py | 31 + backend/handlers/__init__.py | 2 + backend/handlers/base.py | 2 + backend/handlers/generation_handler.py | 26 +- backend/handlers/hf_auth_handler.py | 37 +- backend/handlers/ic_lora_handler.py | 56 +- backend/handlers/image_generation_handler.py | 27 +- backend/handlers/media_handler.py | 173 +++++ backend/handlers/retake_handler.py | 24 +- backend/handlers/settings_handler.py | 5 +- .../handlers/suggest_gap_prompt_handler.py | 32 +- backend/handlers/video_generation_handler.py | 70 +- backend/ltx2_server.py | 42 +- backend/runtime_config/__init__.py | 3 +- backend/runtime_config/runtime_config.py | 10 + backend/runtime_config/server_config.py | 118 ++++ backend/server_utils/media_validation.py | 33 + backend/server_utils/secure_files.py | 32 + backend/services/interfaces.py | 5 + backend/services/media_store/__init__.py | 5 + .../media_store/filesystem_media_store.py | 290 ++++++++ backend/services/media_store/media_store.py | 66 ++ backend/state/app_state_types.py | 3 +- backend/tests/test_auth.py | 20 + backend/tests/test_generation.py | 39 +- backend/tests/test_hf_auth.py | 77 +- backend/tests/test_media.py | 161 +++++ backend/tests/test_server_config.py | 47 ++ backend/tests/test_server_info.py | 26 + backend/tests/test_settings.py | 19 + docs/REMOTE_BACKEND.md | 118 ++++ electron/app-state.ts | 91 ++- electron/backend-media-transfer.ts | 283 ++++++++ electron/csp.ts | 19 +- electron/gpu.ts | 6 +- electron/ipc/app-handlers.ts | 98 ++- electron/python-backend.ts | 215 +++++- frontend/App.tsx | 74 +- .../components/BackendConnectionPanel.tsx | 169 +++++ frontend/components/FirstRunSetup.tsx | 17 +- frontend/components/ICLoraPanel.tsx | 34 +- frontend/components/SettingsModal.tsx | 21 +- frontend/contexts/AppSettingsContext.tsx | 10 +- frontend/generated/backend-openapi.json | 664 +++++++++++++++++- frontend/generated/backend-openapi.ts | 402 ++++++++++- frontend/hooks/use-backend.ts | 32 +- frontend/hooks/use-generation.ts | 201 +++++- frontend/hooks/use-ic-lora.ts | 43 +- frontend/hooks/use-retake.ts | 65 +- frontend/lib/backend-media.ts | 80 +++ frontend/lib/backend.ts | 13 +- .../VideoEditorTimelineEditingPanel.tsx | 40 +- frontend/views/editor/useRegeneration.ts | 14 +- shared/electron-api-schema.ts | 97 ++- 62 files changed, 4229 insertions(+), 282 deletions(-) create mode 100644 backend/_routes/media.py create mode 100644 backend/_routes/server_info.py create mode 100644 backend/handlers/media_handler.py create mode 100644 backend/runtime_config/server_config.py create mode 100644 backend/server_utils/secure_files.py create mode 100644 backend/services/media_store/__init__.py create mode 100644 backend/services/media_store/filesystem_media_store.py create mode 100644 backend/services/media_store/media_store.py create mode 100644 backend/tests/test_media.py create mode 100644 backend/tests/test_server_config.py create mode 100644 backend/tests/test_server_info.py create mode 100644 docs/REMOTE_BACKEND.md create mode 100644 electron/backend-media-transfer.ts create mode 100644 frontend/components/BackendConnectionPanel.tsx create mode 100644 frontend/lib/backend-media.ts diff --git a/README.md b/README.md index c0469cb8a..16281fc97 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/_routes/health.py b/backend/_routes/health.py index b6d8f8a1f..dd4f8cbd3 100644 --- a/backend/_routes/health.py +++ b/backend/_routes/health.py @@ -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") diff --git a/backend/_routes/media.py b/backend/_routes/media.py new file mode 100644 index 000000000..7dfc23649 --- /dev/null +++ b/backend/_routes/media.py @@ -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") diff --git a/backend/_routes/server_info.py b/backend/_routes/server_info.py new file mode 100644 index 000000000..cc6702e64 --- /dev/null +++ b/backend/_routes/server_info.py @@ -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, + ), + ) diff --git a/backend/_routes/settings.py b/backend/_routes/settings.py index 468b9c5b6..486a9e13c 100644 --- a/backend/_routes/settings.py +++ b/backend/_routes/settings.py @@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, Request from _routes._admin_guard import guard_admin_permission +from _routes._errors import HTTPError from state.app_settings import SettingsResponse, UpdateSettingsRequest, to_settings_response from api_types import StatusResponse from state import get_state_service @@ -32,6 +33,8 @@ def route_post_settings( ) -> StatusResponse: patch_data = req.model_dump(exclude_unset=True) if "models_dir" in patch_data or "modelsDir" in patch_data: + if not handler.config.models_dir_editable: + raise HTTPError(409, "MODELS_DIR_MANAGED_BY_SERVER") guard_admin_permission(request) _, _after, changed_paths = handler.settings.update_settings(req) diff --git a/backend/api_model_specs.py b/backend/api_model_specs.py index a7408cf8c..8c1fc00ad 100644 --- a/backend/api_model_specs.py +++ b/backend/api_model_specs.py @@ -169,7 +169,9 @@ 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 ( @@ -177,7 +179,7 @@ def validate_generate_video_request( 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}' " diff --git a/backend/api_types.py b/backend/api_types.py index 31bff4666..037df88ee 100644 --- a/backend/api_types.py +++ b/backend/api_types.py @@ -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 @@ -30,6 +31,7 @@ class ImageConditioningInput(NamedTuple): JsonObject: TypeAlias = dict[str, object] +MediaType: TypeAlias = Literal["image", "audio", "video"] VideoCameraMotion = Literal[ "none", "dolly_in", @@ -83,12 +85,49 @@ 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 + artifact: ArtifactRef | None = None + artifacts: list[ArtifactRef] = Field(default_factory=lambda: list[ArtifactRef]()) + error: str | None = None class DownloadProgressRunningResponse(BaseModel): @@ -126,6 +165,7 @@ class SuggestGapPromptResponse(BaseModel): class GenerateVideoCompleteResponse(BaseModel): status: Literal["complete"] video_path: str + artifact: ArtifactRef class GenerateVideoCancelledResponse(BaseModel): @@ -138,6 +178,7 @@ class GenerateVideoCancelledResponse(BaseModel): class GenerateImageCompleteResponse(BaseModel): status: Literal["complete"] image_paths: list[str] + artifacts: list[ArtifactRef] class GenerateImageCancelledResponse(BaseModel): @@ -162,6 +203,7 @@ class CancelNoActiveGenerationResponse(BaseModel): class RetakeVideoResponse(BaseModel): status: Literal["complete"] video_path: str + artifact: ArtifactRef class RetakePayloadResponse(BaseModel): @@ -186,6 +228,7 @@ class IcLoraExtractResponse(BaseModel): class IcLoraGenerateCompleteResponse(BaseModel): status: Literal["complete"] video_path: str + artifact: ArtifactRef class IcLoraGenerateCancelledResponse(BaseModel): @@ -308,6 +351,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" @@ -317,13 +361,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) @@ -364,14 +419,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 @@ -381,12 +446,19 @@ 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"] @@ -394,18 +466,32 @@ class RetakeRequest(BaseModel): 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 [] @@ -413,7 +499,8 @@ def _default_ic_lora_images() -> list[IcLoraImageInput]: 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 @@ -421,3 +508,9 @@ class IcLoraGenerateRequest(BaseModel): 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 diff --git a/backend/app_factory.py b/backend/app_factory.py index e1bdfa212..cb50446dc 100644 --- a/backend/app_factory.py +++ b/backend/app_factory.py @@ -21,9 +21,11 @@ from _routes.ic_lora import router as ic_lora_router from _routes.image_gen import router as image_gen_router from _routes.models import router as models_router +from _routes.media import router as media_router from _routes.suggest_gap_prompt import router as suggest_gap_prompt_router from _routes.retake import router as retake_router from _routes.runtime_policy import router as runtime_policy_router +from _routes.server_info import router as server_info_router from _routes.settings import router as settings_router from api_types import HTTPErrorResponse from logging_policy import log_http_error, log_unhandled_exception @@ -32,10 +34,7 @@ if TYPE_CHECKING: from app_handler import AppHandler -DEFAULT_ALLOWED_ORIGINS: list[str] = [ - "http://localhost:5173", - "http://127.0.0.1:5173", -] +from runtime_config.server_config import DEFAULT_ALLOWED_ORIGINS DEFAULT_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = { "4XX": { @@ -64,9 +63,10 @@ def create_app( app.state.admin_token = admin_token # type: ignore[attr-defined] app.add_middleware( CORSMiddleware, - allow_origins=allowed_origins or DEFAULT_ALLOWED_ORIGINS, + allow_origins=list(DEFAULT_ALLOWED_ORIGINS) if allowed_origins is None else allowed_origins, allow_methods=["*"], allow_headers=["*"], + expose_headers=["Accept-Ranges", "Content-Disposition", "Content-Length", "Content-Range", "ETag"], ) @app.middleware("http") @@ -78,13 +78,16 @@ async def _auth_middleware( # pyright: ignore[reportUnusedFunction] return await call_next(request) if request.method == "OPTIONS": return await call_next(request) - if request.url.path == "/api/auth/huggingface/callback": + if request.method == "GET" and request.url.path == "/api/auth/huggingface/callback": return await call_next(request) def _token_matches(candidate: str) -> bool: return hmac.compare_digest(candidate, auth_token) # WebSocket: check query param - if request.headers.get("upgrade", "").lower() == "websocket": + if ( + handler.config.deployment_mode == "managed_local" + and request.headers.get("upgrade", "").lower() == "websocket" + ): if _token_matches(request.query_params.get("token", "")): return await call_next(request) return JSONResponse( @@ -95,7 +98,7 @@ def _token_matches(candidate: str) -> bool: auth_header = request.headers.get("authorization", "") if auth_header.startswith("Bearer ") and _token_matches(auth_header[7:]): return await call_next(request) - if auth_header.startswith("Basic "): + if handler.config.deployment_mode == "managed_local" and auth_header.startswith("Basic "): try: decoded = base64.b64decode(auth_header[6:]).decode() _, _, password = decoded.partition(":") @@ -161,5 +164,7 @@ async def _route_generic_error_handler(request: Request, exc: Exception) -> JSON app.include_router(ic_lora_router) app.include_router(runtime_policy_router) app.include_router(hf_auth_router) + app.include_router(server_info_router) + app.include_router(media_router) return app diff --git a/backend/app_handler.py b/backend/app_handler.py index a845e737f..5e23cf4a0 100644 --- a/backend/app_handler.py +++ b/backend/app_handler.py @@ -13,6 +13,7 @@ HuggingFaceAuthHandler, IcLoraHandler, ImageGenerationHandler, + MediaHandler, ModelsHandler, PipelinesHandler, SuggestGapPromptHandler, @@ -34,6 +35,7 @@ HTTPClient, IcLoraPipeline, LTXAPIClient, + MediaStore, ModelDownloader, PoseProcessorPipeline, RetakePipeline, @@ -67,6 +69,7 @@ def __init__( pose_processor_pipeline_class: type[PoseProcessorPipeline], a2v_pipeline_class: type[A2VPipeline], retake_pipeline_class: type[RetakePipeline], + media_store: MediaStore, ) -> None: self.config = config @@ -86,6 +89,7 @@ def __init__( self.pose_processor_pipeline_class = pose_processor_pipeline_class self.a2v_pipeline_class = a2v_pipeline_class self.retake_pipeline_class = retake_pipeline_class + self.media_store = media_store self._lock = threading.RLock() @@ -102,6 +106,14 @@ def __init__( # Handlers (wired in dependency order) # ============================================================ + self.media = MediaHandler( + state=self.state, + lock=self._lock, + config=config, + media_store=media_store, + video_processor=video_processor, + ) + self.settings = SettingsHandler( state=self.state, lock=self._lock, @@ -118,6 +130,7 @@ def __init__( state=self.state, lock=self._lock, config=config, + http=http, ) self.downloads = DownloadHandler( @@ -159,6 +172,7 @@ def __init__( pipelines_handler=self.pipelines, text_handler=self.text, ltx_api_client=ltx_api_client, + media_handler=self.media, config=config, ) @@ -169,6 +183,7 @@ def __init__( pipelines_handler=self.pipelines, config=config, zit_api_client=zit_api_client, + media_handler=self.media, ) self.health = HealthHandler( @@ -186,6 +201,7 @@ def __init__( lock=self._lock, config=config, http=http, + media_handler=self.media, ) self.retake = RetakeHandler( @@ -196,6 +212,7 @@ def __init__( generation_handler=self.generation, pipelines_handler=self.pipelines, text_handler=self.text, + media_handler=self.media, ) self.ic_lora = IcLoraHandler( @@ -205,6 +222,7 @@ def __init__( pipelines_handler=self.pipelines, text_handler=self.text, video_processor=video_processor, + media_handler=self.media, config=config, ) @@ -236,6 +254,7 @@ class ServiceBundle: pose_processor_pipeline_class: type[PoseProcessorPipeline] a2v_pipeline_class: type[A2VPipeline] retake_pipeline_class: type[RetakePipeline] + media_store: MediaStore | None = None def build_default_service_bundle(config: RuntimeConfig) -> ServiceBundle: @@ -256,6 +275,7 @@ def build_default_service_bundle(config: RuntimeConfig) -> ServiceBundle: from services.task_runner.threading_runner import ThreadingRunner from services.text_encoder.ltx_text_encoder import LTXTextEncoder from services.video_processor.video_processor_impl import VideoProcessorImpl + from services.media_store.filesystem_media_store import FilesystemMediaStore http = HTTPClientImpl() @@ -280,6 +300,7 @@ def build_default_service_bundle(config: RuntimeConfig) -> ServiceBundle: pose_processor_pipeline_class=DWPosePipeline, a2v_pipeline_class=LTXa2vPipeline, retake_pipeline_class=LTXRetakePipeline, + media_store=FilesystemMediaStore(app_data_dir=config.app_data_dir, outputs_dir=config.outputs_dir), ) @@ -289,6 +310,15 @@ def build_initial_state( service_bundle: ServiceBundle | None = None, ) -> AppHandler: bundle = service_bundle or build_default_service_bundle(config) + if bundle.media_store is None: + from services.media_store.filesystem_media_store import FilesystemMediaStore + + media_store: MediaStore = FilesystemMediaStore( + app_data_dir=config.app_data_dir, + outputs_dir=config.outputs_dir, + ) + else: + media_store = bundle.media_store return AppHandler( config=config, @@ -309,4 +339,5 @@ def build_initial_state( pose_processor_pipeline_class=bundle.pose_processor_pipeline_class, a2v_pipeline_class=bundle.a2v_pipeline_class, retake_pipeline_class=bundle.retake_pipeline_class, + media_store=media_store, ) diff --git a/backend/handlers/__init__.py b/backend/handlers/__init__.py index 7a93d1d12..93d9a2c6c 100644 --- a/backend/handlers/__init__.py +++ b/backend/handlers/__init__.py @@ -6,6 +6,7 @@ from handlers.health_handler import HealthHandler from handlers.ic_lora_handler import IcLoraHandler from handlers.image_generation_handler import ImageGenerationHandler +from handlers.media_handler import MediaHandler from handlers.models_handler import ModelsHandler from handlers.pipelines_handler import PipelinesHandler from handlers.suggest_gap_prompt_handler import SuggestGapPromptHandler @@ -24,6 +25,7 @@ "GenerationHandler", "VideoGenerationHandler", "ImageGenerationHandler", + "MediaHandler", "HealthHandler", "SuggestGapPromptHandler", "RetakeHandler", diff --git a/backend/handlers/base.py b/backend/handlers/base.py index 8f1ee5470..cd2b04266 100644 --- a/backend/handlers/base.py +++ b/backend/handlers/base.py @@ -41,6 +41,8 @@ def config(self) -> RuntimeConfig: @property def models_dir(self) -> Path: """Effective models dir: custom from settings, or startup default.""" + if self._config.models_dir_override is not None: + return self._config.models_dir_override custom = self._state.app_settings.models_dir return Path(custom) if custom else self._config.default_models_dir diff --git a/backend/handlers/generation_handler.py b/backend/handlers/generation_handler.py index 60163dfc2..51c364aba 100644 --- a/backend/handlers/generation_handler.py +++ b/backend/handlers/generation_handler.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Literal from api_types import ( + ArtifactRef, CancelCancellingResponse, CancelNoActiveGenerationResponse, CancelResponse, @@ -181,13 +182,21 @@ def cancel_generation(self) -> CancelResponse: return CancelNoActiveGenerationResponse(status="no_active_generation") @with_state_lock - def complete_generation(self, result: str | list[str]) -> None: + def complete_generation( + self, + result: str | list[str], + artifacts: ArtifactRef | list[ArtifactRef] | None = None, + ) -> None: running_generation = self._running_generation() if running_generation is None: return slot, running = running_generation - self._set_generation_state(slot, GenerationComplete(id=running.id, result=result)) + artifact_list = [] if artifacts is None else artifacts if isinstance(artifacts, list) else [artifacts] + self._set_generation_state( + slot, + GenerationComplete(id=running.id, result=result, artifacts=artifact_list), + ) @with_state_lock def fail_generation(self, error: str) -> None: @@ -215,30 +224,37 @@ def get_generation_progress(self) -> GenerationProgressResponse: progress=progress.progress, currentStep=progress.current_step, totalSteps=progress.total_steps, + generationId=gen.id, ) - case GenerationComplete(): + case GenerationComplete(id=generation_id, artifacts=artifacts): return GenerationProgressResponse( status="complete", phase="complete", progress=100, currentStep=0, totalSteps=0, + generationId=generation_id, + artifact=artifacts[0] if len(artifacts) == 1 else None, + artifacts=artifacts, ) - case GenerationCancelled(): + case GenerationCancelled(id=generation_id): return GenerationProgressResponse( status="cancelled", phase="cancelled", progress=0, currentStep=0, totalSteps=0, + generationId=generation_id, ) - case GenerationError(): + case GenerationError(id=generation_id, error=error): return GenerationProgressResponse( status="error", phase="error", progress=0, currentStep=0, totalSteps=0, + generationId=generation_id, + error=error, ) case _: return GenerationProgressResponse( diff --git a/backend/handlers/hf_auth_handler.py b/backend/handlers/hf_auth_handler.py index 0db704ffc..0d6ad9c95 100644 --- a/backend/handlers/hf_auth_handler.py +++ b/backend/handlers/hf_auth_handler.py @@ -5,17 +5,19 @@ import base64 import hashlib import hmac +import html import logging import secrets import time from threading import RLock from typing import TYPE_CHECKING -import requests -from pydantic import BaseModel +from pydantic import BaseModel, ValidationError from api_types import HuggingFaceAuthStatusResponse, HuggingFaceLoginResponse, HuggingFaceLogoutResponse from handlers.base import StateHandlerBase, with_state_lock +from services.interfaces import HTTPClient +from server_utils.secure_files import harden_file_permissions, secure_write_text from state.app_state_types import ( AppState, HfAuthenticated, @@ -55,9 +57,15 @@ class _PersistedHfToken(BaseModel): expires_at: float +class _TokenExchangePayload(BaseModel): + access_token: str + expires_in: int = 28800 + + class HuggingFaceAuthHandler(StateHandlerBase): - def __init__(self, state: AppState, lock: RLock, config: RuntimeConfig) -> None: + def __init__(self, state: AppState, lock: RLock, config: RuntimeConfig, http: HTTPClient) -> None: super().__init__(state, lock, config) + self._http = http self._token_file = config.app_data_dir / "hf_auth_token.json" # ============================================================ @@ -81,7 +89,7 @@ def _set_hf_auth_state(self, new_state: HfAuthState) -> None: def _save_token_file(self, data: _PersistedHfToken) -> None: try: - self._token_file.write_text(data.model_dump_json(), encoding="utf-8") + secure_write_text(self._token_file, data.model_dump_json()) except Exception: logger.error("Failed to write HF auth token file") @@ -102,6 +110,7 @@ def load_token(self) -> None: if not self._token_file.exists(): return persisted = _PersistedHfToken.model_validate_json(self._token_file.read_text(encoding="utf-8")) + harden_file_permissions(self._token_file) if persisted.expires_at <= time.time(): self._clear_token_file() return @@ -119,7 +128,7 @@ def load_token(self) -> None: # ============================================================ def _redirect_uri(self) -> str: - return f"http://127.0.0.1:{self.config.backend_port}/api/auth/huggingface/callback" + return f"{self.config.effective_public_base_url}/api/auth/huggingface/callback" @with_state_lock def start_login(self) -> HuggingFaceLoginResponse: @@ -150,7 +159,7 @@ def start_login(self) -> HuggingFaceLoginResponse: def handle_callback(self, code: str, state_param: str, error: str) -> str: """Handle the OAuth callback. Returns an HTML string for the browser.""" if error: - return _ERROR_HTML_TEMPLATE.format(msg=error) + return _ERROR_HTML_TEMPLATE.format(msg=html.escape(error)) if not code or not state_param: return _ERROR_HTML_TEMPLATE.format(msg="Missing code or state parameter") @@ -178,7 +187,7 @@ def _exchange_code(self, code: str, state_param: str) -> bool: # Token exchange — outside lock (network I/O). try: - resp = requests.post( + resp = self._http.post( _HF_TOKEN_URL, data={ "client_id": self.config.hf_oauth_client_id, @@ -201,14 +210,18 @@ def _exchange_code(self, code: str, state_param: str) -> bool: self._set_hf_auth_state(HfNotAuthenticated()) return False - data: dict[str, object] = resp.json() # type: ignore[assignment] - access_token = str(data.get("access_token", "")) - expires_in = int(data.get("expires_in", 28800)) # type: ignore[arg-type] + try: + data = _TokenExchangePayload.model_validate(resp.json()) + except ValidationError: + logger.error("HuggingFace token exchange returned an invalid payload") + with self._lock: + self._set_hf_auth_state(HfNotAuthenticated()) + return False with self._lock: self._set_hf_auth_state(HfAuthenticated( - access_token=access_token, - expires_at=time.time() + expires_in, + access_token=data.access_token, + expires_at=time.time() + data.expires_in, )) return True diff --git a/backend/handlers/ic_lora_handler.py b/backend/handlers/ic_lora_handler.py index 3eec9c9d5..e36f824af 100644 --- a/backend/handlers/ic_lora_handler.py +++ b/backend/handlers/ic_lora_handler.py @@ -24,6 +24,7 @@ from _routes._errors import HTTPError from handlers.base import StateHandlerBase from handlers.generation_handler import GenerationHandler +from handlers.media_handler import MediaHandler from handlers.pipelines_handler import PipelinesHandler from handlers.text_handler import TextHandler from runtime_config.model_download_specs import ( @@ -33,6 +34,7 @@ get_ltx_model_spec, ) from runtime_config.runtime_config import RuntimeConfig +from server_utils.media_validation import validate_image_file from state.conditioning_cache import ConditioningCacheEntry, ConditioningCacheKey from services.interfaces import VideoProcessor from services.services_utils import FrameArray @@ -53,6 +55,7 @@ def __init__( pipelines_handler: PipelinesHandler, text_handler: TextHandler, video_processor: VideoProcessor, + media_handler: MediaHandler, config: RuntimeConfig, ) -> None: super().__init__(state, lock, config) @@ -60,6 +63,7 @@ def __init__( self._pipelines = pipelines_handler self._text = text_handler self._video_processor = video_processor + self._media = media_handler def _build_conditioning_frame( self, @@ -94,9 +98,16 @@ def _require_ic_lora_model_paths(self, conditioning_type: ConditioningType) -> t return lora_path, depth_model_path def extract_conditioning(self, req: IcLoraExtractRequest) -> IcLoraExtractResponse: - video_file = Path(req.video_path) + resolved_video = self._media.resolve_input( + media_id=req.video_media_id, + legacy_path=req.video_path, + expected_type="video", + required=True, + ) + assert resolved_video is not None + video_file = resolved_video if not video_file.exists(): - raise HTTPError(400, f"Video not found: {req.video_path}") + raise HTTPError(400, f"Video not found: {video_file}") cap = self._video_processor.open_video(str(video_file)) info = self._video_processor.get_video_info(cap) @@ -139,9 +150,16 @@ def generate(self, req: IcLoraGenerateRequest) -> IcLoraGenerateResponse: if self._generation.is_generation_running(): raise HTTPError(409, "Generation already in progress") - video_path = Path(req.video_path) + resolved_video = self._media.resolve_input( + media_id=req.video_media_id, + legacy_path=req.video_path, + expected_type="video", + required=True, + ) + assert resolved_video is not None + video_path = resolved_video if not video_path.exists(): - raise HTTPError(400, f"Video not found: {req.video_path}") + raise HTTPError(400, f"Video not found: {video_path}") lora_path, depth_model_path = self._require_ic_lora_model_paths(req.conditioning_type) generation_id = uuid.uuid4().hex[:8] @@ -224,10 +242,23 @@ def generate(self, req: IcLoraGenerateRequest) -> IcLoraGenerateResponse: cache_key, ConditioningCacheEntry(control_video_path, frame_count, fps) ) - images: list[ImageConditioningInput] = [ - ImageConditioningInput(path=img.path, frame_idx=int(img.frame), strength=float(img.strength)) - for img in req.images - ] + images: list[ImageConditioningInput] = [] + for image_input in req.images: + resolved_image = self._media.resolve_input( + media_id=image_input.media_id, + legacy_path=image_input.path, + expected_type="image", + required=True, + ) + assert resolved_image is not None + validated_image = validate_image_file(str(resolved_image)) + images.append( + ImageConditioningInput( + path=str(validated_image), + frame_idx=int(image_input.frame), + strength=float(image_input.strength), + ) + ) self._generation.update_progress("inference", 15, 0, 1) @@ -266,8 +297,13 @@ def generate(self, req: IcLoraGenerateRequest) -> IcLoraGenerateResponse: ) self._generation.update_progress("complete", 100, 1, 1) - self._generation.complete_generation(str(output_path)) - return IcLoraGenerateCompleteResponse(status="complete", video_path=str(output_path)) + artifact = self._media.register_artifact(output_path, media_type="video") + self._generation.complete_generation(str(output_path), artifact) + return IcLoraGenerateCompleteResponse( + status="complete", + video_path=str(output_path), + artifact=artifact, + ) except HTTPError: self._generation.fail_generation("IC-LoRA generation failed") diff --git a/backend/handlers/image_generation_handler.py b/backend/handlers/image_generation_handler.py index 27cd82458..46314d630 100644 --- a/backend/handlers/image_generation_handler.py +++ b/backend/handlers/image_generation_handler.py @@ -19,6 +19,7 @@ ) from handlers.base import StateHandlerBase from handlers.generation_handler import GenerationHandler +from handlers.media_handler import MediaHandler from handlers.pipelines_handler import PipelinesHandler from services.interfaces import ZitAPIClient from state.app_state_types import AppState @@ -38,11 +39,13 @@ def __init__( pipelines_handler: PipelinesHandler, config: RuntimeConfig, zit_api_client: ZitAPIClient, + media_handler: MediaHandler, ) -> None: super().__init__(state, lock, config) self._generation = generation_handler self._pipelines = pipelines_handler self._zit_api_client = zit_api_client + self._media = media_handler def generate(self, req: GenerateImageRequest) -> GenerateImageResponse: if self._generation.is_generation_running(): @@ -52,7 +55,7 @@ def generate(self, req: GenerateImageRequest) -> GenerateImageResponse: height = (req.height // 16) * 16 num_images = max(1, min(12, req.numImages)) - generation_id = uuid.uuid4().hex[:8] + generation_id = req.generationId or uuid.uuid4().hex[:8] settings = self.state.app_settings.model_copy(deep=True) if settings.seed_locked: seed = settings.locked_seed @@ -70,6 +73,7 @@ def generate(self, req: GenerateImageRequest) -> GenerateImageResponse: num_inference_steps=req.numSteps, seed=seed, num_images=num_images, + generation_id=generation_id, ) try: @@ -83,8 +87,13 @@ def generate(self, req: GenerateImageRequest) -> GenerateImageResponse: seed=seed, num_images=num_images, ) - self._generation.complete_generation(output_paths) - return GenerateImageCompleteResponse(status="complete", image_paths=output_paths) + artifacts = [self._media.register_artifact(path, media_type="image") for path in output_paths] + self._generation.complete_generation(output_paths, artifacts) + return GenerateImageCompleteResponse( + status="complete", + image_paths=output_paths, + artifacts=artifacts, + ) except Exception as e: self._generation.fail_generation(str(e)) if "cancelled" in str(e).lower(): @@ -149,8 +158,8 @@ def _generate_via_api( num_inference_steps: int, seed: int, num_images: int, + generation_id: str, ) -> GenerateImageResponse: - generation_id = uuid.uuid4().hex[:8] output_paths: list[Path] = [] timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") settings = self.state.app_settings.model_copy(deep=True) @@ -188,8 +197,14 @@ def _generate_via_api( output_paths.append(output_path) self._generation.update_progress("complete", 100, None, None) - self._generation.complete_generation([str(path) for path in output_paths]) - return GenerateImageCompleteResponse(status="complete", image_paths=[str(path) for path in output_paths]) + output_path_strings = [str(path) for path in output_paths] + artifacts = [self._media.register_artifact(path, media_type="image") for path in output_paths] + self._generation.complete_generation(output_path_strings, artifacts) + return GenerateImageCompleteResponse( + status="complete", + image_paths=output_path_strings, + artifacts=artifacts, + ) except HTTPError as e: self._generation.fail_generation(e.detail) raise diff --git a/backend/handlers/media_handler.py b/backend/handlers/media_handler.py new file mode 100644 index 000000000..8495b6a62 --- /dev/null +++ b/backend/handlers/media_handler.py @@ -0,0 +1,173 @@ +"""Media upload, opaque reference resolution, and artifact registration.""" + +from __future__ import annotations + +import mimetypes +from pathlib import Path +from threading import RLock +from typing import BinaryIO, TYPE_CHECKING + +from PIL import Image + +from _routes._errors import HTTPError +from api_types import ArtifactRef, MediaRef +from handlers.base import StateHandlerBase +from server_utils.media_validation import validate_audio_file, validate_image_file, validate_video_file +from services.interfaces import MediaRecord, MediaStore, MediaType, VideoProcessor +from services.media_store.filesystem_media_store import InvalidMediaStorePathError, MediaTooLargeError +from state.app_state_types import AppState + +if TYPE_CHECKING: + from runtime_config.runtime_config import RuntimeConfig + +_MAX_UPLOAD_BYTES: dict[MediaType, int] = { + "image": 50 * 1024 * 1024, + "audio": 100 * 1024 * 1024, + "video": 2 * 1024 * 1024 * 1024, +} + +_IMAGE_CONTENT_TYPES = { + "PNG": "image/png", + "JPEG": "image/jpeg", + "WEBP": "image/webp", + "GIF": "image/gif", + "BMP": "image/bmp", + "TIFF": "image/tiff", +} + + +class MediaHandler(StateHandlerBase): + def __init__( + self, + state: AppState, + lock: RLock, + config: RuntimeConfig, + media_store: MediaStore, + video_processor: VideoProcessor, + ) -> None: + super().__init__(state, lock, config) + self._store = media_store + self._video_processor = video_processor + self._store.cleanup_expired() + + def upload(self, source: BinaryIO, *, filename: str, media_type: MediaType) -> MediaRef: + self._store.cleanup_expired() + try: + staged = self._store.stage_upload( + source, + filename=filename, + max_bytes=_MAX_UPLOAD_BYTES[media_type], + ) + except MediaTooLargeError: + raise HTTPError(413, "MEDIA_TOO_LARGE") from None + except Exception as exc: + raise HTTPError(500, "MEDIA_UPLOAD_FAILED") from exc + try: + content_type = self._validate_and_detect(staged.path, media_type) + record = self._store.commit_upload( + staged, + media_type=media_type, + filename=filename, + content_type=content_type, + ) + except HTTPError as exc: + self._store.discard_staged(staged) + raise HTTPError(400, "INVALID_MEDIA") from exc + except Exception as exc: + self._store.discard_staged(staged) + raise HTTPError(500, "MEDIA_UPLOAD_FAILED") from exc + return self._media_ref(record) + + def _validate_and_detect(self, path: Path, media_type: MediaType) -> str: + if media_type == "image": + validate_image_file(str(path)) + with Image.open(path) as image: + return _IMAGE_CONTENT_TYPES.get(str(image.format or "").upper(), "application/octet-stream") + if media_type == "audio": + validate_audio_file(str(path)) + else: + validate_video_file(str(path), self._video_processor) + guessed, _encoding = mimetypes.guess_type(path.name) + return guessed or ("audio/mpeg" if media_type == "audio" else "video/mp4") + + def resolve_input( + self, + *, + media_id: str | None, + legacy_path: str | None, + expected_type: MediaType, + required: bool = False, + ) -> Path | None: + normalized_id = media_id.strip() if media_id else "" + normalized_path = legacy_path.strip() if legacy_path else "" + if normalized_id and normalized_path: + raise HTTPError(422, "MEDIA_ID_AND_PATH_ARE_MUTUALLY_EXCLUSIVE") + if normalized_id: + record = self._store.resolve_upload(normalized_id) + if record is None: + raise HTTPError(404, "MEDIA_NOT_FOUND") + if record.media_type != expected_type: + raise HTTPError(409, "MEDIA_TYPE_MISMATCH") + return record.path + if normalized_path: + if not self.config.allow_legacy_path_inputs: + raise HTTPError(422, "REMOTE_PATH_INPUT_DISABLED") + return Path(normalized_path) + if required: + raise HTTPError(422, "MEDIA_INPUT_REQUIRED") + return None + + def register_artifact(self, path: str | Path, *, media_type: MediaType) -> ArtifactRef: + self._store.cleanup_expired() + artifact_path = Path(path) + guessed, _encoding = mimetypes.guess_type(artifact_path.name) + fallback = "image/png" if media_type == "image" else "video/mp4" + try: + record = self._store.register_artifact( + artifact_path, + media_type=media_type, + content_type=guessed or fallback, + ) + except (OSError, InvalidMediaStorePathError) as exc: + raise HTTPError(500, "ARTIFACT_REGISTRATION_FAILED") from exc + except Exception as exc: + raise HTTPError(500, "ARTIFACT_REGISTRATION_FAILED") from exc + return self._artifact_ref(record) + + def resolve_artifact(self, artifact_id: str) -> MediaRecord: + record = self._store.resolve_artifact(artifact_id) + if record is None: + raise HTTPError(404, "ARTIFACT_NOT_FOUND") + return record + + def delete_upload(self, media_id: str) -> None: + if not self._store.delete_upload(media_id): + raise HTTPError(404, "MEDIA_NOT_FOUND") + + def delete_artifact(self, artifact_id: str) -> None: + if not self._store.delete_artifact(artifact_id): + raise HTTPError(404, "ARTIFACT_NOT_FOUND") + + @staticmethod + def _media_ref(record: MediaRecord) -> MediaRef: + return MediaRef( + media_id=record.id, + media_type=record.media_type, + filename=record.filename, + content_type=record.content_type, + size_bytes=record.size_bytes, + sha256=record.sha256, + expires_at=record.expires_at, + ) + + @staticmethod + def _artifact_ref(record: MediaRecord) -> ArtifactRef: + return ArtifactRef( + artifact_id=record.id, + media_type=record.media_type, + filename=record.filename, + content_type=record.content_type, + size_bytes=record.size_bytes, + sha256=record.sha256, + expires_at=record.expires_at, + ) diff --git a/backend/handlers/retake_handler.py b/backend/handlers/retake_handler.py index f3edc5c9f..67077a658 100644 --- a/backend/handlers/retake_handler.py +++ b/backend/handlers/retake_handler.py @@ -19,6 +19,7 @@ from _routes._errors import HTTPError from handlers.base import StateHandlerBase from handlers.generation_handler import GenerationHandler +from handlers.media_handler import MediaHandler from handlers.pipelines_handler import PipelinesHandler from handlers.text_handler import TextHandler from runtime_config.runtime_config import RuntimeConfig @@ -38,15 +39,24 @@ def __init__( generation_handler: GenerationHandler, pipelines_handler: PipelinesHandler, text_handler: TextHandler, + media_handler: MediaHandler, ) -> None: super().__init__(state, lock, config) self._ltx_api_client = ltx_api_client self._generation = generation_handler self._pipelines = pipelines_handler self._text = text_handler + self._media = media_handler def run(self, req: RetakeRequest) -> RetakeResponse: - video_path = req.video_path + resolved_video = self._media.resolve_input( + media_id=req.video_media_id, + legacy_path=req.video_path, + expected_type="video", + required=True, + ) + assert resolved_video is not None + video_path = str(resolved_video) start_time = req.start_time duration = req.duration prompt = req.prompt @@ -110,7 +120,8 @@ def _run_api_retake( output = self.config.outputs_dir / f"retake_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}.mp4" with open(output, "wb") as out: out.write(result.video_bytes) - return RetakeVideoResponse(status="complete", video_path=str(output)) + artifact = self._media.register_artifact(output, media_type="video") + return RetakeVideoResponse(status="complete", video_path=str(output), artifact=artifact) if result.result_payload is not None: return RetakePayloadResponse(status="complete", result=result.result_payload) @@ -173,8 +184,13 @@ def _run_local_retake( raise RuntimeError("Generation was cancelled") self._generation.update_progress("complete", 100, 1, 1) - self._generation.complete_generation(str(output_path)) - return RetakeVideoResponse(status="complete", video_path=str(output_path)) + artifact = self._media.register_artifact(output_path, media_type="video") + self._generation.complete_generation(str(output_path), artifact) + return RetakeVideoResponse( + status="complete", + video_path=str(output_path), + artifact=artifact, + ) except HTTPError: self._generation.fail_generation("Retake generation failed") raise diff --git a/backend/handlers/settings_handler.py b/backend/handlers/settings_handler.py index 6da5c1557..8f8da1646 100644 --- a/backend/handlers/settings_handler.py +++ b/backend/handlers/settings_handler.py @@ -17,6 +17,7 @@ ) from handlers.base import StateHandlerBase, with_state_lock from state.app_state_types import AppState +from server_utils.secure_files import harden_file_permissions, secure_write_text if TYPE_CHECKING: from runtime_config.runtime_config import RuntimeConfig @@ -41,6 +42,7 @@ def load_settings(self, default_settings: AppSettings) -> AppSettings: migrated, ) loaded = AppSettings.model_validate(merged) + harden_file_permissions(settings_file) logger.info("Settings loaded from %s", settings_file) self.state.app_settings = loaded return loaded @@ -53,8 +55,7 @@ def load_settings(self, default_settings: AppSettings) -> AppSettings: def save_settings(self) -> None: try: payload = self.get_settings_snapshot().model_dump(by_alias=False) - with open(self.config.settings_file, "w", encoding="utf-8") as f: - json.dump(payload, f, indent=2) + secure_write_text(self.config.settings_file, json.dumps(payload, indent=2)) except Exception as exc: logger.warning("Could not save settings: %s", exc, exc_info=True) diff --git a/backend/handlers/suggest_gap_prompt_handler.py b/backend/handlers/suggest_gap_prompt_handler.py index cde32e943..15e768787 100644 --- a/backend/handlers/suggest_gap_prompt_handler.py +++ b/backend/handlers/suggest_gap_prompt_handler.py @@ -13,6 +13,7 @@ ) from _routes._errors import HTTPError from handlers.base import StateHandlerBase +from handlers.media_handler import MediaHandler from pydantic import BaseModel, Field, ValidationError from server_utils.media_validation import normalize_optional_path, validate_image_file from services.interfaces import HTTPClient, HttpTimeoutError, JSONValue @@ -66,14 +67,37 @@ def _read_image_file_as_base64(file_path: str | None) -> str | None: class SuggestGapPromptHandler(StateHandlerBase): - def __init__(self, state: AppState, lock: RLock, config: RuntimeConfig, http: HTTPClient) -> None: + def __init__( + self, + state: AppState, + lock: RLock, + config: RuntimeConfig, + http: HTTPClient, + media_handler: MediaHandler, + ) -> None: super().__init__(state, lock, config) self._http = http + self._media = media_handler def suggest_gap(self, req: SuggestGapPromptRequest) -> SuggestGapPromptResponse: - before_frame = _read_image_file_as_base64(req.beforeFrame) - after_frame = _read_image_file_as_base64(req.afterFrame) - input_image = _read_image_file_as_base64(req.inputImage) + before_path = self._media.resolve_input( + media_id=req.beforeFrameMediaId, + legacy_path=req.beforeFrame, + expected_type="image", + ) + after_path = self._media.resolve_input( + media_id=req.afterFrameMediaId, + legacy_path=req.afterFrame, + expected_type="image", + ) + input_path = self._media.resolve_input( + media_id=req.inputImageMediaId, + legacy_path=req.inputImage, + expected_type="image", + ) + before_frame = _read_image_file_as_base64(str(before_path) if before_path is not None else None) + after_frame = _read_image_file_as_base64(str(after_path) if after_path is not None else None) + input_image = _read_image_file_as_base64(str(input_path) if input_path is not None else None) before_prompt = req.beforePrompt after_prompt = req.afterPrompt gap_duration = req.gapDuration diff --git a/backend/handlers/video_generation_handler.py b/backend/handlers/video_generation_handler.py index d8faff5c9..b6d237d59 100644 --- a/backend/handlers/video_generation_handler.py +++ b/backend/handlers/video_generation_handler.py @@ -30,10 +30,10 @@ ) from handlers.base import StateHandlerBase from handlers.generation_handler import GenerationHandler +from handlers.media_handler import MediaHandler from handlers.pipelines_handler import PipelinesHandler from handlers.text_handler import TextHandler from server_utils.media_validation import ( - normalize_optional_path, validate_audio_file, validate_image_file, ) @@ -69,6 +69,7 @@ def __init__( pipelines_handler: PipelinesHandler, text_handler: TextHandler, ltx_api_client: LTXAPIClient, + media_handler: MediaHandler, config: RuntimeConfig, ) -> None: super().__init__(state, lock, config) @@ -76,11 +77,24 @@ def __init__( self._pipelines = pipelines_handler self._text = text_handler self._ltx_api_client = ltx_api_client + self._media = media_handler def get_model_specs(self) -> GenerateVideoModelsSpecsResponse: return build_generate_video_model_specs_response() def generate(self, req: GenerateVideoRequest) -> GenerateVideoResponse: + resolved_audio = self._media.resolve_input( + media_id=req.audioMediaId, + legacy_path=req.audioPath, + expected_type="audio", + ) + resolved_image = self._media.resolve_input( + media_id=req.imageMediaId, + legacy_path=req.imagePath, + expected_type="image", + ) + audio_path = str(resolved_audio) if resolved_audio is not None else None + image_path = str(resolved_image) if resolved_image is not None else None use_api_specs = should_video_generate_with_ltx_api( force_api_generations=self.config.force_api_generations, settings=self.state.app_settings, @@ -90,7 +104,7 @@ def generate(self, req: GenerateVideoRequest) -> GenerateVideoResponse: raise HTTPError(422, validation_error, code="INVALID_VIDEO_GENERATION_SPEC") if use_api_specs: - return self._generate_forced_api(req) + return self._generate_forced_api(req, audio_path=audio_path, image_path=image_path) if self._generation.is_generation_running(): raise HTTPError(409, "Generation already in progress") @@ -99,9 +113,8 @@ def generate(self, req: GenerateVideoRequest) -> GenerateVideoResponse: duration = req.duration fps = req.fps - audio_path = normalize_optional_path(req.audioPath) if audio_path: - return self._generate_a2v(req, duration, fps, audio_path=audio_path) + return self._generate_a2v(req, duration, fps, audio_path=audio_path, image_path=image_path) logger.info("Resolution %s - using fast pipeline", resolution) @@ -130,12 +143,11 @@ def get_9_16_size(res: str) -> tuple[int, int]: num_frames = self._compute_num_frames(duration, fps) image = None - image_path = normalize_optional_path(req.imagePath) if image_path: image = self._prepare_image(image_path, width, height) logger.info("Image: %s -> %sx%s", image_path, width, height) - generation_id = self._make_generation_id() + generation_id = req.generationId or self._make_generation_id() seed = self._resolve_seed() try: @@ -154,8 +166,9 @@ def get_9_16_size(res: str) -> tuple[int, int]: negative_prompt=req.negativePrompt, ) - self._generation.complete_generation(output_path) - return GenerateVideoCompleteResponse(status="complete", video_path=output_path) + artifact = self._media.register_artifact(output_path, media_type="video") + self._generation.complete_generation(output_path, artifact) + return GenerateVideoCompleteResponse(status="complete", video_path=output_path, artifact=artifact) except HTTPError as e: self._generation.fail_generation(e.detail) @@ -259,7 +272,13 @@ def generate_video( os.unlink(temp_image_path) def _generate_a2v( - self, req: GenerateVideoRequest, duration: int, fps: int, *, audio_path: str + self, + req: GenerateVideoRequest, + duration: int, + fps: int, + *, + audio_path: str, + image_path: str | None, ) -> GenerateVideoResponse: validated_audio_path = validate_audio_file(audio_path) audio_path_str = str(validated_audio_path) @@ -280,13 +299,12 @@ def _generate_a2v( image = None temp_image_path: str | None = None - image_path = normalize_optional_path(req.imagePath) if image_path: image = self._prepare_image(image_path, width, height) seed = self._resolve_seed() - generation_id = self._make_generation_id() + generation_id = req.generationId or self._make_generation_id() try: a2v_state = self._pipelines.load_a2v_pipeline() @@ -339,8 +357,13 @@ def _generate_a2v( raise RuntimeError("Generation was cancelled") self._generation.update_progress("complete", 100, total_steps, total_steps) - self._generation.complete_generation(str(output_path)) - return GenerateVideoCompleteResponse(status="complete", video_path=str(output_path)) + artifact = self._media.register_artifact(output_path, media_type="video") + self._generation.complete_generation(str(output_path), artifact) + return GenerateVideoCompleteResponse( + status="complete", + video_path=str(output_path), + artifact=artifact, + ) except HTTPError as e: self._generation.fail_generation(e.detail) @@ -398,15 +421,19 @@ def _make_output_path(self) -> Path: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") return self.config.outputs_dir / f"ltx2_video_{timestamp}_{self._make_generation_id()}.mp4" - def _generate_forced_api(self, req: GenerateVideoRequest) -> GenerateVideoResponse: + def _generate_forced_api( + self, + req: GenerateVideoRequest, + *, + audio_path: str | None, + image_path: str | None, + ) -> GenerateVideoResponse: if self._generation.is_generation_running(): raise HTTPError(409, "Generation already in progress") - generation_id = self._make_generation_id() + generation_id = req.generationId or self._make_generation_id() self._generation.start_api_generation(generation_id) - audio_path = normalize_optional_path(req.audioPath) - image_path = normalize_optional_path(req.imagePath) has_input_audio = bool(audio_path) has_input_image = bool(image_path) @@ -519,8 +546,13 @@ def _generate_forced_api(self, req: GenerateVideoRequest) -> GenerateVideoRespon raise RuntimeError("Generation was cancelled") self._generation.update_progress("complete", 100, None, None) - self._generation.complete_generation(str(output_path)) - return GenerateVideoCompleteResponse(status="complete", video_path=str(output_path)) + artifact = self._media.register_artifact(output_path, media_type="video") + self._generation.complete_generation(str(output_path), artifact) + return GenerateVideoCompleteResponse( + status="complete", + video_path=str(output_path), + artifact=artifact, + ) except HTTPError as e: self._generation.fail_generation(e.detail) raise diff --git a/backend/ltx2_server.py b/backend/ltx2_server.py index 56af79f45..53aedcbba 100644 --- a/backend/ltx2_server.py +++ b/backend/ltx2_server.py @@ -111,7 +111,9 @@ def patched_sdpa( # Constants & Paths # ============================================================ -from runtime_config.port_constant import PORT +from runtime_config.server_config import load_server_config + +server_config = load_server_config() def _get_device() -> torch.device: @@ -142,6 +144,11 @@ def _resolve_app_data_dir() -> Path: DEFAULT_MODELS_DIR = APP_DATA_DIR / "models" DEFAULT_MODELS_DIR.mkdir(parents=True, exist_ok=True) +models_dir_env = os.environ.get("LTX_MODELS_DIR", "").strip() +MODELS_DIR_OVERRIDE = Path(models_dir_env).expanduser().resolve() if models_dir_env else None +if MODELS_DIR_OVERRIDE is not None: + MODELS_DIR_OVERRIDE.mkdir(parents=True, exist_ok=True) + PROJECT_ROOT = Path(__file__).parent.parent OUTPUTS_DIR = APP_DATA_DIR / "outputs" OUTPUTS_DIR.mkdir(parents=True, exist_ok=True) @@ -158,7 +165,7 @@ def _resolve_app_data_dir() -> Path: DEFAULT_APP_SETTINGS = AppSettings() -from app_factory import DEFAULT_ALLOWED_ORIGINS, create_app +from app_factory import create_app from state import RuntimeConfig, build_initial_state from runtime_config.runtime_policy import LocalGenerationMode, decide_local_generation_mode from server_utils.model_layout_migration import migrate_legacy_models_layout @@ -207,7 +214,7 @@ def _resolve_local_generations_mode() -> LocalGenerationMode: DEFAULT_NEGATIVE_PROMPT = """blurry, out of focus, overexposed, underexposed, low contrast, washed out colors, excessive noise, grainy texture, poor lighting, flickering, motion blur, distorted proportions, unnatural skin tones, deformed facial features, asymmetrical face, missing facial features, extra limbs, disfigured hands, wrong hand count, artifacts around text, inconsistent perspective, camera shake, incorrect depth of field""" -HF_OAUTH_CLIENT_ID = "a8189e14-9246-4f19-bd6a-a307bdcb9276" +HF_OAUTH_CLIENT_ID = os.environ.get("LTX_HF_OAUTH_CLIENT_ID", "a8189e14-9246-4f19-bd6a-a307bdcb9276") runtime_config = RuntimeConfig( device=DEVICE, @@ -222,16 +229,23 @@ def _resolve_local_generations_mode() -> LocalGenerationMode: default_negative_prompt=DEFAULT_NEGATIVE_PROMPT, dev_mode=os.environ.get("LTX_DEV_MODE") == "1", hf_oauth_client_id=HF_OAUTH_CLIENT_ID, - backend_port=int(os.environ.get("LTX_PORT", "") or PORT), + backend_port=server_config.port, hf_gating_enabled=os.environ.get("LTX_HF_GATING_ENABLED") == "1", + deployment_mode=server_config.deployment_mode, + public_base_url=server_config.public_base_url, + allow_legacy_path_inputs=server_config.allow_legacy_path_inputs, + models_dir_editable=server_config.models_dir_editable, + models_dir_override=MODELS_DIR_OVERRIDE, ) handler = build_initial_state(runtime_config, DEFAULT_APP_SETTINGS) -auth_token = os.environ.get("LTX_AUTH_TOKEN", "") -admin_token = os.environ.get("LTX_ADMIN_TOKEN", "") - -app = create_app(handler=handler, allowed_origins=DEFAULT_ALLOWED_ORIGINS, auth_token=auth_token, admin_token=admin_token) +app = create_app( + handler=handler, + allowed_origins=list(server_config.allowed_origins), + auth_token=server_config.auth_token, + admin_token=server_config.admin_token, +) def precache_model_files(model_dir: Path) -> int: @@ -291,7 +305,14 @@ def log_hardware_info() -> None: }, } - config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="info", access_log=False, log_config=log_config) + config = uvicorn.Config( + app, + host=server_config.bind_host, + port=port, + log_level="info", + access_log=False, + log_config=log_config, + ) server = uvicorn.Server(config) _orig_startup = server.startup @@ -300,7 +321,8 @@ async def _startup_with_ready_msg(sockets: object = None) -> None: await _orig_startup(sockets=sockets) # type: ignore[arg-type] if server.started: # Machine-parseable ready message — Electron matches this line - print(f"Server running on http://127.0.0.1:{port}", flush=True) + ready_host = "127.0.0.1" if server_config.bind_host in {"0.0.0.0", "::"} else server_config.bind_host + print(f"Server running on http://{ready_host}:{port}", flush=True) server.startup = _startup_with_ready_msg # type: ignore[assignment] diff --git a/backend/runtime_config/__init__.py b/backend/runtime_config/__init__.py index e5b8541d7..955f8c6b2 100644 --- a/backend/runtime_config/__init__.py +++ b/backend/runtime_config/__init__.py @@ -1,5 +1,6 @@ """Runtime config package.""" from runtime_config.runtime_config import RuntimeConfig +from runtime_config.server_config import DeploymentMode, ServerConfig, load_server_config -__all__ = ["RuntimeConfig"] +__all__ = ["DeploymentMode", "RuntimeConfig", "ServerConfig", "load_server_config"] diff --git a/backend/runtime_config/runtime_config.py b/backend/runtime_config/runtime_config.py index 290c2a51d..e709267a3 100644 --- a/backend/runtime_config/runtime_config.py +++ b/backend/runtime_config/runtime_config.py @@ -8,6 +8,7 @@ import torch from runtime_config.runtime_policy import LocalGenerationMode +from runtime_config.server_config import DeploymentMode @dataclass @@ -26,8 +27,17 @@ class RuntimeConfig: backend_port: int hf_oauth_client_id: str = "" hf_gating_enabled: bool = False + deployment_mode: DeploymentMode = "managed_local" + public_base_url: str = "" + allow_legacy_path_inputs: bool = True + models_dir_editable: bool = True + models_dir_override: Path | None = None @property def force_api_generations(self) -> bool: """Derived: local generation is unavailable for this runtime.""" return self.local_generations_mode == "unsupported" + + @property + def effective_public_base_url(self) -> str: + return self.public_base_url.rstrip("/") or f"http://127.0.0.1:{self.backend_port}" diff --git a/backend/runtime_config/server_config.py b/backend/runtime_config/server_config.py new file mode 100644 index 000000000..cf0c6c947 --- /dev/null +++ b/backend/runtime_config/server_config.py @@ -0,0 +1,118 @@ +"""Environment-backed HTTP server configuration. + +This module intentionally has no torch or application imports so startup +configuration can be validated in lightweight tests. +""" + +from __future__ import annotations + +import ipaddress +import os +from dataclasses import dataclass, field +from typing import Literal, Mapping +from urllib.parse import urlparse + +from runtime_config.port_constant import PORT + +DeploymentMode = Literal["managed_local", "standalone"] + +DEFAULT_ALLOWED_ORIGINS: tuple[str, ...] = ( + "null", + "http://localhost:5173", + "http://127.0.0.1:5173", +) + + +def _is_loopback_host(host: str) -> bool: + if host.lower() == "localhost": + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + +def _parse_deployment_mode(value: str) -> DeploymentMode: + normalized = value.strip().lower().replace("-", "_") + if normalized in {"", "managed_local"}: + return "managed_local" + if normalized == "standalone": + return "standalone" + raise RuntimeError("LTX_DEPLOYMENT_MODE must be 'managed_local' or 'standalone'") + + +def _parse_port(value: str) -> int: + if not value: + return PORT + try: + port = int(value) + except ValueError: + raise RuntimeError("LTX_PORT must be an integer") from None + if not 1 <= port <= 65535: + raise RuntimeError("LTX_PORT must be between 1 and 65535") + return port + + +def _validate_public_base_url(value: str) -> str: + candidate = value.rstrip("/") + parsed = urlparse(candidate) + if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.path not in {"", "/"}: + raise RuntimeError("LTX_PUBLIC_BASE_URL must be an HTTP(S) origin without a path") + if parsed.username or parsed.password or parsed.query or parsed.fragment: + raise RuntimeError("LTX_PUBLIC_BASE_URL must not contain credentials, a query, or a fragment") + if parsed.scheme == "http" and not _is_loopback_host(parsed.hostname): + raise RuntimeError("LTX_PUBLIC_BASE_URL must use HTTPS unless it points to loopback") + return candidate + + +@dataclass(frozen=True, slots=True) +class ServerConfig: + deployment_mode: DeploymentMode + bind_host: str + port: int + public_base_url: str + allowed_origins: tuple[str, ...] + auth_token: str = field(repr=False) + admin_token: str = field(repr=False) + + @property + def allow_legacy_path_inputs(self) -> bool: + return self.deployment_mode == "managed_local" + + @property + def models_dir_editable(self) -> bool: + return self.deployment_mode == "managed_local" + + +def load_server_config(environ: Mapping[str, str] | None = None) -> ServerConfig: + env = os.environ if environ is None else environ + mode = _parse_deployment_mode(env.get("LTX_DEPLOYMENT_MODE", "")) + port = _parse_port(env.get("LTX_PORT", "")) + bind_host = env.get("LTX_BIND_HOST", "127.0.0.1").strip() or "127.0.0.1" + auth_token = env.get("LTX_AUTH_TOKEN", "") + admin_token = env.get("LTX_ADMIN_TOKEN", "") + + if mode == "standalone" and len(auth_token) < 32: + raise RuntimeError("Standalone mode requires LTX_AUTH_TOKEN with at least 32 characters") + if not _is_loopback_host(bind_host) and not auth_token: + raise RuntimeError("A non-loopback LTX_BIND_HOST requires LTX_AUTH_TOKEN") + + public_base_url = _validate_public_base_url( + env.get("LTX_PUBLIC_BASE_URL", f"http://127.0.0.1:{port}") + ) + origins_value = env.get("LTX_ALLOWED_ORIGINS", "") + allowed_origins = ( + tuple(origin.strip() for origin in origins_value.split(",") if origin.strip()) + if origins_value + else DEFAULT_ALLOWED_ORIGINS + ) + + return ServerConfig( + deployment_mode=mode, + bind_host=bind_host, + port=port, + public_base_url=public_base_url, + allowed_origins=allowed_origins, + auth_token=auth_token, + admin_token=admin_token, + ) diff --git a/backend/server_utils/media_validation.py b/backend/server_utils/media_validation.py index 1c9ab5707..64ccd7853 100644 --- a/backend/server_utils/media_validation.py +++ b/backend/server_utils/media_validation.py @@ -11,9 +11,11 @@ from PIL import Image from _routes._errors import HTTPError +from services.video_processor.video_processor import VideoProcessor _MAX_IMAGE_BYTES = 50 * 1024 * 1024 _MAX_AUDIO_BYTES = 100 * 1024 * 1024 +_MAX_VIDEO_BYTES = 2 * 1024 * 1024 * 1024 _MAX_IMAGE_PIXELS = 50_000_000 _ALLOWED_IMAGE_FORMATS = {"PNG", "JPEG", "WEBP", "GIF", "BMP", "TIFF"} @@ -133,3 +135,34 @@ def validate_audio_file(path: str) -> Path: raise HTTPError(400, f"Invalid audio file: {path}") return file_path + + +def validate_video_file(path: str, video_processor: VideoProcessor) -> Path: + """Validate a readable video using the injected VideoProcessor service.""" + + try: + file_path = Path(path) + except Exception: + raise HTTPError(400, f"Video file not found: {path}") from None + _assert_is_file(file_path, kind="Video", raw_path=path) + _assert_max_bytes(file_path, limit_bytes=_MAX_VIDEO_BYTES, error_detail=f"Video file too large: {path}") + + try: + capture = video_processor.open_video(str(file_path)) + try: + if not bool(capture.isOpened()): + raise ValueError("not opened") + info = video_processor.get_video_info(capture) + if ( + float(info["fps"]) <= 0 + or int(info["frame_count"]) <= 0 + or int(info["width"]) <= 0 + or int(info["height"]) <= 0 + or video_processor.read_frame(capture, frame_idx=0) is None + ): + raise ValueError("invalid metadata") + finally: + video_processor.release(capture) + except Exception: + raise HTTPError(400, f"Invalid video file: {path}") from None + return file_path diff --git a/backend/server_utils/secure_files.py b/backend/server_utils/secure_files.py new file mode 100644 index 000000000..984198aee --- /dev/null +++ b/backend/server_utils/secure_files.py @@ -0,0 +1,32 @@ +"""Small helpers for atomically persisting files that contain secrets.""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + + +def harden_file_permissions(path: Path) -> None: + try: + os.chmod(path, 0o600) + except OSError: + # Windows and some mounted filesystems do not expose POSIX modes. + pass + + +def secure_write_text(path: Path, contents: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + temporary_path = Path(temporary_name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as output: + output.write(contents) + output.flush() + os.fsync(output.fileno()) + harden_file_permissions(temporary_path) + temporary_path.replace(path) + harden_file_permissions(path) + except Exception: + temporary_path.unlink(missing_ok=True) + raise diff --git a/backend/services/interfaces.py b/backend/services/interfaces.py index 125e2c9c2..fa551c969 100644 --- a/backend/services/interfaces.py +++ b/backend/services/interfaces.py @@ -14,6 +14,7 @@ from services.ic_lora_pipeline.ic_lora_pipeline import IcLoraPipeline from services.image_generation_pipeline.image_generation_pipeline import ImageGenerationPipeline from services.ltx_api_client.ltx_api_client import LTXAPIClient +from services.media_store.media_store import MediaRecord, MediaStore, MediaType, StagedMedia from services.retake_pipeline.retake_pipeline import RetakePipeline from services.model_downloader.model_downloader import ModelDownloader from services.pose_processor_pipeline.pose_processor_pipeline import PoseProcessorPipeline @@ -46,6 +47,10 @@ "ImageGenerationPipeline", "IcLoraPipeline", "LTXAPIClient", + "MediaRecord", + "MediaStore", + "MediaType", + "StagedMedia", "RetakePipeline", "TextEncoder", ] diff --git a/backend/services/media_store/__init__.py b/backend/services/media_store/__init__.py new file mode 100644 index 000000000..c291d4f89 --- /dev/null +++ b/backend/services/media_store/__init__.py @@ -0,0 +1,5 @@ +"""Opaque media storage service.""" + +from services.media_store.media_store import MediaRecord, MediaStore, MediaType, StagedMedia + +__all__ = ["MediaRecord", "MediaStore", "MediaType", "StagedMedia"] diff --git a/backend/services/media_store/filesystem_media_store.py b/backend/services/media_store/filesystem_media_store.py new file mode 100644 index 000000000..576dcf948 --- /dev/null +++ b/backend/services/media_store/filesystem_media_store.py @@ -0,0 +1,290 @@ +"""Filesystem implementation of the opaque media store.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import secrets +import shutil +import threading +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import BinaryIO, Literal + +from pydantic import BaseModel, ConfigDict, ValidationError + +from services.media_store.media_store import MediaRecord, MediaType, StagedMedia + +_ID_PATTERN = re.compile(r"^(?:med|art)_[A-Za-z0-9_-]{24,}$") +_CHUNK_BYTES = 1024 * 1024 + + +class MediaTooLargeError(Exception): + pass + + +class InvalidMediaStorePathError(Exception): + pass + + +class _StoredMetadata(BaseModel): + model_config = ConfigDict(strict=True) + + schema_version: Literal[1] = 1 + id: str + media_type: MediaType + filename: str + content_type: str + size_bytes: int + sha256: str + expires_at: datetime + relative_path: str + + +def _now() -> datetime: + return datetime.now(UTC) + + +def _safe_filename(filename: str) -> str: + name = Path(filename).name.strip() + return name[:255] or "media" + + +def _safe_suffix(filename: str) -> str: + suffix = Path(filename).suffix.lower() + if re.fullmatch(r"\.[a-z0-9]{1,10}", suffix): + return suffix + return ".bin" + + +def _is_within(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + return True + except ValueError: + return False + + +class FilesystemMediaStore: + def __init__( + self, + *, + app_data_dir: Path, + outputs_dir: Path, + upload_ttl: timedelta = timedelta(hours=24), + artifact_ttl: timedelta = timedelta(days=7), + ) -> None: + self._root = (app_data_dir / "media").resolve() + self._staging_dir = self._root / "staging" + self._uploads_dir = self._root / "uploads" + self._artifacts_dir = self._root / "artifacts" + self._outputs_dir = outputs_dir.resolve() + self._upload_ttl = upload_ttl + self._artifact_ttl = artifact_ttl + self._lock = threading.RLock() + for directory in (self._root, self._staging_dir, self._uploads_dir, self._artifacts_dir): + directory.mkdir(parents=True, exist_ok=True) + + @staticmethod + def _new_id(prefix: Literal["med", "art"]) -> str: + return f"{prefix}_{secrets.token_urlsafe(24)}" + + @staticmethod + def _write_metadata(path: Path, metadata: _StoredMetadata) -> None: + temp_path = path.with_suffix(".tmp") + temp_path.write_text(metadata.model_dump_json(), encoding="utf-8") + try: + os.chmod(temp_path, 0o600) + except OSError: + pass + temp_path.replace(path) + + def stage_upload(self, source: BinaryIO, *, filename: str, max_bytes: int) -> StagedMedia: + token = secrets.token_urlsafe(24) + path = self._staging_dir / f"{token}.part{_safe_suffix(filename)}" + digest = hashlib.sha256() + size = 0 + try: + with path.open("xb") as output: + while True: + chunk = source.read(_CHUNK_BYTES) + if not chunk: + break + size += len(chunk) + if size > max_bytes: + raise MediaTooLargeError() + digest.update(chunk) + output.write(chunk) + return StagedMedia(token=token, path=path, size_bytes=size, sha256=digest.hexdigest()) + except Exception: + path.unlink(missing_ok=True) + raise + + def commit_upload( + self, + staged: StagedMedia, + *, + media_type: MediaType, + filename: str, + content_type: str, + ) -> MediaRecord: + with self._lock: + media_id = self._new_id("med") + directory = self._uploads_dir / media_id + directory.mkdir(parents=False, exist_ok=False) + content_path = directory / f"content{_safe_suffix(filename)}" + try: + staged.path.replace(content_path) + expires_at = _now() + self._upload_ttl + metadata = _StoredMetadata( + id=media_id, + media_type=media_type, + filename=_safe_filename(filename), + content_type=content_type, + size_bytes=staged.size_bytes, + sha256=staged.sha256, + expires_at=expires_at, + relative_path=content_path.name, + ) + self._write_metadata(directory / "metadata.json", metadata) + return self._to_record(metadata, content_path) + except Exception: + shutil.rmtree(directory, ignore_errors=True) + raise + + def discard_staged(self, staged: StagedMedia) -> None: + staged.path.unlink(missing_ok=True) + + def _read_record(self, metadata_path: Path, *, root: Path) -> MediaRecord | None: + try: + metadata = _StoredMetadata.model_validate_json(metadata_path.read_text(encoding="utf-8")) + if not _ID_PATTERN.fullmatch(metadata.id) or metadata_path.parent.name != metadata.id: + return None + path = (metadata_path.parent / metadata.relative_path).resolve(strict=True) + resolved_root = root.resolve() + if not _is_within(path, resolved_root) or not path.is_file(): + return None + return self._to_record(metadata, path) + except (OSError, ValidationError, ValueError, json.JSONDecodeError): + return None + + @staticmethod + def _to_record(metadata: _StoredMetadata, path: Path) -> MediaRecord: + return MediaRecord( + id=metadata.id, + media_type=metadata.media_type, + filename=metadata.filename, + content_type=metadata.content_type, + size_bytes=metadata.size_bytes, + sha256=metadata.sha256, + expires_at=metadata.expires_at, + path=path, + ) + + def resolve_upload(self, media_id: str) -> MediaRecord | None: + if not media_id.startswith("med_") or not _ID_PATTERN.fullmatch(media_id): + return None + with self._lock: + record = self._read_record(self._uploads_dir / media_id / "metadata.json", root=self._uploads_dir) + if record is None or record.expires_at <= _now(): + return None + return record + + def delete_upload(self, media_id: str) -> bool: + if not media_id.startswith("med_") or not _ID_PATTERN.fullmatch(media_id): + return False + with self._lock: + directory = self._uploads_dir / media_id + if not directory.is_dir(): + return False + shutil.rmtree(directory) + return True + + def register_artifact(self, path: Path, *, media_type: MediaType, content_type: str) -> MediaRecord: + resolved = path.resolve(strict=True) + if not resolved.is_file() or not _is_within(resolved, self._outputs_dir): + raise InvalidMediaStorePathError("Artifact must be a file inside the outputs directory") + with self._lock: + artifact_id = self._new_id("art") + metadata_path = self._artifacts_dir / artifact_id / "metadata.json" + metadata_path.parent.mkdir(parents=False, exist_ok=False) + digest = hashlib.sha256() + with resolved.open("rb") as artifact_file: + while chunk := artifact_file.read(_CHUNK_BYTES): + digest.update(chunk) + metadata = _StoredMetadata( + id=artifact_id, + media_type=media_type, + filename=resolved.name, + content_type=content_type, + size_bytes=resolved.stat().st_size, + sha256=digest.hexdigest(), + expires_at=_now() + self._artifact_ttl, + relative_path=str(resolved.relative_to(self._outputs_dir)), + ) + try: + self._write_metadata(metadata_path, metadata) + except Exception: + shutil.rmtree(metadata_path.parent, ignore_errors=True) + raise + return self._to_record(metadata, resolved) + + def resolve_artifact(self, artifact_id: str) -> MediaRecord | None: + if not artifact_id.startswith("art_") or not _ID_PATTERN.fullmatch(artifact_id): + return None + with self._lock: + metadata_path = self._artifacts_dir / artifact_id / "metadata.json" + try: + metadata = _StoredMetadata.model_validate_json(metadata_path.read_text(encoding="utf-8")) + if metadata.id != artifact_id: + return None + path = (self._outputs_dir / metadata.relative_path).resolve(strict=True) + if not path.is_file() or not _is_within(path, self._outputs_dir): + return None + record = self._to_record(metadata, path) + except (OSError, ValidationError, ValueError, json.JSONDecodeError): + return None + if record.expires_at <= _now(): + return None + return record + + def delete_artifact(self, artifact_id: str) -> bool: + record = self.resolve_artifact(artifact_id) + if record is None: + return False + with self._lock: + record.path.unlink(missing_ok=True) + shutil.rmtree(self._artifacts_dir / artifact_id, ignore_errors=True) + return True + + def cleanup_expired(self) -> None: + now = _now() + with self._lock: + for staged in self._staging_dir.iterdir(): + try: + if datetime.fromtimestamp(staged.stat().st_mtime, UTC) + timedelta(hours=1) <= now: + staged.unlink(missing_ok=True) + except OSError: + continue + for directory in self._uploads_dir.iterdir(): + record = self._read_record(directory / "metadata.json", root=self._uploads_dir) + if record is not None and record.expires_at <= now: + shutil.rmtree(directory, ignore_errors=True) + for directory in self._artifacts_dir.iterdir(): + try: + metadata = _StoredMetadata.model_validate_json( + (directory / "metadata.json").read_text(encoding="utf-8") + ) + except (OSError, ValidationError): + continue + if metadata.expires_at > now: + continue + try: + artifact_path = (self._outputs_dir / metadata.relative_path).resolve(strict=True) + if artifact_path.is_file() and _is_within(artifact_path, self._outputs_dir): + artifact_path.unlink(missing_ok=True) + except OSError: + pass + shutil.rmtree(directory, ignore_errors=True) diff --git a/backend/services/media_store/media_store.py b/backend/services/media_store/media_store.py new file mode 100644 index 000000000..0e83aaf02 --- /dev/null +++ b/backend/services/media_store/media_store.py @@ -0,0 +1,66 @@ +"""Protocol and lightweight value types for media storage.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import BinaryIO, Literal, Protocol + +MediaType = Literal["image", "audio", "video"] + + +@dataclass(frozen=True, slots=True) +class StagedMedia: + token: str + path: Path + size_bytes: int + sha256: str + + +@dataclass(frozen=True, slots=True) +class MediaRecord: + id: str + media_type: MediaType + filename: str + content_type: str + size_bytes: int + sha256: str + expires_at: datetime + path: Path + + +class MediaStore(Protocol): + def stage_upload(self, source: BinaryIO, *, filename: str, max_bytes: int) -> StagedMedia: + ... + + def commit_upload( + self, + staged: StagedMedia, + *, + media_type: MediaType, + filename: str, + content_type: str, + ) -> MediaRecord: + ... + + def discard_staged(self, staged: StagedMedia) -> None: + ... + + def resolve_upload(self, media_id: str) -> MediaRecord | None: + ... + + def delete_upload(self, media_id: str) -> bool: + ... + + def register_artifact(self, path: Path, *, media_type: MediaType, content_type: str) -> MediaRecord: + ... + + def resolve_artifact(self, artifact_id: str) -> MediaRecord | None: + ... + + def delete_artifact(self, artifact_id: str) -> bool: + ... + + def cleanup_expired(self) -> None: + ... diff --git a/backend/state/app_state_types.py b/backend/state/app_state_types.py index 408bafd98..f7445e370 100644 --- a/backend/state/app_state_types.py +++ b/backend/state/app_state_types.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, NewType, Protocol -from api_types import ModelCheckpointID +from api_types import ArtifactRef, ModelCheckpointID from state.conditioning_cache import ConditioningCache if TYPE_CHECKING: @@ -156,6 +156,7 @@ class GenerationRunning: class GenerationComplete: id: str result: str | list[str] + artifacts: list[ArtifactRef] = field(default_factory=lambda: list[ArtifactRef]()) @dataclass diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 9a33ddb90..662810685 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -72,3 +72,23 @@ def test_websocket_with_token_query_param(test_state): ) # The route may not exist, but auth should pass (not 401) assert response.status_code != 401 + + +def test_standalone_rejects_basic_auth(test_state): + test_state.config.deployment_mode = "standalone" + app = create_app(handler=test_state, auth_token="test-secret") + credentials = base64.b64encode(b":test-secret").decode() + with TestClient(app) as client: + response = client.get("/health", headers={"Authorization": f"Basic {credentials}"}) + assert_http_error(response, status_code=401, code="HTTP_401", message="Unauthorized") + + +def test_standalone_shutdown_is_disabled_even_for_loopback(test_state): + test_state.config.deployment_mode = "standalone" + app = create_app(handler=test_state, auth_token="test-secret") + with TestClient(app) as client: + response = client.post( + "/api/system/shutdown", + headers={"Authorization": "Bearer test-secret"}, + ) + assert response.status_code == 403 diff --git a/backend/tests/test_generation.py b/backend/tests/test_generation.py index a7f770232..0fd2c21f0 100644 --- a/backend/tests/test_generation.py +++ b/backend/tests/test_generation.py @@ -71,6 +71,7 @@ def test_t2v_happy_path(self, client, test_state, fake_services, create_fake_mod "duration": 5, "fps": 24, "cameraMotion": "none", + "generationId": "desktop-video-job-1234", }, ) @@ -79,6 +80,9 @@ def test_t2v_happy_path(self, client, test_state, fake_services, create_fake_mod assert data["status"] == "complete" assert data["video_path"] is not None assert Path(data["video_path"]).exists() + progress = client.get("/api/generation/progress").json() + assert progress["generationId"] == "desktop-video-job-1234" + assert progress["artifact"]["artifact_id"] == data["artifact"]["artifact_id"] pipeline = fake_services.fast_video_pipeline assert len(pipeline.generate_calls) == 1 @@ -1147,13 +1151,43 @@ def test_running_from_api_generation_state(self, client, test_state): assert data["currentStep"] is None assert data["totalSteps"] is None + def test_complete_includes_generation_id_and_artifacts(self, client, test_state): + from api_types import ArtifactRef + + artifact = ArtifactRef( + artifact_id="art_abcdefghijklmnopqrstuvwxyz", + media_type="video", + filename="result.mp4", + content_type="video/mp4", + size_bytes=123, + sha256="a" * 64, + expires_at="2030-01-01T00:00:00Z", + ) + test_state.generation.start_api_generation("recoverable-job") + test_state.generation.complete_generation("/outputs/result.mp4", artifact) + + response = client.get("/api/generation/progress") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "complete" + assert data["generationId"] == "recoverable-job" + assert data["artifact"] == data["artifacts"][0] + assert data["artifact"]["artifact_id"] == "art_abcdefghijklmnopqrstuvwxyz" + class TestGenerateImage: def test_happy_path(self, client, create_fake_model_files): create_fake_model_files(include_zit=True) r = client.post( "/api/generate-image", - json={"prompt": "A cat", "width": 1024, "height": 1024, "numSteps": 4}, + json={ + "prompt": "A cat", + "width": 1024, + "height": 1024, + "numSteps": 4, + "generationId": "desktop-image-job-1234", + }, ) assert r.status_code == 200 @@ -1161,6 +1195,9 @@ def test_happy_path(self, client, create_fake_model_files): assert data["status"] == "complete" assert len(data["image_paths"]) == 1 assert Path(data["image_paths"][0]).exists() + progress = client.get("/api/generation/progress").json() + assert progress["generationId"] == "desktop-image-job-1234" + assert progress["artifacts"][0]["artifact_id"] == data["artifacts"][0]["artifact_id"] def test_dimension_clamping(self, client, fake_services, create_fake_model_files): create_fake_model_files(include_zit=True) diff --git a/backend/tests/test_hf_auth.py b/backend/tests/test_hf_auth.py index b2a93bc5e..b09685e52 100644 --- a/backend/tests/test_hf_auth.py +++ b/backend/tests/test_hf_auth.py @@ -4,12 +4,12 @@ import json import time -from dataclasses import dataclass from pathlib import Path import pytest from state.app_state_types import HfAuthenticated, HfNotAuthenticated, HfOAuthPending +from tests.fakes.services import FakeResponse class TestStartLogin: @@ -34,6 +34,11 @@ def test_second_login_replaces_pending(self, test_state) -> None: assert isinstance(test_state.state.hf_auth_state, HfOAuthPending) assert test_state.state.hf_auth_state.state == resp2.state + def test_redirect_uses_public_base_url(self, test_state) -> None: + test_state.config.public_base_url = "http://127.0.0.1:8123" + resp = test_state.hf_auth.start_login() + assert resp.redirect_uri == "http://127.0.0.1:8123/api/auth/huggingface/callback" + class TestHandleCallback: def test_rejects_error_param(self, test_state) -> None: @@ -71,35 +76,24 @@ def test_rejects_expired_pending(self, test_state) -> None: assert "Token exchange failed" in html assert isinstance(test_state.state.hf_auth_state, HfNotAuthenticated) - def test_happy_path_exchange(self, test_state, monkeypatch) -> None: + def test_happy_path_exchange(self, test_state, fake_services) -> None: """Full callback flow with a faked HF token response.""" resp = test_state.hf_auth.start_login() - @dataclass - class FakeTokenResponse: - status_code: int = 200 - text: str = "" - def json(self) -> dict[str, object]: - return {"access_token": "hf_test_token_123", "expires_in": 3600} - - import handlers.hf_auth_handler as handler_module - monkeypatch.setattr(handler_module.requests, "post", lambda *_args, **_kwargs: FakeTokenResponse()) + fake_services.http.queue( + "post", + FakeResponse(json_payload={"access_token": "hf_test_token_123", "expires_in": 3600}), + ) html = test_state.hf_auth.handle_callback(code="valid-code", state_param=resp.state, error="") assert "Authentication Successful" in html assert isinstance(test_state.state.hf_auth_state, HfAuthenticated) assert test_state.state.hf_auth_state.access_token == "hf_test_token_123" - def test_exchange_failure_sets_not_authenticated(self, test_state, monkeypatch) -> None: + def test_exchange_failure_sets_not_authenticated(self, test_state, fake_services) -> None: resp = test_state.hf_auth.start_login() - @dataclass - class FakeErrorResponse: - status_code: int = 401 - text: str = "unauthorized" - - import handlers.hf_auth_handler as handler_module - monkeypatch.setattr(handler_module.requests, "post", lambda *_args, **_kwargs: FakeErrorResponse()) + fake_services.http.queue("post", FakeResponse(status_code=401, text="unauthorized")) html = test_state.hf_auth.handle_callback(code="bad-code", state_param=resp.state, error="") assert "Token exchange failed" in html @@ -162,18 +156,13 @@ class TestTokenPersistence: def _token_file(self, test_state) -> Path: return test_state.config.app_data_dir / "hf_auth_token.json" - def test_authenticated_state_writes_token_file(self, test_state, monkeypatch) -> None: + def test_authenticated_state_writes_token_file(self, test_state, fake_services) -> None: resp = test_state.hf_auth.start_login() - @dataclass - class FakeTokenResponse: - status_code: int = 200 - text: str = "" - def json(self) -> dict[str, object]: - return {"access_token": "persist_me", "expires_in": 7200} - - import handlers.hf_auth_handler as handler_module - monkeypatch.setattr(handler_module.requests, "post", lambda *_args, **_kwargs: FakeTokenResponse()) + fake_services.http.queue( + "post", + FakeResponse(json_payload={"access_token": "persist_me", "expires_in": 7200}), + ) test_state.hf_auth.handle_callback(code="code", state_param=resp.state, error="") @@ -183,18 +172,13 @@ def json(self) -> dict[str, object]: assert data["access_token"] == "persist_me" assert data["expires_at"] > time.time() - def test_logout_clears_token_file(self, test_state, monkeypatch) -> None: + def test_logout_clears_token_file(self, test_state, fake_services) -> None: resp = test_state.hf_auth.start_login() - @dataclass - class FakeTokenResponse: - status_code: int = 200 - text: str = "" - def json(self) -> dict[str, object]: - return {"access_token": "to_be_cleared", "expires_in": 7200} - - import handlers.hf_auth_handler as handler_module - monkeypatch.setattr(handler_module.requests, "post", lambda *_args, **_kwargs: FakeTokenResponse()) + fake_services.http.queue( + "post", + FakeResponse(json_payload={"access_token": "to_be_cleared", "expires_in": 7200}), + ) test_state.hf_auth.handle_callback(code="code", state_param=resp.state, error="") assert self._token_file(test_state).exists() @@ -244,19 +228,14 @@ def test_load_token_handles_corrupt_file(self, test_state) -> None: assert isinstance(test_state.state.hf_auth_state, HfNotAuthenticated) assert not token_file.exists() # corrupt file should be cleaned up - def test_expired_status_check_clears_token_file(self, test_state, monkeypatch) -> None: + def test_expired_status_check_clears_token_file(self, test_state, fake_services) -> None: """When get_auth_status detects expiry, the token file should also be cleared.""" resp = test_state.hf_auth.start_login() - @dataclass - class FakeTokenResponse: - status_code: int = 200 - text: str = "" - def json(self) -> dict[str, object]: - return {"access_token": "short_lived", "expires_in": 1} - - import handlers.hf_auth_handler as handler_module - monkeypatch.setattr(handler_module.requests, "post", lambda *_args, **_kwargs: FakeTokenResponse()) + fake_services.http.queue( + "post", + FakeResponse(json_payload={"access_token": "short_lived", "expires_in": 1}), + ) test_state.hf_auth.handle_callback(code="code", state_param=resp.state, error="") assert self._token_file(test_state).exists() diff --git a/backend/tests/test_media.py b/backend/tests/test_media.py new file mode 100644 index 000000000..f9499bc9c --- /dev/null +++ b/backend/tests/test_media.py @@ -0,0 +1,161 @@ +"""Opaque media upload and generated artifact integration tests.""" + +from __future__ import annotations + +from pathlib import Path + +from tests.fakes.services import FakeResponse + + +def _upload_image(client, make_test_image) -> dict[str, object]: + response = client.post( + "/api/media", + data={"media_type": "image"}, + files={"file": ("frame.png", make_test_image(), "image/png")}, + ) + assert response.status_code == 200, response.text + return response.json() + + +def test_upload_resolve_and_delete_image(client, test_state, make_test_image) -> None: + data = _upload_image(client, make_test_image) + media_id = str(data["media_id"]) + assert media_id.startswith("med_") + assert data["media_type"] == "image" + assert data["content_type"] == "image/png" + assert int(data["size_bytes"]) > 0 + + record = test_state.media_store.resolve_upload(media_id) + assert record is not None + assert record.path.is_file() + assert record.path.name == "content.png" + + deleted = client.delete(f"/api/media/{media_id}") + assert deleted.status_code == 200 + assert test_state.media_store.resolve_upload(media_id) is None + + +def test_upload_rejects_invalid_image(client) -> None: + response = client.post( + "/api/media", + data={"media_type": "image"}, + files={"file": ("frame.png", b"not-an-image", "image/png")}, + ) + assert response.status_code == 400 + + +def test_artifact_download_range_and_delete(client, test_state) -> None: + output = test_state.config.outputs_dir / "generated.mp4" + output.write_bytes(b"0123456789") + artifact = test_state.media.register_artifact(output, media_type="video") + + response = client.get( + f"/api/artifacts/{artifact.artifact_id}", + headers={"Range": "bytes=2-5"}, + ) + assert response.status_code == 206 + assert response.content == b"2345" + assert response.headers["content-range"] == "bytes 2-5/10" + assert response.headers["x-content-type-options"] == "nosniff" + + deleted = client.delete(f"/api/artifacts/{artifact.artifact_id}") + assert deleted.status_code == 200 + assert not output.exists() + assert client.get(f"/api/artifacts/{artifact.artifact_id}").status_code == 404 + + +def test_artifact_registration_rejects_file_outside_outputs(test_state, tmp_path: Path) -> None: + outside = tmp_path / "outside.mp4" + outside.write_bytes(b"video") + try: + test_state.media.register_artifact(outside, media_type="video") + except Exception as exc: + assert getattr(exc, "code", None) == "ARTIFACT_REGISTRATION_FAILED" + else: + raise AssertionError("outside artifact was accepted") + + +def test_standalone_rejects_legacy_path_and_accepts_media_id( + client, + test_state, + make_test_image, +) -> None: + uploaded = _upload_image(client, make_test_image) + test_state.config.deployment_mode = "standalone" + test_state.config.allow_legacy_path_inputs = False + + local_image = test_state.config.outputs_dir / "local.png" + local_image.write_bytes(b"irrelevant") + rejected = client.post( + "/api/generate", + json={"prompt": "test", "imagePath": str(local_image)}, + ) + assert rejected.status_code == 422 + assert rejected.json()["code"] == "REMOTE_PATH_INPUT_DISABLED" + + # ID resolution itself remains available in standalone mode. Generation + # may fail later because this test intentionally does not install models. + resolved = test_state.media.resolve_input( + media_id=str(uploaded["media_id"]), + legacy_path=None, + expected_type="image", + required=True, + ) + assert resolved is not None + assert resolved.is_file() + + +def test_generate_with_media_id_returns_downloadable_artifact( + client, + test_state, + make_test_image, + create_fake_model_files, +) -> None: + create_fake_model_files() + test_state.state.app_settings.use_local_text_encoder = True + uploaded = _upload_image(client, make_test_image) + + generated = client.post( + "/api/generate", + json={"prompt": "remote image to video", "imageMediaId": uploaded["media_id"]}, + ) + assert generated.status_code == 200, generated.text + artifact = generated.json()["artifact"] + assert artifact["artifact_id"].startswith("art_") + downloaded = client.get(f"/api/artifacts/{artifact['artifact_id']}") + assert downloaded.status_code == 200 + assert downloaded.content == b"fake-video" + + +def test_ic_lora_extract_accepts_video_media_id(client) -> None: + uploaded = client.post( + "/api/media", + data={"media_type": "video"}, + files={"file": ("input.mp4", b"fake-video", "video/mp4")}, + ) + assert uploaded.status_code == 200, uploaded.text + extracted = client.post( + "/api/ic-lora/extract-conditioning", + json={"video_media_id": uploaded.json()["media_id"], "conditioning_type": "canny"}, + ) + assert extracted.status_code == 200, extracted.text + assert extracted.json()["conditioning"].startswith("data:image/jpeg;base64,") + + +def test_gap_prompt_accepts_frame_media_id(client, test_state, fake_services, make_test_image) -> None: + uploaded = _upload_image(client, make_test_image) + test_state.state.app_settings.gemini_api_key = "gemini-test" + fake_services.http.queue( + "post", + FakeResponse( + json_payload={ + "candidates": [{"content": {"parts": [{"text": "A seamless transition"}]}}] + } + ), + ) + response = client.post( + "/api/suggest-gap-prompt", + json={"beforeFrameMediaId": uploaded["media_id"], "gapDuration": 3}, + ) + assert response.status_code == 200, response.text + assert response.json()["suggested_prompt"] == "A seamless transition" diff --git a/backend/tests/test_server_config.py b/backend/tests/test_server_config.py new file mode 100644 index 000000000..8e427ad34 --- /dev/null +++ b/backend/tests/test_server_config.py @@ -0,0 +1,47 @@ +"""Standalone HTTP runtime configuration tests.""" + +from __future__ import annotations + +import pytest + +from runtime_config.server_config import load_server_config + + +def test_managed_local_defaults() -> None: + config = load_server_config({}) + assert config.deployment_mode == "managed_local" + assert config.bind_host == "127.0.0.1" + assert config.allow_legacy_path_inputs is True + assert "null" in config.allowed_origins + + +def test_standalone_requires_long_auth_token() -> None: + with pytest.raises(RuntimeError, match="requires LTX_AUTH_TOKEN"): + load_server_config({"LTX_DEPLOYMENT_MODE": "standalone", "LTX_AUTH_TOKEN": "short"}) + + +def test_standalone_configuration() -> None: + config = load_server_config( + { + "LTX_DEPLOYMENT_MODE": "standalone", + "LTX_AUTH_TOKEN": "x" * 32, + "LTX_PORT": "8123", + "LTX_PUBLIC_BASE_URL": "http://127.0.0.1:8123", + "LTX_ALLOWED_ORIGINS": "null,http://localhost:5173", + } + ) + assert config.deployment_mode == "standalone" + assert config.port == 8123 + assert config.public_base_url == "http://127.0.0.1:8123" + assert config.allow_legacy_path_inputs is False + assert config.models_dir_editable is False + + +def test_non_loopback_plain_http_public_url_is_rejected() -> None: + with pytest.raises(RuntimeError, match="must use HTTPS"): + load_server_config( + { + "LTX_AUTH_TOKEN": "x" * 32, + "LTX_PUBLIC_BASE_URL": "http://atom.local:8000", + } + ) diff --git a/backend/tests/test_server_info.py b/backend/tests/test_server_info.py new file mode 100644 index 000000000..3b6eddee3 --- /dev/null +++ b/backend/tests/test_server_info.py @@ -0,0 +1,26 @@ +"""Backend capability handshake tests.""" + + +def test_managed_local_server_info(client) -> None: + response = client.get("/api/server-info") + assert response.status_code == 200 + assert response.json() == { + "api_version": 2, + "deployment_mode": "managed_local", + "capabilities": { + "media_ids": True, + "artifact_downloads": True, + "legacy_path_inputs": True, + "models_dir_editable": True, + }, + } + + +def test_standalone_server_info(client, test_state) -> None: + test_state.config.deployment_mode = "standalone" + test_state.config.allow_legacy_path_inputs = False + test_state.config.models_dir_editable = False + response = client.get("/api/server-info") + assert response.status_code == 200 + assert response.json()["deployment_mode"] == "standalone" + assert response.json()["capabilities"]["legacy_path_inputs"] is False diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index 44a310e07..336573730 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -160,6 +160,25 @@ def test_effective_models_dir_fallback(self, client, test_state): assert test_state.state.app_settings.models_dir == "" assert test_state.models.models_dir == test_state.config.default_models_dir + def test_standalone_models_dir_is_server_managed(self, client, test_state, tmp_path): + override = tmp_path / "atom-models" + override.mkdir() + test_state.config.deployment_mode = "standalone" + test_state.config.models_dir_editable = False + test_state.config.models_dir_override = override + + response = client.post( + "/api/settings", + json={"modelsDir": "/client/path"}, + headers={"X-Admin-Token": TEST_ADMIN_TOKEN}, + ) + assert response.status_code == 409 + assert response.json()["code"] == "MODELS_DIR_MANAGED_BY_SERVER" + + settings = client.get("/api/settings") + assert settings.json()["modelsDir"] == str(override) + assert test_state.models.models_dir == override + def test_models_dir_persists_and_loads(self, client, test_state, default_app_settings): r = client.post( "/api/settings", diff --git a/docs/REMOTE_BACKEND.md b/docs/REMOTE_BACKEND.md new file mode 100644 index 000000000..e1a936573 --- /dev/null +++ b/docs/REMOTE_BACKEND.md @@ -0,0 +1,118 @@ +# Remote Backend (Experimental) + +LTX Desktop can keep the Electron interface, project files, playback, and export on one computer while running the Python backend and LTX models on a separate GPU machine. + +Remote compute is optional. The default `managed_local` mode continues to start and manage the bundled backend on the same computer as Electron. + +## Architecture + +```text +Mac or PC GPU machine +--------------------------- ----------------------------- +Electron + React UI Standalone FastAPI backend +Project assets and playback -> Media upload by opaque ID +Local thumbnails and export <- Generated artifact download + LTX models and GPU inference +``` + +The standalone backend does not accept client-provided filesystem paths. Inputs are uploaded through `/api/media`, and generated files are downloaded through authenticated artifact IDs. After download, LTX Desktop imports the result into its normal local project-assets directory. + +## Security model + +The standalone server is a trusted, single-user service. It is not designed as a public or multi-tenant API. + +- Standalone mode requires a bearer token of at least 32 characters. +- Raw filesystem-path inputs, Basic authentication, query-string authentication, and remote shutdown are disabled. +- Plain HTTP is supported only through a loopback address. Use an SSH tunnel for a private machine or an HTTPS reverse proxy for a non-loopback address. +- Do not expose port 8000 directly to an untrusted LAN or the internet. + +## Start the backend on the GPU machine + +Install the backend environment from the repository: + +```bash +cd backend +uv sync --frozen +``` + +Generate an authentication token: + +```bash +python -c 'import secrets; print(secrets.token_urlsafe(32))' +``` + +Set the standalone environment and start the server: + +```bash +export LTX_DEPLOYMENT_MODE=standalone +export LTX_APP_DATA_DIR=/srv/ltx-desktop +export LTX_MODELS_DIR=/srv/ltx-models +export LTX_AUTH_TOKEN='replace-with-the-generated-token' +export LTX_BIND_HOST=127.0.0.1 +export LTX_PORT=8000 +export LTX_PUBLIC_BASE_URL=http://127.0.0.1:8000 +export LTX_ALLOWED_ORIGINS='null,http://localhost:5173,http://127.0.0.1:5173' + +uv run python ltx2_server.py +``` + +`LTX_MODELS_DIR` is optional. When set, it is authoritative and cannot be changed from the desktop client. Ensure the service account can read and write both data directories. + +The server is ready when it prints: + +```text +Server running on http://127.0.0.1:8000 +``` + +## Connect through SSH + +On the computer running LTX Desktop, forward the same local port to the GPU machine: + +```bash +ssh -N -L 8000:127.0.0.1:8000 user@atom +``` + +Keeping port 8000 on both sides also preserves the default Hugging Face OAuth callback address. + +In LTX Desktop: + +1. Open **Settings → Compute**. +2. Choose **Remote machine**. +3. Enter `http://127.0.0.1:8000`. +4. Enter the bearer token from `LTX_AUTH_TOKEN`. +5. Select **Test connection**, then **Save & reconnect**. + +The connection requires standalone API version 2 and verifies media-upload and artifact-download capabilities before it is saved. + +## Direct HTTPS connection + +For a direct Tailscale or network connection, terminate TLS in a reverse proxy and set `LTX_PUBLIC_BASE_URL` to the externally reachable HTTPS origin. The backend intentionally rejects a non-loopback `http://` public URL. + +Example environment: + +```bash +export LTX_BIND_HOST=127.0.0.1 +export LTX_PUBLIC_BASE_URL=https://ltx-atom.example.ts.net +export LTX_ALLOWED_ORIGINS='null,http://localhost:5173,http://127.0.0.1:5173' +``` + +Configure the reverse proxy to preserve `Authorization`, `Content-Type`, and `Range` headers and to support large streaming request bodies. + +## Storage lifecycle + +- Uploaded inputs expire after 24 hours. +- Generated artifacts expire after 7 days unless the desktop client downloads and deletes them earlier. +- Partial transfers are written atomically and removed after failures. +- The Electron client validates downloaded size and SHA-256 before importing an artifact. +- Upload references are refreshed before expiry instead of being reused indefinitely. +- Video and image requests carry a client generation ID. If the connection drops after the Atom accepts a job, the desktop client keeps polling that exact job and downloads its completed artifacts after reconnection. + +## Current limitations + +- Remote inference does not yet remove every local media-tool dependency. Existing thumbnail and export code still uses the desktop application's packaged Python/ffmpeg tooling. +- Upload cancellation is not yet connected to generation cancellation. +- Remote compute is intended for one desktop client per backend instance; generation state is still single-client. + +## Return to local compute + +Open **Settings → Compute**, choose **This computer**, and select **Save & reconnect**. LTX Desktop will return to its original managed-local startup and model workflow. diff --git a/electron/app-state.ts b/electron/app-state.ts index 231f73e09..94bda715d 100644 --- a/electron/app-state.ts +++ b/electron/app-state.ts @@ -1,4 +1,4 @@ -import { app } from 'electron' +import { app, safeStorage } from 'electron' import fs from 'fs' import path from 'path' @@ -6,9 +6,30 @@ export interface AppState { analyticsEnabled?: boolean installationId?: string projectAssetsPath?: string + backendConnection?: StoredBackendConnection [key: string]: unknown } +export type BackendConnectionMode = 'managed-local' | 'external' + +interface StoredBackendConnection { + mode: BackendConnectionMode + url?: string + authToken?: string + adminToken?: string +} + +export type BackendConnectionConfig = + | { mode: 'managed-local' } + | { mode: 'external'; url: string; authToken: string; adminToken?: string } + +export interface BackendConnectionSummary { + mode: BackendConnectionMode + url: string + hasAuthToken: boolean + hasAdminToken: boolean +} + export function getAppStatePath(): string { return path.join(app.getPath('userData'), 'app_state.json') } @@ -29,6 +50,74 @@ export function writeAppState(state: AppState): void { fs.writeFileSync(getAppStatePath(), JSON.stringify(state, null, 2)) } +function encryptSecret(value: string): string { + if (!safeStorage.isEncryptionAvailable()) { + throw new Error('Secure credential storage is unavailable on this system') + } + return safeStorage.encryptString(value).toString('base64') +} + +function decryptSecret(value: string | undefined): string { + if (!value) return '' + if (!safeStorage.isEncryptionAvailable()) { + throw new Error('Secure credential storage is unavailable on this system') + } + return safeStorage.decryptString(Buffer.from(value, 'base64')) +} + +export function readBackendConnectionConfig(): BackendConnectionConfig { + const stored = readAppState().backendConnection + if (!stored || stored.mode !== 'external') { + return { mode: 'managed-local' } + } + + if (!stored.url || !stored.authToken) { + throw new Error('The saved external backend connection is incomplete') + } + + const adminToken = decryptSecret(stored.adminToken) + return { + mode: 'external', + url: stored.url, + authToken: decryptSecret(stored.authToken), + ...(adminToken ? { adminToken } : {}), + } +} + +export function getBackendConnectionSummary(): BackendConnectionSummary { + const stored = readAppState().backendConnection + if (!stored || stored.mode !== 'external') { + return { + mode: 'managed-local', + url: '', + hasAuthToken: false, + hasAdminToken: false, + } + } + + return { + mode: 'external', + url: stored.url ?? '', + hasAuthToken: Boolean(stored.authToken), + hasAdminToken: Boolean(stored.adminToken), + } +} + +export function writeBackendConnectionConfig(config: BackendConnectionConfig): void { + const state = readAppState() + if (config.mode === 'managed-local') { + state.backendConnection = { mode: 'managed-local' } + } else { + state.backendConnection = { + mode: 'external', + url: config.url, + authToken: encryptSecret(config.authToken), + ...(config.adminToken ? { adminToken: encryptSecret(config.adminToken) } : {}), + } + } + writeAppState(state) +} + let cachedProjectAssetsPath: string | null = null export function getProjectAssetsPath(): string { diff --git a/electron/backend-media-transfer.ts b/electron/backend-media-transfer.ts new file mode 100644 index 000000000..c50e8bf60 --- /dev/null +++ b/electron/backend-media-transfer.ts @@ -0,0 +1,283 @@ +import { app } from 'electron' +import crypto from 'crypto' +import fs from 'fs' +import http from 'http' +import https from 'https' +import os from 'os' +import path from 'path' +import { pipeline } from 'stream/promises' +import { + getAuthToken, + getBackendConnectionMode, + getBackendConnectionRevision, + getBackendUrl, +} from './python-backend' +import { logger } from './logger' + +export type BackendMediaType = 'image' | 'audio' | 'video' + +export interface BackendMediaRef { + media_id: string + media_type: BackendMediaType + filename: string + content_type: string + size_bytes: number + sha256: string + expires_at: string +} + +export interface BackendArtifactRef { + artifact_id: string + media_type: BackendMediaType + filename: string + content_type: string + size_bytes: number + sha256: string + expires_at: string +} + +const CONTENT_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', + '.wav': 'audio/wav', + '.mp3': 'audio/mpeg', + '.m4a': 'audio/mp4', + '.aac': 'audio/aac', + '.flac': 'audio/flac', + '.mp4': 'video/mp4', + '.mov': 'video/quicktime', + '.webm': 'video/webm', + '.mkv': 'video/x-matroska', +} + +interface CachedMediaUpload { + ref: BackendMediaRef + expiresAtMs: number +} + +const MEDIA_EXPIRY_SAFETY_WINDOW_MS = 5 * 60 * 1000 +const MAX_MEDIA_CACHE_AGE_MS = 23 * 60 * 60 * 1000 +const mediaCache = new Map>() +const artifactCache = new Map>() +let cleanedTransferDirectory = false + +function requireExternalBackend(): { url: string; token: string } { + if (getBackendConnectionMode() !== 'external') { + throw new Error('Remote media transfer is only available for external backends') + } + const url = getBackendUrl() + const token = getAuthToken() + if (!url || !token) throw new Error('External backend is not connected') + return { url, token } +} + +function resolveMediaPath(filePath: string): string { + if (!path.isAbsolute(filePath)) throw new Error('Media source path must be absolute') + const resolvedPath = path.resolve(filePath) + if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isFile()) { + throw new Error(`Media source file does not exist: ${resolvedPath}`) + } + return resolvedPath +} + +function requestModule(url: URL): typeof http | typeof https { + return url.protocol === 'https:' ? https : http +} + +function safeFilename(value: string): string { + const base = path.basename(value).replace(/[^a-zA-Z0-9._-]/g, '_') + return base && base !== '.' && base !== '..' ? base : 'artifact.bin' +} + +function transferDirectory(): string { + const directory = path.join(app.getPath('temp') || os.tmpdir(), 'ltx-desktop', 'backend-transfers') + fs.mkdirSync(directory, { recursive: true }) + if (!cleanedTransferDirectory) { + cleanedTransferDirectory = true + const cutoff = Date.now() - 24 * 60 * 60 * 1000 + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + if (!entry.isFile()) continue + const entryPath = path.join(directory, entry.name) + try { + if (fs.statSync(entryPath).mtimeMs < cutoff) fs.unlinkSync(entryPath) + } catch { + // Best-effort cache cleanup. + } + } + } + return directory +} + +async function readErrorResponse(response: http.IncomingMessage): Promise { + const chunks: Buffer[] = [] + for await (const chunk of response) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + if (chunks.reduce((total, item) => total + item.length, 0) > 1024 * 1024) break + } + return Buffer.concat(chunks).toString('utf8').slice(0, 2000) +} + +async function uploadFile(filePath: string, mediaType: BackendMediaType): Promise { + const { url, token } = requireExternalBackend() + const resolvedPath = resolveMediaPath(filePath) + const stat = fs.statSync(resolvedPath) + if (!stat.isFile()) throw new Error('Media source is not a file') + + const filename = safeFilename(resolvedPath) + const contentType = CONTENT_TYPES[path.extname(filename).toLowerCase()] ?? 'application/octet-stream' + const boundary = `----ltxdesktop-${crypto.randomBytes(18).toString('hex')}` + const escapedFilename = filename.replace(/"/g, '_') + const preamble = Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="media_type"\r\n\r\n${mediaType}\r\n` + + `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${escapedFilename}"\r\n` + + `Content-Type: ${contentType}\r\n\r\n`, + ) + const suffix = Buffer.from(`\r\n--${boundary}--\r\n`) + const endpoint = new URL('/api/media', url) + + return await new Promise((resolve, reject) => { + const request = requestModule(endpoint).request(endpoint, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'Content-Length': String(preamble.length + stat.size + suffix.length), + }, + }, (response) => { + void (async () => { + if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) { + reject(new Error(`Media upload failed (HTTP ${response.statusCode ?? 0}): ${await readErrorResponse(response)}`)) + return + } + const chunks: Buffer[] = [] + for await (const chunk of response) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')) as BackendMediaRef) + } catch { + reject(new Error('Media upload returned invalid JSON')) + } + })().catch(reject) + }) + request.on('error', reject) + request.setTimeout(30_000, () => request.destroy(new Error('Media upload timed out'))) + request.write(preamble) + const source = fs.createReadStream(resolvedPath) + source.on('error', reject) + source.on('end', () => { + request.end(suffix) + }) + source.pipe(request, { end: false }) + }) +} + +export async function uploadBackendMedia( + filePath: string, + mediaType: BackendMediaType, +): Promise { + const resolvedPath = resolveMediaPath(filePath) + const stat = fs.statSync(resolvedPath) + const key = `${getBackendConnectionRevision()}:${mediaType}:${resolvedPath}:${stat.size}:${stat.mtimeMs}` + const cached = mediaCache.get(key) + if (cached) { + const cachedUpload = await cached + if (cachedUpload.expiresAtMs - MEDIA_EXPIRY_SAFETY_WINDOW_MS > Date.now()) { + return cachedUpload.ref + } + mediaCache.delete(key) + } + + const pending = uploadFile(resolvedPath, mediaType) + .then((ref) => { + const serverExpiresAtMs = Date.parse(ref.expires_at) + if (!Number.isFinite(serverExpiresAtMs)) { + throw new Error('Media upload returned an invalid expiry time') + } + // Bound the server timestamp by a local age so clock skew cannot make a + // cache entry outlive the backend's 24-hour upload TTL. + const expiresAtMs = Math.min(serverExpiresAtMs, Date.now() + MAX_MEDIA_CACHE_AGE_MS) + return { ref, expiresAtMs } + }) + .catch((error) => { + mediaCache.delete(key) + throw error + }) + mediaCache.set(key, pending) + return (await pending).ref +} + +async function deleteArtifact(artifactId: string): Promise { + const { url, token } = requireExternalBackend() + try { + await fetch(`${url}/api/artifacts/${encodeURIComponent(artifactId)}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }) + } catch (error) { + logger.warn(`Failed to delete downloaded backend artifact: ${error}`) + } +} + +async function downloadArtifact(artifact: BackendArtifactRef): Promise { + const { url, token } = requireExternalBackend() + if (!artifact.artifact_id || artifact.artifact_id.length > 256) throw new Error('Invalid backend artifact ID') + const filename = safeFilename(artifact.filename) + const unique = crypto.randomBytes(8).toString('hex') + const finalPath = path.join(transferDirectory(), `${unique}-${filename}`) + const partialPath = `${finalPath}.part` + const endpoint = new URL(`/api/artifacts/${encodeURIComponent(artifact.artifact_id)}`, url) + + try { + await new Promise((resolve, reject) => { + const request = requestModule(endpoint).request(endpoint, { + method: 'GET', + headers: { Authorization: `Bearer ${token}` }, + }, (response) => { + void (async () => { + if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) { + reject(new Error(`Artifact download failed (HTTP ${response.statusCode ?? 0}): ${await readErrorResponse(response)}`)) + return + } + const hash = crypto.createHash('sha256') + let receivedBytes = 0 + response.on('data', (chunk: Buffer) => { + hash.update(chunk) + receivedBytes += chunk.length + }) + await pipeline(response, fs.createWriteStream(partialPath, { flags: 'wx' })) + if (receivedBytes !== artifact.size_bytes) { + throw new Error(`Artifact size mismatch: expected ${artifact.size_bytes}, received ${receivedBytes}`) + } + const digest = hash.digest('hex') + if (artifact.sha256 && digest.toLowerCase() !== artifact.sha256.toLowerCase()) { + throw new Error('Artifact checksum verification failed') + } + fs.renameSync(partialPath, finalPath) + resolve() + })().catch(reject) + }) + request.on('error', reject) + request.setTimeout(30_000, () => request.destroy(new Error('Artifact download timed out'))) + request.end() + }) + await deleteArtifact(artifact.artifact_id) + return finalPath + } catch (error) { + try { if (fs.existsSync(partialPath)) fs.unlinkSync(partialPath) } catch { /* best effort */ } + throw error + } +} + +export async function materializeBackendArtifact(artifact: BackendArtifactRef): Promise { + const cached = artifactCache.get(artifact.artifact_id) + if (cached) return cached + const pending = downloadArtifact(artifact).catch((error) => { + artifactCache.delete(artifact.artifact_id) + throw error + }) + artifactCache.set(artifact.artifact_id, pending) + return pending +} diff --git a/electron/csp.ts b/electron/csp.ts index 9d78dd9f3..3f5b848f5 100644 --- a/electron/csp.ts +++ b/electron/csp.ts @@ -1,16 +1,31 @@ import { session } from 'electron' import { isDev } from './config' +import { getBackendConnectionSummary } from './app-state' + +function getConfiguredBackendSources(): string[] { + const summary = getBackendConnectionSummary() + if (summary.mode !== 'external' || !summary.url) return [] + try { + const origin = new URL(summary.url).origin + const wsOrigin = origin.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:') + return [origin, wsOrigin] + } catch { + return [] + } +} // Enforce Content Security Policy via response headers (tamper-proof from renderer) export function setupCSP(): void { session.defaultSession.webRequest.onHeadersReceived((details, callback) => { + const configuredBackendSources = getConfiguredBackendSources().join(' ') + const connectSources = `'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*${configuredBackendSources ? ` ${configuredBackendSources}` : ''}` const csp = isDev ? [ "default-src 'self'", "script-src 'self' 'unsafe-inline'", "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", "font-src 'self' https://fonts.gstatic.com", - "connect-src 'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*", + `connect-src ${connectSources}`, "img-src 'self' data: blob: file:", "media-src 'self' blob: file:", "object-src 'none'", @@ -23,7 +38,7 @@ export function setupCSP(): void { "script-src 'self'", "style-src 'self' https://fonts.googleapis.com", "font-src 'self' https://fonts.gstatic.com", - "connect-src 'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*", + `connect-src ${connectSources}`, "img-src 'self' data: blob: file:", "media-src 'self' blob: file:", "object-src 'none'", diff --git a/electron/gpu.ts b/electron/gpu.ts index 2fe414c20..9a2424941 100644 --- a/electron/gpu.ts +++ b/electron/gpu.ts @@ -1,6 +1,6 @@ import { execSync } from 'child_process' import { logger } from './logger' -import { getAuthToken, getBackendUrl, getPythonPath } from './python-backend' +import { getAuthToken, getBackendConnectionMode, getBackendUrl, getPythonPath } from './python-backend' // Check if NVIDIA GPU is available export async function checkGPU(): Promise<{ available: boolean; name?: string; vram?: number }> { @@ -28,6 +28,10 @@ export async function checkGPU(): Promise<{ available: boolean; name?: string; v logger.warn(`Backend GPU check failed, trying direct check: ${error}`) } + if (getBackendConnectionMode() === 'external') { + return { available: false } + } + // Fallback: try direct Python check try { const pythonPath = getPythonPath() diff --git a/electron/ipc/app-handlers.ts b/electron/ipc/app-handlers.ts index a512b77f7..540b7d124 100644 --- a/electron/ipc/app-handlers.ts +++ b/electron/ipc/app-handlers.ts @@ -3,10 +3,23 @@ import path from 'path' import fs from 'fs' import { checkGPU } from '../gpu' import { isPythonReady, downloadPythonEmbed } from '../python-setup' -import { getBackendHealthStatus, getBackendUrl, getAuthToken, getAdminToken, startPythonBackend } from '../python-backend' +import { + configureBackendConnection, + getBackendConnectionMode, + getBackendConnectionRevision, + getBackendHealthStatus, + getBackendUrl, + getAuthToken, + getAdminToken, + startBackendConnection, + startPythonBackend, + testExternalBackendConnection, +} from '../python-backend' import { getMainWindow } from '../window' import { getAnalyticsState, setAnalyticsEnabled, sendAnalyticsEvent } from '../analytics' import { handle } from './typed-handle' +import { getBackendConnectionSummary } from '../app-state' +import { materializeBackendArtifact, uploadBackendMedia } from '../backend-media-transfer' function getModelsPath(): string { const modelsPath = path.join(app.getPath('userData'), 'models') @@ -69,7 +82,42 @@ function markLicenseAccepted(settingsPath: string): void { export function registerAppHandlers(): void { handle('getBackend', () => { - return { url: getBackendUrl() ?? '', token: getAuthToken() ?? '' } + return { + url: getBackendUrl() ?? '', + token: getAuthToken() ?? '', + mode: getBackendConnectionMode(), + connectionRevision: getBackendConnectionRevision(), + } + }) + + handle('getBackendConnectionConfig', () => { + return getBackendConnectionSummary() + }) + + handle('testBackendConnection', async ({ url, authToken }) => { + try { + const serverInfo = await testExternalBackendConnection(url, authToken) + return { success: true, serverInfo } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + }) + + handle('setBackendConnection', async (config) => { + try { + await configureBackendConnection(config) + return { success: true } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + }) + + handle('startBackendConnection', async () => { + await startBackendConnection() + }) + + handle('retryBackendConnection', async () => { + await startBackendConnection() }) handle('getModelsPath', () => { @@ -148,6 +196,24 @@ export function registerAppHandlers(): void { return getBackendHealthStatus() }) + handle('uploadBackendMedia', async ({ filePath, mediaType }) => { + try { + const media = await uploadBackendMedia(filePath, mediaType) + return { success: true, media } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + }) + + handle('materializeBackendArtifact', async ({ artifact }) => { + try { + const materializedPath = await materializeBackendArtifact(artifact) + return { success: true, path: materializedPath } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + }) + handle('getAnalyticsState', () => { return getAnalyticsState() }) @@ -161,6 +227,9 @@ export function registerAppHandlers(): void { }) handle('openModelsDirChangeDialog', async () => { + if (getBackendConnectionMode() === 'external') { + return { success: false, error: 'Choose the models path on the Atom, not on this computer' } + } const mainWindow = getMainWindow() if (!mainWindow) return { success: false, error: 'No window' } @@ -190,4 +259,29 @@ export function registerAppHandlers(): void { return { success: true, path: newDir } }) + handle('setBackendModelsDirectory', async ({ path: modelsDir }) => { + const url = getBackendUrl() + const auth = getAuthToken() + const admin = getAdminToken() + if (!url || !auth || !admin) { + return { success: false, error: 'An admin token is required to change the backend models directory' } + } + + try { + const resp = await fetch(`${url}/api/settings`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${auth}`, + 'X-Admin-Token': admin, + }, + body: JSON.stringify({ modelsDir }), + }) + if (!resp.ok) return { success: false, error: await resp.text() } + return { success: true, path: modelsDir } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + }) + } diff --git a/electron/python-backend.ts b/electron/python-backend.ts index 235278b29..0f8874702 100644 --- a/electron/python-backend.ts +++ b/electron/python-backend.ts @@ -9,6 +9,13 @@ import { logger, writeLog } from './logger' import { getCurrentLogFilename } from './logging-management' import { getPythonDir } from './python-setup' import { getMainWindow } from './window' +import { + getBackendConnectionSummary, + readBackendConnectionConfig, + writeBackendConnectionConfig, + type BackendConnectionConfig, + type BackendConnectionMode, +} from './app-state' let pythonProcess: ChildProcess | null = null let isIntentionalShutdown = false @@ -30,25 +37,39 @@ let livenessFailureCount = 0 let backendUrl: string | null = null let authToken: string | null = null let adminToken: string | null = null +let activeConnectionMode: BackendConnectionMode = getBackendConnectionSummary().mode +let connectionRevision = 0 export function getBackendUrl(): string | null { return backendUrl } export function getAuthToken(): string | null { return authToken } export function getAdminToken(): string | null { return adminToken } +export function getBackendConnectionMode(): BackendConnectionMode { return activeConnectionMode } +export function getBackendConnectionRevision(): number { return connectionRevision } type BackendOwnership = 'managed' | 'adopted' | null let backendOwnership: BackendOwnership = null export interface BackendHealthStatus { - status: 'alive' | 'restarting' | 'dead' + mode: BackendConnectionMode + status: 'connecting' | 'alive' | 'restarting' | 'unreachable' | 'dead' exitCode?: number | null + checkedAt: number + message?: string } let latestBackendHealthStatus: BackendHealthStatus | null = null -function publishBackendHealthStatus(status: BackendHealthStatus): void { - latestBackendHealthStatus = status - getMainWindow()?.webContents.send('backend-health-status', status) +function publishBackendHealthStatus( + status: Omit & Partial>, +): void { + const payload: BackendHealthStatus = { + ...status, + mode: status.mode ?? activeConnectionMode, + checkedAt: status.checkedAt ?? Date.now(), + } + latestBackendHealthStatus = payload + getMainWindow()?.webContents.send('backend-health-status', payload) } export function getBackendHealthStatus(): BackendHealthStatus | null { @@ -91,6 +112,88 @@ async function probeBackendHealth(timeoutMs = 1500, probeUrl?: string): Promise< } } +export interface ExternalServerInfo { + api_version: number + deployment_mode: 'managed_local' | 'standalone' + capabilities: { + media_ids: boolean + artifact_downloads: boolean + legacy_path_inputs: boolean + models_dir_editable: boolean + } +} + +export function normalizeExternalBackendUrl(value: string): string { + let parsed: URL + try { + parsed = new URL(value.trim()) + } catch { + throw new Error('Enter a valid backend URL') + } + + if (parsed.username || parsed.password || parsed.search || parsed.hash) { + throw new Error('Backend URL must not contain credentials, query parameters, or a fragment') + } + if (parsed.pathname !== '/' && parsed.pathname !== '') { + throw new Error('Backend URL must not include a path') + } + + const hostname = parsed.hostname.toLowerCase() + const isLoopback = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1' + if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && isLoopback)) { + throw new Error('Use HTTPS for remote hosts, or HTTP through a localhost SSH tunnel') + } + return parsed.origin +} + +async function fetchExternalServerInfo( + url: string, + token: string, + timeoutMs = 5000, +): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await fetch(`${url}/api/server-info`, { + headers: { Authorization: `Bearer ${token}` }, + signal: controller.signal, + }) + if (response.status === 401 || response.status === 403) { + throw new Error('The backend rejected the authentication token') + } + if (!response.ok) { + throw new Error(`Server compatibility probe failed (HTTP ${response.status})`) + } + const payload = await response.json() as Partial + if ( + typeof payload.api_version !== 'number' + || payload.api_version < 2 + || payload.deployment_mode !== 'standalone' + || !payload.capabilities?.media_ids + || !payload.capabilities.artifact_downloads + ) { + throw new Error('This backend does not support the remote media protocol required by LTX Desktop') + } + return payload as ExternalServerInfo + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('Timed out while connecting to the backend') + } + throw error + } finally { + clearTimeout(timeout) + } +} + +export async function testExternalBackendConnection( + url: string, + token: string, +): Promise { + const normalizedUrl = normalizeExternalBackendUrl(url) + if (!token.trim()) throw new Error('Authentication token is required') + return fetchExternalServerInfo(normalizedUrl, token.trim()) +} + async function requestAdoptedBackendShutdown(timeoutMs = 2000): Promise { if (!backendUrl) return false const controller = new AbortController() @@ -135,9 +238,26 @@ function startLivenessMonitor(): void { stopLivenessMonitor() livenessMonitorTimer = setInterval(() => { void (async () => { - if (!pythonProcess || backendOwnership !== 'managed' || isIntentionalShutdown) { + if (activeConnectionMode === 'external') { + const healthy = await probeBackendHealth(3000) + if (healthy) { + livenessFailureCount = 0 + if (latestBackendHealthStatus?.status !== 'alive') { + publishBackendHealthStatus({ status: 'alive' }) + } + return + } + livenessFailureCount += 1 + if (livenessFailureCount >= LIVENESS_FAILURE_THRESHOLD) { + publishBackendHealthStatus({ + status: 'unreachable', + message: 'The external backend is not responding. LTX Desktop will keep retrying.', + }) + } return } + + if (!pythonProcess || backendOwnership !== 'managed' || isIntentionalShutdown) return const healthy = await probeBackendHealth(2000) if (healthy) { livenessFailureCount = 0 @@ -158,6 +278,75 @@ function startLivenessMonitor(): void { }, LIVENESS_POLL_INTERVAL_MS) } +async function connectExternalBackend(): Promise { + stopLivenessMonitor() + activeConnectionMode = 'external' + backendOwnership = null + isIntentionalShutdown = false + publishBackendHealthStatus({ status: 'connecting' }) + + try { + const config = readBackendConnectionConfig() + if (config.mode !== 'external') throw new Error('No external backend is configured') + backendUrl = normalizeExternalBackendUrl(config.url) + authToken = config.authToken + adminToken = config.adminToken ?? null + await fetchExternalServerInfo(backendUrl, authToken) + if (!await probeBackendHealth(3000)) { + throw new Error('The backend compatibility check passed, but its health endpoint is unavailable') + } + publishBackendHealthStatus({ status: 'alive' }) + startLivenessMonitor() + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + publishBackendHealthStatus({ status: 'unreachable', message }) + startLivenessMonitor() + throw error + } +} + +export async function startBackendConnection(): Promise { + activeConnectionMode = getBackendConnectionSummary().mode + if (activeConnectionMode === 'external') { + await connectExternalBackend() + return + } + await startPythonBackend() +} + +export async function configureBackendConnection(config: BackendConnectionConfig): Promise { + if (config.mode === 'external') { + const normalizedUrl = normalizeExternalBackendUrl(config.url) + const normalizedToken = config.authToken.trim() + if (!normalizedToken) throw new Error('Authentication token is required') + await fetchExternalServerInfo(normalizedUrl, normalizedToken) + + if (activeConnectionMode === 'managed-local' && pythonProcess) { + stopPythonBackend() + } else { + stopLivenessMonitor() + } + writeBackendConnectionConfig({ + mode: 'external', + url: normalizedUrl, + authToken: normalizedToken, + ...(config.adminToken?.trim() ? { adminToken: config.adminToken.trim() } : {}), + }) + activeConnectionMode = 'external' + } else { + stopLivenessMonitor() + writeBackendConnectionConfig({ mode: 'managed-local' }) + activeConnectionMode = 'managed-local' + } + + backendUrl = null + authToken = null + adminToken = null + backendOwnership = null + latestBackendHealthStatus = null + connectionRevision += 1 +} + function startOwnershipTakeover(): void { if (takeoverInFlight || backendOwnership !== 'adopted') { return @@ -243,6 +432,7 @@ export function getPythonPath(): string { } export async function startPythonBackend(): Promise { + activeConnectionMode = 'managed-local' if (startPromise) { return startPromise } @@ -402,11 +592,13 @@ export async function startPythonBackend(): Promise { pythonProcess.on('exit', async (code) => { logger.info(`Python backend exited with code ${code}`) - stopLivenessMonitor() pythonProcess = null - backendUrl = null - authToken = null - adminToken = null + if (activeConnectionMode === 'managed-local') { + stopLivenessMonitor() + backendUrl = null + authToken = null + adminToken = null + } if (!started) { if (isIntentionalShutdown) { @@ -482,6 +674,11 @@ export async function startPythonBackend(): Promise { } export function stopPythonBackend(): void { + if (activeConnectionMode === 'external') { + // The desktop client never owns, stops, or restarts an external backend. + stopLivenessMonitor() + return + } if (pythonProcess) { isIntentionalShutdown = true stopLivenessMonitor() diff --git a/frontend/App.tsx b/frontend/App.tsx index 23e4de263..5404fb7d2 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -17,6 +17,7 @@ import { SettingsModal, type SettingsTabId } from './components/SettingsModal' import { LogViewer } from './components/LogViewer' import { ApiGatewayModal, type ApiGatewaySection } from './components/ApiGatewayModal' import { Button } from './components/ui/button' +import { BackendConnectionPanel } from './components/BackendConnectionPanel' type SetupState = 'loading' | { needsSetup: boolean; needsLicense: boolean } type RequiredModelsGateState = 'checking' | 'missing' | 'ready' @@ -25,10 +26,17 @@ type LtxUpgradeRecommendation = Extract(null) + const [pythonSetupSelected, setPythonSetupSelected] = useState(false) const [backendStarted, setBackendStarted] = useState(false) const [setupState, setSetupState] = useState('loading') const [isSettingsOpen, setIsSettingsOpen] = useState(false) @@ -53,8 +61,9 @@ function AppContent() { const [apiGatewayRequest, setApiGatewayRequest] = useState(null) - const isBackendRestarting = processStatus === 'restarting' - const isBackendDead = processStatus === 'dead' + const isBackendRestarting = processStatus === 'restarting' && connectionMode !== 'external' + const isBackendDead = processStatus === 'dead' && connectionMode !== 'external' + const isExternalBackendUnreachable = processStatus === 'unreachable' && connectionMode === 'external' const waitingForRuntimePolicy = processStatus === 'alive' && !runtimePolicyLoaded useEffect(() => { @@ -86,6 +95,11 @@ function AppContent() { useEffect(() => { const check = async () => { try { + const connection = await window.electronAPI.getBackendConnectionConfig() + if (connection.mode === 'external') { + setPythonReady(true) + return + } const result = await window.electronAPI.checkPythonReady() setPythonReady(result.ready) } catch (e) { @@ -101,9 +115,9 @@ function AppContent() { setBackendStarted(true) const start = async () => { try { - logger.info('Starting Python backend...') - await window.electronAPI.startPythonBackend() - logger.info('Python backend started successfully') + logger.info('Starting backend connection...') + await window.electronAPI.startBackendConnection() + logger.info('Backend connection started successfully') } catch (e) { logger.error(`Failed to start Python backend: ${e}`) } @@ -342,6 +356,30 @@ function AppContent() { ) : null + const externalBackendOverlay = isExternalBackendUnreachable ? ( +
+
+
+
+ +

Remote backend disconnected

+

+ {backendMessage || 'Check that the Atom backend and SSH tunnel are running. Active work remains open while LTX Desktop reconnects.'} +

+ +
+ +
+
+
+ ) : null + const showGlobalControls = currentView !== 'home' && connected && setupState !== 'loading' && !setupState.needsSetup const shouldBlockUntilSettingsLoaded = forceApiGenerations && !isLoaded const shouldShowForcedFirstRunUpsell = isForcedFirstRun && isLoaded && !settings.hasLtxApiKey @@ -424,7 +462,27 @@ function AppContent() { } if (pythonReady === false) { - return setPythonReady(true)} /> + if (pythonSetupSelected) { + return setPythonReady(true)} /> + } + return ( +
+
+
+
+

Choose where inference runs

+

+ Install the managed runtime for this computer, or connect directly to a standalone backend on another machine. +

+ +
+ +
+
+
+ ) } if (isBackendDead) { @@ -465,6 +523,7 @@ function AppContent() { {restartingOverlay} + {externalBackendOverlay} ) } @@ -596,6 +655,7 @@ function AppContent() { )} {restartingOverlay} + {externalBackendOverlay} ) } diff --git a/frontend/components/BackendConnectionPanel.tsx b/frontend/components/BackendConnectionPanel.tsx new file mode 100644 index 000000000..d019935ae --- /dev/null +++ b/frontend/components/BackendConnectionPanel.tsx @@ -0,0 +1,169 @@ +import { useEffect, useState } from 'react' +import { Check, Loader2, Monitor, Server } from 'lucide-react' +import { Button } from './ui/button' + +type ConnectionMode = 'managed-local' | 'external' + +interface BackendConnectionPanelProps { + onConfigured?: () => void + compact?: boolean +} + +export function BackendConnectionPanel({ onConfigured, compact = false }: BackendConnectionPanelProps) { + const [mode, setMode] = useState('managed-local') + const [url, setUrl] = useState('http://127.0.0.1:8000') + const [authToken, setAuthToken] = useState('') + const [adminToken, setAdminToken] = useState('') + const [hasSavedToken, setHasSavedToken] = useState(false) + const [busy, setBusy] = useState(false) + const [message, setMessage] = useState(null) + const [success, setSuccess] = useState(false) + + useEffect(() => { + void window.electronAPI.getBackendConnectionConfig().then((config) => { + setMode(config.mode) + if (config.url) setUrl(config.url) + setHasSavedToken(config.hasAuthToken) + }).catch((error) => setMessage(String(error))) + }, []) + + const testConnection = async () => { + if (!authToken.trim()) { + setSuccess(false) + setMessage(hasSavedToken + ? 'Re-enter the saved authentication token to test or update this connection.' + : 'Enter the backend authentication token.') + return + } + setBusy(true) + setMessage(null) + setSuccess(false) + try { + const result = await window.electronAPI.testBackendConnection({ url, authToken }) + if (!result.success) throw new Error(result.error) + setSuccess(true) + setMessage(`Connected to standalone API v${result.serverInfo.api_version}.`) + } catch (error) { + setMessage(error instanceof Error ? error.message : String(error)) + } finally { + setBusy(false) + } + } + + const saveConnection = async () => { + setBusy(true) + setMessage(null) + setSuccess(false) + try { + if (mode === 'external' && !authToken.trim()) { + throw new Error('Enter the backend authentication token before saving.') + } + const result = mode === 'managed-local' + ? await window.electronAPI.setBackendConnection({ mode: 'managed-local' }) + : await window.electronAPI.setBackendConnection({ + mode: 'external', + url, + authToken, + ...(adminToken.trim() ? { adminToken } : {}), + }) + if (!result.success) throw new Error(result.error) + setSuccess(true) + setMessage('Connection saved. Reloading LTX Desktop…') + onConfigured?.() + window.setTimeout(() => window.location.reload(), 250) + } catch (error) { + setMessage(error instanceof Error ? error.message : String(error)) + setBusy(false) + } + } + + return ( +
+
+

Compute backend

+

+ Run inference on this computer, or connect to a standalone LTX backend through an SSH tunnel or HTTPS. +

+
+ +
+ + +
+ + {mode === 'external' && ( +
+ + + +

+ Plain HTTP is accepted only on localhost. For an Atom bound to port 8000, use an SSH tunnel to the same Mac port. +

+
+ )} + + {message && ( +
+ {success && } + {message} +
+ )} + +
+ {mode === 'external' && ( + + )} + +
+
+ ) +} diff --git a/frontend/components/FirstRunSetup.tsx b/frontend/components/FirstRunSetup.tsx index 27b526b67..6a2f3bb26 100644 --- a/frontend/components/FirstRunSetup.tsx +++ b/frontend/components/FirstRunSetup.tsx @@ -80,6 +80,10 @@ export function LaunchGate({ }: LaunchGateProps) { const [currentStep, setCurrentStep] = useState(showLicenseStep ? 'license' : 'location') const [installPath, setInstallPath] = useState('') + const [backendConnection, setBackendConnection] = useState<{ + mode: 'managed-local' | 'external' + hasAdminToken: boolean + }>({ mode: 'managed-local', hasAdminToken: false }) const [downloadProgress, setDownloadProgress] = useState(null) const [downloadError, setDownloadError] = useState(null) const [downloadSessionId, setDownloadSessionId] = useState(null) @@ -95,6 +99,12 @@ export function LaunchGate({ const [requiredCheckpointIds, setRequiredCheckpointIds] = useState([]) const { hfAuthStatus, hfAuthPolling, startHuggingFaceLogin } = useHfAuth(currentStep === 'location') const { accessMap, allAuthorized } = useHfModelAccess(requiredCheckpointIds, hfAuthStatus) + + useEffect(() => { + void window.electronAPI.getBackendConnectionConfig().then((config) => { + setBackendConnection({ mode: config.mode, hasAdminToken: config.hasAdminToken }) + }).catch(() => {}) + }, []) const { saveLtxApiKey } = useAppSettings() const modelAccessRef = useRef(null) const downloadQueueRef = useRef([]) @@ -580,11 +590,14 @@ export function LaunchGate({ /> diff --git a/frontend/components/ICLoraPanel.tsx b/frontend/components/ICLoraPanel.tsx index 6327b3e99..9c9321145 100644 --- a/frontend/components/ICLoraPanel.tsx +++ b/frontend/components/ICLoraPanel.tsx @@ -8,6 +8,7 @@ import { logger } from '../lib/logger' import { pathToFileUrl } from '../lib/file-url' import { useHfAuth } from '../hooks/use-hf-auth' import { useHfModelAccess } from '../hooks/use-hf-model-access' +import { prepareBackendMedia } from '../lib/backend-media' export type ICLoraConditioningType = 'canny' | 'depth' @@ -38,6 +39,7 @@ export const CONDITIONING_TYPES: { value: ICLoraConditioningType; label: string; type StartModelDownloadBody = NonNullable> type ModelCheckpointID = NonNullable[number] type DownloadProgress = ApiSuccessOf<'getModelDownloadProgress'> +type ExtractConditioningBody = NonNullable> export function ICLoraPanel({ @@ -186,22 +188,28 @@ export function ICLoraPanel({ isExtractingRef.current = true setIsExtracting(true) setExtractError(null) - const result = await ApiClient.extractIcLoraConditioning({ - video_path: inputVideoPath, - conditioning_type: conditioningType, - frame_time: inputTime, - }) - if (!result.ok) { - logger.warn(`Failed to extract conditioning: ${result.error.message}`) - setExtractError(result.error.message) + try { + const preparedVideo = await prepareBackendMedia(inputVideoPath, 'video') + const request: Record = { + conditioning_type: conditioningType, + frame_time: inputTime, + } + if (preparedVideo?.path) request.video_path = preparedVideo.path + if (preparedVideo?.mediaId) request.video_media_id = preparedVideo.mediaId + const result = await ApiClient.extractIcLoraConditioning(request as ExtractConditioningBody) + if (!result.ok) { + logger.warn(`Failed to extract conditioning: ${result.error.message}`) + setExtractError(result.error.message) + return + } + + setConditioningPreview(result.data.conditioning) + } catch (error) { + setExtractError(error instanceof Error ? error.message : String(error)) + } finally { isExtractingRef.current = false setIsExtracting(false) - return } - - setConditioningPreview(result.data.conditioning) - isExtractingRef.current = false - setIsExtracting(false) }, [inputVideoPath, conditioningType, inputTime, icLoraReady]) const extractTimerRef = useRef | null>(null) diff --git a/frontend/components/SettingsModal.tsx b/frontend/components/SettingsModal.tsx index 9c353d0e3..6432f4d00 100644 --- a/frontend/components/SettingsModal.tsx +++ b/frontend/components/SettingsModal.tsx @@ -1,4 +1,4 @@ -import { AlertCircle, Check, Download, Film, Folder, Info, KeyRound, Settings, Sparkles, X, Zap } from 'lucide-react' +import { AlertCircle, Check, Cpu, Download, Film, Folder, Info, KeyRound, Settings, Sparkles, X, Zap } from 'lucide-react' import React, { useEffect, useMemo, useRef, useState } from 'react' import { Button } from './ui/button' import { useAppSettings, type AppSettings } from '../contexts/AppSettingsContext' @@ -7,6 +7,7 @@ import { logger } from '../lib/logger' import { ApiKeyHelperRow, LtxApiKeyInput, LtxApiKeyHelperRow } from './LtxApiKeyInput' import { useHfAuth } from '../hooks/use-hf-auth' import { useHfModelAccess } from '../hooks/use-hf-model-access' +import { BackendConnectionPanel } from './BackendConnectionPanel' interface SettingsModalProps { isOpen: boolean @@ -14,7 +15,7 @@ interface SettingsModalProps { initialTab?: TabId } -type TabId = 'general' | 'apiKeys' | 'promptEnhancer' | 'about' +type TabId = 'general' | 'compute' | 'apiKeys' | 'promptEnhancer' | 'about' export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProps) { const { settings, updateSettings, saveLtxApiKey, saveFalApiKey, saveGeminiApiKey, forceApiGenerations } = useAppSettings() @@ -248,6 +249,7 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp const tabs = [ { id: 'general' as TabId, label: 'General', icon: Settings }, + { id: 'compute' as TabId, label: 'Compute', icon: Cpu }, { id: 'apiKeys' as TabId, label: 'API Keys', icon: KeyRound }, { id: 'promptEnhancer' as TabId, label: 'Prompt Enhancer', icon: Sparkles }, { id: 'about' as TabId, label: 'About', icon: Info }, @@ -302,6 +304,21 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp {/* Content */}
+ {activeTab === 'compute' && ( + <> + +
+
Models directory on the active backend
+
+ {settings.modelsDir || 'Available after the backend connects'} +
+

+ Standalone backends manage this path on the remote machine; a Mac folder picker cannot change it. +

+
+ + )} + {activeTab === 'general' && ( <> {/* Project Assets Path */} diff --git a/frontend/contexts/AppSettingsContext.tsx b/frontend/contexts/AppSettingsContext.tsx index 26e50f7e9..0bd03e2fc 100644 --- a/frontend/contexts/AppSettingsContext.tsx +++ b/frontend/contexts/AppSettingsContext.tsx @@ -32,7 +32,7 @@ export const DEFAULT_APP_SETTINGS: AppSettings = { modelsDir: '', } -type BackendProcessStatus = 'alive' | 'restarting' | 'dead' +type BackendProcessStatus = 'connecting' | 'alive' | 'restarting' | 'unreachable' | 'dead' interface AppSettingsContextValue { settings: AppSettings @@ -55,7 +55,13 @@ function toBackendProcessStatus(value: unknown): BackendProcessStatus | null { } const record = value as { status?: unknown } - if (record.status === 'alive' || record.status === 'restarting' || record.status === 'dead') { + if ( + record.status === 'connecting' + || record.status === 'alive' + || record.status === 'restarting' + || record.status === 'unreachable' + || record.status === 'dead' + ) { return record.status } return null diff --git a/frontend/generated/backend-openapi.json b/frontend/generated/backend-openapi.json index f30623b5a..80b2cbf23 100644 --- a/frontend/generated/backend-openapi.json +++ b/frontend/generated/backend-openapi.json @@ -140,6 +140,79 @@ "title": "AppSettingsPatch", "type": "object" }, + "ArtifactRef": { + "properties": { + "artifact_id": { + "title": "Artifact Id", + "type": "string" + }, + "content_type": { + "title": "Content Type", + "type": "string" + }, + "expires_at": { + "format": "date-time", + "title": "Expires At", + "type": "string" + }, + "filename": { + "title": "Filename", + "type": "string" + }, + "media_type": { + "enum": [ + "image", + "audio", + "video" + ], + "title": "Media Type", + "type": "string" + }, + "sha256": { + "title": "Sha256", + "type": "string" + }, + "size_bytes": { + "title": "Size Bytes", + "type": "integer" + } + }, + "required": [ + "artifact_id", + "media_type", + "filename", + "content_type", + "size_bytes", + "sha256", + "expires_at" + ], + "title": "ArtifactRef", + "type": "object" + }, + "Body_route_upload_media_api_media_post": { + "properties": { + "file": { + "format": "binary", + "title": "File", + "type": "string" + }, + "media_type": { + "enum": [ + "image", + "audio", + "video" + ], + "title": "Media Type", + "type": "string" + } + }, + "required": [ + "file", + "media_type" + ], + "title": "Body_route_upload_media_api_media_post", + "type": "object" + }, "CancelCancellingResponse": { "properties": { "id": { @@ -369,6 +442,13 @@ }, "GenerateImageCompleteResponse": { "properties": { + "artifacts": { + "items": { + "$ref": "#/components/schemas/ArtifactRef" + }, + "title": "Artifacts", + "type": "array" + }, "image_paths": { "items": { "type": "string" @@ -384,13 +464,28 @@ }, "required": [ "status", - "image_paths" + "image_paths", + "artifacts" ], "title": "GenerateImageCompleteResponse", "type": "object" }, "GenerateImageRequest": { "properties": { + "generationId": { + "anyOf": [ + { + "maxLength": 64, + "minLength": 8, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Generationid" + }, "height": { "default": 1024, "minimum": 16.0, @@ -443,6 +538,9 @@ }, "GenerateVideoCompleteResponse": { "properties": { + "artifact": { + "$ref": "#/components/schemas/ArtifactRef" + }, "status": { "const": "complete", "title": "Status", @@ -455,7 +553,8 @@ }, "required": [ "status", - "video_path" + "video_path", + "artifact" ], "title": "GenerateVideoCompleteResponse", "type": "object" @@ -500,6 +599,17 @@ "title": "Audio", "type": "boolean" }, + "audioMediaId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Audiomediaid" + }, "audioPath": { "anyOf": [ { @@ -554,6 +664,31 @@ "title": "Fps", "type": "integer" }, + "generationId": { + "anyOf": [ + { + "maxLength": 64, + "minLength": 8, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Generationid" + }, + "imageMediaId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Imagemediaid" + }, "imagePath": { "anyOf": [ { @@ -605,6 +740,23 @@ }, "GenerationProgressResponse": { "properties": { + "artifact": { + "anyOf": [ + { + "$ref": "#/components/schemas/ArtifactRef" + }, + { + "type": "null" + } + ] + }, + "artifacts": { + "items": { + "$ref": "#/components/schemas/ArtifactRef" + }, + "title": "Artifacts", + "type": "array" + }, "currentStep": { "anyOf": [ { @@ -616,6 +768,28 @@ ], "title": "Currentstep" }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + }, + "generationId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Generationid" + }, "phase": { "title": "Phase", "type": "string" @@ -883,14 +1057,29 @@ "title": "Frame Time", "type": "number" }, + "video_media_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Video Media Id" + }, "video_path": { - "title": "Video Path", - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Video Path" } }, - "required": [ - "video_path" - ], "title": "IcLoraExtractRequest", "type": "object" }, @@ -942,6 +1131,9 @@ }, "IcLoraGenerateCompleteResponse": { "properties": { + "artifact": { + "$ref": "#/components/schemas/ArtifactRef" + }, "status": { "const": "complete", "title": "Status", @@ -954,7 +1146,8 @@ }, "required": [ "status", - "video_path" + "video_path", + "artifact" ], "title": "IcLoraGenerateCompleteResponse", "type": "object" @@ -1001,13 +1194,30 @@ "title": "Prompt", "type": "string" }, + "video_media_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Video Media Id" + }, "video_path": { - "title": "Video Path", - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Video Path" } }, "required": [ - "video_path", "conditioning_type", "prompt" ], @@ -1021,9 +1231,27 @@ "title": "Frame", "type": "integer" }, + "media_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Media Id" + }, "path": { - "title": "Path", - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Path" }, "strength": { "default": 1.0, @@ -1031,9 +1259,6 @@ "type": "number" } }, - "required": [ - "path" - ], "title": "IcLoraImageInput", "type": "object" }, @@ -1329,6 +1554,55 @@ "title": "LtxUpgradeRecommendationResponse", "type": "object" }, + "MediaRef": { + "properties": { + "content_type": { + "title": "Content Type", + "type": "string" + }, + "expires_at": { + "format": "date-time", + "title": "Expires At", + "type": "string" + }, + "filename": { + "title": "Filename", + "type": "string" + }, + "media_id": { + "title": "Media Id", + "type": "string" + }, + "media_type": { + "enum": [ + "image", + "audio", + "video" + ], + "title": "Media Type", + "type": "string" + }, + "sha256": { + "title": "Sha256", + "type": "string" + }, + "size_bytes": { + "title": "Size Bytes", + "type": "integer" + } + }, + "required": [ + "media_id", + "media_type", + "filename", + "content_type", + "size_bytes", + "sha256", + "expires_at" + ], + "title": "MediaRef", + "type": "object" + }, "ModelDeleteRequest": { "properties": { "cp_ids": { @@ -1497,13 +1771,30 @@ "title": "Start Time", "type": "number" }, + "video_media_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Video Media Id" + }, "video_path": { - "title": "Video Path", - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Video Path" } }, "required": [ - "video_path", "start_time", "duration" ], @@ -1512,6 +1803,9 @@ }, "RetakeVideoResponse": { "properties": { + "artifact": { + "$ref": "#/components/schemas/ArtifactRef" + }, "status": { "const": "complete", "title": "Status", @@ -1524,7 +1818,8 @@ }, "required": [ "status", - "video_path" + "video_path", + "artifact" ], "title": "RetakeVideoResponse", "type": "object" @@ -1542,6 +1837,63 @@ "title": "RuntimePolicyResponse", "type": "object" }, + "ServerCapabilities": { + "properties": { + "artifact_downloads": { + "const": true, + "default": true, + "title": "Artifact Downloads", + "type": "boolean" + }, + "legacy_path_inputs": { + "title": "Legacy Path Inputs", + "type": "boolean" + }, + "media_ids": { + "const": true, + "default": true, + "title": "Media Ids", + "type": "boolean" + }, + "models_dir_editable": { + "title": "Models Dir Editable", + "type": "boolean" + } + }, + "required": [ + "legacy_path_inputs", + "models_dir_editable" + ], + "title": "ServerCapabilities", + "type": "object" + }, + "ServerInfoResponse": { + "properties": { + "api_version": { + "const": 2, + "default": 2, + "title": "Api Version", + "type": "integer" + }, + "capabilities": { + "$ref": "#/components/schemas/ServerCapabilities" + }, + "deployment_mode": { + "enum": [ + "managed_local", + "standalone" + ], + "title": "Deployment Mode", + "type": "string" + } + }, + "required": [ + "deployment_mode", + "capabilities" + ], + "title": "ServerInfoResponse", + "type": "object" + }, "SettingsResponse": { "properties": { "hasFalApiKey": { @@ -1634,6 +1986,17 @@ ], "title": "Afterframe" }, + "afterFrameMediaId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Afterframemediaid" + }, "afterPrompt": { "default": "", "title": "Afterprompt", @@ -1650,6 +2013,17 @@ ], "title": "Beforeframe" }, + "beforeFrameMediaId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Beforeframemediaid" + }, "beforePrompt": { "default": "", "title": "Beforeprompt", @@ -1671,6 +2045,17 @@ ], "title": "Inputimage" }, + "inputImageMediaId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Inputimagemediaid" + }, "mode": { "default": "text-to-video", "enum": [ @@ -1752,6 +2137,101 @@ }, "openapi": "3.1.0", "paths": { + "/api/artifacts/{artifact_id}": { + "delete": { + "operationId": "route_delete_artifact_api_artifacts__artifact_id__delete", + "parameters": [ + { + "in": "path", + "name": "artifact_id", + "required": true, + "schema": { + "title": "Artifact Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusResponse" + } + } + }, + "description": "Successful Response" + }, + "4XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPErrorResponse" + } + } + }, + "description": "Client Error" + }, + "5XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPErrorResponse" + } + } + }, + "description": "Server Error" + } + }, + "summary": "Route Delete Artifact", + "tags": [ + "media" + ] + }, + "get": { + "operationId": "route_download_artifact_api_artifacts__artifact_id__get", + "parameters": [ + { + "in": "path", + "name": "artifact_id", + "required": true, + "schema": { + "title": "Artifact Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful Response" + }, + "4XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPErrorResponse" + } + } + }, + "description": "Client Error" + }, + "5XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPErrorResponse" + } + } + }, + "description": "Server Error" + } + }, + "summary": "Route Download Artifact", + "tags": [ + "media" + ] + } + }, "/api/auth/huggingface/callback": { "get": { "operationId": "route_hf_callback_api_auth_huggingface_callback_get", @@ -2363,6 +2843,109 @@ ] } }, + "/api/media": { + "post": { + "operationId": "route_upload_media_api_media_post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_route_upload_media_api_media_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaRef" + } + } + }, + "description": "Successful Response" + }, + "4XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPErrorResponse" + } + } + }, + "description": "Client Error" + }, + "5XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPErrorResponse" + } + } + }, + "description": "Server Error" + } + }, + "summary": "Route Upload Media", + "tags": [ + "media" + ] + } + }, + "/api/media/{media_id}": { + "delete": { + "operationId": "route_delete_media_api_media__media_id__delete", + "parameters": [ + { + "in": "path", + "name": "media_id", + "required": true, + "schema": { + "title": "Media Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusResponse" + } + } + }, + "description": "Successful Response" + }, + "4XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPErrorResponse" + } + } + }, + "description": "Client Error" + }, + "5XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPErrorResponse" + } + } + }, + "description": "Server Error" + } + }, + "summary": "Route Delete Media", + "tags": [ + "media" + ] + } + }, "/api/models/check-access": { "post": { "operationId": "route_check_model_access_api_models_check_access_post", @@ -2857,6 +3440,47 @@ ] } }, + "/api/server-info": { + "get": { + "operationId": "route_server_info_api_server_info_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerInfoResponse" + } + } + }, + "description": "Successful Response" + }, + "4XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPErrorResponse" + } + } + }, + "description": "Client Error" + }, + "5XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPErrorResponse" + } + } + }, + "description": "Server Error" + } + }, + "summary": "Route Server Info", + "tags": [ + "server" + ] + } + }, "/api/settings": { "get": { "operationId": "route_get_settings_api_settings_get", diff --git a/frontend/generated/backend-openapi.ts b/frontend/generated/backend-openapi.ts index 30dec08a7..adeab4ff7 100644 --- a/frontend/generated/backend-openapi.ts +++ b/frontend/generated/backend-openapi.ts @@ -4,6 +4,24 @@ */ export interface paths { + "/api/artifacts/{artifact_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Route Download Artifact */ + get: operations["route_download_artifact_api_artifacts__artifact_id__get"]; + put?: never; + post?: never; + /** Route Delete Artifact */ + delete: operations["route_delete_artifact_api_artifacts__artifact_id__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/auth/huggingface/callback": { parameters: { query?: never; @@ -223,6 +241,40 @@ export interface paths { patch?: never; trace?: never; }; + "/api/media": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Route Upload Media */ + post: operations["route_upload_media_api_media_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/media/{media_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Route Delete Media */ + delete: operations["route_delete_media_api_media__media_id__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/models/check-access": { parameters: { query?: never; @@ -393,6 +445,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/server-info": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Route Server Info */ + get: operations["route_server_info_api_server_info_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/settings": { parameters: { query?: never; @@ -493,6 +562,42 @@ export interface components { /** Userprefersltxapivideogenerations */ userPrefersLtxApiVideoGenerations?: boolean | null; }; + /** ArtifactRef */ + ArtifactRef: { + /** Artifact Id */ + artifact_id: string; + /** Content Type */ + content_type: string; + /** + * Expires At + * Format: date-time + */ + expires_at: string; + /** Filename */ + filename: string; + /** + * Media Type + * @enum {string} + */ + media_type: "image" | "audio" | "video"; + /** Sha256 */ + sha256: string; + /** Size Bytes */ + size_bytes: number; + }; + /** Body_route_upload_media_api_media_post */ + Body_route_upload_media_api_media_post: { + /** + * File + * Format: binary + */ + file: string; + /** + * Media Type + * @enum {string} + */ + media_type: "image" | "audio" | "video"; + }; /** CancelCancellingResponse */ CancelCancellingResponse: { /** Id */ @@ -577,6 +682,8 @@ export interface components { }; /** GenerateImageCompleteResponse */ GenerateImageCompleteResponse: { + /** Artifacts */ + artifacts: components["schemas"]["ArtifactRef"][]; /** Image Paths */ image_paths: string[]; /** @@ -587,6 +694,8 @@ export interface components { }; /** GenerateImageRequest */ GenerateImageRequest: { + /** Generationid */ + generationId?: string | null; /** * Height * @default 1024 @@ -620,6 +729,7 @@ export interface components { }; /** GenerateVideoCompleteResponse */ GenerateVideoCompleteResponse: { + artifact: components["schemas"]["ArtifactRef"]; /** * Status * @constant @@ -648,6 +758,8 @@ export interface components { * @default false */ audio: boolean; + /** Audiomediaid */ + audioMediaId?: string | null; /** Audiopath */ audioPath?: string | null; /** @@ -668,6 +780,10 @@ export interface components { * @enum {integer} */ fps: 24 | 25 | 48 | 50; + /** Generationid */ + generationId?: string | null; + /** Imagemediaid */ + imageMediaId?: string | null; /** Imagepath */ imagePath?: string | null; /** @@ -692,8 +808,15 @@ export interface components { }; /** GenerationProgressResponse */ GenerationProgressResponse: { + artifact?: components["schemas"]["ArtifactRef"] | null; + /** Artifacts */ + artifacts?: components["schemas"]["ArtifactRef"][]; /** Currentstep */ currentStep: number | null; + /** Error */ + error?: string | null; + /** Generationid */ + generationId?: string | null; /** Phase */ phase: string; /** Progress */ @@ -803,8 +926,10 @@ export interface components { * @default 0 */ frame_time: number; + /** Video Media Id */ + video_media_id?: string | null; /** Video Path */ - video_path: string; + video_path?: string | null; }; /** IcLoraExtractResponse */ IcLoraExtractResponse: { @@ -830,6 +955,7 @@ export interface components { }; /** IcLoraGenerateCompleteResponse */ IcLoraGenerateCompleteResponse: { + artifact: components["schemas"]["ArtifactRef"]; /** * Status * @constant @@ -869,8 +995,10 @@ export interface components { num_inference_steps: number; /** Prompt */ prompt: string; + /** Video Media Id */ + video_media_id?: string | null; /** Video Path */ - video_path: string; + video_path?: string | null; }; /** IcLoraImageInput */ IcLoraImageInput: { @@ -879,8 +1007,10 @@ export interface components { * @default 0 */ frame: number; + /** Media Id */ + media_id?: string | null; /** Path */ - path: string; + path?: string | null; /** * Strength * @default 1 @@ -973,6 +1103,29 @@ export interface components { /** Upgrade Message */ upgrade_message?: string | null; }; + /** MediaRef */ + MediaRef: { + /** Content Type */ + content_type: string; + /** + * Expires At + * Format: date-time + */ + expires_at: string; + /** Filename */ + filename: string; + /** Media Id */ + media_id: string; + /** + * Media Type + * @enum {string} + */ + media_type: "image" | "audio" | "video"; + /** Sha256 */ + sha256: string; + /** Size Bytes */ + size_bytes: number; + }; /** ModelDeleteRequest */ ModelDeleteRequest: { /** Cp Ids */ @@ -1049,11 +1202,14 @@ export interface components { prompt: string; /** Start Time */ start_time: number; + /** Video Media Id */ + video_media_id?: string | null; /** Video Path */ - video_path: string; + video_path?: string | null; }; /** RetakeVideoResponse */ RetakeVideoResponse: { + artifact: components["schemas"]["ArtifactRef"]; /** * Status * @constant @@ -1067,6 +1223,40 @@ export interface components { /** Force Api Generations */ force_api_generations: boolean; }; + /** ServerCapabilities */ + ServerCapabilities: { + /** + * Artifact Downloads + * @default true + * @constant + */ + artifact_downloads: true; + /** Legacy Path Inputs */ + legacy_path_inputs: boolean; + /** + * Media Ids + * @default true + * @constant + */ + media_ids: true; + /** Models Dir Editable */ + models_dir_editable: boolean; + }; + /** ServerInfoResponse */ + ServerInfoResponse: { + /** + * Api Version + * @default 2 + * @constant + */ + api_version: 2; + capabilities: components["schemas"]["ServerCapabilities"]; + /** + * Deployment Mode + * @enum {string} + */ + deployment_mode: "managed_local" | "standalone"; + }; /** SettingsResponse */ SettingsResponse: { /** @@ -1139,6 +1329,8 @@ export interface components { SuggestGapPromptRequest: { /** Afterframe */ afterFrame?: string | null; + /** Afterframemediaid */ + afterFrameMediaId?: string | null; /** * Afterprompt * @default @@ -1146,6 +1338,8 @@ export interface components { afterPrompt: string; /** Beforeframe */ beforeFrame?: string | null; + /** Beforeframemediaid */ + beforeFrameMediaId?: string | null; /** * Beforeprompt * @default @@ -1158,6 +1352,8 @@ export interface components { gapDuration: number; /** Inputimage */ inputImage?: string | null; + /** Inputimagemediaid */ + inputImageMediaId?: string | null; /** * Mode * @default text-to-video @@ -1194,6 +1390,84 @@ export interface components { } export type $defs = Record; export interface operations { + route_download_artifact_api_artifacts__artifact_id__get: { + parameters: { + query?: never; + header?: never; + path: { + artifact_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Client Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPErrorResponse"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPErrorResponse"]; + }; + }; + }; + }; + route_delete_artifact_api_artifacts__artifact_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + artifact_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StatusResponse"]; + }; + }; + /** @description Client Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPErrorResponse"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPErrorResponse"]; + }; + }; + }; + }; route_hf_callback_api_auth_huggingface_callback_get: { parameters: { query?: { @@ -1679,6 +1953,88 @@ export interface operations { }; }; }; + route_upload_media_api_media_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["Body_route_upload_media_api_media_post"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MediaRef"]; + }; + }; + /** @description Client Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPErrorResponse"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPErrorResponse"]; + }; + }; + }; + }; + route_delete_media_api_media__media_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + media_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StatusResponse"]; + }; + }; + /** @description Client Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPErrorResponse"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPErrorResponse"]; + }; + }; + }; + }; route_check_model_access_api_models_check_access_post: { parameters: { query?: never; @@ -2077,6 +2433,44 @@ export interface operations { }; }; }; + route_server_info_api_server_info_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ServerInfoResponse"]; + }; + }; + /** @description Client Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPErrorResponse"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPErrorResponse"]; + }; + }; + }; + }; route_get_settings_api_settings_get: { parameters: { query?: never; diff --git a/frontend/hooks/use-backend.ts b/frontend/hooks/use-backend.ts index a34e8abbb..7da0f2d1b 100644 --- a/frontend/hooks/use-backend.ts +++ b/frontend/hooks/use-backend.ts @@ -2,17 +2,23 @@ import { useState, useEffect, useCallback } from 'react' import { resetBackendCredentials } from '../lib/backend' import { logger } from '../lib/logger' -export type BackendProcessStatus = 'alive' | 'restarting' | 'dead' +export type BackendProcessStatus = 'connecting' | 'alive' | 'restarting' | 'unreachable' | 'dead' +export type BackendConnectionMode = 'managed-local' | 'external' interface BackendHealthStatusPayload { + mode: BackendConnectionMode status: BackendProcessStatus exitCode?: number | null + checkedAt: number + message?: string } interface UseBackendReturn { processStatus: BackendProcessStatus | null connected: boolean isLoading: boolean + connectionMode: BackendConnectionMode | null + message: string | null } function toBackendHealthStatus(value: unknown): BackendHealthStatusPayload | null { @@ -20,23 +26,39 @@ function toBackendHealthStatus(value: unknown): BackendHealthStatusPayload | nul return null } - const record = value as { status?: unknown; exitCode?: unknown } - if (record.status !== 'alive' && record.status !== 'restarting' && record.status !== 'dead') { + const record = value as { mode?: unknown; status?: unknown; exitCode?: unknown; checkedAt?: unknown; message?: unknown } + if (record.mode !== 'managed-local' && record.mode !== 'external') { + return null + } + if ( + record.status !== 'connecting' + && record.status !== 'alive' + && record.status !== 'restarting' + && record.status !== 'unreachable' + && record.status !== 'dead' + ) { return null } return { + mode: record.mode, status: record.status, exitCode: typeof record.exitCode === 'number' || record.exitCode === null ? record.exitCode : undefined, + checkedAt: typeof record.checkedAt === 'number' ? record.checkedAt : Date.now(), + message: typeof record.message === 'string' ? record.message : undefined, } } export function useBackend(): UseBackendReturn { const [processStatus, setProcessStatus] = useState(null) const [isLoading, setIsLoading] = useState(true) + const [connectionMode, setConnectionMode] = useState(null) + const [message, setMessage] = useState(null) const handleBackendStatus = useCallback((payload: BackendHealthStatusPayload) => { setProcessStatus(payload.status) + setConnectionMode(payload.mode) + setMessage(payload.message ?? null) if (payload.status === 'alive') { // Main has verified HTTP reachability before publishing 'alive' and may @@ -47,7 +69,7 @@ export function useBackend(): UseBackendReturn { return } - if (payload.status === 'restarting') { + if (payload.status === 'restarting' || payload.status === 'connecting') { return } @@ -90,5 +112,7 @@ export function useBackend(): UseBackendReturn { processStatus, connected: processStatus === 'alive', isLoading, + connectionMode, + message, } } diff --git a/frontend/hooks/use-generation.ts b/frontend/hooks/use-generation.ts index 27e4ab77b..532b322cc 100644 --- a/frontend/hooks/use-generation.ts +++ b/frontend/hooks/use-generation.ts @@ -1,8 +1,14 @@ import { useState, useCallback, useRef } from 'react' import type { GenerationSettings } from '../components/SettingsPanel' -import { ApiClient, type ApiRequestBodyOf } from '../lib/api-client' +import { ApiClient, type ApiRequestBodyOf, type ApiSuccessOf } from '../lib/api-client' import { createLocalGenerationError, type GenerationError } from '../lib/generation-errors' import { useAppSettings } from '../contexts/AppSettingsContext' +import { getBackendCredentials } from '../lib/backend' +import { + materializeBackendOutput, + materializeBackendOutputs, + prepareBackendMedia, +} from '../lib/backend-media' interface GenerationState { isGenerating: boolean @@ -16,6 +22,63 @@ interface GenerationState { type GenerateVideoRequest = ApiRequestBodyOf<'generateVideo'> type GenerateImageRequest = ApiRequestBodyOf<'generateImage'> +type GenerationProgressPayload = ApiSuccessOf<'getGenerationProgress'> + +const REMOTE_RECOVERY_TIMEOUT_MS = 30 * 60 * 1000 +const REMOTE_RECOVERY_POLL_MS = 2_000 + +function isTransportFailure(value: unknown): boolean { + if (!value || typeof value !== 'object') return false + const result = value as { ok?: unknown; error?: { code?: unknown } } + return result.ok === false + && (result.error?.code === 'NETWORK_ERROR' || result.error?.code === 'RESPONSE_READ_FAILED') +} + +function waitForRemoteRetry(signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new DOMException('Generation cancelled', 'AbortError')) + return + } + const onAbort = () => { + window.clearTimeout(timeout) + reject(new DOMException('Generation cancelled', 'AbortError')) + } + const timeout = window.setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve() + }, REMOTE_RECOVERY_POLL_MS) + signal.addEventListener('abort', onAbort, { once: true }) + }) +} + +async function recoverRemoteGeneration( + generationId: string, + signal: AbortSignal, + onProgress: (progress: GenerationProgressPayload) => void, +): Promise { + const deadline = Date.now() + REMOTE_RECOVERY_TIMEOUT_MS + + while (Date.now() < deadline) { + const progressResult = await ApiClient.getGenerationProgress(undefined, { signal }) + if (progressResult.ok) { + const progress = progressResult.data + if (progress.generationId === generationId) { + onProgress(progress) + if (progress.status === 'complete') return progress + if (progress.status === 'cancelled') { + throw new DOMException('Generation cancelled', 'AbortError') + } + if (progress.status === 'error') { + throw new Error(progress.error || 'The remote generation failed') + } + } + } + await waitForRemoteRetry(signal) + } + + throw new Error('Timed out while waiting to recover the remote generation') +} interface UseGenerationReturn extends GenerationState { generate: (prompt: string, imagePath: string | null, settings: GenerationSettings, audioPath?: string | null) => Promise @@ -117,12 +180,27 @@ export function useGeneration(): UseGenerationReturn { }) abortControllerRef.current = new AbortController() + const generationId = crypto.randomUUID() let progressInterval: ReturnType | null = null let shouldApplyPollingUpdates = true try { + if (imagePath || audioPath) { + setState(prev => ({ + ...prev, + statusMessage: imagePath && audioPath + ? 'Uploading image and audio...' + : imagePath ? 'Uploading image...' : 'Uploading audio...', + })) + } + const [preparedImage, preparedAudio] = await Promise.all([ + prepareBackendMedia(imagePath, 'image'), + prepareBackendMedia(audioPath, 'audio'), + ]) + // Prepare JSON body const body: Record = { + generationId, prompt, model: settings.model, duration: settings.duration, @@ -133,11 +211,15 @@ export function useGeneration(): UseGenerationReturn { negativePrompt: (settings as { negativePrompt?: string }).negativePrompt ?? '', aspectRatio: settings.aspectRatio || '16:9', } - if (imagePath) { - body.imagePath = imagePath + if (preparedImage?.path) { + body.imagePath = preparedImage.path + } else if (preparedImage?.mediaId) { + body.imageMediaId = preparedImage.mediaId } - if (audioPath) { - body.audioPath = audioPath + if (preparedAudio?.path) { + body.audioPath = preparedAudio.path + } else if (preparedAudio?.mediaId) { + body.audioMediaId = preparedAudio.mediaId } // Poll for real progress from backend with time-based interpolation @@ -185,26 +267,60 @@ export function useGeneration(): UseGenerationReturn { progressInterval = setInterval(pollProgress, 500) // Start generation (HTTP POST - synchronous, returns when done) + const backend = await getBackendCredentials() const result = await ApiClient.generateVideo(body as unknown as GenerateVideoRequest, { signal: abortControllerRef.current.signal, }) shouldApplyPollingUpdates = false + let payload: ApiSuccessOf<'generateVideo'> if (!result.ok) { - setState(prev => ({ - ...prev, - isGenerating: false, - error: result, - })) - return + if (backend.mode === 'external' && isTransportFailure(result)) { + setState(prev => ({ + ...prev, + statusMessage: 'Connection lost. Waiting for the remote generation...', + })) + const recovered = await recoverRemoteGeneration( + generationId, + abortControllerRef.current.signal, + (progress) => { + setState(prev => ({ + ...prev, + progress: progress.progress, + statusMessage: progress.status === 'running' + ? getPhaseMessage(progress.phase) + : 'Downloading recovered output...', + })) + }, + ) + const recoveredArtifact = recovered.artifact ?? recovered.artifacts?.[0] + if (!recoveredArtifact) { + throw new Error('The recovered remote generation has no downloadable artifact') + } + payload = { + status: 'complete', + video_path: '', + artifact: recoveredArtifact, + } + } else { + setState(prev => ({ + ...prev, + isGenerating: false, + error: result, + })) + return + } + } else { + payload = result.data } - const payload = result.data if (payload.status === 'complete') { + setState(prev => ({ ...prev, statusMessage: 'Downloading output...', progress: 97 })) + const outputPath = await materializeBackendOutput(payload.video_path, payload.artifact) setState({ isGenerating: false, progress: 100, statusMessage: 'Complete!', - videoPath: payload.video_path, + videoPath: outputPath, imagePath: null, imagePaths: [], error: null, @@ -302,6 +418,9 @@ export function useGeneration(): UseGenerationReturn { }) abortControllerRef.current = new AbortController() + const generationId = crypto.randomUUID() + let progressInterval: ReturnType | null = null + let shouldApplyPollingUpdates = true try { // Skip prompt enhancement for T2I - use original prompt directly @@ -312,8 +431,9 @@ export function useGeneration(): UseGenerationReturn { // Poll for progress const pollProgress = async () => { + if (!shouldApplyPollingUpdates) return const result = await ApiClient.getGenerationProgress() - if (!result.ok) return + if (!result.ok || !shouldApplyPollingUpdates) return const data = result.data const currentImage = data.currentStep || 0 @@ -333,32 +453,62 @@ export function useGeneration(): UseGenerationReturn { })) } - const progressInterval = setInterval(pollProgress, 500) + progressInterval = setInterval(pollProgress, 500) const imageRequest: GenerateImageRequest = { + generationId, prompt: finalPrompt, width: dims.width, height: dims.height, numSteps, numImages, } + const backend = await getBackendCredentials() const result = await ApiClient.generateImage(imageRequest, { signal: abortControllerRef.current.signal, }) - clearInterval(progressInterval) + shouldApplyPollingUpdates = false + let payload: ApiSuccessOf<'generateImage'> if (!result.ok) { - setState(prev => ({ - ...prev, - isGenerating: false, - error: result, - })) - return + if (backend.mode === 'external' && isTransportFailure(result)) { + setState(prev => ({ + ...prev, + statusMessage: 'Connection lost. Waiting for the remote generation...', + })) + const recovered = await recoverRemoteGeneration( + generationId, + abortControllerRef.current.signal, + (progress) => { + setState(prev => ({ + ...prev, + progress: progress.progress, + statusMessage: progress.status === 'running' + ? getPhaseMessage(progress.phase) + : 'Downloading recovered output...', + })) + }, + ) + payload = { + status: 'complete', + image_paths: [], + artifacts: recovered.artifacts ?? [], + } + } else { + setState(prev => ({ + ...prev, + isGenerating: false, + error: result, + })) + return + } + } else { + payload = result.data } - const payload = result.data if (payload.status === 'complete') { - const rawPaths = payload.image_paths + setState(prev => ({ ...prev, statusMessage: 'Downloading output...', progress: 97 })) + const rawPaths = await materializeBackendOutputs(payload.image_paths, payload.artifacts) if (rawPaths.length === 0) { throw new Error('Image generation completed without output images') } @@ -396,6 +546,9 @@ export function useGeneration(): UseGenerationReturn { error: createLocalGenerationError(error instanceof Error ? error.message : 'Unknown error'), })) } + } finally { + shouldApplyPollingUpdates = false + if (progressInterval) clearInterval(progressInterval) } }, [appSettings.hasFalApiKey, forceApiGenerations, refreshSettings]) diff --git a/frontend/hooks/use-ic-lora.ts b/frontend/hooks/use-ic-lora.ts index fac2a6efc..d96165aca 100644 --- a/frontend/hooks/use-ic-lora.ts +++ b/frontend/hooks/use-ic-lora.ts @@ -1,6 +1,7 @@ import { useCallback, useState } from 'react' import { ApiClient, type ApiRequestBodyOf } from '../lib/api-client' import { logger } from '../lib/logger' +import { materializeBackendOutput, prepareBackendMedia } from '../lib/backend-media' export type IcLoraConditioningType = 'canny' | 'depth' @@ -22,7 +23,7 @@ interface UseIcLoraState { result: IcLoraResult | null } -type GenerateIcLoraBody = ApiRequestBodyOf<'generateIcLora'> +type GenerateIcLoraBody = NonNullable> export function useIcLora() { const [state, setState] = useState({ @@ -42,12 +43,32 @@ export function useIcLora() { result: null, }) - const result = await ApiClient.generateIcLora({ - video_path: params.videoPath, + let preparedVideo + try { + setState(prev => ({ ...prev, status: 'Uploading source video' })) + preparedVideo = await prepareBackendMedia(params.videoPath, 'video') + } catch (error) { + setState({ + isGenerating: false, + status: '', + error: error instanceof Error ? error.message : String(error), + result: null, + }) + return + } + const request: GenerateIcLoraBody = { conditioning_type: params.conditioningType, conditioning_strength: params.conditioningStrength, prompt: params.prompt, - } as GenerateIcLoraBody) + num_inference_steps: 30, + cfg_guidance_scale: 1, + negative_prompt: '', + } + if (preparedVideo?.path) request.video_path = preparedVideo.path + if (preparedVideo?.mediaId) request.video_media_id = preparedVideo.mediaId + setState(prev => ({ ...prev, status: 'Generating' })) + + const result = await ApiClient.generateIcLora(request) if (!result.ok) { logger.error(`IC-LoRA error: ${result.error.message}`) setState({ @@ -71,12 +92,24 @@ export function useIcLora() { } if (payload.status === 'complete') { + let outputPath: string + try { + outputPath = await materializeBackendOutput(payload.video_path, payload.artifact) + } catch (error) { + setState({ + isGenerating: false, + status: '', + error: error instanceof Error ? error.message : String(error), + result: null, + }) + return + } setState({ isGenerating: false, status: 'Generation complete!', error: null, result: { - videoPath: payload.video_path, + videoPath: outputPath, }, }) return diff --git a/frontend/hooks/use-retake.ts b/frontend/hooks/use-retake.ts index 9feef4e3b..60169adbf 100644 --- a/frontend/hooks/use-retake.ts +++ b/frontend/hooks/use-retake.ts @@ -1,8 +1,10 @@ import { useCallback, useState } from 'react' -import { ApiClient } from '../lib/api-client' +import { ApiClient, type ApiRequestBodyOf } from '../lib/api-client' import { logger } from '../lib/logger' +import { materializeBackendOutput, prepareBackendMedia } from '../lib/backend-media' export type RetakeMode = 'replace_audio_and_video' | 'replace_video' | 'replace_audio' +type RetakeBody = NonNullable> export interface RetakeSubmitParams { videoPath: string @@ -41,13 +43,30 @@ export function useRetake() { result: null, }) - const result = await ApiClient.retake({ - video_path: params.videoPath, + let preparedVideo + try { + setState(prev => ({ ...prev, retakeStatus: 'Uploading source video' })) + preparedVideo = await prepareBackendMedia(params.videoPath, 'video') + } catch (error) { + setState({ + isRetaking: false, + retakeStatus: '', + retakeError: error instanceof Error ? error.message : String(error), + result: null, + }) + return + } + const request: RetakeBody = { start_time: params.startTime, duration: params.duration, prompt: params.prompt, mode: params.mode, - }) + } + if (preparedVideo?.path) request.video_path = preparedVideo.path + if (preparedVideo?.mediaId) request.video_media_id = preparedVideo.mediaId + setState(prev => ({ ...prev, retakeStatus: 'Generating' })) + + const result = await ApiClient.retake(request) if (!result.ok) { logger.error(`Retake error: ${result.error.message}`) @@ -72,26 +91,40 @@ export function useRetake() { return } - if ('video_path' in payload) { - setState({ - isRetaking: false, - retakeStatus: 'Retake complete!', - retakeError: null, - result: { - videoPath: payload.video_path, - }, - }) - return + if (payload.status === 'complete' && 'video_path' in payload) { + try { + const outputPath = await materializeBackendOutput(payload.video_path, payload.artifact) + setState({ + isRetaking: false, + retakeStatus: 'Retake complete!', + retakeError: null, + result: { + videoPath: outputPath, + }, + }) + return + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + logger.error(`Retake output materialization failed: ${errorMsg}`) + setState({ + isRetaking: false, + retakeStatus: '', + retakeError: errorMsg, + result: null, + }) + return + } } - logger.error(`Retake completed without local video payload: ${JSON.stringify(payload.result)}`) - const errorMsg = 'Retake completed but no local video file was returned' + const errorMsg = 'Retake completed without a downloadable video result' + logger.error(`${errorMsg}: ${JSON.stringify(payload)}`) setState({ isRetaking: false, retakeStatus: '', retakeError: errorMsg, result: null, }) + }, []) const resetRetake = useCallback(() => { diff --git a/frontend/lib/backend-media.ts b/frontend/lib/backend-media.ts new file mode 100644 index 000000000..0b868c0e9 --- /dev/null +++ b/frontend/lib/backend-media.ts @@ -0,0 +1,80 @@ +import { getBackendCredentials } from './backend' + +export type BackendMediaType = 'image' | 'audio' | 'video' + +export interface PreparedBackendMedia { + path?: string + mediaId?: string +} + +export interface BackendArtifact { + artifact_id: string + media_type: BackendMediaType + filename: string + content_type: string + size_bytes: number + sha256: string + expires_at: string +} + +export async function prepareBackendMedia( + filePath: string | null | undefined, + mediaType: BackendMediaType, +): Promise { + if (!filePath) return null + const backend = await getBackendCredentials() + if (backend.mode === 'managed-local') return { path: filePath } + + const result = await window.electronAPI.uploadBackendMedia({ filePath, mediaType }) + if (!result.success) throw new Error(result.error) + return { mediaId: result.media.media_id } +} + +export function toBackendArtifact(value: unknown): BackendArtifact | null { + if (!value || typeof value !== 'object') return null + const record = value as Partial + if ( + typeof record.artifact_id !== 'string' + || (record.media_type !== 'image' && record.media_type !== 'audio' && record.media_type !== 'video') + || typeof record.filename !== 'string' + || typeof record.content_type !== 'string' + || typeof record.size_bytes !== 'number' + || typeof record.sha256 !== 'string' + || typeof record.expires_at !== 'string' + ) { + return null + } + return record as BackendArtifact +} + +export async function materializeBackendOutput( + legacyPath: string | null | undefined, + artifactValue: unknown, +): Promise { + const backend = await getBackendCredentials() + if (backend.mode === 'managed-local' && legacyPath) return legacyPath + + const artifact = toBackendArtifact(artifactValue) + if (!artifact) { + throw new Error(backend.mode === 'external' + ? 'The remote backend completed without a downloadable artifact.' + : 'Generation completed without an output path or artifact.') + } + const result = await window.electronAPI.materializeBackendArtifact({ artifact }) + if (!result.success) throw new Error(result.error) + return result.path +} + +export async function materializeBackendOutputs( + legacyPaths: readonly string[] | null | undefined, + artifactValues: unknown, +): Promise { + const backend = await getBackendCredentials() + if (backend.mode === 'managed-local' && legacyPaths && legacyPaths.length > 0) { + return [...legacyPaths] + } + if (!Array.isArray(artifactValues) || artifactValues.length === 0) { + throw new Error('The remote backend completed without downloadable artifacts.') + } + return Promise.all(artifactValues.map((artifact) => materializeBackendOutput(null, artifact))) +} diff --git a/frontend/lib/backend.ts b/frontend/lib/backend.ts index b78d6ef3c..f01edfba3 100644 --- a/frontend/lib/backend.ts +++ b/frontend/lib/backend.ts @@ -1,6 +1,13 @@ -let cached: { url: string; token: string } | null = null +export interface BackendCredentials { + url: string + token: string + mode: 'managed-local' | 'external' + connectionRevision: number +} + +let cached: BackendCredentials | null = null -export async function getBackendCredentials(): Promise<{ url: string; token: string }> { +export async function getBackendCredentials(): Promise { if (!cached) cached = await window.electronAPI.getBackend() return cached } @@ -18,7 +25,7 @@ export async function backendFetch(path: string, init?: RequestInit): Promise { const { url, token } = await getBackendCredentials() - const ws = url.replace('http://', 'ws://') + const ws = url.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:') const sep = path.includes('?') ? '&' : '?' return `${ws}${path}${sep}token=${token}` } diff --git a/frontend/views/editor/VideoEditorTimelineEditingPanel.tsx b/frontend/views/editor/VideoEditorTimelineEditingPanel.tsx index ea690c6e6..9a2138ba7 100644 --- a/frontend/views/editor/VideoEditorTimelineEditingPanel.tsx +++ b/frontend/views/editor/VideoEditorTimelineEditingPanel.tsx @@ -25,8 +25,9 @@ import { addVisualAssetToProject } from '../../lib/asset-copy' import { GapGenerationModal } from './GapGenerationModal' import { ClipContextMenu, type ClipContextMenuState } from './ClipContextMenu' import type { TimelineClip, Track, SubtitleClip, Asset, TextOverlayStyle } from '../../types/project-model' -import { ApiClient } from '../../lib/api-client' +import { ApiClient, type ApiRequestBodyOf } from '../../lib/api-client' import { pathToFileUrl } from '../../lib/file-url' +import { prepareBackendMedia } from '../../lib/backend-media' import { areVideoGenerationSettingsEquivalent, getVideoGenerationModelSpecs, @@ -91,6 +92,7 @@ type TimelineMenuState = { timelineId: string; x: number; y: number } | null type GapSelection = { trackIndex: number; startTime: number; endTime: number } | null type GapAnchor = { x: number; gapTop: number; gapBottom: number } | null type GapGenerateMode = 'text-to-video' | 'image-to-video' | 'text-to-image' +type SuggestGapPromptBody = NonNullable> interface GapGenerationApi { generate: (prompt: string, imagePath: string | null, settings: GenerationSettings) => Promise @@ -754,21 +756,31 @@ export function VideoEditorTimelineEditingPanel(props: VideoEditorTimelineEditin let inputImagePath = '' const imageFile = gapImageFileRef.current if (imageFile && mode === 'image-to-video') { - const electronPath = (imageFile as { path?: string }).path + const electronPath = window.electronAPI.getPathForFile(imageFile) if (electronPath) { inputImagePath = electronPath } } - const result = await ApiClient.suggestGapPrompt({ + const [preparedBefore, preparedAfter, preparedInput] = await Promise.all([ + prepareBackendMedia(beforeFrame, 'image'), + prepareBackendMedia(afterFrame, 'image'), + prepareBackendMedia(inputImagePath, 'image'), + ]) + const request: Record = { gapDuration: gap.endTime - gap.startTime, mode, beforePrompt, afterPrompt, - beforeFrame, - afterFrame, - ...(inputImagePath ? { inputImage: inputImagePath } : {}), - }, { + } + if (preparedBefore?.path) request.beforeFrame = preparedBefore.path + if (preparedBefore?.mediaId) request.beforeFrameMediaId = preparedBefore.mediaId + if (preparedAfter?.path) request.afterFrame = preparedAfter.path + if (preparedAfter?.mediaId) request.afterFrameMediaId = preparedAfter.mediaId + if (preparedInput?.path) request.inputImage = preparedInput.path + if (preparedInput?.mediaId) request.inputImageMediaId = preparedInput.mediaId + + const result = await ApiClient.suggestGapPrompt(request as SuggestGapPromptBody, { signal: abortController.signal, }) if (!result.ok) { @@ -882,18 +894,8 @@ export function VideoEditorTimelineEditingPanel(props: VideoEditorTimelineEditin } else { let imagePath: string | null = null if (gapImageFile) { - const electronPath = (gapImageFile as { path?: string }).path - if (electronPath) { - imagePath = electronPath - } else { - const buf = await gapImageFile.arrayBuffer() - const b64 = btoa(String.fromCharCode(...new Uint8Array(buf))) - const modelsPath = await window.electronAPI.getModelsPath() - const tmpDir = modelsPath.replace(/[/\\]models$/, '') - const tmpPath = `${tmpDir}/tmp_gap_image_${Date.now()}.png` - await window.electronAPI.saveFile({ filePath: tmpPath, data: b64, encoding: 'base64' }) - imagePath = tmpPath - } + imagePath = window.electronAPI.getPathForFile(gapImageFile) || null + if (!imagePath) throw new Error('Could not resolve the selected image file') } await gapGenerationApi.generate(finalPrompt, imagePath, settings) } diff --git a/frontend/views/editor/useRegeneration.ts b/frontend/views/editor/useRegeneration.ts index 7b1dc4eb5..188955c0d 100644 --- a/frontend/views/editor/useRegeneration.ts +++ b/frontend/views/editor/useRegeneration.ts @@ -3,8 +3,9 @@ import type { Asset, TimelineClip } from '../../types/project-model' import type { GenerationSettings } from '../../components/SettingsPanel' import type { GenerationError } from '../../lib/generation-errors' import { addVisualAssetToProject } from '../../lib/asset-copy' -import { ApiClient } from '../../lib/api-client' +import { ApiClient, type ApiRequestBodyOf } from '../../lib/api-client' import { logger } from '../../lib/logger' +import { prepareBackendMedia } from '../../lib/backend-media' import { selectAssets, selectClips, @@ -29,6 +30,8 @@ const LEGACY_IMAGE_RESOLUTION_MAP: Record = { '2160p': '2048p', } +type SuggestGapPromptBody = NonNullable> + function normalizeVideoResolution(value: string | undefined): string { if (!value) return '540p' if (value === '540p' || value === '720p' || value === '1080p' || value === '1440p' || value === '2160p') { @@ -144,14 +147,17 @@ export function useRegeneration(params: UseRegenerationParams) { } if (framePath) { - const result = await ApiClient.suggestGapPrompt({ + const preparedFrame = await prepareBackendMedia(framePath, 'image') + const request: Record = { gapDuration: asset.duration || 5, mode: asset.type === 'image' ? 'text-to-image' : 'text-to-video', beforePrompt: '', afterPrompt: '', - beforeFrame: framePath, afterFrame: '', - }) + } + if (preparedFrame?.path) request.beforeFrame = preparedFrame.path + if (preparedFrame?.mediaId) request.beforeFrameMediaId = preparedFrame.mediaId + const result = await ApiClient.suggestGapPrompt(request as SuggestGapPromptBody) if (!result.ok) { throw new Error(result.error.message) } diff --git a/shared/electron-api-schema.ts b/shared/electron-api-schema.ts index c60a24546..1935109ba 100644 --- a/shared/electron-api-schema.ts +++ b/shared/electron-api-schema.ts @@ -50,9 +50,55 @@ const logsResponse = z.object({ error: z.string().optional(), }) +const backendConnectionMode = z.enum(['managed-local', 'external']) + const backendHealthStatus = z.object({ - status: z.enum(['alive', 'restarting', 'dead']), + mode: backendConnectionMode, + status: z.enum(['connecting', 'alive', 'restarting', 'unreachable', 'dead']), exitCode: z.number().nullable().optional(), + checkedAt: z.number(), + message: z.string().optional(), +}) + +const externalServerInfo = z.object({ + api_version: z.number(), + deployment_mode: z.enum(['managed_local', 'standalone']), + capabilities: z.object({ + media_ids: z.boolean(), + artifact_downloads: z.boolean(), + legacy_path_inputs: z.boolean(), + models_dir_editable: z.boolean(), + }), +}) + +const backendConnectionInput = z.discriminatedUnion('mode', [ + z.object({ mode: z.literal('managed-local') }), + z.object({ + mode: z.literal('external'), + url: z.string(), + authToken: z.string(), + adminToken: z.string().optional(), + }), +]) + +const backendMediaRef = z.object({ + media_id: z.string(), + media_type: z.enum(['image', 'audio', 'video']), + filename: z.string(), + content_type: z.string(), + size_bytes: z.number(), + sha256: z.string(), + expires_at: z.string(), +}) + +const backendArtifactRef = z.object({ + artifact_id: z.string(), + media_type: z.enum(['image', 'audio', 'video']), + filename: z.string(), + content_type: z.string(), + size_bytes: z.number(), + sha256: z.string(), + expires_at: z.string(), }) export type BackendHealthStatus = z.infer @@ -61,7 +107,37 @@ export const electronAPISchemas = { // App info getBackend: { input: z.object({}), - output: z.object({ url: z.string(), token: z.string() }), + output: z.object({ + url: z.string(), + token: z.string(), + mode: backendConnectionMode, + connectionRevision: z.number(), + }), + }, + getBackendConnectionConfig: { + input: z.object({}), + output: z.object({ + mode: backendConnectionMode, + url: z.string(), + hasAuthToken: z.boolean(), + hasAdminToken: z.boolean(), + }), + }, + testBackendConnection: { + input: z.object({ url: z.string(), authToken: z.string() }), + output: ipcResult({ serverInfo: externalServerInfo }), + }, + setBackendConnection: { + input: backendConnectionInput, + output: emptyResult, + }, + startBackendConnection: { + input: z.object({}), + output: z.void(), + }, + retryBackendConnection: { + input: z.object({}), + output: z.void(), }, getModelsPath: { input: z.object({}), @@ -277,6 +353,19 @@ export const electronAPISchemas = { output: backendHealthStatus.nullable(), }, + // Remote backend media transfer + uploadBackendMedia: { + input: z.object({ + filePath: z.string(), + mediaType: z.enum(['image', 'audio', 'video']), + }), + output: ipcResult({ media: backendMediaRef }), + }, + materializeBackendArtifact: { + input: z.object({ artifact: backendArtifactRef }), + output: ipcResult({ path: z.string() }), + }, + // Video processing extractVideoFrame: { input: z.object({ videoPath: z.string(), seekTime: z.number(), width: z.number().optional(), quality: z.number().optional() }), @@ -294,6 +383,10 @@ export const electronAPISchemas = { input: z.object({}), output: ipcResult({ path: z.string() }), }, + setBackendModelsDirectory: { + input: z.object({ path: z.string() }), + output: ipcResult({ path: z.string() }), + }, // Analytics getAnalyticsState: { From 05d1366f8086508f551f03d91ad708c396a9baa6 Mon Sep 17 00:00:00 2001 From: luisnomad Date: Mon, 20 Jul 2026 23:20:31 +0200 Subject: [PATCH 2/7] chore: improve remote backend smoke testing --- docs/REMOTE_BACKEND.md | 5 +++++ electron/app-paths.ts | 4 ++++ electron/ipc/app-handlers.ts | 3 +++ 3 files changed, 12 insertions(+) diff --git a/docs/REMOTE_BACKEND.md b/docs/REMOTE_BACKEND.md index e1a936573..d05ba063f 100644 --- a/docs/REMOTE_BACKEND.md +++ b/docs/REMOTE_BACKEND.md @@ -113,6 +113,11 @@ Configure the reverse proxy to preserve `Authorization`, `Content-Type`, and `Ra - Upload cancellation is not yet connected to generation cancellation. - Remote compute is intended for one desktop client per backend instance; generation state is still single-client. +## Follow-up TODO + +- [ ] Add phase-aware generation progress so the UI distinguishes cold model loading, text encoding, inference, export, artifact transfer, and completion instead of showing only `Generating...`. +- [ ] Add a persistent backend status bar showing the connected machine, model location, active model, and live CPU, GPU, and RAM usage. + ## Return to local compute Open **Settings → Compute**, choose **This computer**, and select **Save & reconnect**. LTX Desktop will return to its original managed-local startup and model workflow. diff --git a/electron/app-paths.ts b/electron/app-paths.ts index 40a361155..e67e265b2 100644 --- a/electron/app-paths.ts +++ b/electron/app-paths.ts @@ -5,6 +5,10 @@ import os from 'os' export const APP_FOLDER_NAME = 'LTXDesktop' function resolveUserDataPath(): string { + const override = process.env.LTX_USER_DATA_DIR?.trim() + if (override) { + return path.resolve(override) + } if (process.platform === 'win32') { const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local') diff --git a/electron/ipc/app-handlers.ts b/electron/ipc/app-handlers.ts index 540b7d124..e520f757c 100644 --- a/electron/ipc/app-handlers.ts +++ b/electron/ipc/app-handlers.ts @@ -179,6 +179,9 @@ export function registerAppHandlers(): void { }) handle('checkPythonReady', () => { + if (!app.isPackaged && process.env.LTX_FORCE_REMOTE_SETUP === '1') { + return { ready: false } + } return isPythonReady() }) From 1b46f1a90c7250992d10b432033f6816ec4aa5cd Mon Sep 17 00:00:00 2001 From: luisnomad Date: Tue, 21 Jul 2026 00:03:33 +0200 Subject: [PATCH 3/7] fix: harden remote backend mode --- backend/_routes/settings.py | 3 - backend/api_types.py | 1 - backend/app_handler.py | 8 +- backend/architecture.md | 7 +- backend/handlers/generation_handler.py | 1 - backend/handlers/media_handler.py | 39 +++-- backend/handlers/settings_handler.py | 4 + backend/ltx2_server.py | 8 +- backend/server_utils/media_validation.py | 27 ++- backend/services/interfaces.py | 9 +- backend/services/media_store/__init__.py | 4 - .../media_store/filesystem_media_store.py | 60 ++++--- backend/services/media_store/media_store.py | 8 + backend/tests/test_filesystem_media_store.py | 157 ++++++++++++++++++ backend/tests/test_generation.py | 5 +- backend/tests/test_media.py | 21 +++ docs/REMOTE_BACKEND.md | 13 +- electron/app-paths.ts | 4 - electron/app-state.ts | 9 +- electron/backend-media-transfer.ts | 5 +- electron/ipc/app-handlers.ts | 52 +++--- electron/ipc/file-handlers.ts | 10 +- electron/path-validation.ts | 18 ++ electron/preload.ts | 13 +- electron/python-backend.ts | 22 +-- frontend/App.tsx | 95 +++++------ .../components/BackendConnectionPanel.tsx | 19 +-- frontend/components/FirstRunSetup.tsx | 15 +- frontend/components/SettingsModal.tsx | 2 +- frontend/generated/backend-openapi.json | 10 -- frontend/generated/backend-openapi.ts | 1 - frontend/hooks/use-backend.ts | 4 +- frontend/hooks/use-generation.ts | 2 +- frontend/lib/backend.ts | 1 - shared/electron-api-schema.ts | 12 +- 35 files changed, 429 insertions(+), 240 deletions(-) create mode 100644 backend/tests/test_filesystem_media_store.py diff --git a/backend/_routes/settings.py b/backend/_routes/settings.py index 486a9e13c..468b9c5b6 100644 --- a/backend/_routes/settings.py +++ b/backend/_routes/settings.py @@ -7,7 +7,6 @@ from fastapi import APIRouter, Depends, Request from _routes._admin_guard import guard_admin_permission -from _routes._errors import HTTPError from state.app_settings import SettingsResponse, UpdateSettingsRequest, to_settings_response from api_types import StatusResponse from state import get_state_service @@ -33,8 +32,6 @@ def route_post_settings( ) -> StatusResponse: patch_data = req.model_dump(exclude_unset=True) if "models_dir" in patch_data or "modelsDir" in patch_data: - if not handler.config.models_dir_editable: - raise HTTPError(409, "MODELS_DIR_MANAGED_BY_SERVER") guard_admin_permission(request) _, _after, changed_paths = handler.settings.update_settings(req) diff --git a/backend/api_types.py b/backend/api_types.py index 037df88ee..417b39139 100644 --- a/backend/api_types.py +++ b/backend/api_types.py @@ -125,7 +125,6 @@ class GenerationProgressResponse(BaseModel): currentStep: int | None totalSteps: int | None generationId: str | None = None - artifact: ArtifactRef | None = None artifacts: list[ArtifactRef] = Field(default_factory=lambda: list[ArtifactRef]()) error: str | None = None diff --git a/backend/app_handler.py b/backend/app_handler.py index 5e23cf4a0..b0cbff3c4 100644 --- a/backend/app_handler.py +++ b/backend/app_handler.py @@ -254,6 +254,7 @@ class ServiceBundle: pose_processor_pipeline_class: type[PoseProcessorPipeline] a2v_pipeline_class: type[A2VPipeline] retake_pipeline_class: type[RetakePipeline] + # Integration tests use the real store inside their temporary app-data directory. media_store: MediaStore | None = None @@ -300,7 +301,11 @@ def build_default_service_bundle(config: RuntimeConfig) -> ServiceBundle: pose_processor_pipeline_class=DWPosePipeline, a2v_pipeline_class=LTXa2vPipeline, retake_pipeline_class=LTXRetakePipeline, - media_store=FilesystemMediaStore(app_data_dir=config.app_data_dir, outputs_dir=config.outputs_dir), + media_store=FilesystemMediaStore( + app_data_dir=config.app_data_dir, + outputs_dir=config.outputs_dir, + delete_expired_artifact_files=config.deployment_mode == "standalone", + ), ) @@ -316,6 +321,7 @@ def build_initial_state( media_store: MediaStore = FilesystemMediaStore( app_data_dir=config.app_data_dir, outputs_dir=config.outputs_dir, + delete_expired_artifact_files=config.deployment_mode == "standalone", ) else: media_store = bundle.media_store diff --git a/backend/architecture.md b/backend/architecture.md index 93e9ce862..7abff992e 100644 --- a/backend/architecture.md +++ b/backend/architecture.md @@ -8,7 +8,8 @@ This document describes the **Python backend** architecture and the contracts we ## Big picture -At runtime, the backend is a **local FastAPI server**. Endpoints are intentionally thin and delegate all work to a +At runtime, the backend is a **FastAPI server** used either as an Electron-managed local process or as a standalone +single-user service. Endpoints are intentionally thin and delegate all work to a single, shared **`AppHandler`** instance which owns: - `RuntimeConfig`: constant environment configuration (treat as immutable). @@ -106,7 +107,7 @@ This enables exhaustive matching and makes illegal states harder to represent. ### Why threads -This backend is optimized for a **single local client** with **heavy requests** (GPU/CPU work), rather than a high-QPS, +This backend is optimized for a **single trusted desktop client** with **heavy requests** (GPU/CPU work), rather than a high-QPS, multi-tenant server. Most endpoints are defined as synchronous `def` route handlers, which FastAPI/Starlette executes via a **thread pool**. @@ -165,6 +166,8 @@ Some side effects are not suitable for integration tests (GPU-heavy compute, net - The tested app (fake implementations). - Services should be narrowly scoped to their side effect and avoid business/state logic. - If a heavy side effect must be avoided in tests, it should be avoided **only** by introducing (or using) a service. +- The lightweight filesystem media store is exercised directly in temporary test directories; GPU and network services + still use fakes. ### Service interfaces and naming conventions diff --git a/backend/handlers/generation_handler.py b/backend/handlers/generation_handler.py index 51c364aba..ddc4c37cc 100644 --- a/backend/handlers/generation_handler.py +++ b/backend/handlers/generation_handler.py @@ -234,7 +234,6 @@ def get_generation_progress(self) -> GenerationProgressResponse: currentStep=0, totalSteps=0, generationId=generation_id, - artifact=artifacts[0] if len(artifacts) == 1 else None, artifacts=artifacts, ) case GenerationCancelled(id=generation_id): diff --git a/backend/handlers/media_handler.py b/backend/handlers/media_handler.py index 8495b6a62..ef62f7387 100644 --- a/backend/handlers/media_handler.py +++ b/backend/handlers/media_handler.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import mimetypes from pathlib import Path from threading import RLock @@ -12,19 +13,25 @@ from _routes._errors import HTTPError from api_types import ArtifactRef, MediaRef from handlers.base import StateHandlerBase -from server_utils.media_validation import validate_audio_file, validate_image_file, validate_video_file -from services.interfaces import MediaRecord, MediaStore, MediaType, VideoProcessor -from services.media_store.filesystem_media_store import InvalidMediaStorePathError, MediaTooLargeError +from server_utils.media_validation import ( + MAX_MEDIA_BYTES, + validate_audio_file, + validate_image_file, + validate_video_file, +) +from services.interfaces import ( + MediaRecord, + MediaStore, + MediaTooLargeError, + MediaType, + VideoProcessor, +) from state.app_state_types import AppState if TYPE_CHECKING: from runtime_config.runtime_config import RuntimeConfig -_MAX_UPLOAD_BYTES: dict[MediaType, int] = { - "image": 50 * 1024 * 1024, - "audio": 100 * 1024 * 1024, - "video": 2 * 1024 * 1024 * 1024, -} +logger = logging.getLogger(__name__) _IMAGE_CONTENT_TYPES = { "PNG": "image/png", @@ -48,15 +55,21 @@ def __init__( super().__init__(state, lock, config) self._store = media_store self._video_processor = video_processor - self._store.cleanup_expired() + self._cleanup_expired() + + def _cleanup_expired(self) -> None: + try: + self._store.cleanup_expired() + except Exception: + logger.warning("Media-store cleanup failed", exc_info=True) def upload(self, source: BinaryIO, *, filename: str, media_type: MediaType) -> MediaRef: - self._store.cleanup_expired() + self._cleanup_expired() try: staged = self._store.stage_upload( source, filename=filename, - max_bytes=_MAX_UPLOAD_BYTES[media_type], + max_bytes=MAX_MEDIA_BYTES[media_type], ) except MediaTooLargeError: raise HTTPError(413, "MEDIA_TOO_LARGE") from None @@ -118,7 +131,7 @@ def resolve_input( return None def register_artifact(self, path: str | Path, *, media_type: MediaType) -> ArtifactRef: - self._store.cleanup_expired() + self._cleanup_expired() artifact_path = Path(path) guessed, _encoding = mimetypes.guess_type(artifact_path.name) fallback = "image/png" if media_type == "image" else "video/mp4" @@ -128,8 +141,6 @@ def register_artifact(self, path: str | Path, *, media_type: MediaType) -> Artif media_type=media_type, content_type=guessed or fallback, ) - except (OSError, InvalidMediaStorePathError) as exc: - raise HTTPError(500, "ARTIFACT_REGISTRATION_FAILED") from exc except Exception as exc: raise HTTPError(500, "ARTIFACT_REGISTRATION_FAILED") from exc return self._artifact_ref(record) diff --git a/backend/handlers/settings_handler.py b/backend/handlers/settings_handler.py index 8f8da1646..7b91757d2 100644 --- a/backend/handlers/settings_handler.py +++ b/backend/handlers/settings_handler.py @@ -7,6 +7,7 @@ from threading import RLock from typing import TYPE_CHECKING +from _routes._errors import HTTPError from state.app_settings import AppSettings, UpdateSettingsRequest from handlers._settings_utils import ( collect_changed_paths, @@ -67,6 +68,9 @@ def get_settings_snapshot(self) -> AppSettings: def update_settings(self, patch: UpdateSettingsRequest) -> tuple[AppSettings, AppSettings, set[str]]: patch_payload = strip_none_values(ensure_json_object(patch.model_dump(by_alias=False, exclude_unset=True))) + if "models_dir" in patch_payload and not self.config.models_dir_editable: + raise HTTPError(409, "MODELS_DIR_MANAGED_BY_SERVER") + for key_field in ("ltx_api_key", "gemini_api_key", "fal_api_key"): if key_field in patch_payload and patch_payload[key_field] == "": del patch_payload[key_field] diff --git a/backend/ltx2_server.py b/backend/ltx2_server.py index 53aedcbba..f400422cb 100644 --- a/backend/ltx2_server.py +++ b/backend/ltx2_server.py @@ -142,18 +142,16 @@ def _resolve_app_data_dir() -> Path: APP_DATA_DIR = _resolve_app_data_dir() DEFAULT_MODELS_DIR = APP_DATA_DIR / "models" -DEFAULT_MODELS_DIR.mkdir(parents=True, exist_ok=True) - models_dir_env = os.environ.get("LTX_MODELS_DIR", "").strip() MODELS_DIR_OVERRIDE = Path(models_dir_env).expanduser().resolve() if models_dir_env else None -if MODELS_DIR_OVERRIDE is not None: - MODELS_DIR_OVERRIDE.mkdir(parents=True, exist_ok=True) +ACTIVE_MODELS_DIR = MODELS_DIR_OVERRIDE or DEFAULT_MODELS_DIR +ACTIVE_MODELS_DIR.mkdir(parents=True, exist_ok=True) PROJECT_ROOT = Path(__file__).parent.parent OUTPUTS_DIR = APP_DATA_DIR / "outputs" OUTPUTS_DIR.mkdir(parents=True, exist_ok=True) -logger.info(f"Models directory: {DEFAULT_MODELS_DIR}") +logger.info("Models directory: %s", ACTIVE_MODELS_DIR) # ============================================================ # Settings diff --git a/backend/server_utils/media_validation.py b/backend/server_utils/media_validation.py index 64ccd7853..9b42b413b 100644 --- a/backend/server_utils/media_validation.py +++ b/backend/server_utils/media_validation.py @@ -11,11 +11,14 @@ from PIL import Image from _routes._errors import HTTPError +from services.media_store.media_store import MediaType from services.video_processor.video_processor import VideoProcessor -_MAX_IMAGE_BYTES = 50 * 1024 * 1024 -_MAX_AUDIO_BYTES = 100 * 1024 * 1024 -_MAX_VIDEO_BYTES = 2 * 1024 * 1024 * 1024 +MAX_MEDIA_BYTES: dict[MediaType, int] = { + "image": 50 * 1024 * 1024, + "audio": 100 * 1024 * 1024, + "video": 2 * 1024 * 1024 * 1024, +} _MAX_IMAGE_PIXELS = 50_000_000 _ALLOWED_IMAGE_FORMATS = {"PNG", "JPEG", "WEBP", "GIF", "BMP", "TIFF"} @@ -58,7 +61,11 @@ def validate_image_file(path: str) -> Path: raise HTTPError(400, f"Image file not found: {path}") from None _assert_is_file(file_path, kind="Image", raw_path=path) - _assert_max_bytes(file_path, limit_bytes=_MAX_IMAGE_BYTES, error_detail=f"Image file too large: {path}") + _assert_max_bytes( + file_path, + limit_bytes=MAX_MEDIA_BYTES["image"], + error_detail=f"Image file too large: {path}", + ) try: with Image.open(file_path) as img: @@ -128,7 +135,11 @@ def validate_audio_file(path: str) -> Path: raise HTTPError(400, f"Audio file not found: {path}") from None _assert_is_file(file_path, kind="Audio", raw_path=path) - _assert_max_bytes(file_path, limit_bytes=_MAX_AUDIO_BYTES, error_detail=f"Audio file too large: {path}") + _assert_max_bytes( + file_path, + limit_bytes=MAX_MEDIA_BYTES["audio"], + error_detail=f"Audio file too large: {path}", + ) header = _read_header(file_path) if not _sniff_audio(header, file_path.suffix): @@ -145,7 +156,11 @@ def validate_video_file(path: str, video_processor: VideoProcessor) -> Path: except Exception: raise HTTPError(400, f"Video file not found: {path}") from None _assert_is_file(file_path, kind="Video", raw_path=path) - _assert_max_bytes(file_path, limit_bytes=_MAX_VIDEO_BYTES, error_detail=f"Video file too large: {path}") + _assert_max_bytes( + file_path, + limit_bytes=MAX_MEDIA_BYTES["video"], + error_detail=f"Video file too large: {path}", + ) try: capture = video_processor.open_video(str(file_path)) diff --git a/backend/services/interfaces.py b/backend/services/interfaces.py index fa551c969..0933d8f8c 100644 --- a/backend/services/interfaces.py +++ b/backend/services/interfaces.py @@ -14,7 +14,13 @@ from services.ic_lora_pipeline.ic_lora_pipeline import IcLoraPipeline from services.image_generation_pipeline.image_generation_pipeline import ImageGenerationPipeline from services.ltx_api_client.ltx_api_client import LTXAPIClient -from services.media_store.media_store import MediaRecord, MediaStore, MediaType, StagedMedia +from services.media_store.media_store import ( + MediaRecord, + MediaStore, + MediaTooLargeError, + MediaType, + StagedMedia, +) from services.retake_pipeline.retake_pipeline import RetakePipeline from services.model_downloader.model_downloader import ModelDownloader from services.pose_processor_pipeline.pose_processor_pipeline import PoseProcessorPipeline @@ -49,6 +55,7 @@ "LTXAPIClient", "MediaRecord", "MediaStore", + "MediaTooLargeError", "MediaType", "StagedMedia", "RetakePipeline", diff --git a/backend/services/media_store/__init__.py b/backend/services/media_store/__init__.py index c291d4f89..5f5242cef 100644 --- a/backend/services/media_store/__init__.py +++ b/backend/services/media_store/__init__.py @@ -1,5 +1 @@ """Opaque media storage service.""" - -from services.media_store.media_store import MediaRecord, MediaStore, MediaType, StagedMedia - -__all__ = ["MediaRecord", "MediaStore", "MediaType", "StagedMedia"] diff --git a/backend/services/media_store/filesystem_media_store.py b/backend/services/media_store/filesystem_media_store.py index 576dcf948..c4ead23a2 100644 --- a/backend/services/media_store/filesystem_media_store.py +++ b/backend/services/media_store/filesystem_media_store.py @@ -9,26 +9,25 @@ import secrets import shutil import threading +import time from datetime import UTC, datetime, timedelta from pathlib import Path from typing import BinaryIO, Literal from pydantic import BaseModel, ConfigDict, ValidationError -from services.media_store.media_store import MediaRecord, MediaType, StagedMedia +from services.media_store.media_store import ( + InvalidMediaStorePathError, + MediaRecord, + MediaTooLargeError, + MediaType, + StagedMedia, +) _ID_PATTERN = re.compile(r"^(?:med|art)_[A-Za-z0-9_-]{24,}$") _CHUNK_BYTES = 1024 * 1024 -class MediaTooLargeError(Exception): - pass - - -class InvalidMediaStorePathError(Exception): - pass - - class _StoredMetadata(BaseModel): model_config = ConfigDict(strict=True) @@ -75,6 +74,8 @@ def __init__( outputs_dir: Path, upload_ttl: timedelta = timedelta(hours=24), artifact_ttl: timedelta = timedelta(days=7), + cleanup_interval: timedelta = timedelta(hours=1), + delete_expired_artifact_files: bool = False, ) -> None: self._root = (app_data_dir / "media").resolve() self._staging_dir = self._root / "staging" @@ -83,6 +84,9 @@ def __init__( self._outputs_dir = outputs_dir.resolve() self._upload_ttl = upload_ttl self._artifact_ttl = artifact_ttl + self._cleanup_interval_seconds = max(0.0, cleanup_interval.total_seconds()) + self._delete_expired_artifact_files = delete_expired_artifact_files + self._last_cleanup_monotonic: float | None = None self._lock = threading.RLock() for directory in (self._root, self._staging_dir, self._uploads_dir, self._artifacts_dir): directory.mkdir(parents=True, exist_ok=True) @@ -206,20 +210,23 @@ def register_artifact(self, path: Path, *, media_type: MediaType, content_type: resolved = path.resolve(strict=True) if not resolved.is_file() or not _is_within(resolved, self._outputs_dir): raise InvalidMediaStorePathError("Artifact must be a file inside the outputs directory") + + digest = hashlib.sha256() + with resolved.open("rb") as artifact_file: + while chunk := artifact_file.read(_CHUNK_BYTES): + digest.update(chunk) + size_bytes = resolved.stat().st_size + with self._lock: artifact_id = self._new_id("art") metadata_path = self._artifacts_dir / artifact_id / "metadata.json" metadata_path.parent.mkdir(parents=False, exist_ok=False) - digest = hashlib.sha256() - with resolved.open("rb") as artifact_file: - while chunk := artifact_file.read(_CHUNK_BYTES): - digest.update(chunk) metadata = _StoredMetadata( id=artifact_id, media_type=media_type, filename=resolved.name, content_type=content_type, - size_bytes=resolved.stat().st_size, + size_bytes=size_bytes, sha256=digest.hexdigest(), expires_at=_now() + self._artifact_ttl, relative_path=str(resolved.relative_to(self._outputs_dir)), @@ -260,8 +267,18 @@ def delete_artifact(self, artifact_id: str) -> bool: return True def cleanup_expired(self) -> None: - now = _now() + cleanup_started = time.monotonic() with self._lock: + if ( + self._last_cleanup_monotonic is not None + and cleanup_started - self._last_cleanup_monotonic < self._cleanup_interval_seconds + ): + return + + # Throttle failed best-effort scans too, so a damaged store cannot + # turn every upload or generation completion into repeated I/O. + self._last_cleanup_monotonic = cleanup_started + now = _now() for staged in self._staging_dir.iterdir(): try: if datetime.fromtimestamp(staged.stat().st_mtime, UTC) + timedelta(hours=1) <= now: @@ -281,10 +298,11 @@ def cleanup_expired(self) -> None: continue if metadata.expires_at > now: continue - try: - artifact_path = (self._outputs_dir / metadata.relative_path).resolve(strict=True) - if artifact_path.is_file() and _is_within(artifact_path, self._outputs_dir): - artifact_path.unlink(missing_ok=True) - except OSError: - pass + if self._delete_expired_artifact_files: + try: + artifact_path = (self._outputs_dir / metadata.relative_path).resolve(strict=True) + if artifact_path.is_file() and _is_within(artifact_path, self._outputs_dir): + artifact_path.unlink(missing_ok=True) + except OSError: + pass shutil.rmtree(directory, ignore_errors=True) diff --git a/backend/services/media_store/media_store.py b/backend/services/media_store/media_store.py index 0e83aaf02..e02605758 100644 --- a/backend/services/media_store/media_store.py +++ b/backend/services/media_store/media_store.py @@ -10,6 +10,14 @@ MediaType = Literal["image", "audio", "video"] +class MediaTooLargeError(Exception): + pass + + +class InvalidMediaStorePathError(Exception): + pass + + @dataclass(frozen=True, slots=True) class StagedMedia: token: str diff --git a/backend/tests/test_filesystem_media_store.py b/backend/tests/test_filesystem_media_store.py new file mode 100644 index 000000000..cf5c94942 --- /dev/null +++ b/backend/tests/test_filesystem_media_store.py @@ -0,0 +1,157 @@ +"""Direct tests for the filesystem-backed media-store lifecycle.""" + +from __future__ import annotations + +import json +from datetime import timedelta +from io import BytesIO +from pathlib import Path + +import pytest + +from services.media_store.filesystem_media_store import FilesystemMediaStore +from services.media_store.media_store import MediaTooLargeError + + +def _make_store( + tmp_path: Path, + *, + upload_ttl: timedelta = timedelta(hours=24), + artifact_ttl: timedelta = timedelta(days=7), + cleanup_interval: timedelta = timedelta(0), + delete_expired_artifact_files: bool = False, +) -> tuple[FilesystemMediaStore, Path, Path]: + app_data_dir = tmp_path / "filesystem-store-app-data" + outputs_dir = tmp_path / "filesystem-store-outputs" + outputs_dir.mkdir() + store = FilesystemMediaStore( + app_data_dir=app_data_dir, + outputs_dir=outputs_dir, + upload_ttl=upload_ttl, + artifact_ttl=artifact_ttl, + cleanup_interval=cleanup_interval, + delete_expired_artifact_files=delete_expired_artifact_files, + ) + return store, app_data_dir, outputs_dir + + +def _artifact_metadata_path(app_data_dir: Path, artifact_id: str) -> Path: + return app_data_dir / "media" / "artifacts" / artifact_id / "metadata.json" + + +def test_managed_local_expiry_preserves_output_file(tmp_path: Path) -> None: + store, app_data_dir, outputs_dir = _make_store( + tmp_path, + artifact_ttl=timedelta(seconds=-1), + delete_expired_artifact_files=False, + ) + output = outputs_dir / "generated.mp4" + output.write_bytes(b"video") + artifact = store.register_artifact(output, media_type="video", content_type="video/mp4") + + store.cleanup_expired() + + assert output.is_file() + assert not _artifact_metadata_path(app_data_dir, artifact.id).parent.exists() + assert store.resolve_artifact(artifact.id) is None + + +def test_standalone_expiry_deletes_owned_output_file(tmp_path: Path) -> None: + store, app_data_dir, outputs_dir = _make_store( + tmp_path, + artifact_ttl=timedelta(seconds=-1), + delete_expired_artifact_files=True, + ) + output = outputs_dir / "generated.mp4" + output.write_bytes(b"video") + artifact = store.register_artifact(output, media_type="video", content_type="video/mp4") + + store.cleanup_expired() + + assert not output.exists() + assert not _artifact_metadata_path(app_data_dir, artifact.id).parent.exists() + assert store.resolve_artifact(artifact.id) is None + + +def test_expired_upload_content_is_removed(tmp_path: Path) -> None: + store, _app_data_dir, _outputs_dir = _make_store( + tmp_path, + upload_ttl=timedelta(seconds=-1), + ) + staged = store.stage_upload(BytesIO(b"image"), filename="frame.png", max_bytes=100) + uploaded = store.commit_upload( + staged, + media_type="image", + filename="frame.png", + content_type="image/png", + ) + + store.cleanup_expired() + + assert not uploaded.path.exists() + assert store.resolve_upload(uploaded.id) is None + + +def test_cleanup_is_throttled_after_first_scan(tmp_path: Path) -> None: + store, app_data_dir, outputs_dir = _make_store( + tmp_path, + artifact_ttl=timedelta(seconds=-1), + cleanup_interval=timedelta(hours=1), + ) + first_output = outputs_dir / "first.mp4" + first_output.write_bytes(b"first") + first = store.register_artifact(first_output, media_type="video", content_type="video/mp4") + store.cleanup_expired() + assert not _artifact_metadata_path(app_data_dir, first.id).parent.exists() + + second_output = outputs_dir / "second.mp4" + second_output.write_bytes(b"second") + second = store.register_artifact(second_output, media_type="video", content_type="video/mp4") + store.cleanup_expired() + + assert _artifact_metadata_path(app_data_dir, second.id).is_file() + + +def test_oversize_upload_removes_staged_file(tmp_path: Path) -> None: + store, app_data_dir, _outputs_dir = _make_store(tmp_path) + + with pytest.raises(MediaTooLargeError): + store.stage_upload(BytesIO(b"too large"), filename="frame.png", max_bytes=3) + + assert list((app_data_dir / "media" / "staging").iterdir()) == [] + + +def test_corrupt_artifact_metadata_is_rejected_without_deleting_output(tmp_path: Path) -> None: + store, app_data_dir, outputs_dir = _make_store(tmp_path) + output = outputs_dir / "generated.mp4" + output.write_bytes(b"video") + artifact = store.register_artifact(output, media_type="video", content_type="video/mp4") + metadata_path = _artifact_metadata_path(app_data_dir, artifact.id) + metadata_path.write_text("not-json", encoding="utf-8") + + assert store.resolve_artifact(artifact.id) is None + store.cleanup_expired() + assert output.is_file() + + +def test_expired_traversal_metadata_cannot_delete_outside_outputs(tmp_path: Path) -> None: + store, app_data_dir, outputs_dir = _make_store( + tmp_path, + artifact_ttl=timedelta(seconds=-1), + delete_expired_artifact_files=True, + ) + output = outputs_dir / "generated.mp4" + output.write_bytes(b"video") + outside = tmp_path / "outside.mp4" + outside.write_bytes(b"outside") + artifact = store.register_artifact(output, media_type="video", content_type="video/mp4") + metadata_path = _artifact_metadata_path(app_data_dir, artifact.id) + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + metadata["relative_path"] = "../outside.mp4" + metadata_path.write_text(json.dumps(metadata), encoding="utf-8") + + store.cleanup_expired() + + assert outside.read_bytes() == b"outside" + assert output.read_bytes() == b"video" + assert not metadata_path.parent.exists() diff --git a/backend/tests/test_generation.py b/backend/tests/test_generation.py index 0fd2c21f0..45a128b5d 100644 --- a/backend/tests/test_generation.py +++ b/backend/tests/test_generation.py @@ -82,7 +82,7 @@ def test_t2v_happy_path(self, client, test_state, fake_services, create_fake_mod assert Path(data["video_path"]).exists() progress = client.get("/api/generation/progress").json() assert progress["generationId"] == "desktop-video-job-1234" - assert progress["artifact"]["artifact_id"] == data["artifact"]["artifact_id"] + assert progress["artifacts"][0]["artifact_id"] == data["artifact"]["artifact_id"] pipeline = fake_services.fast_video_pipeline assert len(pipeline.generate_calls) == 1 @@ -1172,8 +1172,7 @@ def test_complete_includes_generation_id_and_artifacts(self, client, test_state) data = response.json() assert data["status"] == "complete" assert data["generationId"] == "recoverable-job" - assert data["artifact"] == data["artifacts"][0] - assert data["artifact"]["artifact_id"] == "art_abcdefghijklmnopqrstuvwxyz" + assert data["artifacts"][0]["artifact_id"] == "art_abcdefghijklmnopqrstuvwxyz" class TestGenerateImage: diff --git a/backend/tests/test_media.py b/backend/tests/test_media.py index f9499bc9c..b908dee1e 100644 --- a/backend/tests/test_media.py +++ b/backend/tests/test_media.py @@ -4,6 +4,9 @@ from pathlib import Path +import pytest + +from _routes._errors import HTTPError from tests.fakes.services import FakeResponse @@ -44,6 +47,24 @@ def test_upload_rejects_invalid_image(client) -> None: assert response.status_code == 400 +def test_uploaded_media_type_must_match_requested_input( + client, + test_state, + make_test_image, +) -> None: + uploaded = _upload_image(client, make_test_image) + + with pytest.raises(HTTPError) as exc_info: + test_state.media.resolve_input( + media_id=str(uploaded["media_id"]), + legacy_path=None, + expected_type="video", + ) + + assert exc_info.value.status_code == 409 + assert exc_info.value.code == "MEDIA_TYPE_MISMATCH" + + def test_artifact_download_range_and_delete(client, test_state) -> None: output = test_state.config.outputs_dir / "generated.mp4" output.write_bytes(b"0123456789") diff --git a/docs/REMOTE_BACKEND.md b/docs/REMOTE_BACKEND.md index d05ba063f..961dd85a9 100644 --- a/docs/REMOTE_BACKEND.md +++ b/docs/REMOTE_BACKEND.md @@ -7,7 +7,7 @@ Remote compute is optional. The default `managed_local` mode continues to start ## Architecture ```text -Mac or PC GPU machine +Desktop computer GPU machine --------------------------- ----------------------------- Electron + React UI Standalone FastAPI backend Project assets and playback -> Media upload by opaque ID @@ -69,7 +69,7 @@ Server running on http://127.0.0.1:8000 On the computer running LTX Desktop, forward the same local port to the GPU machine: ```bash -ssh -N -L 8000:127.0.0.1:8000 user@atom +ssh -N -L 8000:127.0.0.1:8000 user@gpu-host ``` Keeping port 8000 on both sides also preserves the default Hugging Face OAuth callback address. @@ -92,7 +92,7 @@ Example environment: ```bash export LTX_BIND_HOST=127.0.0.1 -export LTX_PUBLIC_BASE_URL=https://ltx-atom.example.ts.net +export LTX_PUBLIC_BASE_URL=https://ltx-gpu.example.com export LTX_ALLOWED_ORIGINS='null,http://localhost:5173,http://127.0.0.1:5173' ``` @@ -105,7 +105,7 @@ Configure the reverse proxy to preserve `Authorization`, `Content-Type`, and `Ra - Partial transfers are written atomically and removed after failures. - The Electron client validates downloaded size and SHA-256 before importing an artifact. - Upload references are refreshed before expiry instead of being reused indefinitely. -- Video and image requests carry a client generation ID. If the connection drops after the Atom accepts a job, the desktop client keeps polling that exact job and downloads its completed artifacts after reconnection. +- Video and image requests carry a client generation ID. If the connection drops after the backend accepts a job, the desktop client keeps polling that exact job and downloads its completed artifacts after reconnection. ## Current limitations @@ -113,11 +113,6 @@ Configure the reverse proxy to preserve `Authorization`, `Content-Type`, and `Ra - Upload cancellation is not yet connected to generation cancellation. - Remote compute is intended for one desktop client per backend instance; generation state is still single-client. -## Follow-up TODO - -- [ ] Add phase-aware generation progress so the UI distinguishes cold model loading, text encoding, inference, export, artifact transfer, and completion instead of showing only `Generating...`. -- [ ] Add a persistent backend status bar showing the connected machine, model location, active model, and live CPU, GPU, and RAM usage. - ## Return to local compute Open **Settings → Compute**, choose **This computer**, and select **Save & reconnect**. LTX Desktop will return to its original managed-local startup and model workflow. diff --git a/electron/app-paths.ts b/electron/app-paths.ts index e67e265b2..40a361155 100644 --- a/electron/app-paths.ts +++ b/electron/app-paths.ts @@ -5,10 +5,6 @@ import os from 'os' export const APP_FOLDER_NAME = 'LTXDesktop' function resolveUserDataPath(): string { - const override = process.env.LTX_USER_DATA_DIR?.trim() - if (override) { - return path.resolve(override) - } if (process.platform === 'win32') { const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local') diff --git a/electron/app-state.ts b/electron/app-state.ts index 94bda715d..d125a3fff 100644 --- a/electron/app-state.ts +++ b/electron/app-state.ts @@ -16,18 +16,16 @@ interface StoredBackendConnection { mode: BackendConnectionMode url?: string authToken?: string - adminToken?: string } export type BackendConnectionConfig = | { mode: 'managed-local' } - | { mode: 'external'; url: string; authToken: string; adminToken?: string } + | { mode: 'external'; url: string; authToken: string } export interface BackendConnectionSummary { mode: BackendConnectionMode url: string hasAuthToken: boolean - hasAdminToken: boolean } export function getAppStatePath(): string { @@ -75,12 +73,10 @@ export function readBackendConnectionConfig(): BackendConnectionConfig { throw new Error('The saved external backend connection is incomplete') } - const adminToken = decryptSecret(stored.adminToken) return { mode: 'external', url: stored.url, authToken: decryptSecret(stored.authToken), - ...(adminToken ? { adminToken } : {}), } } @@ -91,7 +87,6 @@ export function getBackendConnectionSummary(): BackendConnectionSummary { mode: 'managed-local', url: '', hasAuthToken: false, - hasAdminToken: false, } } @@ -99,7 +94,6 @@ export function getBackendConnectionSummary(): BackendConnectionSummary { mode: 'external', url: stored.url ?? '', hasAuthToken: Boolean(stored.authToken), - hasAdminToken: Boolean(stored.adminToken), } } @@ -112,7 +106,6 @@ export function writeBackendConnectionConfig(config: BackendConnectionConfig): v mode: 'external', url: config.url, authToken: encryptSecret(config.authToken), - ...(config.adminToken ? { adminToken: encryptSecret(config.adminToken) } : {}), } } writeAppState(state) diff --git a/electron/backend-media-transfer.ts b/electron/backend-media-transfer.ts index c50e8bf60..40b864543 100644 --- a/electron/backend-media-transfer.ts +++ b/electron/backend-media-transfer.ts @@ -9,7 +9,6 @@ import { pipeline } from 'stream/promises' import { getAuthToken, getBackendConnectionMode, - getBackendConnectionRevision, getBackendUrl, } from './python-backend' import { logger } from './logger' @@ -180,7 +179,9 @@ export async function uploadBackendMedia( ): Promise { const resolvedPath = resolveMediaPath(filePath) const stat = fs.statSync(resolvedPath) - const key = `${getBackendConnectionRevision()}:${mediaType}:${resolvedPath}:${stat.size}:${stat.mtimeMs}` + const { url, token } = requireExternalBackend() + const connectionKey = crypto.createHash('sha256').update(`${url}\0${token}`).digest('hex') + const key = `${connectionKey}:${mediaType}:${resolvedPath}:${stat.size}:${stat.mtimeMs}` const cached = mediaCache.get(key) if (cached) { const cachedUpload = await cached diff --git a/electron/ipc/app-handlers.ts b/electron/ipc/app-handlers.ts index e520f757c..3733231d7 100644 --- a/electron/ipc/app-handlers.ts +++ b/electron/ipc/app-handlers.ts @@ -1,4 +1,4 @@ -import { app, dialog } from 'electron' +import { app, dialog, ipcMain } from 'electron' import path from 'path' import fs from 'fs' import { checkGPU } from '../gpu' @@ -6,7 +6,6 @@ import { isPythonReady, downloadPythonEmbed } from '../python-setup' import { configureBackendConnection, getBackendConnectionMode, - getBackendConnectionRevision, getBackendHealthStatus, getBackendUrl, getAuthToken, @@ -20,6 +19,9 @@ import { getAnalyticsState, setAnalyticsEnabled, sendAnalyticsEvent } from '../a import { handle } from './typed-handle' import { getBackendConnectionSummary } from '../app-state' import { materializeBackendArtifact, uploadBackendMedia } from '../backend-media-transfer' +import { getAllowedRoots } from '../config' +import { approveExistingFilePath, validatePath } from '../path-validation' +import { SELECTED_FILE_PATH_APPROVAL_CHANNEL } from '../../shared/electron-api-schema' function getModelsPath(): string { const modelsPath = path.join(app.getPath('userData'), 'models') @@ -81,12 +83,23 @@ function markLicenseAccepted(settingsPath: string): void { } export function registerAppHandlers(): void { + ipcMain.on(SELECTED_FILE_PATH_APPROVAL_CHANNEL, (event, selectedPath: unknown) => { + try { + if (event.sender !== getMainWindow()?.webContents || typeof selectedPath !== 'string') { + throw new Error('Invalid selected file approval request') + } + approveExistingFilePath(selectedPath) + event.returnValue = true + } catch { + event.returnValue = false + } + }) + handle('getBackend', () => { return { url: getBackendUrl() ?? '', token: getAuthToken() ?? '', mode: getBackendConnectionMode(), - connectionRevision: getBackendConnectionRevision(), } }) @@ -179,9 +192,6 @@ export function registerAppHandlers(): void { }) handle('checkPythonReady', () => { - if (!app.isPackaged && process.env.LTX_FORCE_REMOTE_SETUP === '1') { - return { ready: false } - } return isPythonReady() }) @@ -201,7 +211,8 @@ export function registerAppHandlers(): void { handle('uploadBackendMedia', async ({ filePath, mediaType }) => { try { - const media = await uploadBackendMedia(filePath, mediaType) + const validatedPath = validatePath(filePath, getAllowedRoots()) + const media = await uploadBackendMedia(validatedPath, mediaType) return { success: true, media } } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) } @@ -231,7 +242,7 @@ export function registerAppHandlers(): void { handle('openModelsDirChangeDialog', async () => { if (getBackendConnectionMode() === 'external') { - return { success: false, error: 'Choose the models path on the Atom, not on this computer' } + return { success: false, error: 'Choose the models path on the remote backend, not on this computer' } } const mainWindow = getMainWindow() if (!mainWindow) return { success: false, error: 'No window' } @@ -262,29 +273,4 @@ export function registerAppHandlers(): void { return { success: true, path: newDir } }) - handle('setBackendModelsDirectory', async ({ path: modelsDir }) => { - const url = getBackendUrl() - const auth = getAuthToken() - const admin = getAdminToken() - if (!url || !auth || !admin) { - return { success: false, error: 'An admin token is required to change the backend models directory' } - } - - try { - const resp = await fetch(`${url}/api/settings`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${auth}`, - 'X-Admin-Token': admin, - }, - body: JSON.stringify({ modelsDir }), - }) - if (!resp.ok) return { success: false, error: await resp.text() } - return { success: true, path: modelsDir } - } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) } - } - }) - } diff --git a/electron/ipc/file-handlers.ts b/electron/ipc/file-handlers.ts index 6d82f1541..5f06905c2 100644 --- a/electron/ipc/file-handlers.ts +++ b/electron/ipc/file-handlers.ts @@ -72,12 +72,7 @@ function resolveLocalSourcePath(srcPath: string): string { } const normalized = srcPath.trim() - - if (!path.isAbsolute(normalized)) { - throw new Error(`Source path must be absolute: ${srcPath}`) - } - - const resolved = path.resolve(normalized) + const resolved = validatePath(normalized, getAllowedRoots()) if (!fs.existsSync(resolved)) { throw new Error(`Source file does not exist: ${resolved}`) } @@ -368,7 +363,8 @@ export function registerFileHandlers(): void { const results: Record = {} for (const p of filePaths) { try { - results[p] = fs.existsSync(p) + const validatedPath = validatePath(p, getAllowedRoots()) + results[p] = fs.existsSync(validatedPath) } catch { results[p] = false } diff --git a/electron/path-validation.ts b/electron/path-validation.ts index 7ad5da5bb..09a416365 100644 --- a/electron/path-validation.ts +++ b/electron/path-validation.ts @@ -1,4 +1,5 @@ import path from 'path' +import fs from 'fs' const isWindows = process.platform === 'win32' @@ -14,11 +15,26 @@ function stripFileUrl(fileUrl: string): string { } const approvedPaths = new Set() +const approvedFiles = new Set() export function approvePath(filePath: string): void { approvedPaths.add(normalize(filePath)) } +export function approveExistingFilePath(filePath: string): string { + if (!path.isAbsolute(filePath)) { + throw new Error(`Selected file path must be absolute: ${filePath}`) + } + + const resolved = path.resolve(filePath) + if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) { + throw new Error(`Selected file does not exist: ${resolved}`) + } + + approvedFiles.add(normalize(resolved)) + return resolved +} + export function validatePath(inputPath: string, allowedRoots: string[]): string { const cleaned = inputPath.startsWith('file://') ? stripFileUrl(inputPath) : inputPath const resolved = path.resolve(cleaned) @@ -28,6 +44,8 @@ export function validatePath(inputPath: string, allowedRoots: string[]): string if (norm === root || norm.startsWith(root + path.sep)) return resolved } + if (approvedFiles.has(norm)) return resolved + let found = false approvedPaths.forEach((approved) => { if (norm === approved || norm.startsWith(approved + path.sep)) found = true diff --git a/electron/preload.ts b/electron/preload.ts index bacdbb134..e70356858 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,4 +1,8 @@ -import { electronAPISchemas, type BackendHealthStatus } from '../shared/electron-api-schema' +import { + electronAPISchemas, + SELECTED_FILE_PATH_APPROVAL_CHANNEL, + type BackendHealthStatus, +} from '../shared/electron-api-schema' import { HF_GATING_ENABLED } from '../shared/feature-flags' const { contextBridge, ipcRenderer, webUtils } = require('electron') @@ -25,7 +29,12 @@ api.onBackendHealthStatus = (cb: (data: BackendHealthStatus) => void) => { } } -api.getPathForFile = (file: File) => webUtils.getPathForFile(file) +api.getPathForFile = (file: File) => { + const filePath = webUtils.getPathForFile(file) + if (!filePath) return '' + const approved = ipcRenderer.sendSync(SELECTED_FILE_PATH_APPROVAL_CHANNEL, filePath) + return approved === true ? filePath : '' +} api.platform = process.platform diff --git a/electron/python-backend.ts b/electron/python-backend.ts index 0f8874702..4f3707a79 100644 --- a/electron/python-backend.ts +++ b/electron/python-backend.ts @@ -38,13 +38,11 @@ let backendUrl: string | null = null let authToken: string | null = null let adminToken: string | null = null let activeConnectionMode: BackendConnectionMode = getBackendConnectionSummary().mode -let connectionRevision = 0 export function getBackendUrl(): string | null { return backendUrl } export function getAuthToken(): string | null { return authToken } export function getAdminToken(): string | null { return adminToken } export function getBackendConnectionMode(): BackendConnectionMode { return activeConnectionMode } -export function getBackendConnectionRevision(): number { return connectionRevision } type BackendOwnership = 'managed' | 'adopted' | null @@ -54,19 +52,17 @@ export interface BackendHealthStatus { mode: BackendConnectionMode status: 'connecting' | 'alive' | 'restarting' | 'unreachable' | 'dead' exitCode?: number | null - checkedAt: number message?: string } let latestBackendHealthStatus: BackendHealthStatus | null = null function publishBackendHealthStatus( - status: Omit & Partial>, + status: Omit & Partial>, ): void { const payload: BackendHealthStatus = { ...status, mode: status.mode ?? activeConnectionMode, - checkedAt: status.checkedAt ?? Date.now(), } latestBackendHealthStatus = payload getMainWindow()?.webContents.send('backend-health-status', payload) @@ -290,7 +286,7 @@ async function connectExternalBackend(): Promise { if (config.mode !== 'external') throw new Error('No external backend is configured') backendUrl = normalizeExternalBackendUrl(config.url) authToken = config.authToken - adminToken = config.adminToken ?? null + adminToken = null await fetchExternalServerInfo(backendUrl, authToken) if (!await probeBackendHealth(3000)) { throw new Error('The backend compatibility check passed, but its health endpoint is unavailable') @@ -321,21 +317,20 @@ export async function configureBackendConnection(config: BackendConnectionConfig if (!normalizedToken) throw new Error('Authentication token is required') await fetchExternalServerInfo(normalizedUrl, normalizedToken) - if (activeConnectionMode === 'managed-local' && pythonProcess) { - stopPythonBackend() - } else { - stopLivenessMonitor() - } writeBackendConnectionConfig({ mode: 'external', url: normalizedUrl, authToken: normalizedToken, - ...(config.adminToken?.trim() ? { adminToken: config.adminToken.trim() } : {}), }) + if (activeConnectionMode === 'managed-local' && pythonProcess) { + stopPythonBackend() + } else { + stopLivenessMonitor() + } activeConnectionMode = 'external' } else { - stopLivenessMonitor() writeBackendConnectionConfig({ mode: 'managed-local' }) + stopLivenessMonitor() activeConnectionMode = 'managed-local' } @@ -344,7 +339,6 @@ export async function configureBackendConnection(config: BackendConnectionConfig adminToken = null backendOwnership = null latestBackendHealthStatus = null - connectionRevision += 1 } function startOwnershipTakeover(): void { diff --git a/frontend/App.tsx b/frontend/App.tsx index 5404fb7d2..70a579c83 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -21,6 +21,7 @@ import { BackendConnectionPanel } from './components/BackendConnectionPanel' type SetupState = 'loading' | { needsSetup: boolean; needsLicense: boolean } type RequiredModelsGateState = 'checking' | 'missing' | 'ready' +type ConfiguredBackendMode = 'managed-local' | 'external' type LtxRecommendation = ApiSuccessOf<'getLtxRecommendation'> type LtxUpgradeRecommendation = Extract @@ -36,6 +37,7 @@ function AppContent() { const { settings, saveLtxApiKey, saveFalApiKey, forceApiGenerations, isLoaded, runtimePolicyLoaded } = useAppSettings() const [pythonReady, setPythonReady] = useState(null) + const [configuredBackendMode, setConfiguredBackendMode] = useState(null) const [pythonSetupSelected, setPythonSetupSelected] = useState(false) const [backendStarted, setBackendStarted] = useState(false) const [setupState, setSetupState] = useState('loading') @@ -96,15 +98,12 @@ function AppContent() { const check = async () => { try { const connection = await window.electronAPI.getBackendConnectionConfig() - if (connection.mode === 'external') { - setPythonReady(true) - return - } + setConfiguredBackendMode(connection.mode) const result = await window.electronAPI.checkPythonReady() setPythonReady(result.ready) } catch (e) { logger.error(`Failed to check Python readiness: ${e}`) - setPythonReady(true) + setPythonReady(false) } } void check() @@ -356,27 +355,32 @@ function AppContent() {
) : null - const externalBackendOverlay = isExternalBackendUnreachable ? ( -
-
-
-
- -

Remote backend disconnected

-

- {backendMessage || 'Check that the Atom backend and SSH tunnel are running. Active work remains open while LTX Desktop reconnects.'} -

- -
- -
+ const externalBackendBanner = isExternalBackendUnreachable ? ( +
+ +
+
Remote backend disconnected
+

+ {backendMessage || 'Local editing remains available while LTX Desktop reconnects.'} +

+ +
) : null @@ -385,23 +389,20 @@ function AppContent() { const shouldShowForcedFirstRunUpsell = isForcedFirstRun && isLoaded && !settings.hasLtxApiKey const shouldShowGlobalForcedUpsell = forceApiGenerations && setupState !== 'loading' && !setupState.needsSetup && isLoaded && !settings.hasLtxApiKey const shouldBlockForLtxKey = shouldShowForcedFirstRunUpsell || shouldShowGlobalForcedUpsell - - useEffect(() => { - if (shouldBlockForLtxKey && apiGatewayRequest === null) { - setApiGatewayRequest({ + const forcedApiGatewayRequest: ApiGatewayRequest | null = shouldBlockForLtxKey + ? { requiredKeys: ['ltx'], title: 'Connect API Keys', description: 'This app is configured for API-only generation. Add your API key to continue.', blocking: true, includeOptionalMissing: true, - }) - } - }, [shouldBlockForLtxKey, apiGatewayRequest]) - - const shouldShowGateway = apiGatewayRequest !== null + } + : null + const activeApiGatewayRequest = apiGatewayRequest ?? forcedApiGatewayRequest + const shouldShowGateway = activeApiGatewayRequest !== null const gatewaySections: ApiGatewaySection[] = useMemo(() => { - if (!apiGatewayRequest) return [] + if (!activeApiGatewayRequest) return [] const handleSaveLtxKey = async (apiKey: string) => { if (isForcedFirstRun) { @@ -416,7 +417,7 @@ function AppContent() { keyType: 'ltx', title: 'LTX API', description: 'Video generation, prompt enhancement, and cloud text encoding.', - required: apiGatewayRequest.requiredKeys.includes('ltx'), + required: activeApiGatewayRequest.requiredKeys.includes('ltx'), isConfigured: settings.hasLtxApiKey, inputLabel: 'LTX API key', placeholder: 'Enter your LTX API key...', @@ -428,7 +429,7 @@ function AppContent() { keyType: 'fal', title: 'FAL AI', description: 'Required to generate images with Z Image Turbo.', - required: apiGatewayRequest.requiredKeys.includes('fal'), + required: activeApiGatewayRequest.requiredKeys.includes('fal'), isConfigured: settings.hasFalApiKey, inputLabel: 'FAL AI API key', placeholder: 'Enter your FAL AI API key...', @@ -440,11 +441,11 @@ function AppContent() { return sections.filter((section) => { if (section.required) return true - if (apiGatewayRequest.includeOptionalMissing) return true + if (activeApiGatewayRequest.includeOptionalMissing) return true return false }) }, [ - apiGatewayRequest, + activeApiGatewayRequest, isForcedFirstRun, saveApiKeyForFirstRun, saveFalApiKey, @@ -462,7 +463,7 @@ function AppContent() { } if (pythonReady === false) { - if (pythonSetupSelected) { + if (configuredBackendMode === 'external' || pythonSetupSelected) { return setPythonReady(true)} /> } return ( @@ -472,10 +473,10 @@ function AppContent() {

Choose where inference runs

- Install the managed runtime for this computer, or connect directly to a standalone backend on another machine. + LTX Desktop needs local media tools for project assets and export, even when inference runs on another machine.

@@ -523,7 +524,7 @@ function AppContent() {
{restartingOverlay} - {externalBackendOverlay} + {externalBackendBanner} ) } @@ -602,10 +603,10 @@ function AppContent() { /> setApiGatewayRequest(null)} - title={apiGatewayRequest?.title ?? 'Connect API Keys'} - description={apiGatewayRequest?.description ?? 'Add the required API keys to continue.'} + title={activeApiGatewayRequest?.title ?? 'Connect API Keys'} + description={activeApiGatewayRequest?.description ?? 'Add the required API keys to continue.'} sections={gatewaySections} /> {ltxUpgradeRecommendation && ( @@ -655,7 +656,7 @@ function AppContent() { )} {restartingOverlay} - {externalBackendOverlay} + {externalBackendBanner} ) } diff --git a/frontend/components/BackendConnectionPanel.tsx b/frontend/components/BackendConnectionPanel.tsx index d019935ae..ca039fe38 100644 --- a/frontend/components/BackendConnectionPanel.tsx +++ b/frontend/components/BackendConnectionPanel.tsx @@ -5,15 +5,13 @@ import { Button } from './ui/button' type ConnectionMode = 'managed-local' | 'external' interface BackendConnectionPanelProps { - onConfigured?: () => void compact?: boolean } -export function BackendConnectionPanel({ onConfigured, compact = false }: BackendConnectionPanelProps) { +export function BackendConnectionPanel({ compact = false }: BackendConnectionPanelProps) { const [mode, setMode] = useState('managed-local') const [url, setUrl] = useState('http://127.0.0.1:8000') const [authToken, setAuthToken] = useState('') - const [adminToken, setAdminToken] = useState('') const [hasSavedToken, setHasSavedToken] = useState(false) const [busy, setBusy] = useState(false) const [message, setMessage] = useState(null) @@ -64,12 +62,10 @@ export function BackendConnectionPanel({ onConfigured, compact = false }: Backen mode: 'external', url, authToken, - ...(adminToken.trim() ? { adminToken } : {}), }) if (!result.success) throw new Error(result.error) setSuccess(true) setMessage('Connection saved. Reloading LTX Desktop…') - onConfigured?.() window.setTimeout(() => window.location.reload(), 250) } catch (error) { setMessage(error instanceof Error ? error.message : String(error)) @@ -130,19 +126,8 @@ export function BackendConnectionPanel({ onConfigured, compact = false }: Backen className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-white outline-none focus:border-blue-500" /> -

- Plain HTTP is accepted only on localhost. For an Atom bound to port 8000, use an SSH tunnel to the same Mac port. + Plain HTTP is accepted only on localhost. Use an SSH tunnel or HTTPS to reach a backend on another machine.

)} diff --git a/frontend/components/FirstRunSetup.tsx b/frontend/components/FirstRunSetup.tsx index 6a2f3bb26..d8b2675b9 100644 --- a/frontend/components/FirstRunSetup.tsx +++ b/frontend/components/FirstRunSetup.tsx @@ -80,10 +80,7 @@ export function LaunchGate({ }: LaunchGateProps) { const [currentStep, setCurrentStep] = useState(showLicenseStep ? 'license' : 'location') const [installPath, setInstallPath] = useState('') - const [backendConnection, setBackendConnection] = useState<{ - mode: 'managed-local' | 'external' - hasAdminToken: boolean - }>({ mode: 'managed-local', hasAdminToken: false }) + const [backendConnectionMode, setBackendConnectionMode] = useState<'managed-local' | 'external'>('managed-local') const [downloadProgress, setDownloadProgress] = useState(null) const [downloadError, setDownloadError] = useState(null) const [downloadSessionId, setDownloadSessionId] = useState(null) @@ -102,7 +99,7 @@ export function LaunchGate({ useEffect(() => { void window.electronAPI.getBackendConnectionConfig().then((config) => { - setBackendConnection({ mode: config.mode, hasAdminToken: config.hasAdminToken }) + setBackendConnectionMode(config.mode) }).catch(() => {}) }, []) const { saveLtxApiKey } = useAppSettings() @@ -590,14 +587,12 @@ export function LaunchGate({ /> diff --git a/frontend/components/SettingsModal.tsx b/frontend/components/SettingsModal.tsx index 6432f4d00..f64444f2a 100644 --- a/frontend/components/SettingsModal.tsx +++ b/frontend/components/SettingsModal.tsx @@ -313,7 +313,7 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp {settings.modelsDir || 'Available after the backend connects'}

- Standalone backends manage this path on the remote machine; a Mac folder picker cannot change it. + Standalone backends manage this path on the remote machine; a local folder picker cannot change it.

diff --git a/frontend/generated/backend-openapi.json b/frontend/generated/backend-openapi.json index 80b2cbf23..b656d6fdc 100644 --- a/frontend/generated/backend-openapi.json +++ b/frontend/generated/backend-openapi.json @@ -740,16 +740,6 @@ }, "GenerationProgressResponse": { "properties": { - "artifact": { - "anyOf": [ - { - "$ref": "#/components/schemas/ArtifactRef" - }, - { - "type": "null" - } - ] - }, "artifacts": { "items": { "$ref": "#/components/schemas/ArtifactRef" diff --git a/frontend/generated/backend-openapi.ts b/frontend/generated/backend-openapi.ts index adeab4ff7..21703fe39 100644 --- a/frontend/generated/backend-openapi.ts +++ b/frontend/generated/backend-openapi.ts @@ -808,7 +808,6 @@ export interface components { }; /** GenerationProgressResponse */ GenerationProgressResponse: { - artifact?: components["schemas"]["ArtifactRef"] | null; /** Artifacts */ artifacts?: components["schemas"]["ArtifactRef"][]; /** Currentstep */ diff --git a/frontend/hooks/use-backend.ts b/frontend/hooks/use-backend.ts index 7da0f2d1b..f826057f6 100644 --- a/frontend/hooks/use-backend.ts +++ b/frontend/hooks/use-backend.ts @@ -9,7 +9,6 @@ interface BackendHealthStatusPayload { mode: BackendConnectionMode status: BackendProcessStatus exitCode?: number | null - checkedAt: number message?: string } @@ -26,7 +25,7 @@ function toBackendHealthStatus(value: unknown): BackendHealthStatusPayload | nul return null } - const record = value as { mode?: unknown; status?: unknown; exitCode?: unknown; checkedAt?: unknown; message?: unknown } + const record = value as { mode?: unknown; status?: unknown; exitCode?: unknown; message?: unknown } if (record.mode !== 'managed-local' && record.mode !== 'external') { return null } @@ -44,7 +43,6 @@ function toBackendHealthStatus(value: unknown): BackendHealthStatusPayload | nul mode: record.mode, status: record.status, exitCode: typeof record.exitCode === 'number' || record.exitCode === null ? record.exitCode : undefined, - checkedAt: typeof record.checkedAt === 'number' ? record.checkedAt : Date.now(), message: typeof record.message === 'string' ? record.message : undefined, } } diff --git a/frontend/hooks/use-generation.ts b/frontend/hooks/use-generation.ts index 532b322cc..28dbe5efe 100644 --- a/frontend/hooks/use-generation.ts +++ b/frontend/hooks/use-generation.ts @@ -292,7 +292,7 @@ export function useGeneration(): UseGenerationReturn { })) }, ) - const recoveredArtifact = recovered.artifact ?? recovered.artifacts?.[0] + const recoveredArtifact = recovered.artifacts?.[0] if (!recoveredArtifact) { throw new Error('The recovered remote generation has no downloadable artifact') } diff --git a/frontend/lib/backend.ts b/frontend/lib/backend.ts index f01edfba3..21e116660 100644 --- a/frontend/lib/backend.ts +++ b/frontend/lib/backend.ts @@ -2,7 +2,6 @@ export interface BackendCredentials { url: string token: string mode: 'managed-local' | 'external' - connectionRevision: number } let cached: BackendCredentials | null = null diff --git a/shared/electron-api-schema.ts b/shared/electron-api-schema.ts index 1935109ba..a0e1ba841 100644 --- a/shared/electron-api-schema.ts +++ b/shared/electron-api-schema.ts @@ -1,5 +1,9 @@ import { z } from 'zod' +// Private preload-to-main channel. It is intentionally not part of +// electronAPISchemas, so renderer code cannot approve arbitrary path strings. +export const SELECTED_FILE_PATH_APPROVAL_CHANNEL = 'approve-selected-file-path' + const fileFilter = z.object({ name: z.string(), extensions: z.array(z.string()) }) function ipcResult(valueShape: T) { @@ -56,7 +60,6 @@ const backendHealthStatus = z.object({ mode: backendConnectionMode, status: z.enum(['connecting', 'alive', 'restarting', 'unreachable', 'dead']), exitCode: z.number().nullable().optional(), - checkedAt: z.number(), message: z.string().optional(), }) @@ -77,7 +80,6 @@ const backendConnectionInput = z.discriminatedUnion('mode', [ mode: z.literal('external'), url: z.string(), authToken: z.string(), - adminToken: z.string().optional(), }), ]) @@ -111,7 +113,6 @@ export const electronAPISchemas = { url: z.string(), token: z.string(), mode: backendConnectionMode, - connectionRevision: z.number(), }), }, getBackendConnectionConfig: { @@ -120,7 +121,6 @@ export const electronAPISchemas = { mode: backendConnectionMode, url: z.string(), hasAuthToken: z.boolean(), - hasAdminToken: z.boolean(), }), }, testBackendConnection: { @@ -383,10 +383,6 @@ export const electronAPISchemas = { input: z.object({}), output: ipcResult({ path: z.string() }), }, - setBackendModelsDirectory: { - input: z.object({ path: z.string() }), - output: ipcResult({ path: z.string() }), - }, // Analytics getAnalyticsState: { From a9d889daaa9c5192f470eaa18ff0c1189b4f26dd Mon Sep 17 00:00:00 2001 From: luisnomad Date: Tue, 21 Jul 2026 00:05:27 +0200 Subject: [PATCH 4/7] fix: isolate and bound remote transfers --- electron/backend-media-transfer.ts | 30 ++++++++++++++++++++++++++---- electron/ipc/file-handlers.ts | 4 ++-- electron/python-backend.ts | 19 ++++++++++++++++++- shared/electron-api-schema.ts | 3 ++- 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/electron/backend-media-transfer.ts b/electron/backend-media-transfer.ts index 40b864543..6fe57bd89 100644 --- a/electron/backend-media-transfer.ts +++ b/electron/backend-media-transfer.ts @@ -5,6 +5,7 @@ import http from 'http' import https from 'https' import os from 'os' import path from 'path' +import { Transform } from 'stream' import { pipeline } from 'stream/promises' import { getAuthToken, @@ -12,6 +13,7 @@ import { getBackendUrl, } from './python-backend' import { logger } from './logger' +import { MAX_BACKEND_ARTIFACT_BYTES } from '../shared/electron-api-schema' export type BackendMediaType = 'image' | 'audio' | 'video' @@ -225,6 +227,13 @@ async function deleteArtifact(artifactId: string): Promise { async function downloadArtifact(artifact: BackendArtifactRef): Promise { const { url, token } = requireExternalBackend() if (!artifact.artifact_id || artifact.artifact_id.length > 256) throw new Error('Invalid backend artifact ID') + if ( + !Number.isSafeInteger(artifact.size_bytes) + || artifact.size_bytes <= 0 + || artifact.size_bytes > MAX_BACKEND_ARTIFACT_BYTES + ) { + throw new Error('Invalid backend artifact size') + } const filename = safeFilename(artifact.filename) const unique = crypto.randomBytes(8).toString('hex') const finalPath = path.join(transferDirectory(), `${unique}-${filename}`) @@ -242,13 +251,26 @@ async function downloadArtifact(artifact: BackendArtifactRef): Promise { reject(new Error(`Artifact download failed (HTTP ${response.statusCode ?? 0}): ${await readErrorResponse(response)}`)) return } + const contentLength = response.headers['content-length'] + if (contentLength !== undefined && Number(contentLength) !== artifact.size_bytes) { + response.destroy() + reject(new Error(`Artifact size mismatch: expected ${artifact.size_bytes}, server announced ${contentLength}`)) + return + } const hash = crypto.createHash('sha256') let receivedBytes = 0 - response.on('data', (chunk: Buffer) => { - hash.update(chunk) - receivedBytes += chunk.length + const verifier = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + receivedBytes += chunk.length + if (receivedBytes > artifact.size_bytes || receivedBytes > MAX_BACKEND_ARTIFACT_BYTES) { + callback(new Error('Artifact download exceeded its declared size')) + return + } + hash.update(chunk) + callback(null, chunk) + }, }) - await pipeline(response, fs.createWriteStream(partialPath, { flags: 'wx' })) + await pipeline(response, verifier, fs.createWriteStream(partialPath, { flags: 'wx' })) if (receivedBytes !== artifact.size_bytes) { throw new Error(`Artifact size mismatch: expected ${artifact.size_bytes}, received ${receivedBytes}`) } diff --git a/electron/ipc/file-handlers.ts b/electron/ipc/file-handlers.ts index 5f06905c2..2d1dd8829 100644 --- a/electron/ipc/file-handlers.ts +++ b/electron/ipc/file-handlers.ts @@ -363,8 +363,8 @@ export function registerFileHandlers(): void { const results: Record = {} for (const p of filePaths) { try { - const validatedPath = validatePath(p, getAllowedRoots()) - results[p] = fs.existsSync(validatedPath) + const normalizedPath = p.trim() + results[p] = path.isAbsolute(normalizedPath) && fs.statSync(path.resolve(normalizedPath)).isFile() } catch { results[p] = false } diff --git a/electron/python-backend.ts b/electron/python-backend.ts index 4f3707a79..0ef1703f6 100644 --- a/electron/python-backend.ts +++ b/electron/python-backend.ts @@ -31,6 +31,13 @@ const STARTUP_PROBE_TIMEOUT_MS = 30_000 const STARTUP_PROBE_INTERVAL_MS = 500 const LIVENESS_POLL_INTERVAL_MS = 10_000 const LIVENESS_FAILURE_THRESHOLD = 3 +const STANDALONE_ONLY_ENV_KEYS = [ + 'LTX_DEPLOYMENT_MODE', + 'LTX_BIND_HOST', + 'LTX_PUBLIC_BASE_URL', + 'LTX_ALLOWED_ORIGINS', + 'LTX_MODELS_DIR', +] as const let livenessMonitorTimer: NodeJS.Timeout | null = null let livenessFailureCount = 0 @@ -72,6 +79,16 @@ export function getBackendHealthStatus(): BackendHealthStatus | null { return latestBackendHealthStatus } +function managedBackendEnvironment(): NodeJS.ProcessEnv { + const environment = { ...process.env } + for (const key of STANDALONE_ONLY_ENV_KEYS) { + delete environment[key] + } + environment.LTX_DEPLOYMENT_MODE = 'managed_local' + environment.LTX_BIND_HOST = '127.0.0.1' + return environment +} + function getBackendPath(): string { if (isDev) { return path.join(getCurrentDir(), 'backend') @@ -472,7 +489,7 @@ export async function startPythonBackend(): Promise { pythonProcess = spawn(pythonPath, pythonArgs, { cwd: backendPath, env: { - ...process.env, + ...managedBackendEnvironment(), PYTHONUNBUFFERED: '1', PYTHONNOUSERSITE: '1', // Only pass LTX_PORT when the developer explicitly set it diff --git a/shared/electron-api-schema.ts b/shared/electron-api-schema.ts index a0e1ba841..3761d22f7 100644 --- a/shared/electron-api-schema.ts +++ b/shared/electron-api-schema.ts @@ -3,6 +3,7 @@ import { z } from 'zod' // Private preload-to-main channel. It is intentionally not part of // electronAPISchemas, so renderer code cannot approve arbitrary path strings. export const SELECTED_FILE_PATH_APPROVAL_CHANNEL = 'approve-selected-file-path' +export const MAX_BACKEND_ARTIFACT_BYTES = 20 * 1024 * 1024 * 1024 const fileFilter = z.object({ name: z.string(), extensions: z.array(z.string()) }) @@ -98,7 +99,7 @@ const backendArtifactRef = z.object({ media_type: z.enum(['image', 'audio', 'video']), filename: z.string(), content_type: z.string(), - size_bytes: z.number(), + size_bytes: z.number().int().positive().max(MAX_BACKEND_ARTIFACT_BYTES), sha256: z.string(), expires_at: z.string(), }) From 6b1135a775b6d9b7e16bf7e64db78a465120cb4d Mon Sep 17 00:00:00 2001 From: luisnomad Date: Tue, 21 Jul 2026 11:03:53 +0200 Subject: [PATCH 5/7] fix: approve remote artifact transfer directory --- electron/backend-media-transfer.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/electron/backend-media-transfer.ts b/electron/backend-media-transfer.ts index 6fe57bd89..317f75bce 100644 --- a/electron/backend-media-transfer.ts +++ b/electron/backend-media-transfer.ts @@ -13,6 +13,7 @@ import { getBackendUrl, } from './python-backend' import { logger } from './logger' +import { approvePath } from './path-validation' import { MAX_BACKEND_ARTIFACT_BYTES } from '../shared/electron-api-schema' export type BackendMediaType = 'image' | 'audio' | 'video' @@ -95,6 +96,7 @@ function safeFilename(value: string): string { function transferDirectory(): string { const directory = path.join(app.getPath('temp') || os.tmpdir(), 'ltx-desktop', 'backend-transfers') fs.mkdirSync(directory, { recursive: true }) + approvePath(directory) if (!cleanedTransferDirectory) { cleanedTransferDirectory = true const cutoff = Date.now() - 24 * 60 * 60 * 1000 From 6cdfdc85c0b73465d80b439871cd9a9e6a505ff5 Mon Sep 17 00:00:00 2001 From: luisnomad Date: Tue, 21 Jul 2026 11:35:45 +0200 Subject: [PATCH 6/7] feat: add remote compute onboarding --- backend/api_model_specs.py | 4 +- docs/REMOTE_BACKEND.md | 22 ++--- frontend/App.tsx | 55 +++++++++-- .../components/BackendConnectionPanel.tsx | 58 ++++++++---- frontend/components/FirstRunSetup.tsx | 94 +++++++++++-------- frontend/components/SettingsModal.tsx | 12 +-- 6 files changed, 163 insertions(+), 82 deletions(-) diff --git a/backend/api_model_specs.py b/backend/api_model_specs.py index 8c1fc00ad..bcb132a0b 100644 --- a/backend/api_model_specs.py +++ b/backend/api_model_specs.py @@ -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={ @@ -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={ diff --git a/docs/REMOTE_BACKEND.md b/docs/REMOTE_BACKEND.md index 961dd85a9..b4243b167 100644 --- a/docs/REMOTE_BACKEND.md +++ b/docs/REMOTE_BACKEND.md @@ -50,7 +50,7 @@ export LTX_MODELS_DIR=/srv/ltx-models export LTX_AUTH_TOKEN='replace-with-the-generated-token' export LTX_BIND_HOST=127.0.0.1 export LTX_PORT=8000 -export LTX_PUBLIC_BASE_URL=http://127.0.0.1:8000 +export LTX_PUBLIC_BASE_URL=http://127.0.0.1:18000 export LTX_ALLOWED_ORIGINS='null,http://localhost:5173,http://127.0.0.1:5173' uv run python ltx2_server.py @@ -66,23 +66,23 @@ Server running on http://127.0.0.1:8000 ## Connect through SSH -On the computer running LTX Desktop, forward the same local port to the GPU machine: +On the computer running LTX Desktop, forward local port `18000` to backend port `8000` on the GPU machine: ```bash -ssh -N -L 8000:127.0.0.1:8000 user@gpu-host +ssh -N -L 18000:127.0.0.1:8000 user@gpu-host ``` -Keeping port 8000 on both sides also preserves the default Hugging Face OAuth callback address. +Port `18000` avoids colliding with the managed-local backend while LTX Desktop tests the remote connection. `LTX_PUBLIC_BASE_URL` must use the forwarded address so browser-based authentication callbacks return to the remote backend. -In LTX Desktop: +On first launch, LTX Desktop asks where generation should run. Choose **Remote machine**. After onboarding, use the **Remote compute** status control or open **Settings → Compute** to change the connection. -1. Open **Settings → Compute**. -2. Choose **Remote machine**. -3. Enter `http://127.0.0.1:8000`. -4. Enter the bearer token from `LTX_AUTH_TOKEN`. -5. Select **Test connection**, then **Save & reconnect**. +Then: -The connection requires standalone API version 2 and verifies media-upload and artifact-download capabilities before it is saved. +1. Enter `http://127.0.0.1:18000`. +2. Enter the bearer token from `LTX_AUTH_TOKEN`. +3. Select **Test connection**, then **Connect & continue** during onboarding or **Save & reconnect** in Settings. + +The connection requires remote-backend protocol version 2 and verifies media-upload and artifact-download capabilities before it is saved. ## Direct HTTPS connection diff --git a/frontend/App.tsx b/frontend/App.tsx index 70a579c83..c63b63bcc 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Loader2, AlertCircle, Settings, FileText } from 'lucide-react' +import { Loader2, AlertCircle, Settings, FileText, Monitor, Server } from 'lucide-react' import { ApiClient, type ApiSuccessOf } from './lib/api-client' import { ProjectProvider } from './contexts/ProjectContext' import { ViewProvider, useView } from './contexts/ViewContext' @@ -38,7 +38,9 @@ function AppContent() { const [pythonReady, setPythonReady] = useState(null) const [configuredBackendMode, setConfiguredBackendMode] = useState(null) + const [configuredBackendUrl, setConfiguredBackendUrl] = useState('') const [pythonSetupSelected, setPythonSetupSelected] = useState(false) + const [firstRunComputeConfirmed, setFirstRunComputeConfirmed] = useState(false) const [backendStarted, setBackendStarted] = useState(false) const [setupState, setSetupState] = useState('loading') const [isSettingsOpen, setIsSettingsOpen] = useState(false) @@ -99,6 +101,7 @@ function AppContent() { try { const connection = await window.electronAPI.getBackendConnectionConfig() setConfiguredBackendMode(connection.mode) + setConfiguredBackendUrl(connection.url) const result = await window.electronAPI.checkPythonReady() setPythonReady(result.ready) } catch (e) { @@ -384,7 +387,7 @@ function AppContent() { ) : null - const showGlobalControls = currentView !== 'home' && connected && setupState !== 'loading' && !setupState.needsSetup + const showGlobalControls = connected && setupState !== 'loading' && !setupState.needsSetup const shouldBlockUntilSettingsLoaded = forceApiGenerations && !isLoaded const shouldShowForcedFirstRunUpsell = isForcedFirstRun && isLoaded && !settings.hasLtxApiKey const shouldShowGlobalForcedUpsell = forceApiGenerations && setupState !== 'loading' && !setupState.needsSetup && isLoaded && !settings.hasLtxApiKey @@ -415,15 +418,15 @@ function AppContent() { const sections: ApiGatewaySection[] = [ { keyType: 'ltx', - title: 'LTX API', + title: 'LTX Cloud API', description: 'Video generation, prompt enhancement, and cloud text encoding.', required: activeApiGatewayRequest.requiredKeys.includes('ltx'), isConfigured: settings.hasLtxApiKey, - inputLabel: 'LTX API key', - placeholder: 'Enter your LTX API key...', + inputLabel: 'LTX Cloud API key', + placeholder: 'Enter your LTX Cloud API key...', onSave: handleSaveLtxKey, onGetKey: () => window.electronAPI.openLtxApiKeyPage(), - getKeyLabel: 'Get LTX API key', + getKeyLabel: 'Get LTX Cloud API key', }, { keyType: 'fal', @@ -550,6 +553,31 @@ function AppContent() { ) } + if ( + setupState.needsSetup + && configuredBackendMode === 'managed-local' + && !firstRunComputeConfirmed + ) { + return ( +
+
+
+
+

Choose where generation runs

+

+ LTX Desktop and your project files stay on this computer. The models can run here, or on a headless GPU machine such as a workstation or home server. +

+
+ setFirstRunComputeConfirmed(true)} + /> +
+
+
+ ) + } + if (setupState.needsSetup && !forceApiGenerations) { return } @@ -575,6 +603,21 @@ function AppContent() { {showGlobalControls && (
+
{mode === 'external' && (

- Plain HTTP is accepted only on localhost. Use an SSH tunnel or HTTPS to reach a backend on another machine. + Use the LTX_AUTH_TOKEN value configured on your GPU machine. This is not an LTX cloud API key. +

+

+ SSH example: ssh -N -L 18000:127.0.0.1:8000 user@gpu-host. Plain HTTP is accepted only on localhost; use HTTPS for direct network connections.

)} @@ -146,7 +162,11 @@ export function BackendConnectionPanel({ compact = false }: BackendConnectionPan )} diff --git a/frontend/components/FirstRunSetup.tsx b/frontend/components/FirstRunSetup.tsx index d8b2675b9..06b87e8c2 100644 --- a/frontend/components/FirstRunSetup.tsx +++ b/frontend/components/FirstRunSetup.tsx @@ -94,6 +94,7 @@ export function LaunchGate({ const [actionError, setActionError] = useState(null) const [isActionPending, setIsActionPending] = useState(false) const [requiredCheckpointIds, setRequiredCheckpointIds] = useState([]) + const [recommendationsLoaded, setRecommendationsLoaded] = useState(false) const { hfAuthStatus, hfAuthPolling, startHuggingFaceLogin } = useHfAuth(currentStep === 'location') const { accessMap, allAuthorized } = useHfModelAccess(requiredCheckpointIds, hfAuthStatus) @@ -149,26 +150,32 @@ export function LaunchGate({ const refreshModelRecommendations = useCallback(async () => { if (licenseOnly) return - const [settingsResult, ltxResult, imgGenResult] = await Promise.all([ - ApiClient.getSettings(), - ApiClient.getLtxRecommendation(), - ApiClient.getImgGenRecommendation(), - ]) - if (!settingsResult.ok) { - logger.error(`Failed to fetch model recommendations: ${settingsResult.error.message}`) - return - } - if (!ltxResult.ok) { - logger.error(`Failed to fetch model recommendations: ${ltxResult.error.message}`) - return - } - if (!imgGenResult.ok) { - logger.error(`Failed to fetch model recommendations: ${imgGenResult.error.message}`) - return - } + setRecommendationsLoaded(false) + + try { + const [settingsResult, ltxResult, imgGenResult] = await Promise.all([ + ApiClient.getSettings(), + ApiClient.getLtxRecommendation(), + ApiClient.getImgGenRecommendation(), + ]) + if (!settingsResult.ok) { + logger.error(`Failed to fetch model recommendations: ${settingsResult.error.message}`) + return + } + if (!ltxResult.ok) { + logger.error(`Failed to fetch model recommendations: ${ltxResult.error.message}`) + return + } + if (!imgGenResult.ok) { + logger.error(`Failed to fetch model recommendations: ${imgGenResult.error.message}`) + return + } - setInstallPath(settingsResult.data.modelsDir ?? '') - setRequiredCheckpointIds(buildAccessCheckpointIds(ltxResult.data, imgGenResult.data)) + setInstallPath(settingsResult.data.modelsDir ?? '') + setRequiredCheckpointIds(buildAccessCheckpointIds(ltxResult.data, imgGenResult.data)) + } finally { + setRecommendationsLoaded(true) + } }, [licenseOnly]) const startDownloadStep = useCallback(async (step: DownloadStepSpec) => { @@ -360,7 +367,11 @@ export function LaunchGate({ // Get button text const getNextButtonText = () => { if (currentStep === 'license') return licenseOnly ? 'Accept' : 'Next' - if (currentStep === 'location') return 'Install' + if (currentStep === 'location') { + if (!recommendationsLoaded) return 'Checking models...' + if (requiredCheckpointIds.length === 0) return 'Continue' + return backendConnectionMode === 'external' ? 'Install on remote machine' : 'Install' + } if (currentStep === 'complete') return 'Finish' return 'Continue' } @@ -368,7 +379,10 @@ export function LaunchGate({ // Check if next button should be disabled const isNextDisabled = () => { if (currentStep === 'license') return !licenseAccepted || isActionPending - if (currentStep === 'location') return hfAuthStatus !== 'authenticated' || !allAuthorized + if (currentStep === 'location') { + if (!recommendationsLoaded) return true + return requiredCheckpointIds.length > 0 && (hfAuthStatus !== 'authenticated' || !allAuthorized) + } if (currentStep === 'complete') return isActionPending return false } @@ -558,10 +572,12 @@ export function LaunchGate({ fontWeight: 700, marginBottom: 6 }}> - Choose Location + {backendConnectionMode === 'external' ? 'Verify remote models' : 'Choose model location'}

- Select where to install the model files. + {backendConnectionMode === 'external' + ? 'The GPU machine owns the models and runs inference. LTX Desktop will keep project media on this computer.' + : 'Select where to install the model files used for local inference.'}

-
- Available: {availableSpace} -
+ {backendConnectionMode === 'managed-local' && ( +
+ Available: {availableSpace} +
+ )} {/* LTX API Key - Optional but saves ~25 GB download */} @@ -629,7 +647,7 @@ export function LaunchGate({ }}>
{/* HuggingFace Authentication */} - {window.electronAPI.hfGatingEnabled && ( + {window.electronAPI.hfGatingEnabled && recommendationsLoaded && requiredCheckpointIds.length > 0 && (

- Standalone backends manage this path on the remote machine; a local folder picker cannot change it. + The remote backend manages this path on the GPU machine; a local folder picker cannot change it.

@@ -375,10 +375,10 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp
- Generate With API + Generate With LTX Cloud

- Use LTX API for video generation when an LTX API key is configured. + Send video generation to LTX Cloud instead of the active local or remote compute backend.

- LTX API + LTX Cloud text encoding Recommended

@@ -728,11 +728,11 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp

-

LTX API

+

LTX Cloud API

- Your LTX API key is used for cloud text encoding, prompt enhancement, and API video generation. + This cloud API key is separate from a remote backend access token. It is used for cloud text encoding, prompt enhancement, and LTX Cloud video generation. Add your key below to unlock these features.

From 2e965255c7f0f22be2eaf0b1b748f3e0ab9e1249 Mon Sep 17 00:00:00 2001 From: luisnomad Date: Tue, 21 Jul 2026 23:15:41 +0200 Subject: [PATCH 7/7] feat: support authenticated trusted-LAN backends --- backend/runtime_config/server_config.py | 2 -- backend/tests/test_server_config.py | 25 +++++++++----- docs/REMOTE_BACKEND.md | 26 +++++++++++++-- electron/python-backend.ts | 6 ++-- .../components/BackendConnectionPanel.tsx | 33 ++++++++++++++++--- 5 files changed, 71 insertions(+), 21 deletions(-) diff --git a/backend/runtime_config/server_config.py b/backend/runtime_config/server_config.py index cf0c6c947..d1bdaea94 100644 --- a/backend/runtime_config/server_config.py +++ b/backend/runtime_config/server_config.py @@ -60,8 +60,6 @@ def _validate_public_base_url(value: str) -> str: raise RuntimeError("LTX_PUBLIC_BASE_URL must be an HTTP(S) origin without a path") if parsed.username or parsed.password or parsed.query or parsed.fragment: raise RuntimeError("LTX_PUBLIC_BASE_URL must not contain credentials, a query, or a fragment") - if parsed.scheme == "http" and not _is_loopback_host(parsed.hostname): - raise RuntimeError("LTX_PUBLIC_BASE_URL must use HTTPS unless it points to loopback") return candidate diff --git a/backend/tests/test_server_config.py b/backend/tests/test_server_config.py index 8e427ad34..b4a0a7003 100644 --- a/backend/tests/test_server_config.py +++ b/backend/tests/test_server_config.py @@ -37,11 +37,20 @@ def test_standalone_configuration() -> None: assert config.models_dir_editable is False -def test_non_loopback_plain_http_public_url_is_rejected() -> None: - with pytest.raises(RuntimeError, match="must use HTTPS"): - load_server_config( - { - "LTX_AUTH_TOKEN": "x" * 32, - "LTX_PUBLIC_BASE_URL": "http://atom.local:8000", - } - ) +def test_non_loopback_bind_requires_authentication() -> None: + with pytest.raises(RuntimeError, match="requires LTX_AUTH_TOKEN"): + load_server_config({"LTX_BIND_HOST": "0.0.0.0"}) + + +def test_authenticated_standalone_allows_plain_http_on_a_trusted_lan() -> None: + config = load_server_config( + { + "LTX_DEPLOYMENT_MODE": "standalone", + "LTX_AUTH_TOKEN": "x" * 32, + "LTX_BIND_HOST": "0.0.0.0", + "LTX_PUBLIC_BASE_URL": "http://192.168.1.50:8000", + } + ) + + assert config.bind_host == "0.0.0.0" + assert config.public_base_url == "http://192.168.1.50:8000" diff --git a/docs/REMOTE_BACKEND.md b/docs/REMOTE_BACKEND.md index b4243b167..7df79fdcb 100644 --- a/docs/REMOTE_BACKEND.md +++ b/docs/REMOTE_BACKEND.md @@ -23,7 +23,8 @@ The standalone server is a trusted, single-user service. It is not designed as a - Standalone mode requires a bearer token of at least 32 characters. - Raw filesystem-path inputs, Basic authentication, query-string authentication, and remote shutdown are disabled. -- Plain HTTP is supported only through a loopback address. Use an SSH tunnel for a private machine or an HTTPS reverse proxy for a non-loopback address. +- Plain HTTP is available for a trusted private network when the backend is deliberately bound to a non-loopback interface. The bearer token still protects the API, but the token and media are not encrypted in transit. +- Use an SSH tunnel or HTTPS when the network is shared, untrusted, or outside your control. - Do not expose port 8000 directly to an untrusted LAN or the internet. ## Start the backend on the GPU machine @@ -64,6 +65,27 @@ The server is ready when it prints: Server running on http://127.0.0.1:8000 ``` +## Connect directly on a trusted LAN + +For a private network you control, the backend can listen directly without an SSH tunnel or reverse proxy. This mode remains authenticated and must be enabled explicitly: + +```bash +export LTX_DEPLOYMENT_MODE=standalone +export LTX_APP_DATA_DIR=/srv/ltx-desktop +export LTX_MODELS_DIR=/srv/ltx-models +export LTX_AUTH_TOKEN='replace-with-the-generated-token' +export LTX_BIND_HOST=0.0.0.0 +export LTX_PORT=8000 +export LTX_PUBLIC_BASE_URL=http://192.168.1.50:8000 +export LTX_ALLOWED_ORIGINS='null,http://localhost:5173,http://127.0.0.1:5173' + +uv run python ltx2_server.py +``` + +Use a stable hostname or IP address that the desktop machine can resolve; never use `0.0.0.0` as the client URL. In LTX Desktop, enter `http://192.168.1.50:8000` and the bearer token, then test and save the connection. + +Binding to a specific private-network address instead of `0.0.0.0` reduces exposure when the host has several interfaces. A host firewall can further restrict the port to the desktop machine or trusted subnet. Authentication prevents unauthorized API use, but HTTP does not prevent another device on the network from observing the token or media in transit. + ## Connect through SSH On the computer running LTX Desktop, forward local port `18000` to backend port `8000` on the GPU machine: @@ -86,7 +108,7 @@ The connection requires remote-backend protocol version 2 and verifies media-upl ## Direct HTTPS connection -For a direct Tailscale or network connection, terminate TLS in a reverse proxy and set `LTX_PUBLIC_BASE_URL` to the externally reachable HTTPS origin. The backend intentionally rejects a non-loopback `http://` public URL. +For a direct Tailscale, shared-network, or internet connection, terminate TLS in a reverse proxy and set `LTX_PUBLIC_BASE_URL` to the externally reachable HTTPS origin. Example environment: diff --git a/electron/python-backend.ts b/electron/python-backend.ts index 0ef1703f6..416046fd4 100644 --- a/electron/python-backend.ts +++ b/electron/python-backend.ts @@ -151,10 +151,8 @@ export function normalizeExternalBackendUrl(value: string): string { throw new Error('Backend URL must not include a path') } - const hostname = parsed.hostname.toLowerCase() - const isLoopback = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1' - if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && isLoopback)) { - throw new Error('Use HTTPS for remote hosts, or HTTP through a localhost SSH tunnel') + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw new Error('Backend URL must use HTTP or HTTPS') } return parsed.origin } diff --git a/frontend/components/BackendConnectionPanel.tsx b/frontend/components/BackendConnectionPanel.tsx index 08ba12f6f..df57b9710 100644 --- a/frontend/components/BackendConnectionPanel.tsx +++ b/frontend/components/BackendConnectionPanel.tsx @@ -1,9 +1,20 @@ import { useEffect, useState } from 'react' -import { Check, Loader2, Monitor, Server } from 'lucide-react' +import { AlertTriangle, Check, Loader2, Monitor, Server } from 'lucide-react' import { Button } from './ui/button' type ConnectionMode = 'managed-local' | 'external' +function isPlainHttpRemoteUrl(value: string): boolean { + try { + const parsed = new URL(value.trim()) + const hostname = parsed.hostname.toLowerCase() + const isLoopback = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1' + return parsed.protocol === 'http:' && !isLoopback + } catch { + return false + } +} + interface BackendConnectionPanelProps { compact?: boolean onboarding?: boolean @@ -16,12 +27,13 @@ export function BackendConnectionPanel({ onContinueWithManagedLocal, }: BackendConnectionPanelProps) { const [mode, setMode] = useState('managed-local') - const [url, setUrl] = useState('http://127.0.0.1:18000') + const [url, setUrl] = useState('') const [authToken, setAuthToken] = useState('') const [hasSavedToken, setHasSavedToken] = useState(false) const [busy, setBusy] = useState(false) const [message, setMessage] = useState(null) const [success, setSuccess] = useState(false) + const usesPlainHttpRemote = mode === 'external' && isPlainHttpRemoteUrl(url) useEffect(() => { void window.electronAPI.getBackendConnectionConfig().then((config) => { @@ -119,12 +131,12 @@ export function BackendConnectionPanel({ {mode === 'external' && (
@@ -143,7 +155,18 @@ export function BackendConnectionPanel({ Use the LTX_AUTH_TOKEN value configured on your GPU machine. This is not an LTX cloud API key.

- SSH example: ssh -N -L 18000:127.0.0.1:8000 user@gpu-host. Plain HTTP is accepted only on localhost; use HTTPS for direct network connections. + Direct trusted LAN example: http://gpu-host.local:8000. Configure the backend to listen on its LAN interface. +

+ {usesPlainHttpRemote && ( +
+ + + Trusted-network HTTP sends the access token and media without transport encryption. Use an SSH tunnel or HTTPS on shared or untrusted networks. + +
+ )} +

+ SSH alternative: ssh -N -L 18000:127.0.0.1:8000 user@gpu-host. Direct HTTPS remains recommended outside a trusted LAN.

)}