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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ concurrency:
cancel-in-progress: true

jobs:
archive-markers:
name: Archive markers
if: github.repository == 'Lightricks/ltx-desktop-internal'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- name: Check archive markers
run: bash .github/sync/check-archive-markers.sh

typecheck:
name: Type Checking
runs-on: ubuntu-latest
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,9 @@ python-embed/

# Downloaded during CI for Windows NSIS installer
resources/vc_redist.x64.exe

# Local-only measurement media (mp4s untracked from docs/measurements/)
measurements-media/
# Archived measurement evidence lives on the internal hw-measurements branch;
# keep it out of the working tree (force-add + re-export per its README).
docs/measurements/
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ Key patterns:
- Video editor (largest frontend file): `frontend/views/VideoEditor.tsx`
- Project types: `frontend/types/project.ts`

## Measurement evidence archive

Budget constants in `backend/runtime_config/` cite internal measurement run keys.
Budget comments explain why, not what was measured: mechanism, failure mode,
and what breaks if the constant changes. No run keys, brackets, or fit math in
code prose — those live behind the Evidence links and in the budgets ledger.

## Tests

Before writing tests, list the cases you intend to cover and wait for approval.
Expand Down
321 changes: 102 additions & 219 deletions README.md

Large diffs are not rendered by default.

39 changes: 30 additions & 9 deletions backend/_routes/assets.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from __future__ import annotations

import os
import shutil
import tempfile
from collections.abc import Callable, Coroutine
from pathlib import Path
from pathlib import Path, PureWindowsPath
from typing import Annotated, Any, BinaryIO

from fastapi import APIRouter, Depends, File, Query, Request, UploadFile
Expand Down Expand Up @@ -36,7 +36,7 @@
ALLOWED_VIDEO_SUFFIXES,
MAX_AUDIO_BYTES,
MAX_IMAGE_BYTES,
MAX_VIDEO_BYTES,
MAX_VIDEO_UPLOAD_BYTES,
)
from state import get_state_service

Expand All @@ -52,7 +52,7 @@
# Remote-only ASGI body cap: largest permitted file plus multipart framing.
# Enforced on actual receive bytes before Starlette parses/spools multipart.
MAX_REMOTE_UPLOAD_BODY_BYTES = (
max(MAX_IMAGE_BYTES, MAX_AUDIO_BYTES, MAX_VIDEO_BYTES)
max(MAX_IMAGE_BYTES, MAX_AUDIO_BYTES, MAX_VIDEO_UPLOAD_BYTES)
+ _MULTIPART_OVERHEAD_BYTES
)

