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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Weekly version updates so the Python/JS stacks do not drift behind
# patched releases (pentest finding 1.2).
version: 2
updates:
- package-ecosystem: "uv"
directory: "/backend"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
groups:
python-dependencies:
patterns:
- "*"

- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
groups:
js-dependencies:
patterns:
- "*"

- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
9 changes: 8 additions & 1 deletion backend/_routes/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from _routes._admin_guard import guard_admin_permission
from state.app_settings import SettingsResponse, UpdateSettingsRequest, to_settings_response
from api_types import StatusResponse
from api_types import GeminiModelsResponsePayload, StatusResponse
from state import get_state_service
from app_handler import AppHandler

Expand Down Expand Up @@ -46,3 +46,10 @@ def route_post_settings(
handler.pipelines.unload_gpu_pipeline()

return StatusResponse(status="ok")


@router.get("/settings/gemini-models", response_model=GeminiModelsResponsePayload)
def route_list_gemini_models(
handler: AppHandler = Depends(get_state_service),
) -> GeminiModelsResponsePayload:
return handler.settings.list_gemini_models()
17 changes: 17 additions & 0 deletions backend/api_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ class GenerationProgressResponse(BaseModel):
# than a different, unrelated one that reused the single global progress slot in the
# meantime.
id: str | None = None
# Same poll that disables Generate (`status == "running"`). Stop is allowed only for a
# local GPU slot — reservation, denoise, or cancelled-in-flight unwind. LTX/FAL API jobs
# occupy the slot (Generate stays locked) but have no public cancel.
cancellable: bool


class DownloadProgressRunningResponse(BaseModel):
Expand Down Expand Up @@ -1127,3 +1131,16 @@ class LoraDownloadProgressResponse(BaseModel):
progress: float = 0.0
speed_bytes_per_sec: float = 0.0
error: str | None = None


class GeminiModelOptionPayload(BaseModel):
model_config = ConfigDict(strict=True)
id: str
displayName: str
description: str = ""


class GeminiModelsResponsePayload(BaseModel):
model_config = ConfigDict(strict=True)
models: list[GeminiModelOptionPayload]
resolvedModel: str
1 change: 1 addition & 0 deletions backend/app_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ def __init__(
state=self.state,
lock=self._lock,
config=config,
http=http,
)

self.models = ModelsHandler(
Expand Down
24 changes: 18 additions & 6 deletions backend/handlers/download_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@
logger = logging.getLogger(__name__)


def _canonical_download_cp_id(cp_id: ModelCheckpointID) -> ModelCheckpointID:
# HF removed ltx-2.3-spatial-upscaler-x2-1.0.safetensors; fetch 1.1 onto the 1.1 path.
if cp_id == "ltx-2.3-spatial-upscaler-x2-1.0":
return "ltx-2.3-spatial-upscaler-x2-1.1"
return cp_id


class DownloadHandler(StateHandlerBase):
def __init__(
self,
Expand Down Expand Up @@ -285,12 +292,17 @@ def _rollback_committed_checkpoints(self, cp_ids: Iterable[ModelCheckpointID]) -
else:
path.unlink(missing_ok=True)

def _canonical_download_cp_ids(
self, cp_ids: Iterable[ModelCheckpointID]
) -> tuple[ModelCheckpointID, ...]:
return self._ordered_cp_ids({_canonical_download_cp_id(cp_id) for cp_id in cp_ids})

def _discover_download_cp_ids(self, requested_cp_ids: set[ModelCheckpointID]) -> tuple[ModelCheckpointID, ...]:
missing: set[ModelCheckpointID] = set()
for cp_id in requested_cp_ids:
if not self._models_handler.is_cp_downloaded(cp_id):
missing.add(cp_id)
return self._ordered_cp_ids(missing)
return tuple(
cp_id
for cp_id in self._canonical_download_cp_ids(requested_cp_ids)
if not self._models_handler.is_cp_downloaded(cp_id)
)

def _download_worker(self, cp_ids: tuple[ModelCheckpointID, ...], *, atomic_commit: bool) -> None:
if not cp_ids:
Expand Down Expand Up @@ -334,7 +346,7 @@ def start_model_download(self, *, download_type: str, cp_ids: set[ModelCheckpoin
# Resolve what to download (may raise) before touching the lock.
if download_type == "upgrade":
resolved_upgrade = self._models_handler.resolve_upgrade_download(cp_ids)
ordered_cp_ids = resolved_upgrade.cp_ids
ordered_cp_ids = self._canonical_download_cp_ids(resolved_upgrade.cp_ids)
atomic_commit = True
elif download_type == "download":
ordered_cp_ids = self._discover_download_cp_ids(set(cp_ids))
Expand Down
7 changes: 5 additions & 2 deletions backend/handlers/extend_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from runtime_config.ltx_capabilities import local_caps, supports
from runtime_config.model_download_specs import resolve_active_ltx_model_id
from runtime_config.runtime_config import RuntimeConfig
from services.generation_interrupt import GenerationCancelledError, is_cancel_exception
from services.ltx_api_client.ltx_api_client import LTXAPIClientError
from services.interfaces import LTXAPIClient
from state.app_state_types import AppState
Expand Down Expand Up @@ -215,9 +216,11 @@ def _run_local_extend(
target_frames=target_frames,
)

# Denoiser interrupt cannot abort VAE decode / ffmpeg; a Stop after the last
# denoise step still finishes encode, then this check drops the file.
if self._generation.is_generation_cancelled():
output_path.unlink(missing_ok=True)
raise RuntimeError("Generation was cancelled")
raise GenerationCancelledError()

self._generation.update_progress("complete", 100, 1, 1)
self._generation.complete_generation(str(output_path))
Expand All @@ -227,7 +230,7 @@ def _run_local_extend(
raise
except Exception as exc:
self._generation.fail_generation(str(exc))
if "cancelled" in str(exc).lower():
if is_cancel_exception(exc):
return RetakeCancelledResponse(status="cancelled")
raise HTTPError(500, f"Generation error: {exc}") from exc
finally:
Expand Down
100 changes: 89 additions & 11 deletions backend/handlers/generation_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
GenerationProgressResponse,
)
from handlers.base import StateHandlerBase, with_state_lock
from services import generation_interrupt
from services.generation_interrupt import GenerationCancelledError
from services.patches import diffusion_stage_cache
from state.app_state_types import (
ApiGeneration,
Expand Down Expand Up @@ -50,24 +52,35 @@ def try_reserve_generation_start(self) -> bool:

Shared across every generation kind (video/image/retake/extend/ic-lora all hold a
reference to this same GenerationHandler instance) — one global gate, not one per
endpoint. fail_generation() and start_generation()/start_api_generation() clear this on
every success/failure path they're reached from; the timeout below is a backstop for any
path that raises before reaching either (e.g. a request-validation check that runs after
the reservation but outside the try/except that calls fail_generation).
endpoint. reserved_generation_start() holds generation_in_flight until the handler
actually returns (including GPU unwind after Stop). start_generation() only clears
generation_starting_since so progress can move off phase=starting. The timeout below
is a backstop for a reservation that never got a context finally (legacy try_reserve
without the context manager).
"""
since = self.state.generation_starting_since
if self.is_generation_running():
logger.info("Generation start reservation denied: a generation is already running")
return False
if since is not None and time.monotonic() - since < _RESERVATION_TIMEOUT_S:
if self.state.generation_in_flight:
# After start_generation(), starting_since is None and in_flight stays True through
# generate() unwind — do not expire that. Only a pre-start reservation whose
# timestamp aged out (no finally) is reclaimable.
if since is None or time.monotonic() - since < _RESERVATION_TIMEOUT_S:
logger.info("Generation start reservation denied: a generation is still in flight")
return False
elif since is not None and time.monotonic() - since < _RESERVATION_TIMEOUT_S:
logger.info("Generation start reservation denied: another reservation is still active")
return False
self.state.generation_in_flight = True
self.state.generation_starting_since = time.monotonic()
return True

@with_state_lock
def release_generation_start_reservation(self) -> None:
self.state.generation_in_flight = False
self.state.generation_starting_since = None
generation_interrupt.clear()

@contextmanager
def reserved_generation_start(self) -> Iterator[None]:
Expand All @@ -94,6 +107,15 @@ def start_generation(self, generation_id: str) -> None:
raise RuntimeError("Generation already in progress")
if self.state.gpu_slot is None:
raise RuntimeError("No active GPU pipeline")
if generation_interrupt.is_requested():
# Stop during reservation (enhance / pipeline load). Do not clear the Event
# or launch Running — that would let this call proceed and a second Start
# overlap on the GPU.
self.state.generation_starting_since = None
self.state.active_generation = GpuGeneration(
state=GenerationCancelled(id=generation_id)
)
raise GenerationCancelledError()
self.state.generation_starting_since = None

# EXPERIMENTAL: push the live Settings toggle, then drop any transformer
Expand All @@ -103,6 +125,7 @@ def start_generation(self, generation_id: str) -> None:
# docstring section for the RTX 5090 repro (~42GB reported on a 32GB card).
diffusion_stage_cache.set_enabled(self.state.app_settings.diffusion_stage_cache_enabled)
diffusion_stage_cache.evict()
generation_interrupt.clear()

self.state.active_generation = GpuGeneration(
state=GenerationRunning(
Expand All @@ -116,12 +139,19 @@ def start_generation(self, generation_id: str) -> None:
def start_api_generation(self, generation_id: str) -> None:
if self.is_generation_running():
raise RuntimeError("Generation already in progress")
if generation_interrupt.is_requested():
self.state.generation_starting_since = None
self.state.active_generation = ApiGeneration(
state=GenerationCancelled(id=generation_id)
)
raise GenerationCancelledError()
self.state.generation_starting_since = None

# EXPERIMENTAL: see start_generation -- an API generation doesn't build a
# local transformer itself, but evicting here still releases VRAM held by
# a previous local generation's cached build.
diffusion_stage_cache.evict()
generation_interrupt.clear()

self.state.active_generation = ApiGeneration(
state=GenerationRunning(
Expand Down Expand Up @@ -216,6 +246,16 @@ def is_generation_cancelled(self) -> bool:
case _:
return False

def raise_if_cancelled(self) -> None:
"""Abort if the user cancelled THIS in-flight job.

AppState GenerationCancelled is sticky after the slot is released. Checking it
here made the next Generate return cancelled immediately (the Windows repro after
Stop). The interrupt Event is cleared on release; it is the signal for this
reservation.
"""
generation_interrupt.raise_if_requested()

@with_state_lock
def update_progress(
self,
Expand All @@ -238,16 +278,25 @@ def update_progress(
def cancel_generation(self) -> CancelResponse:
running_generation = self._running_generation()
if running_generation is not None:
generation_interrupt.request()
slot, running = running_generation
self._set_generation_state(slot, GenerationCancelled(id=running.id))
return CancelCancellingResponse(status="cancelling", id=running.id)

cancelled_generation = self._cancelled_generation()
match cancelled_generation:
case (_, GenerationCancelled(id=generation_id)):
return CancelCancellingResponse(status="cancelling", id=generation_id)
case _:
return CancelNoActiveGenerationResponse(status="no_active_generation")
if self.state.generation_in_flight:
generation_interrupt.request()
# After start_generation(), starting_since is None and active_generation is this
# job. During reservation it still holds the previous job's sticky terminal id.
if self.state.generation_starting_since is None:
cancelled_in_flight = self._cancelled_generation()
if cancelled_in_flight is not None:
return CancelCancellingResponse(status="cancelling", id=cancelled_in_flight[1].id)
return CancelCancellingResponse(status="cancelling", id="pending")

# Sticky GenerationCancelled after the slot is released is idle, not in-flight.
# Returning "cancelling" here used to make a second Stop look accepted without
# arming the Event.
return CancelNoActiveGenerationResponse(status="no_active_generation")

@with_state_lock
def complete_generation(self, result: str | list[str] | None = None) -> None:
Expand Down Expand Up @@ -277,6 +326,14 @@ def fail_generation(self, error: str) -> None:

logger.error("Generation failed without active running job: %s", error)

@with_state_lock
def _local_gpu_slot_occupied(self) -> bool:
match self.state.active_generation:
case GpuGeneration() if self.state.gpu_slot is not None:
return True
case _:
return False

@with_state_lock
def get_generation_progress(self) -> GenerationProgressResponse:
# Checked before matching active_generation: try_reserve_generation_start() only succeeds
Expand All @@ -293,15 +350,19 @@ def get_generation_progress(self) -> GenerationProgressResponse:
# baseline capture) must not see "idle" during this window and wrongly
# conclude the single global slot is free — see try_reserve_generation_start's
# docstring for why this window needed closing on the write side too.
# cancellable=True: Stop during reservation sets the interrupt Event before
# start_generation()/start_api_generation(); sticky previous slot is not this job.
return GenerationProgressResponse(
status="running",
phase="starting",
progress=0,
currentStep=0,
totalSteps=0,
cancellable=True,
)

gen = self._generation_for_polling()
gpu_cancellable = self._local_gpu_slot_occupied()

match gen:
case GenerationRunning(id=generation_id, progress=progress):
Expand All @@ -312,6 +373,7 @@ def get_generation_progress(self) -> GenerationProgressResponse:
currentStep=progress.current_step,
totalSteps=progress.total_steps,
id=generation_id,
cancellable=gpu_cancellable,
)
case GenerationComplete(id=generation_id, result=result):
return GenerationProgressResponse(
Expand All @@ -322,6 +384,19 @@ def get_generation_progress(self) -> GenerationProgressResponse:
totalSteps=0,
result=result,
id=generation_id,
cancellable=False,
)
case GenerationCancelled(id=generation_id) if self.state.generation_in_flight:
# Slot is still occupied until pipeline.generate() unwinds. Report running so
# the cross-project Generate lock does not treat Stop as "slot is free".
return GenerationProgressResponse(
status="running",
phase="cancelled",
progress=0,
currentStep=0,
totalSteps=0,
id=generation_id,
cancellable=gpu_cancellable,
)
case GenerationCancelled(id=generation_id):
return GenerationProgressResponse(
Expand All @@ -331,6 +406,7 @@ def get_generation_progress(self) -> GenerationProgressResponse:
currentStep=0,
totalSteps=0,
id=generation_id,
cancellable=False,
)
case GenerationError(id=generation_id):
return GenerationProgressResponse(
Expand All @@ -340,6 +416,7 @@ def get_generation_progress(self) -> GenerationProgressResponse:
currentStep=0,
totalSteps=0,
id=generation_id,
cancellable=False,
)
case _:
return GenerationProgressResponse(
Expand All @@ -348,6 +425,7 @@ def get_generation_progress(self) -> GenerationProgressResponse:
progress=0,
currentStep=0,
totalSteps=0,
cancellable=False,
)

@with_state_lock
Expand Down
Loading
Loading