Expand Down Expand Up @@ -332,6 +332,26 @@ def route_phone_extract_audio(
return remote.asset(handler.assets.extract_audio(asset_id))


def _upload_name(filename: str | None, suffix: str) -> str:
"""The client's base name (either path separator), or a generic one if it is not a plain name."""
name = PureWindowsPath(filename or "").name.strip()
stem = name[: -len(suffix)] if name.lower().endswith(suffix) else ""
if (
not stem.strip(". ")
or any(ord(char) < 32 or char in '<>:"|?*' for char in name)
or len(name.encode()) > 200
or name.split(".")[0].rstrip(" ").upper() in _WINDOWS_DEVICE_NAMES
):
return f"upload{suffix}"
return name


# Windows opens these as devices, whatever the extension.
_WINDOWS_DEVICE_NAMES = frozenset(
{"CON", "PRN", "AUX", "NUL", *(f"{port}{n}" for port in ("COM", "LPT") for n in "123456789¹²³")}
)


def _upload_suffix(filename: str | None) -> str:
suffix = Path(filename or "").suffix.lower()
if suffix not in (
Expand All @@ -345,7 +365,7 @@ def _upload_max_bytes(suffix: str) -> int:
if suffix in ALLOWED_AUDIO_SUFFIXES:
return MAX_AUDIO_BYTES
if suffix in ALLOWED_VIDEO_SUFFIXES:
return MAX_VIDEO_BYTES
return MAX_VIDEO_UPLOAD_BYTES
return MAX_IMAGE_BYTES


Expand Down Expand Up @@ -400,16 +420,17 @@ async def route_upload_asset(
suffix = _upload_suffix(file.filename)
max_bytes = _upload_max_bytes(suffix)
_reject_declared_oversize(file, request, max_bytes=max_bytes)
fd, tmp_name = tempfile.mkstemp(prefix=_UPLOAD_TEMP_PREFIX, suffix=suffix)
tmp_path = Path(tmp_name)
# The asset is named after the ingested file, so it keeps the upload's own name.
tmp_dir = Path(tempfile.mkdtemp(prefix=_UPLOAD_TEMP_PREFIX))
tmp_path = tmp_dir / _upload_name(file.filename, suffix)
try:
with os.fdopen(fd, "wb") as handle:
with tmp_path.open("xb") as handle:
await _write_bounded_upload(file, handle, max_bytes)
return remote.asset(
handler.assets.ingest_asset(IngestAssetRequest(path=str(tmp_path))),
)
finally:
tmp_path.unlink(missing_ok=True)
shutil.rmtree(tmp_dir, ignore_errors=True)


def _file_response_for(
Expand Down
147 changes: 147 additions & 0 deletions backend/_routes/assistant.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import asyncio
import time
from collections.abc import AsyncIterator
from contextlib import aclosing
from uuid import UUID

from fastapi import APIRouter, Depends, Request
from fastapi.responses import StreamingResponse

from api_types import (
AssistantConversation, AssistantConversationSummary, AssistantModelDownloadRequest, AssistantModelsResponse, AssistantQueueRequest,
AssistantSelection, AssistantSelectionRequest, AssistantTurnRequest, AssistantTurnResponse, StatusResponse,
)
from app_handler import AppHandler
from remote.pairing import AuthenticatedDevice, RemotePairing
from state import get_state_service

router = APIRouter(prefix="/api/assistant", tags=["assistant"])
# Remote gets the chat, never model downloads or deletes.
phone_assistant_router = APIRouter(prefix="/api/assistant", tags=["assistant"])


@router.get("/conversations", response_model=list[AssistantConversationSummary])
@phone_assistant_router.get("/conversations", response_model=list[AssistantConversationSummary])
def list_conversations(handler: AppHandler = Depends(get_state_service)) -> list[AssistantConversationSummary]:
return handler.assistant.list_conversations()


@router.get("/conversations/{conversation_id}", response_model=AssistantConversation)
@phone_assistant_router.get("/conversations/{conversation_id}", response_model=AssistantConversation)
def get_conversation(conversation_id: str, handler: AppHandler = Depends(get_state_service)) -> AssistantConversation:
return handler.assistant.get_conversation(conversation_id)


@router.delete("/conversations/{conversation_id}", response_model=StatusResponse)
@phone_assistant_router.delete("/conversations/{conversation_id}", response_model=StatusResponse)
def delete_conversation(conversation_id: str, handler: AppHandler = Depends(get_state_service)) -> StatusResponse:
handler.assistant.delete_conversation(conversation_id)
return StatusResponse(status="ok")


@router.post("/conversations/{conversation_id}/queue", response_model=AssistantConversation)
def enqueue_message(
conversation_id: UUID, req: AssistantQueueRequest, handler: AppHandler = Depends(get_state_service),
) -> AssistantConversation:
return handler.assistant.enqueue(str(conversation_id), req, origin="desktop")


@phone_assistant_router.post("/conversations/{conversation_id}/queue", response_model=AssistantConversation)
def enqueue_phone_message(
conversation_id: UUID, req: AssistantQueueRequest, handler: AppHandler = Depends(get_state_service),
) -> AssistantConversation:
return handler.assistant.enqueue(str(conversation_id), req, origin="remote")


@router.delete("/conversations/{conversation_id}/queue/{item_id}", response_model=AssistantConversation)
@phone_assistant_router.delete("/conversations/{conversation_id}/queue/{item_id}", response_model=AssistantConversation)
def remove_queued_message(conversation_id: UUID, item_id: UUID, handler: AppHandler = Depends(get_state_service)) -> AssistantConversation:
return handler.assistant.remove_queued(str(conversation_id), str(item_id))


@router.post("/conversations/{conversation_id}/queue/release", response_model=AssistantConversation)
@phone_assistant_router.post("/conversations/{conversation_id}/queue/release", response_model=AssistantConversation)
def release_queue(conversation_id: UUID, handler: AppHandler = Depends(get_state_service)) -> AssistantConversation:
return handler.assistant.release_queue(str(conversation_id))


@router.get("/selection", response_model=AssistantSelection)
@phone_assistant_router.get("/selection", response_model=AssistantSelection)
def assistant_selection(handler: AppHandler = Depends(get_state_service)) -> AssistantSelection:
return handler.assistant.selection()


@router.put("/selection", response_model=AssistantSelection)
@phone_assistant_router.put("/selection", response_model=AssistantSelection)
def set_assistant_selection(req: AssistantSelectionRequest, handler: AppHandler = Depends(get_state_service)) -> AssistantSelection:
return handler.assistant.set_selection(req)


@router.get("/models", response_model=AssistantModelsResponse)
@phone_assistant_router.get("/models", response_model=AssistantModelsResponse)
def assistant_models(handler: AppHandler = Depends(get_state_service)) -> AssistantModelsResponse:
return handler.assistant_models.list_models()


@router.post("/models/download", response_model=StatusResponse)
def download_assistant_model(req: AssistantModelDownloadRequest, handler: AppHandler = Depends(get_state_service)) -> StatusResponse:
handler.assistant_models.start_download(req.model)
return StatusResponse(status="started")


@router.post("/models/delete", response_model=StatusResponse)
def delete_assistant_model(req: AssistantModelDownloadRequest, handler: AppHandler = Depends(get_state_service)) -> StatusResponse:
handler.assistant_models.delete_model(req.model)
return StatusResponse(status="ok")


@router.post("/turn", response_model=AssistantTurnResponse)
async def assistant_turn(
req: AssistantTurnRequest, handler: AppHandler = Depends(get_state_service),
) -> AssistantTurnResponse:
return await handler.assistant.turn(req, origin="desktop")


@phone_assistant_router.post("/turn", response_model=AssistantTurnResponse)
async def assistant_phone_turn(
req: AssistantTurnRequest, handler: AppHandler = Depends(get_state_service),
) -> AssistantTurnResponse:
return await handler.assistant.turn(req, origin="remote")


@router.post("/runs/{run_id}/continue", response_model=AssistantTurnResponse)
@phone_assistant_router.post("/runs/{run_id}/continue", response_model=AssistantTurnResponse)
async def continue_assistant(run_id: str, handler: AppHandler = Depends(get_state_service)) -> AssistantTurnResponse:
return await handler.assistant.continue_run(run_id)


@router.get("/runs/{run_id}", response_model=AssistantTurnResponse)
@phone_assistant_router.get("/runs/{run_id}", response_model=AssistantTurnResponse)
def assistant_run(run_id: str, handler: AppHandler = Depends(get_state_service)) -> AssistantTurnResponse:
return handler.assistant.get_run(run_id)


@router.get("/runs/{run_id}/events")
@phone_assistant_router.get("/runs/{run_id}/events")
async def assistant_events(run_id: str, request: Request, handler: AppHandler = Depends(get_state_service)) -> StreamingResponse:
device = getattr(request.state, "paired_device", None)
pairing = getattr(request.app.state, "remote_pairing", None)

async def events() -> AsyncIterator[str]:
checked = time.monotonic()
# aclosing: an early return must remove the watcher now, not when the generator is collected.
async with aclosing(handler.assistant.watch_progress(run_id)) as updates:
async for progress in updates:
if isinstance(device, AuthenticatedDevice) and isinstance(pairing, RemotePairing) and time.monotonic() - checked > 5:
# Remote auth runs when the stream opens; a revoked phone must stop receiving the reply.
if not await asyncio.to_thread(pairing.is_device_active, device.id):
return
checked = time.monotonic()
yield ": heartbeat\n\n" if progress is None else f"data: {progress.model_dump_json()}\n\n"
return StreamingResponse(events(), media_type="text/event-stream", headers={"Cache-Control": "no-cache"})


@router.post("/runs/{run_id}/stop", response_model=AssistantTurnResponse)
@phone_assistant_router.post("/runs/{run_id}/stop", response_model=AssistantTurnResponse)
def stop_assistant(run_id: str, handler: AppHandler = Depends(get_state_service)) -> AssistantTurnResponse:
return handler.assistant.stop(run_id)
17 changes: 16 additions & 1 deletion backend/_routes/generation.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Route handlers for /api/generate, /api/generate/cancel, /api/generation/progress."""
"""Route handlers for /api/generate, /api/generate/estimate, /api/generate/cancel, /api/generation/progress."""

from __future__ import annotations

Expand All @@ -12,6 +12,8 @@
from api_model_specs import video_generation_feature
from api_types import (
CancelResponse,
EstimateVideoRequest,
EstimateVideoResponse,
GenerateVideoModelsSpecsResponse,
GenerateVideoRequest,
GenerateVideoResponse,
Expand Down Expand Up @@ -99,6 +101,19 @@ def route_generate_cancel(handler: AppHandler = Depends(get_state_service)) -> C
return handler.generation.cancel_generation()


@router.post("/generate/estimate", response_model=EstimateVideoResponse)
def route_generate_estimate(
req: EstimateVideoRequest,
handler: AppHandler = Depends(get_state_service),
) -> EstimateVideoResponse:
"""POST /api/generate/estimate — pre-generate wall-time estimate for one cell.

Kept intentionally: no current in-repo callers, but this is the
foundation for post-V1 ETA work. Do not remove as dead surface.
"""
return handler.video_generation.estimate_local_fast(req)


@router.get("/generation/progress", response_model=GenerationProgressResponse)
def route_generation_progress(handler: AppHandler = Depends(get_state_service)) -> GenerationProgressResponse:
"""GET /api/generation/progress."""
Expand Down
17 changes: 15 additions & 2 deletions backend/_routes/generations.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@

from api_types import (
CreateAudioToVideoRequest,
CreateIcLoraRecipeRequest,
IcLoraRecipeCreate,
CreateExtendRequest,
CreateImageToVideoRequest,
CreateLoraRecipeRequest,
CreateRetakeRequest,
CreateTextToVideoRequest,
EditPreflightRequest,
EditPreflightResponse,
Generation,
QueueSnapshot,
ReorderGenerationQueueRequest,
Expand Down Expand Up @@ -174,7 +176,7 @@ def route_create_retake(
tags=["generations"],
)
def route_create_ic_lora_recipe(
req: CreateIcLoraRecipeRequest,
req: IcLoraRecipeCreate,
recipe_id: str = Path(pattern=_RECIPE_ID_PATTERN),
handler: AppHandler = Depends(get_state_service),
) -> Generation:
Expand All @@ -196,6 +198,17 @@ def route_create_extend(
)


@router.post(
"/generations/edit/preflight",
response_model=EditPreflightResponse,
tags=["generations"],
)
def route_preflight_edit(
req: EditPreflightRequest, handler: AppHandler = Depends(get_state_service)
) -> EditPreflightResponse:
return handler.queued_generations.preflight_edit(req)


@router.get("/generations", response_model=list[Generation], tags=["generations"])
def route_list_generations(
feature: str, handler: AppHandler = Depends(get_state_service)
Expand Down
10 changes: 10 additions & 0 deletions backend/_routes/ic_lora.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
IcLoraExtractResponse,
IcLoraGenerateRequest,
IcLoraGenerateResponse,
IcLoraPreflightRequest,
IcLoraPreflightResponse,
)
from services.services_utils import JSONValue
from state import get_state_service
Expand All @@ -22,6 +24,14 @@
router = APIRouter(prefix="/api/ic-lora", tags=["ic-lora"])


@router.post("/preflight", response_model=IcLoraPreflightResponse)
def route_ic_lora_preflight(
req: IcLoraPreflightRequest,
handler: AppHandler = Depends(get_state_service),
) -> IcLoraPreflightResponse:
return handler.ic_lora.preflight(req)


@router.post("/extract-conditioning", response_model=IcLoraExtractResponse)
def route_ic_lora_extract(
req: IcLoraExtractRequest,
Expand Down
4 changes: 2 additions & 2 deletions backend/_routes/prompt_enhancement.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
PromptEnhancerStatusResponse,
)
from app_handler import AppHandler
from services.prompt_enhancement.resolve_enhance_assets import resolve_enhance_image_assets
from services.prompt_enhancement.resolve_enhance_assets import resolve_enhance_media_assets
from state import get_state_service

router = APIRouter(prefix="/api", tags=["prompt-enhancement"])
Expand All @@ -31,5 +31,5 @@ def route_enhance_prompt(
) -> EnhancePromptResponse:
"""POST /api/enhance-prompt."""
return handler.prompt_enhancement.enhance(
resolve_enhance_image_assets(handler.assets, req)
resolve_enhance_media_assets(handler.assets, req)
)
Loading
Loading