From 843ed17bbfa50a8d0903cf3057e5099873943f88 Mon Sep 17 00:00:00 2001 From: ltx-desktop-bot Date: Wed, 19 Aug 2026 08:31:50 +0000 Subject: [PATCH] Sync from internal - 2026-08-19 --- .github/dependabot.yml | 28 + backend/_routes/settings.py | 9 +- backend/api_types.py | 17 + backend/app_handler.py | 1 + backend/handlers/download_handler.py | 24 +- backend/handlers/extend_handler.py | 7 +- backend/handlers/generation_handler.py | 100 +++- backend/handlers/ic_lora_handler.py | 25 +- backend/handlers/image_generation_handler.py | 27 +- .../handlers/prompt_enhancement_handler.py | 21 +- backend/handlers/retake_handler.py | 7 +- backend/handlers/settings_handler.py | 32 +- .../handlers/suggest_gap_prompt_handler.py | 21 +- backend/handlers/video_generation_handler.py | 30 +- backend/ltx2_server.py | 7 + backend/pyproject.toml | 18 +- backend/runtime_config/ltx_capabilities.py | 21 +- .../runtime_config/model_download_specs.py | 3 + backend/server_utils/win_dll_search.py | 22 + .../a2v_pipeline/distilled_a2v_pipeline.py | 21 +- backend/services/gemini_text_client.py | 330 ++++++++++- backend/services/generation_interrupt.py | 99 ++++ .../zit_image_generation_pipeline.py | 3 + .../services/patches/diffusion_interrupt.py | 56 ++ .../gemini_prompt_enhancer_pipeline.py | 30 +- .../services/text_encoder/ltx_text_encoder.py | 53 +- backend/state/app_settings.py | 4 + backend/state/app_state_types.py | 6 + backend/tests/conftest.py | 11 + backend/tests/fakes/services.py | 55 +- backend/tests/test_api_calls.py | 37 ++ backend/tests/test_diffusion_interrupt.py | 38 ++ backend/tests/test_distilled_a2v_image_crf.py | 46 ++ backend/tests/test_generation.py | 209 ++++++- backend/tests/test_generation_interrupt.py | 108 ++++ backend/tests/test_ic_lora.py | 38 ++ backend/tests/test_ic_lora_generate.py | 36 ++ backend/tests/test_image_edit.py | 5 +- backend/tests/test_ltx_capabilities.py | 25 +- backend/tests/test_ltx_text_encoder.py | 50 +- backend/tests/test_model_download_specs.py | 20 + backend/tests/test_models.py | 23 +- backend/tests/test_prompt_enhancement.py | 76 +++ backend/tests/test_settings.py | 531 ++++++++++++++++++ backend/tests/test_state_actions.py | 71 +++ backend/tests/test_win_dll_search.py | 26 + backend/uv.lock | 178 +++--- electron-builder.yml | 1 + electron/app-state.ts | 24 + electron/export/ffmpeg-utils.ts | 4 +- electron/gpu.ts | 3 +- electron/ipc/app-handlers.ts | 53 ++ electron/main.ts | 1 + electron/preload.ts | 8 +- electron/python-backend.ts | 7 +- electron/updater.ts | 193 ++++++- electron/win-dll-search.ts | 24 + frontend/App.tsx | 13 + frontend/components/SettingsDropdown.tsx | 7 +- frontend/components/SettingsModal.tsx | 184 +++++- frontend/components/UpdateAvailableModal.css | 165 ++++++ frontend/components/UpdateAvailableModal.tsx | 133 +++++ frontend/contexts/AppSettingsContext.tsx | 5 + frontend/generated/backend-openapi.json | 108 +++- frontend/generated/backend-openapi.ts | 83 +++ frontend/hooks/use-app-update.ts | 111 ++++ frontend/hooks/use-extend.ts | 25 +- frontend/hooks/use-generation.ts | 198 ++++--- frontend/hooks/use-global-generation-lock.ts | 44 +- frontend/hooks/use-ic-lora.ts | 10 + frontend/hooks/use-retake.ts | 15 +- frontend/lib/api-client.ts | 2 + frontend/lib/generation-active.ts | 38 +- frontend/lib/genspace-gallery.ts | 112 ++++ frontend/views/GenSpace.tsx | 311 +++++----- frontend/views/VideoEditor.tsx | 6 + frontend/views/editor/AssetContextMenu.tsx | 4 +- frontend/views/editor/ClipContextMenu.tsx | 8 +- .../views/editor/VideoEditorAssetsPanel.tsx | 9 +- .../VideoEditorTimelineEditingPanel.tsx | 14 +- frontend/views/editor/useRegeneration.ts | 21 +- .../genspace/GenSpaceFilterEmptyState.tsx | 50 ++ .../genspace/GenSpaceGallerySizeMenu.tsx | 130 +++++ .../views/genspace/GenSpaceGalleryToolbar.tsx | 83 +++ frontend/views/genspace/GenSpaceSortMenu.tsx | 53 ++ .../views/genspace/GenSpaceTypeFilter.tsx | 35 ++ frontend/views/genspace/useGenSpaceGallery.ts | 67 +++ package.json | 3 +- pnpm-lock.yaml | 144 +++++ pnpm-workspace.yaml | 1 + shared/electron-api-schema.ts | 41 ++ vite.config.ts | 2 +- 92 files changed, 4566 insertions(+), 592 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 backend/server_utils/win_dll_search.py create mode 100644 backend/services/generation_interrupt.py create mode 100644 backend/services/patches/diffusion_interrupt.py create mode 100644 backend/tests/test_diffusion_interrupt.py create mode 100644 backend/tests/test_distilled_a2v_image_crf.py create mode 100644 backend/tests/test_generation_interrupt.py create mode 100644 backend/tests/test_win_dll_search.py create mode 100644 electron/win-dll-search.ts create mode 100644 frontend/components/UpdateAvailableModal.css create mode 100644 frontend/components/UpdateAvailableModal.tsx create mode 100644 frontend/hooks/use-app-update.ts create mode 100644 frontend/lib/genspace-gallery.ts create mode 100644 frontend/views/genspace/GenSpaceFilterEmptyState.tsx create mode 100644 frontend/views/genspace/GenSpaceGallerySizeMenu.tsx create mode 100644 frontend/views/genspace/GenSpaceGalleryToolbar.tsx create mode 100644 frontend/views/genspace/GenSpaceSortMenu.tsx create mode 100644 frontend/views/genspace/GenSpaceTypeFilter.tsx create mode 100644 frontend/views/genspace/useGenSpaceGallery.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..927d01043 --- /dev/null +++ b/.github/dependabot.yml @@ -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" diff --git a/backend/_routes/settings.py b/backend/_routes/settings.py index fe4b2f3cd..fad2bb5bd 100644 --- a/backend/_routes/settings.py +++ b/backend/_routes/settings.py @@ -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 @@ -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() diff --git a/backend/api_types.py b/backend/api_types.py index b14dbef2a..7880ac565 100644 --- a/backend/api_types.py +++ b/backend/api_types.py @@ -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): @@ -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 diff --git a/backend/app_handler.py b/backend/app_handler.py index 9a7f6b331..5cfca0764 100644 --- a/backend/app_handler.py +++ b/backend/app_handler.py @@ -115,6 +115,7 @@ def __init__( state=self.state, lock=self._lock, config=config, + http=http, ) self.models = ModelsHandler( diff --git a/backend/handlers/download_handler.py b/backend/handlers/download_handler.py index b3070a71f..a62fb3181 100644 --- a/backend/handlers/download_handler.py +++ b/backend/handlers/download_handler.py @@ -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, @@ -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: @@ -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)) diff --git a/backend/handlers/extend_handler.py b/backend/handlers/extend_handler.py index 74e3d66c0..a922e0f4d 100644 --- a/backend/handlers/extend_handler.py +++ b/backend/handlers/extend_handler.py @@ -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 @@ -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)) @@ -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: diff --git a/backend/handlers/generation_handler.py b/backend/handlers/generation_handler.py index 123092c13..499f9c81a 100644 --- a/backend/handlers/generation_handler.py +++ b/backend/handlers/generation_handler.py @@ -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, @@ -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]: @@ -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 @@ -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( @@ -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( @@ -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, @@ -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: @@ -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 @@ -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): @@ -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( @@ -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( @@ -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( @@ -340,6 +416,7 @@ def get_generation_progress(self) -> GenerationProgressResponse: currentStep=0, totalSteps=0, id=generation_id, + cancellable=False, ) case _: return GenerationProgressResponse( @@ -348,6 +425,7 @@ def get_generation_progress(self) -> GenerationProgressResponse: progress=0, currentStep=0, totalSteps=0, + cancellable=False, ) @with_state_lock diff --git a/backend/handlers/ic_lora_handler.py b/backend/handlers/ic_lora_handler.py index 3c21f0660..d9446188a 100644 --- a/backend/handlers/ic_lora_handler.py +++ b/backend/handlers/ic_lora_handler.py @@ -46,6 +46,7 @@ from services.lora_catalog import LoraCatalogProvider from services.services_utils import FrameArray from server_utils.heartbeat import log_heartbeat +from services.generation_interrupt import GenerationCancelledError, is_cancel_exception from state.app_state_types import AppState, ICLoraState if TYPE_CHECKING: @@ -256,6 +257,15 @@ def _outpaint_from_request(req: IcLoraGenerateRequest) -> OutpaintParams | None: return None return OutpaintParams(left=p.left, right=p.right, top=p.top, bottom=p.bottom) + def _complete_or_drop_cancelled(self, output_path: Path) -> None: + # 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 GenerationCancelledError() + self._generation.update_progress("complete", 100, 1, 1) + self._generation.complete_generation(str(output_path)) + def _generate_ic_lora(self, req: IcLoraGenerateRequest) -> IcLoraGenerateResponse: assert req.ic_lora_id is not None # branch guard in generate() with self._generation.reserved_generation_start(): @@ -311,6 +321,7 @@ def _generate_ic_lora(self, req: IcLoraGenerateRequest) -> IcLoraGenerateRespons # Never enhance an empty prompt — there's nothing to expand and the enhancer would hallucinate. enhance_prompt = use_api and self.state.app_settings.prompt_enhancer_enabled_t2v and has_prompt self._text.prepare_text_encoding(req.prompt, enhance_prompt=enhance_prompt) + self._generation.raise_if_cancelled() input_artifact = MediaArtifact(path=req.input_path, kind=ic_lora.input.kind) ctx = PreprocessingContext( @@ -402,8 +413,7 @@ def _generate_ic_lora(self, req: IcLoraGenerateRequest) -> IcLoraGenerateRespons mute_audio=s.audio_mode == "off", conditioning_mask_path=control.mask_path, ) - self._generation.update_progress("complete", 100, 1, 1) - self._generation.complete_generation(str(output_path)) + self._complete_or_drop_cancelled(output_path) return IcLoraGenerateCompleteResponse(status="complete", video_path=str(output_path)) except HTTPError: self._generation.fail_generation("IC-LoRA generation failed") @@ -415,7 +425,8 @@ def _generate_ic_lora(self, req: IcLoraGenerateRequest) -> IcLoraGenerateRespons raise HTTPError(400, str(exc)) from exc except Exception as exc: self._generation.fail_generation(str(exc)) - if "cancelled" in str(exc).lower(): + if is_cancel_exception(exc): + logger.info("Generation cancelled by user") return IcLoraGenerateCancelledResponse(status="cancelled") raise HTTPError(500, f"Generation error: {exc}") from exc finally: @@ -483,6 +494,7 @@ def generate(self, req: IcLoraGenerateRequest) -> IcLoraGenerateResponse: self._text.prepare_text_encoding(req.prompt, enhance_prompt=enhance_prompt) t_text_end = time.perf_counter() logger.info("[ic-lora] Text encoding (%s): %.2fs", encoding_method, t_text_end - t_text_start) + self._generation.raise_if_cancelled() preprocess_time = 0.0 @@ -537,6 +549,7 @@ def generate(self, req: IcLoraGenerateRequest) -> IcLoraGenerateResponse: frame_idx = 0 while frame_idx < frame_count: + self._generation.raise_if_cancelled() frame = self._video_processor.read_frame(cap) if frame is None: break @@ -633,8 +646,7 @@ def generate(self, req: IcLoraGenerateRequest) -> IcLoraGenerateResponse: t_inference_end - t_inference_start, ) - self._generation.update_progress("complete", 100, 1, 1) - self._generation.complete_generation(str(output_path)) + self._complete_or_drop_cancelled(output_path) return IcLoraGenerateCompleteResponse(status="complete", video_path=str(output_path)) except HTTPError: @@ -642,7 +654,8 @@ def generate(self, req: IcLoraGenerateRequest) -> IcLoraGenerateResponse: raise except Exception as exc: self._generation.fail_generation(str(exc)) - if "cancelled" in str(exc).lower(): + if is_cancel_exception(exc): + logger.info("Generation cancelled by user") return IcLoraGenerateCancelledResponse(status="cancelled") raise HTTPError(500, f"Generation error: {exc}") from exc finally: diff --git a/backend/handlers/image_generation_handler.py b/backend/handlers/image_generation_handler.py index 9024be001..55b6f8d20 100644 --- a/backend/handlers/image_generation_handler.py +++ b/backend/handlers/image_generation_handler.py @@ -23,6 +23,7 @@ from handlers.base import StateHandlerBase from handlers.generation_handler import GenerationHandler from handlers.pipelines_handler import PipelinesHandler +from services.generation_interrupt import is_cancel_exception from services.interfaces import ZitAPIClient from server_utils.media_validation import validate_image_file from services.services_utils import clamp_strength, compute_edit_dimensions, effective_edit_steps @@ -83,6 +84,7 @@ def generate(self, req: GenerateImageRequest) -> GenerateImageResponse: generation_id = uuid.uuid4().hex[:8] try: + self._generation.raise_if_cancelled() self._pipelines.load_image_generation_pipeline_to_gpu() self._generation.start_generation(generation_id) output_paths = self.generate_image( @@ -97,7 +99,7 @@ def generate(self, req: GenerateImageRequest) -> GenerateImageResponse: return GenerateImageCompleteResponse(status="complete", image_paths=output_paths) except Exception as e: self._generation.fail_generation(str(e)) - if "cancelled" in str(e).lower(): + if is_cancel_exception(e): logger.info("Image generation cancelled by user") return GenerateImageCancelledResponse(status="cancelled") raise HTTPError(500, str(e)) from e @@ -132,6 +134,7 @@ def _edit( generation_id = uuid.uuid4().hex[:8] try: + self._generation.raise_if_cancelled() self._pipelines.load_image_generation_pipeline_to_gpu() self._generation.start_generation(generation_id) output_paths = self.edit_image( @@ -146,7 +149,7 @@ def _edit( return GenerateImageCompleteResponse(status="complete", image_paths=output_paths) except Exception as e: self._generation.fail_generation(str(e)) - if "cancelled" in str(e).lower(): + if is_cancel_exception(e): logger.info("Image edit cancelled by user") return GenerateImageCancelledResponse(status="cancelled") raise HTTPError(500, str(e)) from e @@ -160,8 +163,7 @@ def edit_image( seed: int, num_images: int, ) -> list[str]: - if self._generation.is_generation_cancelled(): - raise RuntimeError("Generation was cancelled") + self._generation.raise_if_cancelled() self._generation.update_progress("loading_model", 5, 0, num_inference_steps) image_generation_pipeline = self._pipelines.load_image_generation_pipeline_to_gpu() @@ -190,8 +192,7 @@ def generate_image( seed: int, num_images: int, ) -> list[str]: - if self._generation.is_generation_cancelled(): - raise RuntimeError("Generation was cancelled") + self._generation.raise_if_cancelled() self._generation.update_progress("loading_model", 5, 0, num_inference_steps) image_generation_pipeline = self._pipelines.load_image_generation_pipeline_to_gpu() @@ -222,8 +223,7 @@ def _run_local_batch( try: for i in range(num_images): - if self._generation.is_generation_cancelled(): - raise RuntimeError("Generation was cancelled") + self._generation.raise_if_cancelled() progress = 15 + int((i / num_images) * 80) self._generation.update_progress("inference", progress, i, num_images) @@ -233,8 +233,7 @@ def _run_local_batch( image.save(str(output_path)) outputs.append(output_path) - if self._generation.is_generation_cancelled(): - raise RuntimeError("Generation was cancelled") + self._generation.raise_if_cancelled() except Exception: for path in outputs: path.unlink(missing_ok=True) @@ -320,15 +319,13 @@ def _run_api_batch( raise HTTPError(500, "FAL_API_KEY_NOT_CONFIGURED") for idx in range(num_images): - if self._generation.is_generation_cancelled(): - raise RuntimeError("Generation was cancelled") + self._generation.raise_if_cancelled() inference_progress = 15 + int((idx / num_images) * 60) self._generation.update_progress("inference", inference_progress, None, None) result_bytes = call_provider(settings.fal_api_key, seed + idx) - if self._generation.is_generation_cancelled(): - raise RuntimeError("Generation was cancelled") + self._generation.raise_if_cancelled() download_progress = 75 + int(((idx + 1) / num_images) * 20) self._generation.update_progress("downloading_output", download_progress, None, None) @@ -349,7 +346,7 @@ def _run_api_batch( self._generation.fail_generation(str(e)) for path in output_paths: path.unlink(missing_ok=True) - if "cancelled" in str(e).lower(): + if is_cancel_exception(e): logger.info("Image generation cancelled by user") return GenerateImageCancelledResponse(status="cancelled") raise HTTPError(500, str(e)) from e diff --git a/backend/handlers/prompt_enhancement_handler.py b/backend/handlers/prompt_enhancement_handler.py index 330019315..55d1e4328 100644 --- a/backend/handlers/prompt_enhancement_handler.py +++ b/backend/handlers/prompt_enhancement_handler.py @@ -15,6 +15,7 @@ from handlers.pipelines_handler import PipelinesHandler from handlers.text_handler import TextHandler from server_utils.media_validation import validate_image_file +from services.gemini_text_client import resolve_gemini_model from services.interfaces import PromptEnhancerPipeline from services.lora_catalog import LoraCatalogProvider from services.prompt_enhancement import ( @@ -217,14 +218,24 @@ def _run_free_rewrite( seed = self._random_seed() if req.provider == "api": - logger.info("Enhancing prompt via Gemini API") + resolved_model = resolve_gemini_model(self.state.app_settings.gemini_model) + logger.info("Enhancing prompt via Gemini API (%s)", resolved_model) api_key = self.state.app_settings.gemini_api_key if req.imagePath is not None: return self._gemini_pipeline.enhance_i2v( - req.prompt, req.imagePath, system_prompt=system_prompt, seed=seed, api_key=api_key + req.prompt, + req.imagePath, + system_prompt=system_prompt, + seed=seed, + api_key=api_key, + model=resolved_model, ) return self._gemini_pipeline.enhance_t2v( - req.prompt, system_prompt=system_prompt, seed=seed, api_key=api_key + req.prompt, + system_prompt=system_prompt, + seed=seed, + api_key=api_key, + model=resolved_model, ) logger.info("Enhancing prompt via local Gemma") @@ -245,12 +256,14 @@ def _run_template_fill( seed = self._random_seed() try: if req.provider == "api": - logger.info("Enhancing prompt via Gemini API") + resolved_model = resolve_gemini_model(self.state.app_settings.gemini_model) + logger.info("Enhancing prompt via Gemini API (%s)", resolved_model) raw = self._gemini_pipeline.enhance_t2v( req.prompt, system_prompt=system_prompt, seed=seed, api_key=self.state.app_settings.gemini_api_key, + model=resolved_model, ) else: logger.info("Enhancing prompt via local Gemma") diff --git a/backend/handlers/retake_handler.py b/backend/handlers/retake_handler.py index 9666beee2..57756ca36 100644 --- a/backend/handlers/retake_handler.py +++ b/backend/handlers/retake_handler.py @@ -32,6 +32,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 @@ -227,9 +228,11 @@ def _run_local_retake( 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)) @@ -239,7 +242,7 @@ def _run_local_retake( 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: diff --git a/backend/handlers/settings_handler.py b/backend/handlers/settings_handler.py index 927a851de..0d68f21d6 100644 --- a/backend/handlers/settings_handler.py +++ b/backend/handlers/settings_handler.py @@ -7,7 +7,8 @@ from threading import RLock from typing import TYPE_CHECKING -from api_types import LTXLocalModelId +from api_types import GeminiModelsResponsePayload, LTXLocalModelId +from _routes._errors import HTTPError from state.app_settings import AppSettings, UpdateSettingsRequest from handlers._settings_utils import ( collect_changed_paths, @@ -17,6 +18,13 @@ strip_none_values, ) from handlers.base import StateHandlerBase, with_state_lock +from services.gemini_text_client import ( + is_text_to_text_gemini_model, + list_gemini_generate_content_models, + normalize_gemini_model_id, + resolve_gemini_model, +) +from services.interfaces import HTTPClient from state.app_state_types import AppState if TYPE_CHECKING: @@ -26,8 +34,9 @@ class SettingsHandler(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 @with_state_lock def load_settings(self, default_settings: AppSettings) -> AppSettings: @@ -75,6 +84,13 @@ def update_settings(self, patch: UpdateSettingsRequest) -> tuple[AppSettings, Ap 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] + # Empty gemini_model is kept: it means "use DEFAULT_GEMINI_MODEL", not "leave unchanged". + # Image/audio/video generators cannot enhance a prompt — persist them as "use default" + # so a leftover Nano Banana setting cannot round-trip back into the picker. + if "gemini_model" in patch_payload: + model_id = normalize_gemini_model_id(str(patch_payload["gemini_model"])) + if model_id and not is_text_to_text_gemini_model(model_id): + patch_payload["gemini_model"] = "" # active_ltx_model_id is patchable here only because the patch model is auto-derived # from every AppSettings field — but it must go through set_active_ltx_model, which @@ -99,6 +115,18 @@ def update_settings(self, patch: UpdateSettingsRequest) -> tuple[AppSettings, Ap self.save_settings() return before, after, changed_paths + def list_gemini_models(self) -> GeminiModelsResponsePayload: + settings = self.get_settings_snapshot() + if not settings.gemini_api_key: + raise HTTPError(400, "GEMINI_API_KEY_MISSING") + resolved_model = resolve_gemini_model(settings.gemini_model) + models = list_gemini_generate_content_models( + self._http, + api_key=settings.gemini_api_key, + include_id=resolved_model, + ) + return GeminiModelsResponsePayload(models=models, resolvedModel=resolved_model) + def _trim_prompt_cache(self) -> None: te = self.state.text_encoder if te is None: diff --git a/backend/handlers/suggest_gap_prompt_handler.py b/backend/handlers/suggest_gap_prompt_handler.py index 91f12ca4a..d500c8cdc 100644 --- a/backend/handlers/suggest_gap_prompt_handler.py +++ b/backend/handlers/suggest_gap_prompt_handler.py @@ -14,7 +14,11 @@ from _routes._errors import HTTPError from handlers.base import StateHandlerBase from server_utils.media_validation import image_mime_type, normalize_optional_path, validate_image_file -from services.gemini_text_client import call_gemini_generate_content +from services.gemini_text_client import ( + apply_gemini_thinking_config, + call_gemini_generate_content, + resolve_gemini_model, +) from services.interfaces import HTTPClient, JSONValue from state.app_state_types import AppState @@ -122,21 +126,20 @@ def suggest_gap(self, req: SuggestGapPromptRequest) -> SuggestGapPromptResponse: user_parts.append({"inlineData": {"mimeType": mime_type, "data": data}}) contents: list[JSONValue] = [{"role": "user", "parts": user_parts}] - # thinkingBudget=0 disables 2.5-flash's default "thinking" pass, which would - # otherwise eat into maxOutputTokens (starving the actual suggestion) and add latency. - generation_config: dict[str, JSONValue] = { - "temperature": 0.7, - "maxOutputTokens": 512, - "thinkingConfig": {"thinkingBudget": 0}, - } try: + resolved_model = resolve_gemini_model(self.state.app_settings.gemini_model) + logger.info("Suggesting gap prompt via Gemini API (%s)", resolved_model) suggested_prompt = call_gemini_generate_content( self._http, api_key=gemini_api_key, + model=resolved_model, contents=contents, system_instruction=system_text, - generation_config=generation_config, + generation_config=apply_gemini_thinking_config( + resolved_model, + {"temperature": 0.7, "maxOutputTokens": 512}, + ), timeout=30, ) except HTTPError as exc: diff --git a/backend/handlers/video_generation_handler.py b/backend/handlers/video_generation_handler.py index 4f0711e75..ff8ac5b23 100644 --- a/backend/handlers/video_generation_handler.py +++ b/backend/handlers/video_generation_handler.py @@ -50,6 +50,7 @@ validate_audio_file, validate_image_file, ) +from services.generation_interrupt import GenerationCancelledError, is_cancel_exception from services.interfaces import LTXAPIClient from services.ltx_api_client.ltx_api_client import LTXAPIClientError from state.app_state_types import AppState @@ -232,6 +233,7 @@ def generate(self, req: GenerateVideoRequest) -> GenerateVideoResponse: ) try: + self._generation.raise_if_cancelled() self._pipelines.load_gpu_pipeline("fast", loras=loras) self._generation.start_generation(generation_id) @@ -257,7 +259,7 @@ def generate(self, req: GenerateVideoRequest) -> GenerateVideoResponse: raise except Exception as e: self._generation.fail_generation(str(e)) - if "cancelled" in str(e).lower(): + if is_cancel_exception(e): logger.info("Generation cancelled by user") return GenerateVideoCancelledResponse(status="cancelled") @@ -302,8 +304,7 @@ def generate_video( ) logger.info("[%s] Generation started (model=fast, %dx%d, %s, %d fps)", gen_mode, width, height, frames_log, int(fps)) - if self._generation.is_generation_cancelled(): - raise RuntimeError("Generation was cancelled") + self._generation.raise_if_cancelled() total_steps = 8 @@ -334,6 +335,7 @@ def generate_video( t_text_end = time.perf_counter() logger.info("[%s] Text encoding (%s): %.2fs", gen_mode, encoding_method, t_text_end - t_text_start) + self._generation.raise_if_cancelled() self._generation.update_progress("inference", 15, 0, total_steps) # Guard for the /64 two-stage grid. Half-way values round up: Python's round() is @@ -357,10 +359,12 @@ def generate_video( t_inference_end = time.perf_counter() logger.info("[%s] Inference: %.2fs", gen_mode, t_inference_end - t_inference_start) + # 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(): if output_path.exists(): output_path.unlink() - raise RuntimeError("Generation was cancelled") + raise GenerationCancelledError() t_total_end = time.perf_counter() logger.info("[%s] Total generation: %.2fs (load=%.2fs, text=%.2fs, inference=%.2fs)", @@ -422,6 +426,7 @@ def _generate_a2v( ) enhanced_prompt = a2v_base_prompt + self.config.camera_motion_prompts.get(req.cameraMotion, "") + self._generation.raise_if_cancelled() a2v_state = self._pipelines.load_a2v_pipeline(loras=loras) self._generation.start_generation(generation_id) @@ -432,6 +437,7 @@ def _generate_a2v( self._generation.update_progress("loading_model", 5, 0, total_steps) self._generation.update_progress("encoding_text", 10, 0, total_steps) self._text.prepare_text_encoding(enhanced_prompt, enhance_prompt=a2v_enhance) + self._generation.raise_if_cancelled() self._generation.update_progress("inference", 15, 0, total_steps) a2v_state.pipeline.generate( @@ -450,10 +456,12 @@ def _generate_a2v( output_path=str(output_path), ) + # 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(): if output_path.exists(): output_path.unlink() - raise RuntimeError("Generation was cancelled") + raise GenerationCancelledError() self._generation.update_progress("complete", 100, total_steps, total_steps) self._generation.complete_generation(str(output_path)) @@ -464,7 +472,7 @@ def _generate_a2v( raise except Exception as e: self._generation.fail_generation(str(e)) - if "cancelled" in str(e).lower(): + if is_cancel_exception(e): logger.info("Generation cancelled by user") return GenerateVideoCancelledResponse(status="cancelled") raise HTTPError(500, str(e)) from e @@ -542,8 +550,7 @@ def _generate_forced_api(self, req: GenerateVideoRequest) -> GenerateVideoRespon prompt = req.prompt - if self._generation.is_generation_cancelled(): - raise RuntimeError("Generation was cancelled") + self._generation.raise_if_cancelled() if has_input_audio: validated_audio_path = validate_audio_file(audio_path) @@ -616,13 +623,12 @@ def _generate_forced_api(self, req: GenerateVideoRequest) -> GenerateVideoRespon ) self._generation.update_progress("downloading_output", 85, None, None) - if self._generation.is_generation_cancelled(): - raise RuntimeError("Generation was cancelled") + self._generation.raise_if_cancelled() output_path = self._write_forced_api_video(video_bytes) 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, None, None) self._generation.complete_generation(str(output_path)) @@ -636,7 +642,7 @@ def _generate_forced_api(self, req: GenerateVideoRequest) -> GenerateVideoRespon raise mapped_error from e except Exception as e: self._generation.fail_generation(str(e)) - if "cancelled" in str(e).lower(): + if is_cancel_exception(e): logger.info("Generation cancelled by user") return GenerateVideoCancelledResponse(status="cancelled") raise HTTPError(500, str(e)) from e diff --git a/backend/ltx2_server.py b/backend/ltx2_server.py index 3db1031c0..65d532357 100644 --- a/backend/ltx2_server.py +++ b/backend/ltx2_server.py @@ -3,6 +3,11 @@ import os import sys +from server_utils.win_dll_search import remove_cwd_from_dll_search_path + +# Before torch / native extensions: do not search CWD for DLLs (Windows hijack). +remove_cwd_from_dll_search_path() + faulthandler.enable(file=sys.stderr, all_threads=True) from typing import Any, cast @@ -58,6 +63,8 @@ del _diffvae_decode_vram import services.patches.natten_libnatten_gate as _natten_libnatten_gate # pyright: ignore[reportUnusedImport] # Remove once ltx-core natten_available checks HAS_LIBNATTEN del _natten_libnatten_gate +import services.patches.diffusion_interrupt as _diffusion_interrupt # pyright: ignore[reportUnusedImport] # Remove once ltx-pipelines denoiser/loop accepts an interrupt callback +del _diffusion_interrupt from state.app_settings import AppSettings diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 258fb07b8..7833aae45 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ version = "1.0.0" description = "LTX-2 Video Generation Backend" requires-python = ">=3.12" dependencies = [ - "pillow>=10.3.0", + "pillow>=12.3.0", # macOS caps at <2.12: ltx-core imports torchaudio (audio VAE), torchaudio tops out at # 2.11.0 with empty torch metadata, so an uncapped torch resolves to 2.12.1 and the # torchaudio 2.11.0 ABI mismatch crashes `import torchaudio` on Apple Silicon. Pinning @@ -22,14 +22,15 @@ dependencies = [ # LTX-2 inference dependencies (v1.2.0 is the 2.5-capable release). "ltx-core==1.2.0", "ltx-pipelines==1.2.0", - "diffusers>=0.36.0", + "diffusers>=0.39.0", "ftfy>=6.0.0", "imageio>=2.37.2", "imageio-ffmpeg>=0.6.0", "peft>=0.13.2", "protobuf>=3.20.0", - # ltx-core 1.2 / Gemma 4 need transformers 5.8+; 5.15 breaks Gemma 4 config access. - "transformers>=5.8.0,<5.15", + # ltx-core 1.2 / Gemma 4 need transformers 5.8+; 5.15 makes Gemma 4 + # attention dims per-layer (AmbiguousGlobalPerLayerAttributeError). + "transformers>=5.14.1,<5.15", "sentencepiece>=0.1.99", "sageattention>=1.0.0; sys_platform != 'darwin'", # Official NATTEN wheels are Linux-only. GCS-hosted Windows wheel matches @@ -42,9 +43,11 @@ dependencies = [ # memory per attention call and OOMs longer generations. macOS-only. "ninja>=1.11; sys_platform == 'darwin'", "opencv-python-headless>=4.8.0", - "fastapi>=0.115.0", + "fastapi>=0.141.1", + # FastAPI 0.129 pinned starlette<1; 0.141+ allows the 1.x line. + "starlette>=1.6.0", "uvicorn[standard]>=0.30.0", - "python-multipart>=0.0.9", + "python-multipart>=0.0.32", "triton-windows; sys_platform == 'win32'", "triton; sys_platform == 'linux'", ] @@ -70,7 +73,6 @@ torchvision = [ # Linux keeps resolving sageattention from PyPI (no reported Blackwell crashes there yet). sageattention = { url = "https://github.com/woct0rdho/SageAttention/releases/download/v2.2.0-windows.post5/sageattention-2.2.0+cu128torch2.10.0andhigher.post5-cp310-abi3-win_amd64.whl", marker = "sys_platform == 'win32'" } natten = { url = "https://storage.googleapis.com/ltx-desktop-artifacts/wheels/natten-0.21.6+torch2100cu128-cp313-cp313-win_amd64.whl", marker = "sys_platform == 'win32'" } -diffusers = { git = "https://github.com/huggingface/diffusers.git", rev = "01de02e8b4f2cc91df4f3e91cb6535ebcbeb490c" } # Official LTX-2 v1.2.0 packages (not on PyPI). Same git+subdirectory pattern as # 2.3; the tag is the 2.5-capable release. We do not take ltx-core's own # tool.uv.sources (cu132) — this app's pytorch-cu128 pin above owns torch. @@ -101,7 +103,7 @@ version = "1.2.0" requires-dist = ["ltx-core", "av", "tqdm", "pillow", "openimageio", "cloudpickle>=3.1"] [project.optional-dependencies] -test = ["pytest>=8.0", "requests>=2.31", "httpx>=0.27"] +test = ["pytest>=8.0", "requests>=2.34.2", "httpx>=0.27"] dev = ["pyright>=1.1.380", "debugpy>=1.8"] [tool.pytest.ini_options] diff --git a/backend/runtime_config/ltx_capabilities.py b/backend/runtime_config/ltx_capabilities.py index beebbeae2..a33a58a91 100644 --- a/backend/runtime_config/ltx_capabilities.py +++ b/backend/runtime_config/ltx_capabilities.py @@ -42,23 +42,14 @@ class LtxOfferingCapabilities: resolution_pixels_16_9: dict[LTXVideoGenResolution, tuple[int, int]] -# Shared local Fast sizes except 540p, which is version-specific: 2.3 is 960×544 -# (off 16:9 on the /64 two-stage grid); 2.5 is 1024×576. -_LOCAL_720P_1080P: dict[LTXVideoGenResolution, tuple[int, int]] = { +# Local Fast sizes: one /64 two-stage grid for 2.3 and 2.5. Splitting 540p +# (2.3 960×544 vs 2.5 1024×576) made a model switch fail assert_resolution. +_LOCAL_PIXELS_16_9: dict[LTXVideoGenResolution, tuple[int, int]] = { + "540p": (1024, 576), "720p": (1280, 704), "1080p": (1920, 1088), } -_LOCAL_2_3_PIXELS_16_9: dict[LTXVideoGenResolution, tuple[int, int]] = { - "540p": (960, 544), - **_LOCAL_720P_1080P, -} - -_LOCAL_2_5_PIXELS_16_9: dict[LTXVideoGenResolution, tuple[int, int]] = { - "540p": (1024, 576), - **_LOCAL_720P_1080P, -} - _API_PIXELS_16_9: dict[LTXVideoGenResolution, tuple[int, int]] = { "1080p": (1920, 1080), "1440p": (2560, 1440), @@ -75,7 +66,7 @@ class LtxOfferingCapabilities: user_loras=True, camera_motion=True, auto_duration=False, - resolution_pixels_16_9=_LOCAL_2_3_PIXELS_16_9, + resolution_pixels_16_9=_LOCAL_PIXELS_16_9, ) # DistilledA2V is wired for local 2.5. Auto duration is DurationHead on the @@ -91,7 +82,7 @@ class LtxOfferingCapabilities: user_loras=True, camera_motion=True, auto_duration=True, - resolution_pixels_16_9=_LOCAL_2_5_PIXELS_16_9, + resolution_pixels_16_9=_LOCAL_PIXELS_16_9, ) # API rows follow ltxv-api handlers. camera_motion is a named LoRA on the tia2v diff --git a/backend/runtime_config/model_download_specs.py b/backend/runtime_config/model_download_specs.py index be514ec16..d6b268f43 100644 --- a/backend/runtime_config/model_download_specs.py +++ b/backend/runtime_config/model_download_specs.py @@ -191,6 +191,9 @@ def get_model_cp_spec(cp_id: ModelCheckpointID) -> ModelCheckpointSpec: # Superseded by 1.1, but kept as a known checkpoint so persisted settings / # in-flight sessions referencing it still validate, and an orphaned on-disk # copy can be listed/deleted rather than failing enum validation. + # Hugging Face removed 1.0 from LTX-2.3. DownloadHandler remaps this id to + # 1.1 so downloads land on the live path; this spec only describes the + # orphaned local file. return ModelCheckpointSpec( relative_path=Path("ltx-2.3-spatial-upscaler-x2-1.0.safetensors"), expected_size_bytes=995_743_504, diff --git a/backend/server_utils/win_dll_search.py b/backend/server_utils/win_dll_search.py new file mode 100644 index 000000000..1ef42070d --- /dev/null +++ b/backend/server_utils/win_dll_search.py @@ -0,0 +1,22 @@ +"""Remove the current working directory from the Windows DLL search path.""" + +from __future__ import annotations + +import sys + + +def remove_cwd_from_dll_search_path() -> None: + """Call ``SetDllDirectoryW("")`` so ``LoadLibrary`` does not search CWD. + + No-op off Windows. Must run before importing native extensions (torch, etc.). + """ + if sys.platform != "win32": + return + + import ctypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.SetDllDirectoryW.argtypes = [ctypes.c_wchar_p] + kernel32.SetDllDirectoryW.restype = ctypes.c_bool + if not kernel32.SetDllDirectoryW(""): + print(f"SetDllDirectoryW failed: {ctypes.get_last_error()}", file=sys.stderr) diff --git a/backend/services/a2v_pipeline/distilled_a2v_pipeline.py b/backend/services/a2v_pipeline/distilled_a2v_pipeline.py index fa8d4d702..0a1955f2c 100644 --- a/backend/services/a2v_pipeline/distilled_a2v_pipeline.py +++ b/backend/services/a2v_pipeline/distilled_a2v_pipeline.py @@ -8,7 +8,7 @@ from __future__ import annotations from collections.abc import Iterator, Sequence -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, Protocol, cast import torch @@ -17,9 +17,25 @@ if TYPE_CHECKING: from ltx_core.loader.primitives import LoraPathStrengthAndSDOps + from ltx_pipelines.utils.args import ImageConditioningInput as LtxImageInput from ltx_pipelines.utils.model_paths import ModelPaths +class _ImageCrfResolver(Protocol): + def resolve_crf(self, images: Sequence[LtxImageInput]) -> list[LtxImageInput]: ... + + +def resolve_image_conditionings( + images: Sequence[tuple[str, int, float]], + image_conditioner: _ImageCrfResolver, +) -> list[LtxImageInput]: + """Build LTX image inputs and fill checkpoint CRF via ImageConditioner.resolve_crf.""" + from ltx_pipelines.utils.args import ImageConditioningInput as LtxImageInput + + ltx_images = [LtxImageInput(path, frame_idx, strength) for path, frame_idx, strength in images] + return image_conditioner.resolve_crf(ltx_images) + + class DistilledA2VPipeline: """Two-stage distilled audio-to-video pipeline. @@ -96,7 +112,6 @@ def __call__( from ltx_core.components.noisers import GaussianNoiser from ltx_core.model.audio_vae import encode_audio as vae_encode_audio from ltx_core.types import Audio, AudioLatentShape - from ltx_pipelines.utils.args import ImageConditioningInput as LtxImageInput from ltx_pipelines.utils.constants import DISTILLED_SIGMA_VALUES, STAGE_2_DISTILLED_SIGMA_VALUES from ltx_pipelines.utils.denoisers import SimpleDenoiser from ltx_pipelines.utils.helpers import ( @@ -108,7 +123,7 @@ def __call__( assert_resolution(height=height, width=width, is_two_stage=True) - ltx_images = [LtxImageInput(path, frame_idx, strength) for path, frame_idx, strength in images] + ltx_images = resolve_image_conditionings(images, self.image_conditioner) generator = torch.Generator(device=self.device).manual_seed(seed) noiser = GaussianNoiser(generator=generator) dtype = torch.bfloat16 diff --git a/backend/services/gemini_text_client.py b/backend/services/gemini_text_client.py index 1cade3601..af132f0c8 100644 --- a/backend/services/gemini_text_client.py +++ b/backend/services/gemini_text_client.py @@ -2,19 +2,79 @@ from __future__ import annotations +import time +from dataclasses import dataclass +from threading import Lock from typing import cast +from urllib.parse import urlencode from _routes._errors import HTTPError -from pydantic import BaseModel, Field, ValidationError +from api_types import GeminiModelOptionPayload +from pydantic import BaseModel, ConfigDict, Field, ValidationError from services.interfaces import HTTPClient, HttpTransportError, JSONValue -GEMINI_GENERATE_CONTENT_URL = ( - "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent" +DEFAULT_GEMINI_MODEL = "gemini-2.5-flash-lite" +GEMINI_API_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" +_MODELS_PREFIX = "models/" +_GEMINI_LIST_PAGE_SIZE = 1000 +_GEMINI_LIST_MAX_PAGES = 5 +_GEMINI_MODELS_CACHE_TTL_S = 3600.0 + + +@dataclass(frozen=True, slots=True) +class _GeminiModelsListCache: + api_key: str + models: tuple[GeminiModelOptionPayload, ...] + expires_at: float + + +_gemini_models_list_cache: _GeminiModelsListCache | None = None +_gemini_models_list_cache_lock = Lock() + +# models.list returns every generateContent endpoint, including branded aliases +# (nano-banana-pro, lyria-*, deep-research-*) whose descriptions are often empty. +# Enhance / gap suggestions are Gemini-API *chat* models (text-out, optional image input). +# Hosted Gemma shares generateContent but returns empty text on this system prompt; +# local Enhance already runs Gemma on-device. +_TEXT_MODEL_ID_PREFIXES = ("gemini-",) +_NON_TEXT_OUTPUT_ID_MARKERS = ( + "embedding", + "imagen", + "veo", + "tts", + "image-generation", + "-image", + "native-audio", + "-audio-", + "live", + "omni", + "robotics", + "computer-use", +) +_NON_TEXT_OUTPUT_LABEL_MARKERS = ( + "image generation", + "generate images", + "generates images", + "image editing", + "nano banana", + "text-to-speech", + "text to speech", + "speech generation", + "video generation", + "generate video", + "generates video", + "audio generation", + "music generation", + "lyria", + "deep research", + "antigravity", ) class _GeminiPart(BaseModel): - text: str + model_config = ConfigDict(extra="ignore") + text: str = "" + thought: bool = False class _GeminiContent(BaseModel): @@ -29,6 +89,24 @@ class _GeminiResponsePayload(BaseModel): candidates: list[_GeminiCandidate] = Field(min_length=1) +class _GeminiListedModelPayload(BaseModel): + model_config = ConfigDict(extra="ignore") + name: str = "" + displayName: str = "" + description: str = "" + supportedGenerationMethods: list[str] = [] + # Not in the published v1beta Model schema (or google.genai.types.Model). Parsed + # if Google starts sending them; list filtering does not depend on them. + supportedInputModalities: list[str] = [] + supportedOutputModalities: list[str] = [] + + +class _GeminiListModelsResponsePayload(BaseModel): + model_config = ConfigDict(extra="ignore") + models: list[_GeminiListedModelPayload] = [] + nextPageToken: str | None = None + + # Gemini returns these as HTTP 200 with no usable `content` — either the prompt was rejected # before any candidate was generated (`promptFeedback.blockReason`, empty `candidates`) or a # candidate was generated then withheld (`candidates[0].finishReason`, no `content` key at all). @@ -68,13 +146,251 @@ def extract_gemini_text(payload: object) -> str: parsed = _GeminiResponsePayload.model_validate(payload) except ValidationError: raise HTTPError(500, "GEMINI_PARSE_ERROR") - return parsed.candidates[0].content.parts[0].text + # Skip thought parts — Enhance needs the rewritten prompt, not the reasoning trace. + text_parts = [part.text for part in parsed.candidates[0].content.parts if not part.thought] + return "".join(text_parts) + + +def normalize_gemini_model_id(model: str) -> str: + stripped = model.strip() + if stripped.startswith(_MODELS_PREFIX): + stripped = stripped[len(_MODELS_PREFIX) :] + return stripped + + +def resolve_gemini_model(stored: str) -> str: + """Empty/missing stored value means the out-of-the-box default. + + A previously persisted image/audio/video generator (Nano Banana, Omni, …) is + treated the same as empty — Enhance and gap suggestions are text-out only. + """ + model_id = normalize_gemini_model_id(stored) or DEFAULT_GEMINI_MODEL + if model_id != DEFAULT_GEMINI_MODEL and not is_text_to_text_gemini_model(model_id): + return DEFAULT_GEMINI_MODEL + return model_id + + +def _gemini_model_sort_key(model_id: str) -> tuple[int, str]: + lowered = model_id.lower() + flash_or_lite = 0 if ("flash" in lowered or "lite" in lowered) else 1 + return (flash_or_lite, model_id) + + +def _gemini_auth_headers(api_key: str) -> dict[str, str]: + return {"x-goog-api-key": api_key} + + +def gemini_generate_content_url(model: str) -> str: + model_id = resolve_gemini_model(model) + return f"{GEMINI_API_BASE_URL}/models/{model_id}:generateContent" + + +def _list_models_url(*, page_token: str | None = None) -> str: + params: dict[str, str] = {"pageSize": str(_GEMINI_LIST_PAGE_SIZE)} + if page_token: + params["pageToken"] = page_token + return f"{GEMINI_API_BASE_URL}/models?{urlencode(params)}" + + +def _normalized_modalities(values: list[str]) -> list[str]: + return [value.strip().upper() for value in values if value.strip()] + + +def is_text_to_text_gemini_model( + model_id: str, + description: str = "", + display_name: str = "", + input_modalities: list[str] | None = None, + output_modalities: list[str] | None = None, +) -> bool: + """True for Gemini-API chat models that emit text (optional image *input*). + + Hosted Gemma is excluded: generateContent comes back empty on Enhance's system + prompt. Local Enhance already uses Gemma on-device. + + If supported_*_modalities are present, output must be text-only. Input may + include IMAGE (Enhance i2v / gap frames). Requiring input==[TEXT] would drop + Flash. + """ + lowered_id = normalize_gemini_model_id(model_id).lower() + if lowered_id.startswith("gemma-"): + return False + outputs = _normalized_modalities(output_modalities or []) + inputs = _normalized_modalities(input_modalities or []) + if outputs: + if outputs != ["TEXT"]: + return False + return not inputs or "TEXT" in inputs + + if not lowered_id.startswith(_TEXT_MODEL_ID_PREFIXES): + return False + if any(marker in lowered_id for marker in _NON_TEXT_OUTPUT_ID_MARKERS): + return False + label = f"{display_name} {description}".lower() + return not any(marker in label for marker in _NON_TEXT_OUTPUT_LABEL_MARKERS) + + +def _sorted_gemini_model_options( + models_by_id: dict[str, GeminiModelOptionPayload], +) -> list[GeminiModelOptionPayload]: + return [models_by_id[model_id] for model_id in sorted(models_by_id, key=_gemini_model_sort_key)] + + +def clear_gemini_models_cache() -> None: + global _gemini_models_list_cache + with _gemini_models_list_cache_lock: + _gemini_models_list_cache = None + + +def _cached_gemini_models(api_key: str) -> list[GeminiModelOptionPayload] | None: + with _gemini_models_list_cache_lock: + entry = _gemini_models_list_cache + if entry is None or entry.api_key != api_key or entry.expires_at <= time.monotonic(): + return None + return [model.model_copy() for model in entry.models] + + +def _store_gemini_models_cache(api_key: str, models: list[GeminiModelOptionPayload]) -> None: + global _gemini_models_list_cache + with _gemini_models_list_cache_lock: + _gemini_models_list_cache = _GeminiModelsListCache( + api_key=api_key, + models=tuple(model.model_copy() for model in models), + expires_at=time.monotonic() + _GEMINI_MODELS_CACHE_TTL_S, + ) + + +def _with_included_model( + models: list[GeminiModelOptionPayload], + include_id: str | None, +) -> list[GeminiModelOptionPayload]: + models_by_id = {model.id: model for model in models} + included = normalize_gemini_model_id(include_id) if include_id else "" + # Don't re-inject a stored image/audio/video generator the filter just dropped — + # otherwise a leftover Nano Banana setting stays selectable and callable. + if included and included not in models_by_id and is_text_to_text_gemini_model(included): + models_by_id[included] = GeminiModelOptionPayload( + id=included, + displayName=included, + description="", + ) + return _sorted_gemini_model_options(models_by_id) + + +def list_gemini_generate_content_models( + http: HTTPClient, + *, + api_key: str, + include_id: str | None = None, +) -> list[GeminiModelOptionPayload]: + """Page Gemini's models.list, keeping text-output generateContent models. + + `include_id` is appended when the resolved setting is not in the upstream list so a + dropdown bound to that id is never blank. Successful lists are cached per API key for + an hour — Settings reopens should not hit Google again. + """ + cached = _cached_gemini_models(api_key) + if cached is not None: + return _with_included_model(cached, include_id) + + models_by_id: dict[str, GeminiModelOptionPayload] = {} + page_token: str | None = None + for _ in range(_GEMINI_LIST_MAX_PAGES): + try: + response = http.get( + _list_models_url(page_token=page_token), + headers=_gemini_auth_headers(api_key), + timeout=30, + ) + except HttpTransportError as exc: + raise HTTPError(504, "Gemini API request timed out") from exc + + if response.status_code != 200: + raise HTTPError(response.status_code, f"Gemini API error: {response.text}") + + try: + parsed = _GeminiListModelsResponsePayload.model_validate(response.json()) + except ValidationError: + raise HTTPError(500, "GEMINI_PARSE_ERROR") + + for item in parsed.models: + if "generateContent" not in item.supportedGenerationMethods: + continue + model_id = normalize_gemini_model_id(item.name) + description = item.description.strip() + display_name = item.displayName.strip() + if ( + not model_id + or model_id in models_by_id + or not is_text_to_text_gemini_model( + model_id, + description, + display_name=display_name, + input_modalities=item.supportedInputModalities, + output_modalities=item.supportedOutputModalities, + ) + ): + continue + models_by_id[model_id] = GeminiModelOptionPayload( + id=model_id, + displayName=display_name or model_id, + description=description, + ) + + page_token = (parsed.nextPageToken or "").strip() or None + if not page_token: + break + + fetched = _sorted_gemini_model_options(models_by_id) + _store_gemini_models_cache(api_key, fetched) + return _with_included_model(fetched, include_id) + + +# Thinking tokens count against maxOutputTokens. 512 is only enough when thinking is off; +# otherwise the rewrite is truncated (or missing parts entirely). +_THINKING_MODEL_MAX_OUTPUT_TOKENS = 2048 +_2_5_PRO_MIN_THINKING_BUDGET = 128 + + +def gemini_thinking_config_for_model(model: str) -> dict[str, JSONValue] | None: + """Per-model thinking so Enhance gets a full rewritten prompt. + + 2.5 Flash/Lite: thinkingBudget 0 (otherwise thinking eats the output budget). + 2.5 Pro: thinking cannot be 0; use the minimum allowed budget. + Gemini 3: thinkingLevel LOW (MINIMAL 400s on some Flash/Pro variants). + """ + lowered = normalize_gemini_model_id(model).lower() + if lowered.startswith("gemini-3"): + return {"thinkingLevel": "LOW"} + if "2.5" not in lowered: + return None + if "flash" in lowered or "lite" in lowered: + return {"thinkingBudget": 0} + if "pro" in lowered: + return {"thinkingBudget": _2_5_PRO_MIN_THINKING_BUDGET} + return None + + +def apply_gemini_thinking_config( + model: str, generation_config: dict[str, JSONValue] +) -> dict[str, JSONValue]: + thinking_config = gemini_thinking_config_for_model(model) + if thinking_config is None: + return generation_config + config: dict[str, JSONValue] = {**generation_config, "thinkingConfig": thinking_config} + if thinking_config.get("thinkingBudget") == 0: + return config + current = config.get("maxOutputTokens") + if isinstance(current, int) and current < _THINKING_MODEL_MAX_OUTPUT_TOKENS: + config["maxOutputTokens"] = _THINKING_MODEL_MAX_OUTPUT_TOKENS + return config def call_gemini_generate_content( http: HTTPClient, *, api_key: str, + model: str, contents: list[JSONValue], system_instruction: str | None = None, generation_config: dict[str, JSONValue] | None = None, @@ -89,8 +405,8 @@ def call_gemini_generate_content( try: response = http.post( - GEMINI_GENERATE_CONTENT_URL, - headers={"Content-Type": "application/json", "x-goog-api-key": api_key}, + gemini_generate_content_url(model), + headers={"Content-Type": "application/json", **_gemini_auth_headers(api_key)}, json_payload=payload, timeout=timeout, ) diff --git a/backend/services/generation_interrupt.py b/backend/services/generation_interrupt.py new file mode 100644 index 000000000..4a1e47c60 --- /dev/null +++ b/backend/services/generation_interrupt.py @@ -0,0 +1,99 @@ +"""Process-wide cooperative cancel for in-flight generation. + +The denoise thread must not take AppState's RLock. Cancel HTTP sets this Event; +the inference loop (and Diffusers step callback) polls it between steps. + +On MPS, ``Event.wait`` is used as a GIL yield so ``POST /api/generate/cancel`` +and ``GET /health`` can run between steps. CUDA kernels already release the GIL, +so the cancel check is a non-blocking ``is_set``. +""" + +from __future__ import annotations + +import threading +from collections.abc import Callable +from typing import Any, TypeVar + +_cancel = threading.Event() +_MPS_YIELD_S = 0.001 +_yield_timeout_s: float | None = None + +_D = TypeVar("_D") + + +class GenerationCancelledError(RuntimeError): + def __init__(self, message: str = "Generation was cancelled") -> None: + super().__init__(message) + + +def request() -> None: + _cancel.set() + + +def clear() -> None: + _cancel.clear() + + +def is_requested() -> bool: + return _cancel.is_set() + + +def raise_if_requested() -> None: + if _cancel.is_set(): + raise GenerationCancelledError() + + +def _gil_yield_timeout_s() -> float: + global _yield_timeout_s + if _yield_timeout_s is None: + timeout = 0.0 + try: + import torch + + if torch.backends.mps.is_available(): + timeout = _MPS_YIELD_S + except Exception: + timeout = 0.0 + _yield_timeout_s = timeout + return _yield_timeout_s + + +def yield_and_check() -> None: + """Abort if cancel was requested. On MPS, yield the GIL first so /health can run.""" + timeout = _gil_yield_timeout_s() + if timeout <= 0: + raise_if_requested() + return + if _cancel.wait(timeout): + raise GenerationCancelledError() + + +def wrap_denoiser(denoiser: Callable[..., _D]) -> Callable[..., _D]: + def wrapped(*args: Any, **kwargs: Any) -> _D: + yield_and_check() + return denoiser(*args, **kwargs) + + return wrapped + + +def is_cancel_exception(exc: BaseException) -> bool: + """True only for GenerationCancelledError, including when wrapped as __cause__/__context__.""" + seen: set[int] = set() + current: BaseException | None = exc + while current is not None and id(current) not in seen: + if isinstance(current, GenerationCancelledError): + return True + seen.add(id(current)) + current = current.__cause__ or current.__context__ + return False + + +def diffusers_step_callback( + pipeline: object, + step_index: int, + timestep: object, + callback_kwargs: dict[str, Any], +) -> dict[str, Any]: + del pipeline, step_index, timestep + yield_and_check() + return callback_kwargs diff --git a/backend/services/image_generation_pipeline/zit_image_generation_pipeline.py b/backend/services/image_generation_pipeline/zit_image_generation_pipeline.py index 858ed5962..c8676200d 100644 --- a/backend/services/image_generation_pipeline/zit_image_generation_pipeline.py +++ b/backend/services/image_generation_pipeline/zit_image_generation_pipeline.py @@ -10,6 +10,7 @@ from diffusers.pipelines.auto_pipeline import ZImagePipeline # type: ignore[reportUnknownVariableType] from PIL.Image import Image as PILImage +from services.generation_interrupt import diffusers_step_callback from services.services_utils import ( ImagePipelineOutputLike, PILImageType, @@ -94,6 +95,7 @@ def generate( generator=generator, output_type="pil", return_dict=True, + callback_on_step_end=diffusers_step_callback, ) return self._normalize_output(output) @@ -130,6 +132,7 @@ def edit( generator=generator, output_type="pil", return_dict=True, + callback_on_step_end=diffusers_step_callback, ) return self._normalize_output(output) diff --git a/backend/services/patches/diffusion_interrupt.py b/backend/services/patches/diffusion_interrupt.py new file mode 100644 index 000000000..66cdff750 --- /dev/null +++ b/backend/services/patches/diffusion_interrupt.py @@ -0,0 +1,56 @@ +"""Monkey-patch: abort denoising between transformer forwards. + +``ltx_pipelines`` sampler loops have no interrupt callback. Every local video +path (distilled t2v/i2v, A2V, retake, extend, IC-LoRA) goes through +``DiffusionStage.__call__`` → ``loop(..., denoiser=...)``. Wrapping that +denoiser checks cancel at every denoise call (res2s calls it twice per step). + +Raising from the denoiser unwinds ``DiffusionStage.__call__``'s transformer +context, so stage 2 / VAE / ffmpeg never run and ``diffusion_stage_cache`` +``_in_use`` drops. GPU weights stay loaded. + +Remove once ltx-pipelines denoiser/loop accepts an interrupt callback. + +Usage: + import services.patches.diffusion_interrupt # noqa: F401 +""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable +from typing import Any, cast + +from ltx_pipelines.utils.blocks import DiffusionStage + +from services.generation_interrupt import wrap_denoiser + +assert callable(getattr(DiffusionStage, "__call__", None)), ( + "ltx_pipelines.utils.blocks.DiffusionStage.__call__ missing — re-verify this patch on rev bump" +) +_original_call = DiffusionStage.__call__ +assert "denoiser" in inspect.signature(_original_call).parameters, ( + "DiffusionStage.__call__ has no 'denoiser' parameter — re-verify this patch on rev bump" +) +_signature_fn: object | None = None +_cached_signature: inspect.Signature | None = None + + +def _original_signature() -> inspect.Signature: + global _signature_fn, _cached_signature + fn = _original_call + if _signature_fn is not fn or _cached_signature is None: + _cached_signature = inspect.signature(fn) + _signature_fn = fn + return _cached_signature + + +def _call_with_interrupt(self: DiffusionStage, *args: Any, **kwargs: Any) -> Any: + bound = _original_signature().bind(self, *args, **kwargs) + bound.apply_defaults() + original_denoiser = cast(Callable[..., Any], bound.arguments["denoiser"]) + bound.arguments["denoiser"] = wrap_denoiser(original_denoiser) + return _original_call(*bound.args, **bound.kwargs) + + +DiffusionStage.__call__ = _call_with_interrupt # type: ignore[method-assign] diff --git a/backend/services/prompt_enhancer_pipeline/gemini_prompt_enhancer_pipeline.py b/backend/services/prompt_enhancer_pipeline/gemini_prompt_enhancer_pipeline.py index 6d4d19c54..c58f2994b 100644 --- a/backend/services/prompt_enhancer_pipeline/gemini_prompt_enhancer_pipeline.py +++ b/backend/services/prompt_enhancer_pipeline/gemini_prompt_enhancer_pipeline.py @@ -8,7 +8,7 @@ from PIL import Image from _routes._errors import HTTPError -from services.gemini_text_client import call_gemini_generate_content +from services.gemini_text_client import apply_gemini_thinking_config, call_gemini_generate_content from services.interfaces import HTTPClient, JSONValue from services.prompt_enhancement import build_default_free_rewrite_system_prompt @@ -29,12 +29,12 @@ class GeminiPromptEnhancerPipeline: def __init__(self, http: HTTPClient) -> None: self._http = http - def enhance_t2v(self, prompt: str, system_prompt: str | None, seed: int, *, api_key: str) -> str: + def enhance_t2v(self, prompt: str, system_prompt: str | None, seed: int, *, api_key: str, model: str) -> str: contents: list[JSONValue] = [{"role": "user", "parts": [{"text": prompt}]}] - return self._call(contents, system_prompt, seed, api_key) + return self._call(contents, system_prompt, seed, api_key, model) def enhance_i2v( - self, prompt: str, image_path: str, system_prompt: str | None, seed: int, *, api_key: str + self, prompt: str, image_path: str, system_prompt: str | None, seed: int, *, api_key: str, model: str ) -> str: # Our own validate_image_file() allows more (GIF/BMP/TIFF, up to 50MB) than Gemini's # inlineData accepts — without this, those pass our gate and only fail once they bounce @@ -63,10 +63,10 @@ def enhance_i2v( {"inlineData": {"mimeType": f"image/{fmt.lower()}", "data": image_data}}, ] contents: list[JSONValue] = [{"role": "user", "parts": parts}] - return self._call(contents, system_prompt, seed, api_key) + return self._call(contents, system_prompt, seed, api_key, model) def _call( - self, contents: list[JSONValue], system_prompt: str | None, seed: int, api_key: str + self, contents: list[JSONValue], system_prompt: str | None, seed: int, api_key: str, model: str ) -> str: # Unlike the local Gemma pipeline, Gemini has no implicit default system prompt of its # own — omitting systemInstruction entirely gets a chatty, markdown-formatted essay @@ -75,18 +75,14 @@ def _call( return call_gemini_generate_content( self._http, api_key=api_key, + model=model, contents=contents, system_instruction=resolved_system_prompt, - # thinkingBudget=0: same fix as suggest_gap_prompt_handler.py's own generateContent - # call — without it, 2.5-flash's default "thinking" pass can eat the whole - # maxOutputTokens budget, finishing with no content parts at all (surfaced as an - # opaque parse error) instead of the rewritten prompt. maxOutputTokens matches the - # local Gemma pipeline's own default (`_generate`'s `max_new_tokens=512`) — already - # the proven ceiling for this exact feature's output. - generation_config={ - "seed": seed, - "maxOutputTokens": 512, - "thinkingConfig": {"thinkingBudget": 0}, - }, + # maxOutputTokens 512 matches local Gemma when thinking is off. Thinking models + # get a higher cap in apply_gemini_thinking_config so the rewrite is not truncated. + generation_config=apply_gemini_thinking_config( + model, + {"seed": seed, "maxOutputTokens": 512}, + ), timeout=30, ) diff --git a/backend/services/text_encoder/ltx_text_encoder.py b/backend/services/text_encoder/ltx_text_encoder.py index 1c1452ed4..eb4f24a74 100644 --- a/backend/services/text_encoder/ltx_text_encoder.py +++ b/backend/services/text_encoder/ltx_text_encoder.py @@ -6,8 +6,8 @@ import logging import pickle import time -from collections.abc import Callable -from typing import TYPE_CHECKING, Any +from collections.abc import Callable, Sequence +from typing import TYPE_CHECKING, Any, TypeGuard import torch @@ -19,6 +19,19 @@ logger = logging.getLogger(__name__) +# Exact GLOBAL/STACK_GLOBAL symbols in a torch tensor pickle (protocols 2/4/5), +# including CUDA tensors the LTX API emits. Anything else — including other +# torch.* callables — is refused so a MITM'd /v1/prompt-embedding body cannot +# name os.system, torch.jit, etc. +_ALLOWED_PICKLE_GLOBALS: frozenset[tuple[str, str]] = frozenset( + { + ("torch._utils", "_rebuild_tensor_v2"), + ("torch.storage", "_load_from_bytes"), + ("collections", "OrderedDict"), + ("_codecs", "encode"), + } +) + class _CpuUnpickler(pickle.Unpickler): """Unpickler that maps torch storages to CPU on load. @@ -31,20 +44,32 @@ class _CpuUnpickler(pickle.Unpickler): """ def find_class(self, module: str, name: str) -> Any: + if (module, name) not in _ALLOWED_PICKLE_GLOBALS: + raise pickle.UnpicklingError(f"disallowed pickle global: {module}.{name}") if module == "torch.storage" and name == "_load_from_bytes": def _load_from_bytes_cpu(b: bytes) -> Any: - return torch.load(io.BytesIO(b), map_location="cpu") + return torch.load(io.BytesIO(b), map_location="cpu", weights_only=True) return _load_from_bytes_cpu - # Restrict deserialization to torch's rebuild helpers + stdlib containers. The - # payload comes from a network response; without this, a compromised / MITM'd - # response could name any importable callable (os.system, builtins.eval, …) and - # turn unpickling into RCE. A module-level allowlist blocks that while still - # admitting whatever torch rebuild symbol the conditioning stream actually uses. - if module.split(".", 1)[0] == "torch" or module == "collections": - return super().find_class(module, name) - raise pickle.UnpicklingError(f"disallowed pickle global: {module}.{name}") + return super().find_class(module, name) + + +def _is_sequence(value: object) -> TypeGuard[Sequence[object]]: + return isinstance(value, (list, tuple)) + + +def _first_embedding_tensor(conditioning: object) -> torch.Tensor: + """Pull ``conditioning[0][0]`` only if the nest is sequences of a tensor.""" + if not _is_sequence(conditioning) or len(conditioning) == 0: + raise pickle.UnpicklingError("unexpected conditioning container") + first = conditioning[0] + if not _is_sequence(first) or len(first) == 0: + raise pickle.UnpicklingError("unexpected conditioning row") + embeddings = first[0] + if not isinstance(embeddings, torch.Tensor): + raise pickle.UnpicklingError("conditioning is not a tensor") + return embeddings class LTXTextEncoder: @@ -250,11 +275,7 @@ def encode_via_api( # Map CUDA storages to CPU during unpickling so this works on non-CUDA hosts # (Apple Silicon / CPU-only); the tensors are moved to self.device below. conditioning = _CpuUnpickler(io.BytesIO(response.content)).load() # noqa: S301 - if not conditioning or len(conditioning) == 0: - logger.warning("LTX API returned unexpected conditioning format") - return None - - embeddings = conditioning[0][0] + embeddings = _first_embedding_tensor(conditioning) video_dim = 4096 if embeddings.shape[-1] > video_dim: video_context = embeddings[..., :video_dim].contiguous().to(dtype=torch.bfloat16, device=self.device) diff --git a/backend/state/app_settings.py b/backend/state/app_settings.py index bda0a84f9..ad56126a0 100644 --- a/backend/state/app_settings.py +++ b/backend/state/app_settings.py @@ -65,6 +65,9 @@ class AppSettings(SettingsBaseModel): # fallback when the preferred provider is temporarily unavailable) ever sets this. prompt_enhancer_provider_preference: Literal["local", "api"] | None = None gemini_api_key: str = "" + # Empty string means "use DEFAULT_GEMINI_MODEL at generate time" — unlike API keys, an + # empty patch is persisted so the user can reset to the default without a tombstone value. + gemini_model: str = "" seed_locked: bool = False locked_seed: int = 42 models_dir: str = "" @@ -140,6 +143,7 @@ class SettingsResponse(SettingsBaseModel): prompt_enhancer_enabled_i2v: bool = False prompt_enhancer_provider_preference: Literal["local", "api"] | None = None has_gemini_api_key: bool = False + gemini_model: str = "" seed_locked: bool = False locked_seed: int = 42 models_dir: str = "" diff --git a/backend/state/app_state_types.py b/backend/state/app_state_types.py index 8cbe7917b..e2d23bd92 100644 --- a/backend/state/app_state_types.py +++ b/backend/state/app_state_types.py @@ -305,3 +305,9 @@ class AppState: # that raises on some validation path before ever reaching start_generation()/ # fail_generation() (both of which clear it) must not block every future generation forever. generation_starting_since: float | None = None + # True for the whole reserved_generation_start() body, including after start_generation() + # clears generation_starting_since and after cancel flips GenerationRunning → Cancelled. + # Without this, Stop during text-encoder/transformer build frees the slot while + # pipeline.generate() is still on the GPU; the next Start double-loads weights + # (meta vs cuda:0). + generation_in_flight: bool = False diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 6258d25a4..c5b539223 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -22,6 +22,7 @@ from state import RuntimeConfig, build_initial_state, set_state_service_for_tests from state.app_settings import AppSettings from state.app_state_types import HfAuthenticated +from services.gemini_text_client import clear_gemini_models_cache from tests.fake_camera_motion_prompts import FAKE_CAMERA_MOTION_PROMPTS from tests.fakes.services import FakeServices @@ -33,6 +34,15 @@ DEFAULT_APP_SETTINGS = AppSettings() +@pytest.fixture(autouse=True) +def _reset_generation_interrupt() -> None: + from services.generation_interrupt import clear + + clear() + yield + clear() + + @pytest.fixture def fake_services() -> FakeServices: return FakeServices() @@ -41,6 +51,7 @@ def fake_services() -> FakeServices: @pytest.fixture(autouse=True) def test_state(tmp_path: Path, fake_services: FakeServices): """Provide a fresh AppHandler per test and register it in DI.""" + clear_gemini_models_cache() app_data = tmp_path / "app_data" default_models_dir = app_data / "models" outputs_dir = tmp_path / "outputs" diff --git a/backend/tests/fakes/services.py b/backend/tests/fakes/services.py index 7b85e01bf..6e82592d1 100644 --- a/backend/tests/fakes/services.py +++ b/backend/tests/fakes/services.py @@ -6,6 +6,8 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, ClassVar +import threading +import time from PIL import Image from frame_math import AutoDurationSpec @@ -644,6 +646,10 @@ def __init__(self) -> None: self.compile_calls = 0 self.create_loras: list[list[tuple[str, float]]] = [] self.raise_on_generate: Exception | None = None + self.inference_steps = 0 + self.step_delay_s = 0.0 + self.steps_completed = 0 + self.entered_inference = threading.Event() def _record_generate(self, payload: dict[str, Any]) -> None: self.generate_calls.append(payload) @@ -713,18 +719,26 @@ def generate( images: list[ImageConditioningInput], output_path: str, ) -> None: - self._record_generate( - { - "prompt": prompt, - "seed": seed, - "height": height, - "width": width, - "num_frames": num_frames, - "frame_rate": frame_rate, - "images": images, - "output_path": output_path, - } - ) + payload = { + "prompt": prompt, + "seed": seed, + "height": height, + "width": width, + "num_frames": num_frames, + "frame_rate": frame_rate, + "images": images, + "output_path": output_path, + } + if self.inference_steps: + from services.generation_interrupt import raise_if_requested + + self.entered_inference.set() + for _ in range(self.inference_steps): + raise_if_requested() + self.steps_completed += 1 + if self.step_delay_s: + time.sleep(self.step_delay_s) + self._record_generate(payload) class FakeZitOutput: @@ -760,9 +774,26 @@ def __init__(self) -> None: self.edit_calls: list[dict[str, Any]] = [] self.raise_on_edit: Exception | None = None self.fail_edit_after: int | None = None # raise once len(edit_calls) exceeds this + self.inference_steps = 0 + self.step_delay_s = 0.0 + self.steps_completed = 0 + self.entered_inference = threading.Event() def generate(self, **kwargs: Any) -> FakeZitOutput: self.generate_calls.append(kwargs) + callback = kwargs.get("callback_on_step_end") + if self.inference_steps: + from services.generation_interrupt import raise_if_requested + + self.entered_inference.set() + for step_idx in range(self.inference_steps): + if callback is not None: + callback(self, step_idx, None, {}) + else: + raise_if_requested() + self.steps_completed += 1 + if self.step_delay_s: + time.sleep(self.step_delay_s) if self.raise_on_generate is not None: if self.fail_generate_after is None or len(self.generate_calls) > self.fail_generate_after: raise self.raise_on_generate diff --git a/backend/tests/test_api_calls.py b/backend/tests/test_api_calls.py index b2b23483f..154351072 100644 --- a/backend/tests/test_api_calls.py +++ b/backend/tests/test_api_calls.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import uuid from services.interfaces import HttpTransportError @@ -45,6 +46,42 @@ def test_happy_path_with_prompts(self, client, test_state): data = r.json() assert data["status"] == "success" assert data["suggested_prompt"] == "A smooth transition scene" + assert "models/gemini-2.5-flash-lite:generateContent" in test_state.http.calls[-1].url + + def test_uses_configured_gemini_model(self, client, test_state, caplog): + test_state.state.app_settings.gemini_api_key = "key" + test_state.state.app_settings.gemini_model = "gemini-2.0-flash" + test_state.http.queue("post", _gemini_ok("A smooth transition scene")) + caplog.set_level(logging.INFO, logger="handlers.suggest_gap_prompt_handler") + + r = client.post( + "/api/suggest-gap-prompt", + json={"beforePrompt": "sunset on a beach", "afterPrompt": "sunrise over mountains", "gapDuration": 3}, + ) + assert r.status_code == 200 + url = test_state.http.calls[-1].url + assert "models/gemini-2.0-flash:generateContent" in url + assert "gemini-2.5-flash:" not in url + assert any( + record.getMessage() == "Suggesting gap prompt via Gemini API (gemini-2.0-flash)" + for record in caplog.records + ) + assert "thinkingConfig" not in test_state.http.calls[-1].json_payload["generationConfig"] + + def test_stored_non_text_gemini_model_uses_default(self, client, test_state): + test_state.state.app_settings.gemini_api_key = "key" + test_state.state.app_settings.gemini_model = "nano-banana-pro" + test_state.http.queue("post", _gemini_ok("A smooth transition scene")) + + r = client.post( + "/api/suggest-gap-prompt", + json={"beforePrompt": "sunset on a beach", "afterPrompt": "sunrise over mountains", "gapDuration": 3}, + ) + assert r.status_code == 200 + assert "models/gemini-2.5-flash-lite:generateContent" in test_state.http.calls[-1].url + assert test_state.http.calls[-1].json_payload["generationConfig"]["thinkingConfig"] == { + "thinkingBudget": 0 + } def test_happy_path_with_frames(self, client, test_state, make_test_image, tmp_path): test_state.state.app_settings.gemini_api_key = "key" diff --git a/backend/tests/test_diffusion_interrupt.py b/backend/tests/test_diffusion_interrupt.py new file mode 100644 index 000000000..45b8b7a6e --- /dev/null +++ b/backend/tests/test_diffusion_interrupt.py @@ -0,0 +1,38 @@ +"""Unit tests for the DiffusionStage.__call__ denoiser interrupt patch.""" + +from __future__ import annotations + +import inspect + +import pytest +from ltx_pipelines.utils.blocks import DiffusionStage + +import services.patches.diffusion_interrupt as diffusion_interrupt +from services.generation_interrupt import GenerationCancelledError, request + + +def test_patch_rebinds_diffusion_stage_call() -> None: + assert callable(getattr(DiffusionStage, "__call__", None)) + assert DiffusionStage.__call__ is diffusion_interrupt._call_with_interrupt + assert "denoiser" in inspect.signature(diffusion_interrupt._original_call).parameters + + +def test_call_wrap_raises_before_later_denoiser_calls(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[object] = [] + + def dummy_call(self: object, denoiser: object, latents: object = None) -> object: + del self + return denoiser(latents) # type: ignore[operator] + + monkeypatch.setattr(diffusion_interrupt, "_original_call", dummy_call) + + def denoiser(latents: object) -> object: + calls.append(latents) + return latents + + stage = object() + assert diffusion_interrupt._call_with_interrupt(stage, denoiser, latents="step-1") == "step-1" + request() + with pytest.raises(GenerationCancelledError): + diffusion_interrupt._call_with_interrupt(stage, denoiser, latents="step-2") + assert calls == ["step-1"] diff --git a/backend/tests/test_distilled_a2v_image_crf.py b/backend/tests/test_distilled_a2v_image_crf.py new file mode 100644 index 000000000..90d3961e6 --- /dev/null +++ b/backend/tests/test_distilled_a2v_image_crf.py @@ -0,0 +1,46 @@ +"""CPU-only checks for distilled A2V image CRF resolution. + +Image+audio generation failed because DistilledA2VPipeline built +ImageConditioningInput tuples with crf=None and never called +ImageConditioner.resolve_crf, which DistilledPipeline runs for image-only. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from ltx_pipelines.utils.args import ImageConditioningInput as LtxImageInput +from services.a2v_pipeline.distilled_a2v_pipeline import resolve_image_conditionings + +_CHECKPOINT_CRF = 29 + + +class _FakeImageConditioner: + def __init__(self) -> None: + self.received: list[LtxImageInput] | None = None + + def resolve_crf(self, images: Sequence[LtxImageInput]) -> list[LtxImageInput]: + self.received = list(images) + return [ + image if image.crf is not None else image._replace(crf=_CHECKPOINT_CRF) + for image in images + ] + + +def test_resolve_image_conditionings_builds_inputs_and_calls_resolve_crf() -> None: + conditioner = _FakeImageConditioner() + + result = resolve_image_conditionings( + [("start.png", 0, 0.8), ("end.png", 24, 1.0)], + conditioner, + ) + + assert conditioner.received is not None + assert [(image.path, image.frame_idx, image.strength, image.crf) for image in conditioner.received] == [ + ("start.png", 0, 0.8, None), + ("end.png", 24, 1.0, None), + ] + assert [(image.path, image.frame_idx, image.strength, image.crf) for image in result] == [ + ("start.png", 0, 0.8, _CHECKPOINT_CRF), + ("end.png", 24, 1.0, _CHECKPOINT_CRF), + ] diff --git a/backend/tests/test_generation.py b/backend/tests/test_generation.py index c7c43573d..ce28ec02f 100644 --- a/backend/tests/test_generation.py +++ b/backend/tests/test_generation.py @@ -2,11 +2,19 @@ from __future__ import annotations +import threading +from collections.abc import Callable from dataclasses import dataclass from pathlib import Path +import pytest + +from _routes._errors import HTTPError +from api_types import GenerateImageRequest, GenerateVideoRequest from frame_math import AutoDurationSpec from runtime_config.model_download_specs import delete_cp_path, get_ltx_model_spec, resolve_model_path +from services import generation_interrupt +from services.generation_interrupt import GenerationCancelledError from services.ltx_api_client.ltx_api_client import LTXAPIClientError from state.app_state_types import GpuSlot, VideoPipelineState from tests.http_error_assertions import assert_http_error @@ -64,6 +72,32 @@ def _fake_running_generation_state(test_state) -> None: test_state.generation.start_generation("running") +def _cancel_in_flight(client, entered_inference: threading.Event, run: Callable[[], object]) -> object: + """Run `run` on a thread, cancel via HTTP once inference has started, return its result. + + TestClient is not safe for overlapping POSTs; the in-flight generate is a direct + handler call so cancel can use the HTTP client. + """ + result: dict[str, object] = {} + + def target() -> None: + try: + result["value"] = run() + except Exception as exc: + result["error"] = exc + + thread = threading.Thread(target=target) + thread.start() + assert entered_inference.wait(timeout=5.0), "pipeline never entered inference" + cancel = client.post("/api/generate/cancel") + assert cancel.status_code == 200 + thread.join(timeout=8.0) + assert not thread.is_alive(), "in-flight generate did not finish after cancel" + if "error" in result: + raise result["error"] # type: ignore[misc] + return result["value"] + + class TestGenerate: def test_t2v_requires_downloaded_ltx_model(self, client): r = client.post("/api/generate", json=_T2V_JSON) @@ -312,8 +346,7 @@ def test_resolution_mapping_540p_on_2_5(self, client, test_state, fake_services, assert call["height"] == 576 def test_resolution_mapping_540p_on_2_3(self, client, test_state, fake_services, create_fake_model_files): - # 2.3 540p is 960×544; snap_up_to_multiple(..., 64) maps height to 576 - # on the two-stage grid. + # Same 540p as 2.5: 1024×576 is already on the /64 two-stage grid. create_fake_model_files(model_id="ltx-2.3-22b-distilled-1.1") test_state.state.app_settings.active_ltx_model_id = "ltx-2.3-22b-distilled-1.1" _enable_local_text_encoding(test_state) @@ -323,28 +356,30 @@ def test_resolution_mapping_540p_on_2_3(self, client, test_state, fake_services, pipeline = fake_services.fast_video_pipeline call = pipeline.generate_calls[0] - assert call["width"] == 960 + assert call["width"] == 1024 assert call["height"] == 576 def test_local_resolutions_are_all_on_the_two_stage_grid( self, client, test_state, fake_services, create_fake_model_files ): - # 2.5 Fast sizes are already /64. Two-stage halves each dimension onto a /32 latent grid, - # so anything not divisible by 64 gets silently snapped. - create_fake_model_files() - _enable_local_text_encoding(test_state) - - for resolution in ("540p", "720p", "1080p"): - for aspect_ratio in ("16:9", "9:16"): - fake_services.fast_video_pipeline.generate_calls.clear() - r = client.post( - "/api/generate", - json={**_T2V_JSON, "resolution": resolution, "aspectRatio": aspect_ratio, "duration": 5}, - ) - assert r.status_code == 200 - call = fake_services.fast_video_pipeline.generate_calls[0] - assert call["width"] % 64 == 0, f"{resolution} {aspect_ratio}: width {call['width']}" - assert call["height"] % 64 == 0, f"{resolution} {aspect_ratio}: height {call['height']}" + # Both 2.3 and 2.5 Fast sizes must already be /64. Two-stage halves each + # dimension onto a /32 latent grid; sizes not divisible by 64 are rejected. + for model_id in ("ltx-2.5-22b-distilled", "ltx-2.3-22b-distilled-1.1"): + create_fake_model_files(model_id=model_id) + test_state.state.app_settings.active_ltx_model_id = model_id + _enable_local_text_encoding(test_state) + + for resolution in ("540p", "720p", "1080p"): + for aspect_ratio in ("16:9", "9:16"): + fake_services.fast_video_pipeline.generate_calls.clear() + r = client.post( + "/api/generate", + json={**_T2V_JSON, "resolution": resolution, "aspectRatio": aspect_ratio, "duration": 5}, + ) + assert r.status_code == 200 + call = fake_services.fast_video_pipeline.generate_calls[0] + assert call["width"] % 64 == 0, f"{model_id} {resolution} {aspect_ratio}: width {call['width']}" + assert call["height"] % 64 == 0, f"{model_id} {resolution} {aspect_ratio}: height {call['height']}" def test_resolution_mapping_720p(self, client, test_state, fake_services, create_fake_model_files): create_fake_model_files() @@ -384,7 +419,7 @@ def test_error_sets_generation_error(self, client, test_state, fake_services, cr def test_cancelled_response(self, client, test_state, fake_services, create_fake_model_files): create_fake_model_files() _enable_local_text_encoding(test_state) - fake_services.fast_video_pipeline.raise_on_generate = RuntimeError("cancelled") + fake_services.fast_video_pipeline.raise_on_generate = GenerationCancelledError() r = client.post("/api/generate", json=_T2V_JSON) assert r.status_code == 200 @@ -700,7 +735,8 @@ def test_a2v_uses_resolution_map_on_2_5(self, client, test_state, fake_services, assert call["width"] == expected_w, f"{resolution}: expected width {expected_w}, got {call['width']}" assert call["height"] == expected_h, f"{resolution}: expected height {expected_h}, got {call['height']}" - def test_a2v_540p_on_2_3_uses_historical_pixels(self, client, test_state, fake_services, create_fake_model_files, tmp_path): + def test_a2v_540p_on_2_3_matches_2_5_pixels(self, client, test_state, fake_services, create_fake_model_files, tmp_path): + # A2V does not snap; 960×544 used to fail assert_resolution (not ÷64). create_fake_model_files(model_id="ltx-2.3-22b-distilled-1.1") test_state.state.app_settings.active_ltx_model_id = "ltx-2.3-22b-distilled-1.1" _enable_local_text_encoding(test_state) @@ -721,8 +757,8 @@ def test_a2v_540p_on_2_3_uses_historical_pixels(self, client, test_state, fake_s assert r.status_code == 200 call = fake_services.a2v_pipeline.generate_calls[0] - assert call["width"] == 960 - assert call["height"] == 544 + assert call["width"] == 1024 + assert call["height"] == 576 def test_a2v_forced_api_rejects_missing_audio_file(self, client, test_state): test_state.config.local_generations_mode = "unsupported" @@ -771,7 +807,7 @@ def test_a2v_forced_api_missing_key_returns_integrity_error(self, client, test_s def test_a2v_forced_api_cancelled_response(self, client, test_state, fake_services, tmp_path): test_state.config.local_generations_mode = "unsupported" test_state.state.app_settings.ltx_api_key = "api-key" - fake_services.ltx_api_client.raise_on_audio_to_video = RuntimeError("cancelled") + fake_services.ltx_api_client.raise_on_audio_to_video = GenerationCancelledError() audio_file = tmp_path / "test_audio.wav" _write_test_wav(audio_file) @@ -1308,7 +1344,7 @@ def test_invalid_camera_motion_rejected_with_422(self, client, test_state): def test_forced_api_cancelled_response(self, client, test_state, fake_services): test_state.config.local_generations_mode = "unsupported" test_state.state.app_settings.ltx_api_key = "api-key" - fake_services.ltx_api_client.raise_on_text_to_video = RuntimeError("cancelled") + fake_services.ltx_api_client.raise_on_text_to_video = GenerationCancelledError() r = client.post( "/api/generate", @@ -1606,11 +1642,97 @@ def test_cancel_active(self, client, test_state): assert r.status_code == 200 data = r.json() assert data["status"] == "cancelling" + assert generation_interrupt.is_requested() + + idle = client.post("/api/generate/cancel") + assert idle.status_code == 200 + assert idle.json()["status"] == "no_active_generation" def test_cancel_no_active(self, client): r = client.post("/api/generate/cancel") assert r.status_code == 200 assert r.json()["status"] == "no_active_generation" + assert not generation_interrupt.is_requested() + + def test_in_flight_cancel_stops_before_remaining_steps( + self, client, test_state, fake_services, create_fake_model_files + ): + create_fake_model_files() + _enable_local_text_encoding(test_state) + pipeline = fake_services.fast_video_pipeline + pipeline.inference_steps = 12 + pipeline.step_delay_s = 0.05 + + response = _cancel_in_flight( + client, + pipeline.entered_inference, + lambda: test_state.video_generation.generate(GenerateVideoRequest.model_validate(_T2V_JSON)), + ) + + assert response.status == "cancelled" + assert pipeline.steps_completed < pipeline.inference_steps + assert pipeline.generate_calls == [] + assert test_state.state.gpu_slot is not None + + def test_generate_after_user_stop_is_not_immediately_cancelled( + self, client, test_state, fake_services, create_fake_model_files + ): + create_fake_model_files() + _enable_local_text_encoding(test_state) + pipeline = fake_services.fast_video_pipeline + pipeline.inference_steps = 8 + pipeline.step_delay_s = 0.02 + + cancelled = _cancel_in_flight( + client, + pipeline.entered_inference, + lambda: test_state.video_generation.generate( + GenerateVideoRequest.model_validate(_T2V_JSON) + ), + ) + assert cancelled.status == "cancelled" + + pipeline.inference_steps = 1 + pipeline.step_delay_s = 0 + r = client.post("/api/generate", json=_T2V_JSON) + assert r.status_code == 200 + assert r.json()["status"] == "complete" + + def test_second_generate_409s_until_cancelled_job_unwinds( + self, client, test_state, fake_services, create_fake_model_files + ): + create_fake_model_files() + _enable_local_text_encoding(test_state) + pipeline = fake_services.fast_video_pipeline + pipeline.inference_steps = 20 + pipeline.step_delay_s = 0.05 + + result: dict[str, object] = {} + + def run() -> None: + result["value"] = test_state.video_generation.generate( + GenerateVideoRequest.model_validate(_T2V_JSON) + ) + + thread = threading.Thread(target=run) + thread.start() + assert pipeline.entered_inference.wait(timeout=5.0) + cancel = client.post("/api/generate/cancel") + assert cancel.status_code == 200 + assert cancel.json()["status"] == "cancelling" + + with pytest.raises(HTTPError) as exc_info: + test_state.video_generation.generate(GenerateVideoRequest.model_validate(_T2V_JSON)) + assert exc_info.value.status_code == 409 + + thread.join(timeout=8.0) + assert not thread.is_alive() + assert result["value"].status == "cancelled" # type: ignore[union-attr] + idle = client.post("/api/generate/cancel") + assert idle.status_code == 200 + assert idle.json()["status"] == "no_active_generation" + assert test_state.generation.try_reserve_generation_start() is True + test_state.generation.release_generation_start_reservation() class TestGenerateModelSpecs: @@ -1676,6 +1798,7 @@ def test_idle(self, client): r = client.get("/api/generation/progress") assert r.status_code == 200 assert r.json()["status"] == "idle" + assert r.json()["cancellable"] is False def test_running(self, client, test_state): _fake_running_generation_state(test_state) @@ -1689,6 +1812,7 @@ def test_running(self, client, test_state): assert data["progress"] == 50 assert data["currentStep"] == 4 assert data["totalSteps"] == 8 + assert data["cancellable"] is True def test_running_from_api_generation_state(self, client, test_state): test_state.generation.start_api_generation("api-running") @@ -1702,6 +1826,17 @@ def test_running_from_api_generation_state(self, client, test_state): assert data["progress"] == 35 assert data["currentStep"] is None assert data["totalSteps"] is None + assert data["cancellable"] is False + + def test_starting_reservation_is_cancellable(self, client, test_state): + assert test_state.generation.try_reserve_generation_start() is True + r = client.get("/api/generation/progress") + assert r.status_code == 200 + data = r.json() + assert data["status"] == "running" + assert data["phase"] == "starting" + assert data["cancellable"] is True + test_state.generation.release_generation_start_reservation() class TestGenerateImage: @@ -1749,12 +1884,32 @@ def test_error(self, client, fake_services, create_fake_model_files): def test_cancelled(self, client, fake_services, create_fake_model_files): create_fake_model_files(include_zit=True) - fake_services.image_generation_pipeline.raise_on_generate = RuntimeError("cancelled") + fake_services.image_generation_pipeline.raise_on_generate = GenerationCancelledError() r = client.post("/api/generate-image", json={"prompt": "test"}) assert r.status_code == 200 assert r.json()["status"] == "cancelled" + def test_in_flight_cancel_stops_before_remaining_steps( + self, client, test_state, fake_services, create_fake_model_files + ): + create_fake_model_files(include_zit=True) + pipeline = fake_services.image_generation_pipeline + pipeline.inference_steps = 12 + pipeline.step_delay_s = 0.05 + + response = _cancel_in_flight( + client, + pipeline.entered_inference, + lambda: test_state.image_generation.generate( + GenerateImageRequest.model_validate({"prompt": "test", "numSteps": 4}) + ), + ) + + assert response.status == "cancelled" + assert pipeline.steps_completed < pipeline.inference_steps + assert test_state.state.gpu_slot is not None + def test_partial_outputs_cleaned_up_on_mid_batch_error(self, client, fake_services, create_fake_model_files, tmp_path): create_fake_model_files(include_zit=True) fake_services.image_generation_pipeline.fail_generate_after = 1 @@ -1793,7 +1948,7 @@ def test_generate_image_missing_fal_key(self, client, test_state, fake_services) def test_generate_image_cancelled(self, client, test_state, fake_services): test_state.config.local_generations_mode = "unsupported" test_state.state.app_settings.fal_api_key = "fal-key" - fake_services.zit_api_client.raise_on_text_to_image = RuntimeError("cancelled") + fake_services.zit_api_client.raise_on_text_to_image = GenerationCancelledError() r = client.post("/api/generate-image", json={"prompt": "A cat"}) diff --git a/backend/tests/test_generation_interrupt.py b/backend/tests/test_generation_interrupt.py new file mode 100644 index 000000000..31ebe3be6 --- /dev/null +++ b/backend/tests/test_generation_interrupt.py @@ -0,0 +1,108 @@ +"""Unit tests for the process-wide generation interrupt token.""" + +from __future__ import annotations + +import pytest + +from services.generation_interrupt import ( + GenerationCancelledError, + clear, + diffusers_step_callback, + is_cancel_exception, + is_requested, + raise_if_requested, + request, + wrap_denoiser, + yield_and_check, +) + + +def test_request_sets_event_and_clear_unsets_it() -> None: + clear() + assert not is_requested() + request() + assert is_requested() + clear() + assert not is_requested() + + +def test_raise_if_requested_raises_generation_cancelled() -> None: + request() + with pytest.raises(GenerationCancelledError, match="cancelled"): + raise_if_requested() + + +def test_wrap_denoiser_stops_after_request() -> None: + calls: list[int] = [] + + def denoiser(step: int) -> int: + calls.append(step) + if step == 2: + request() + return step + + wrapped = wrap_denoiser(denoiser) + assert wrapped(1) == 1 + assert wrapped(2) == 2 + with pytest.raises(GenerationCancelledError): + wrapped(3) + assert calls == [1, 2] + + +def test_yield_and_check_raises_when_requested() -> None: + request() + with pytest.raises(GenerationCancelledError): + yield_and_check() + + +def test_diffusers_step_callback_raises_when_requested() -> None: + kwargs = {"latents": object()} + assert diffusers_step_callback(object(), 0, None, kwargs) is kwargs + request() + with pytest.raises(GenerationCancelledError): + diffusers_step_callback(object(), 1, None, kwargs) + + +def test_is_cancel_exception() -> None: + assert is_cancel_exception(GenerationCancelledError()) + wrapped = RuntimeError("pipeline failed") + wrapped.__cause__ = GenerationCancelledError() + assert is_cancel_exception(wrapped) + via_context = RuntimeError("pipeline failed") + via_context.__context__ = GenerationCancelledError() + assert is_cancel_exception(via_context) + cyclic = RuntimeError("a") + cyclic.__cause__ = cyclic + assert not is_cancel_exception(cyclic) + assert not is_cancel_exception(RuntimeError("cancelled")) + assert not is_cancel_exception(RuntimeError("operation cancelled by peer")) + assert not is_cancel_exception(RuntimeError("GPU OOM")) + + +def test_zit_pipeline_passes_step_end_callback() -> None: + import inspect + + from services.image_generation_pipeline.zit_image_generation_pipeline import ( + ZitImageGenerationPipeline, + ) + + generate_src = inspect.getsource(ZitImageGenerationPipeline.generate) + edit_src = inspect.getsource(ZitImageGenerationPipeline.edit) + assert "callback_on_step_end=diffusers_step_callback" in generate_src + assert "callback_on_step_end=diffusers_step_callback" in edit_src + + +def test_image_callback_stops_later_steps() -> None: + from tests.fakes.services import FakeImageGenerationPipeline + + pipeline = FakeImageGenerationPipeline() + pipeline.inference_steps = 6 + + def callback(pipe: object, step_index: int, timestep: object, callback_kwargs: dict) -> dict: + if step_index == 1: + request() + return diffusers_step_callback(pipe, step_index, timestep, callback_kwargs) + + with pytest.raises(GenerationCancelledError): + pipeline.generate(callback_on_step_end=callback) + assert pipeline.steps_completed == 1 diff --git a/backend/tests/test_ic_lora.py b/backend/tests/test_ic_lora.py index 7f0cb8a5a..cd483c969 100644 --- a/backend/tests/test_ic_lora.py +++ b/backend/tests/test_ic_lora.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import logging import struct from pathlib import Path @@ -89,6 +90,43 @@ def test_happy_path(self, client, test_state, create_fake_model_files, create_fa assert response.json()["status"] == "complete" assert Path(response.json()["video_path"]).exists() + def test_stop_after_generate_unlinks_and_returns_cancelled( + self, client, test_state, fake_services, create_fake_model_files, create_fake_ic_lora_files, caplog + ): + # Denoiser Stop after the last step still runs VAE/ffmpeg; video/retake/extend then + # drop the file. IC-LoRA must do the same instead of importing it as complete. + _install_ic_lora_capable_model(create_fake_model_files, create_fake_ic_lora_files) + test_state.state.app_settings.use_local_text_encoder = True + + video_path = test_state.config.outputs_dir / "test_video.mp4" + video_path.write_bytes(b"\x00" * 100) + test_state.video_processor.register_video(str(video_path), FakeCapture(frames=["frame-a", "frame-b"])) + + pipeline = fake_services.ic_lora_pipeline + real_generate = pipeline.generate + + def generate_then_cancel(**kwargs): + real_generate(**kwargs) + test_state.generation.cancel_generation() + + pipeline.generate = generate_then_cancel # type: ignore[method-assign] + + caplog.set_level(logging.INFO, logger="handlers.ic_lora_handler") + response = client.post( + "/api/ic-lora/generate", + json={ + "video_path": str(video_path), + "conditioning_type": "canny", + "prompt": "test prompt", + "images": [], + }, + ) + assert response.status_code == 200 + assert response.json()["status"] == "cancelled" + written = Path(pipeline.generate_calls[-1]["output_path"]) + assert not written.exists() + assert any(record.getMessage() == "Generation cancelled by user" for record in caplog.records) + def test_builtin_control_rejected_on_2_5(self, client, test_state, create_fake_model_files): create_fake_model_files() test_state.state.app_settings.use_local_text_encoder = True diff --git a/backend/tests/test_ic_lora_generate.py b/backend/tests/test_ic_lora_generate.py index 3c5af4ed9..193d39029 100644 --- a/backend/tests/test_ic_lora_generate.py +++ b/backend/tests/test_ic_lora_generate.py @@ -1,3 +1,4 @@ +import logging from pathlib import Path import numpy as np @@ -29,6 +30,41 @@ def test_ic_lora_generate_runs_inference(client: TestClient, tmp_path: Path, cre assert fake_services.ic_lora_pipeline.generate_calls[-1]["num_frames"] == 121 +def test_catalog_stop_after_generate_unlinks_and_returns_cancelled( + client: TestClient, tmp_path: Path, test_state, create_fake_model_files, fake_services, caplog +): + create_fake_model_files() + client.post("/api/ic-loras/download", json={"ic_lora_id": "ingredients-v1"}) + img = tmp_path / "in.png" + img.write_bytes(b"\x89PNG\r\n") + + pipeline = fake_services.ic_lora_pipeline + real_generate = pipeline.generate + + def generate_then_cancel(**kwargs): + real_generate(**kwargs) + test_state.generation.cancel_generation() + + pipeline.generate = generate_then_cancel # type: ignore[method-assign] + + caplog.set_level(logging.INFO, logger="handlers.ic_lora_handler") + resp = client.post( + "/api/ic-lora/generate", + json={ + "ic_lora_id": "ingredients-v1", + "input_path": str(img), + "control_values": {"duration": 5}, + "prompt": "a slow orbit", + "conditioning_type": "custom", + }, + ) + assert resp.status_code == 200 + assert resp.json()["status"] == "cancelled" + written = Path(pipeline.generate_calls[-1]["output_path"]) + assert not written.exists() + assert any(record.getMessage() == "Generation cancelled by user" for record in caplog.records) + + def test_ic_lora_generate_uses_ic_lora_default_when_no_override( client: TestClient, tmp_path: Path, create_fake_model_files, fake_services ): diff --git a/backend/tests/test_image_edit.py b/backend/tests/test_image_edit.py index 825cc3946..2fc5ba5a4 100644 --- a/backend/tests/test_image_edit.py +++ b/backend/tests/test_image_edit.py @@ -5,6 +5,7 @@ import io from pathlib import Path +from services.generation_interrupt import GenerationCancelledError from services.services_utils import compute_edit_dimensions from tests.http_error_assertions import assert_http_error @@ -174,7 +175,7 @@ def test_pipeline_error(self, client, fake_services, create_fake_model_files, tm def test_cancelled(self, client, fake_services, create_fake_model_files, tmp_path): create_fake_model_files(include_zit=True) src = _write_source_png(tmp_path) - fake_services.image_generation_pipeline.raise_on_edit = RuntimeError("cancelled") + fake_services.image_generation_pipeline.raise_on_edit = GenerationCancelledError() r = client.post( "/api/generate-image", @@ -311,7 +312,7 @@ def test_missing_fal_key(self, client, test_state, tmp_path): def test_cancelled(self, client, test_state, fake_services, tmp_path): self._force_api(test_state) src = _write_source_png(tmp_path) - fake_services.zit_api_client.raise_on_image_to_image = RuntimeError("cancelled") + fake_services.zit_api_client.raise_on_image_to_image = GenerationCancelledError() r = client.post( "/api/generate-image", json={"prompt": "x", "imagePath": src, "strength": 0.6}, diff --git a/backend/tests/test_ltx_capabilities.py b/backend/tests/test_ltx_capabilities.py index f2155de33..2f2ae7072 100644 --- a/backend/tests/test_ltx_capabilities.py +++ b/backend/tests/test_ltx_capabilities.py @@ -13,14 +13,31 @@ ) -def test_local_2_3_540p_is_historical_960x544(): +def test_local_models_share_one_two_stage_pixel_map(): + """2.3 and 2.5 must advertise the same local sizes so a model switch cannot + land 540p on 960×544, which the two-stage pipeline rejects (not ÷64).""" + from runtime_config.model_download_specs import ALL_LTX_LOCAL_MODEL_IDS + + reference = local_caps("ltx-2.5-22b-distilled").resolution_pixels_16_9 + for model_id in ALL_LTX_LOCAL_MODEL_IDS: + caps = local_caps(model_id) + assert caps.resolution_pixels_16_9 == reference, model_id + for resolution in reference: + for aspect in ("16:9", "9:16"): + width, height = pixels_for(caps, resolution, aspect) + assert width % 64 == 0 and height % 64 == 0, ( + f"{model_id} {resolution} {aspect}: {width}x{height}" + ) + + +def test_local_2_3_540p_matches_2_5(): caps = local_caps("ltx-2.3-22b-distilled-1.1") - assert pixels_for(caps, "540p", "16:9") == (960, 544) - assert pixels_for(caps, "540p", "9:16") == (544, 960) + assert pixels_for(caps, "540p", "16:9") == (1024, 576) + assert pixels_for(caps, "540p", "9:16") == (576, 1024) def test_local_2_3_v10_shares_2_3_pixel_map(): - assert pixels_for(local_caps("ltx-2.3-22b-distilled"), "540p", "16:9") == (960, 544) + assert pixels_for(local_caps("ltx-2.3-22b-distilled"), "540p", "16:9") == (1024, 576) def test_local_2_5_540p_is_legal_16_9(): diff --git a/backend/tests/test_ltx_text_encoder.py b/backend/tests/test_ltx_text_encoder.py index f9e4821ad..bbcba6309 100644 --- a/backend/tests/test_ltx_text_encoder.py +++ b/backend/tests/test_ltx_text_encoder.py @@ -7,8 +7,13 @@ import pickle import pytest +import torch -from services.text_encoder.ltx_text_encoder import _CpuUnpickler # noqa: SLF001 +from services.text_encoder.ltx_text_encoder import ( # noqa: SLF001 + _ALLOWED_PICKLE_GLOBALS, + _CpuUnpickler, + _first_embedding_tensor, +) def _unpickler() -> _CpuUnpickler: @@ -18,15 +23,50 @@ def _unpickler() -> _CpuUnpickler: def test_find_class_rejects_arbitrary_callables() -> None: # The conditioning payload is a network response; find_class must not resolve # arbitrary importable callables (that would make unpickling an RCE vector). - with pytest.raises(pickle.UnpicklingError): + with pytest.raises(pickle.UnpicklingError, match="disallowed pickle global"): _unpickler().find_class("os", "system") - with pytest.raises(pickle.UnpicklingError): + with pytest.raises(pickle.UnpicklingError, match="disallowed pickle global"): _unpickler().find_class("builtins", "eval") -def test_find_class_allows_torch_and_stdlib_containers() -> None: - # torch rebuild helpers + stdlib containers the real payload needs still resolve. +def test_find_class_rejects_other_torch_symbols() -> None: + # A prefix allowlist of torch.* would still admit jit/package gadgets. + with pytest.raises(pickle.UnpicklingError, match="disallowed pickle global"): + _unpickler().find_class("torch.jit", "script") + with pytest.raises(pickle.UnpicklingError, match="disallowed pickle global"): + _unpickler().find_class("torch", "Tensor") + + +def test_find_class_allows_only_tensor_rebuild_symbols() -> None: + assert _ALLOWED_PICKLE_GLOBALS == frozenset( + { + ("torch._utils", "_rebuild_tensor_v2"), + ("torch.storage", "_load_from_bytes"), + ("collections", "OrderedDict"), + ("_codecs", "encode"), + } + ) assert _unpickler().find_class("collections", "OrderedDict") is collections.OrderedDict + assert _unpickler().find_class("_codecs", "encode") is __import__("_codecs").encode # The CPU-remapping storage shim is injected, not the raw torch symbol. assert callable(_unpickler().find_class("torch.storage", "_load_from_bytes")) assert callable(_unpickler().find_class("torch._utils", "_rebuild_tensor_v2")) + + +def test_unpickler_roundtrips_nested_tensor_payload() -> None: + embeddings = torch.randn(1, 8, 4096 + 384, dtype=torch.bfloat16) + payload = pickle.dumps([[embeddings]]) + loaded = _CpuUnpickler(io.BytesIO(payload)).load() + recovered = _first_embedding_tensor(loaded) + assert recovered.shape == embeddings.shape + assert recovered.dtype == embeddings.dtype + assert torch.equal(recovered.float(), embeddings.float()) + + +def test_first_embedding_tensor_rejects_non_tensor_nests() -> None: + with pytest.raises(pickle.UnpicklingError, match="container"): + _first_embedding_tensor("nope") + with pytest.raises(pickle.UnpicklingError, match="row"): + _first_embedding_tensor([torch.zeros(1)]) + with pytest.raises(pickle.UnpicklingError, match="not a tensor"): + _first_embedding_tensor([["nope"]]) diff --git a/backend/tests/test_model_download_specs.py b/backend/tests/test_model_download_specs.py index 680003f93..7b676d496 100644 --- a/backend/tests/test_model_download_specs.py +++ b/backend/tests/test_model_download_specs.py @@ -82,6 +82,26 @@ def test_ltx_2_5_model_cp_ids_include_split_vaes(): assert spec.duration_head_cp == "ltx-2.5-duration-head" +def test_2_3_models_use_spatial_upscaler_1_1(): + for model_id in ALL_LTX_LOCAL_MODEL_IDS: + spec = get_ltx_model_spec(model_id) + if not spec.model_cp.startswith("ltx-2.3-"): + continue + assert spec.upscale_cp == "ltx-2.3-spatial-upscaler-x2-1.1" + + +def test_2_3_spatial_upscaler_legacy_spec_keeps_1_0_local_path(): + live = get_model_cp_spec("ltx-2.3-spatial-upscaler-x2-1.1") + legacy = get_model_cp_spec("ltx-2.3-spatial-upscaler-x2-1.0") + assert live.download_filename == "ltx-2.3-spatial-upscaler-x2-1.1.safetensors" + # Local path stays 1.0 so an orphaned file still lists/deletes. Downloads of this + # id are remapped to 1.1 in DownloadHandler — the spec itself must not fetch 1.1 + # onto the 1.0 filename. + assert legacy.relative_path == Path("ltx-2.3-spatial-upscaler-x2-1.0.safetensors") + assert legacy.download_filename == "ltx-2.3-spatial-upscaler-x2-1.0.safetensors" + assert legacy.repo_filename is None + + def test_2_3_has_no_split_video_vaes(): spec = get_ltx_model_spec("ltx-2.3-22b-distilled-1.1") assert spec.video_vae_cp is None diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index acc7e69d7..d1b5d17fa 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -94,11 +94,13 @@ class TestRecommendations: def test_ltx_recommendation_requires_primary_local_bundle(self, client): response = client.get("/api/models/ltx-recommendation") assert response.status_code == 200 - assert response.json() == { + payload = response.json() + assert payload == { "status": "download", "cps_to_download": _required_download_cps(include_text_encoder=True), "optional_cp_ids": _optional_download_cps(include_text_encoder=False), } + assert "ltx-2.3-spatial-upscaler-x2-1.0" not in payload["cps_to_download"] def test_ltx_recommendation_skips_text_encoder_for_2_5_when_api_key_exists(self, client, test_state): test_state.state.app_settings.ltx_api_key = "test-key" @@ -196,11 +198,14 @@ def test_recommendation_surfaces_missing_shared_companion_for_current_base(self, response = client.get("/api/models/ltx-recommendation") assert response.status_code == 200 - assert response.json() == { + payload = response.json() + assert payload == { "status": "download", "cps_to_download": [older_spec.upscale_cp], "optional_cp_ids": [older_spec.text_encoder_cp], } + assert payload["cps_to_download"] == ["ltx-2.3-spatial-upscaler-x2-1.1"] + assert "ltx-2.3-spatial-upscaler-x2-1.0" not in payload["cps_to_download"] def test_ltx_recommendation_reports_missing_text_encoder_for_current_model(self, client, test_state, create_fake_model_files): create_fake_model_files() @@ -410,6 +415,18 @@ def test_download_start_success(self, client, test_state): assert response.json()["status"] == "started" assert _cp_path(test_state, IMG_GEN_MODEL_CP_ID).exists() + def test_legacy_2_3_upscaler_1_0_download_lands_on_1_1(self, client, test_state): + response = client.post( + "/api/models/download", + json={"type": "download", "cp_ids": ["ltx-2.3-spatial-upscaler-x2-1.0"]}, + ) + assert response.status_code == 200 + assert _cp_path(test_state, "ltx-2.3-spatial-upscaler-x2-1.1").exists() + assert not _cp_path(test_state, "ltx-2.3-spatial-upscaler-x2-1.0").exists() + assert [call["filename"] for call in test_state.model_downloader.calls] == [ + "ltx-2.3-spatial-upscaler-x2-1.1.safetensors" + ] + def test_download_conflicts_when_another_session_is_running(self, client, test_state): test_state.downloads.start_download({"ltx-2.3-22b-distilled"}) response = client.post( @@ -619,6 +636,8 @@ def test_versions_list_newest_first_with_flags(self, client, test_state, create_ assert newest["is_newest"] is True # an uninstalled version reports the cp(s) it still needs assert "ltx-2.3-22b-distilled" in older["cps_to_download"] + for item in versions: + assert "ltx-2.3-spatial-upscaler-x2-1.0" not in item["cps_to_download"] def test_set_active_requires_installed(self, client, test_state): # 1.0 is not on disk -> cannot activate diff --git a/backend/tests/test_prompt_enhancement.py b/backend/tests/test_prompt_enhancement.py index 93a330212..f11a57bef 100644 --- a/backend/tests/test_prompt_enhancement.py +++ b/backend/tests/test_prompt_enhancement.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + from api_types import ( IcLoraCatalogItem, InputSpec, @@ -435,6 +437,80 @@ def test_free_rewrite_calls_gemini_without_local_gemma(self, client, test_state) r = client.post("/api/enhance-prompt", json={"prompt": "a cat", "provider": "api"}) assert r.status_code == 200 assert r.json()["enhancedPrompt"] == "a cat, enhanced" + assert "models/gemini-2.5-flash-lite:generateContent" in test_state.http.calls[-1].url + + def test_posts_to_configured_gemini_model(self, client, test_state): + test_state.state.app_settings.gemini_api_key = "key" + test_state.state.app_settings.gemini_model = "gemini-2.0-flash" + test_state.http.queue("post", _gemini_ok("a cat, enhanced")) + + r = client.post("/api/enhance-prompt", json={"prompt": "a cat", "provider": "api"}) + assert r.status_code == 200 + url = test_state.http.calls[-1].url + assert "models/gemini-2.0-flash:generateContent" in url + assert "gemini-2.5-flash:" not in url + + def test_empty_gemini_model_uses_default_at_generate_time(self, client, test_state): + test_state.state.app_settings.gemini_api_key = "key" + test_state.state.app_settings.gemini_model = "" + test_state.http.queue("post", _gemini_ok("a cat, enhanced")) + + r = client.post("/api/enhance-prompt", json={"prompt": "a cat", "provider": "api"}) + assert r.status_code == 200 + assert "models/gemini-2.5-flash-lite:generateContent" in test_state.http.calls[-1].url + + def test_stored_non_text_gemini_model_uses_default_at_generate_time(self, client, test_state): + test_state.state.app_settings.gemini_api_key = "key" + for stored in ("nano-banana-pro", "gemma-4-31b-it"): + test_state.state.app_settings.gemini_model = stored + test_state.http.queue("post", _gemini_ok("a cat, enhanced")) + + r = client.post("/api/enhance-prompt", json={"prompt": "a cat", "provider": "api"}) + assert r.status_code == 200 + assert "models/gemini-2.5-flash-lite:generateContent" in test_state.http.calls[-1].url + assert stored not in test_state.http.calls[-1].url + + def test_thinking_budget_zero_is_sent_only_for_2_5_flash(self, client, test_state): + test_state.state.app_settings.gemini_api_key = "key" + test_state.http.queue( + "post", + _gemini_ok("a cat, enhanced"), + _gemini_ok("a cat, enhanced"), + _gemini_ok("a cat, enhanced"), + ) + + r = client.post("/api/enhance-prompt", json={"prompt": "a cat", "provider": "api"}) + assert r.status_code == 200 + default_config = test_state.http.calls[-1].json_payload["generationConfig"] + assert default_config["thinkingConfig"] == {"thinkingBudget": 0} + assert default_config["maxOutputTokens"] == 512 + + test_state.state.app_settings.gemini_model = "gemini-2.0-flash" + r = client.post("/api/enhance-prompt", json={"prompt": "a cat", "provider": "api"}) + assert r.status_code == 200 + assert "thinkingConfig" not in test_state.http.calls[-1].json_payload["generationConfig"] + assert "models/gemini-2.0-flash:generateContent" in test_state.http.calls[-1].url + + test_state.state.app_settings.gemini_model = "gemini-3.1-pro-preview" + r = client.post("/api/enhance-prompt", json={"prompt": "a cat", "provider": "api"}) + assert r.status_code == 200 + pro_config = test_state.http.calls[-1].json_payload["generationConfig"] + assert pro_config["thinkingConfig"] == {"thinkingLevel": "LOW"} + assert pro_config["maxOutputTokens"] == 2048 + assert "models/gemini-3.1-pro-preview:generateContent" in test_state.http.calls[-1].url + + def test_logs_resolved_gemini_model(self, client, test_state, caplog): + test_state.state.app_settings.gemini_api_key = "key" + test_state.state.app_settings.gemini_model = "" + test_state.http.queue("post", _gemini_ok("a cat, enhanced")) + caplog.set_level(logging.INFO, logger="handlers.prompt_enhancement_handler") + + r = client.post("/api/enhance-prompt", json={"prompt": "a cat", "provider": "api"}) + assert r.status_code == 200 + assert any( + record.getMessage() == "Enhancing prompt via Gemini API (gemini-2.5-flash-lite)" + for record in caplog.records + ) def test_no_selection_still_gets_a_system_instruction(self, client, test_state): # Regression: Gemini (unlike local Gemma) has no implicit default system prompt of its diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index 2d2d313a8..722d936bc 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -5,11 +5,23 @@ import json from pathlib import Path +import pytest from state.app_settings import AppSettings, UpdateSettingsRequest, resolved_use_conv_vae +from services.gemini_text_client import ( + DEFAULT_GEMINI_MODEL, + apply_gemini_thinking_config, + clear_gemini_models_cache, + gemini_thinking_config_for_model, + is_text_to_text_gemini_model, + resolve_gemini_model, +) +from services.interfaces import HttpTransportError from state import build_initial_state from app_handler import ServiceBundle from tests.conftest import TEST_ADMIN_TOKEN +from tests.fakes import FakeResponse from tests.fakes.services import FakeServices +from tests.http_error_assertions import assert_http_error class TestGetSettings: @@ -26,6 +38,7 @@ def test_default_settings(self, client, default_app_settings, test_state): assert data["promptEnhancerEnabledT2V"] is True assert data["promptEnhancerEnabledI2V"] is False assert data["hasGeminiApiKey"] is False + assert data["geminiModel"] == "" assert data["seedLocked"] is False assert data["lockedSeed"] == 42 assert data["useConvVae"] is resolved_use_conv_vae(AppSettings()) @@ -115,6 +128,32 @@ def test_empty_string_does_not_erase_key(self, client, test_state): assert test_state.state.app_settings.ltx_api_key == "real-key" assert test_state.state.app_settings.fal_api_key == "fal-key" + def test_empty_gemini_model_persists_as_use_default(self, client, test_state): + # Unlike API keys, an empty model string is stored so generate-time resolution can + # fall back to DEFAULT_GEMINI_MODEL without keeping a stale explicit id. + r = client.post("/api/settings", json={"geminiModel": "gemini-2.0-flash"}) + assert r.status_code == 200 + assert test_state.state.app_settings.gemini_model == "gemini-2.0-flash" + assert client.get("/api/settings").json()["geminiModel"] == "gemini-2.0-flash" + + r = client.post("/api/settings", json={"geminiModel": ""}) + assert r.status_code == 200 + assert test_state.state.app_settings.gemini_model == "" + assert client.get("/api/settings").json()["geminiModel"] == "" + + def test_non_text_gemini_model_persists_as_use_default(self, client, test_state): + for model_id in ("nano-banana-pro", "gemma-4-31b-it"): + r = client.post("/api/settings", json={"geminiModel": model_id}) + assert r.status_code == 200 + assert test_state.state.app_settings.gemini_model == "" + assert client.get("/api/settings").json()["geminiModel"] == "" + + def test_omitted_gemini_model_does_not_erase(self, client, test_state): + test_state.state.app_settings.gemini_model = "gemini-2.0-flash" + r = client.post("/api/settings", json={"useTorchCompile": True}) + assert r.status_code == 200 + assert test_state.state.app_settings.gemini_model == "gemini-2.0-flash" + def test_omitted_key_does_not_erase_key(self, client, test_state): test_state.state.app_settings.ltx_api_key = "real-key" r = client.post("/api/settings", json={"useTorchCompile": True}) @@ -295,6 +334,14 @@ def test_user_prefers_api_video_generations_persists(self, client, test_state, d loaded = self._new_state(test_state, default_app_settings) assert loaded.state.app_settings.user_prefers_ltx_api_video_generations is True + def test_gemini_model_persists_and_loads(self, client, test_state, default_app_settings): + r = client.post("/api/settings", json={"geminiModel": "gemini-2.0-flash"}) + assert r.status_code == 200 + assert test_state.state.app_settings.gemini_model == "gemini-2.0-flash" + + loaded = self._new_state(test_state, default_app_settings) + assert loaded.state.app_settings.gemini_model == "gemini-2.0-flash" + class TestSettingsSchemaDrift: def test_update_request_tracks_app_settings_fields(self): @@ -321,3 +368,487 @@ def test_explicit_true_overrides_linux_default(self, monkeypatch): def test_explicit_false_overrides_darwin_default(self, monkeypatch): monkeypatch.setattr("state.app_settings.sys.platform", "darwin") assert resolved_use_conv_vae(AppSettings(use_conv_vae=False)) is False + + +def _gemini_listed_model( + name: str, + *, + display_name: str | None = None, + methods: list[str] | None = None, + description: str = "", + input_modalities: list[str] | None = None, + output_modalities: list[str] | None = None, +) -> dict[str, object]: + model_id = name.removeprefix("models/") + payload: dict[str, object] = { + "name": name, + "displayName": display_name if display_name is not None else model_id, + "description": description, + "supportedGenerationMethods": methods if methods is not None else ["generateContent"], + } + if input_modalities is not None: + payload["supportedInputModalities"] = input_modalities + if output_modalities is not None: + payload["supportedOutputModalities"] = output_modalities + return payload + + +@pytest.mark.parametrize( + ("model_id", "description", "display_name", "expected"), + [ + ("gemini-2.5-flash-lite", "Fast text model", "Gemini 2.5 Flash-Lite", True), + ("gemini-2.5-flash", "Multimodal model that understands images and video", "Gemini 2.5 Flash", True), + ("gemini-2.5-pro", "", "Gemini 2.5 Pro", True), + ("gemini-2.5-pro-preview-06-05", "", "Gemini 2.5 Pro Preview", True), + ("gemini-3-flash-preview", "", "Gemini 3 Flash Preview", True), + ("gemini-3.1-pro-preview", "", "Gemini 3.1 Pro Preview", True), + ("gemini-pro", "", "Gemini Pro", True), + ("gemma-3-27b-it", "Open text model", "Gemma 4 31B IT", False), + ("gemma-4-31b-it", "", "Gemma 4 31B IT", False), + ("nano-banana-pro", "", "Nano Banana Pro", False), + ("gemini-3-pro-image", "", "Nano Banana Pro", False), + ("lyria-3-clip-preview", "", "Lyria 3 Clip Preview", False), + ("lyria-3-pro-preview", "", "Lyria 3 Pro Preview", False), + ("antigravity-preview-05-2026", "", "Antigravity Agent Preview", False), + ("deep-research-preview-04-2026", "", "Deep Research Preview (Apr-21-2026)", False), + ("gemini-omni-flash-preview", "", "Gemini Omni Flash Preview", False), + ("text-embedding-004", "", "", False), + ("gemini-embedding-001", "", "", False), + ("gemini-2.5-flash-image", "", "Gemini 2.5 Flash Image", False), + ("gemini-2.0-flash-preview-image-generation", "", "", False), + ("gemini-2.5-flash-preview-tts", "", "", False), + ("gemini-2.5-flash-native-audio-dialog", "", "", False), + ("veo-2.0-generate-001", "", "", False), + ("imagen-3.0-generate-002", "", "", False), + ("gemini-2.0-flash-exp", "Experimental release of Gemini 2.0 Flash", "", True), + ("gemini-2.5-flash", "Image generation model", "", False), + ], +) +def test_is_text_to_text_gemini_model( + model_id: str, description: str, display_name: str, expected: bool +) -> None: + assert ( + is_text_to_text_gemini_model(model_id, description, display_name=display_name) is expected + ) + + +@pytest.mark.parametrize( + ("stored", "expected"), + [ + ("", DEFAULT_GEMINI_MODEL), + (" ", DEFAULT_GEMINI_MODEL), + ("gemini-2.0-flash", "gemini-2.0-flash"), + ("models/gemini-2.0-flash", "gemini-2.0-flash"), + ("gemini-custom-id", "gemini-custom-id"), + ("nano-banana-pro", DEFAULT_GEMINI_MODEL), + ("gemma-4-31b-it", DEFAULT_GEMINI_MODEL), + ("gemini-2.5-pro", "gemini-2.5-pro"), + ("gemini-3-flash-preview", "gemini-3-flash-preview"), + ("gemini-3.1-pro-preview", "gemini-3.1-pro-preview"), + ("gemini-3-pro-image", DEFAULT_GEMINI_MODEL), + ("gemini-omni-flash-preview", DEFAULT_GEMINI_MODEL), + ], +) +def test_resolve_gemini_model(stored: str, expected: str) -> None: + assert resolve_gemini_model(stored) == expected + + +@pytest.mark.parametrize( + ("model", "expected"), + [ + ("gemini-2.5-flash-lite", {"thinkingBudget": 0}), + ("gemini-2.5-flash", {"thinkingBudget": 0}), + ("gemini-2.5-pro", {"thinkingBudget": 128}), + ("gemini-2.5-pro-preview-06-05", {"thinkingBudget": 128}), + ("gemini-2.0-flash", None), + ("gemini-3-flash-preview", {"thinkingLevel": "LOW"}), + ("gemini-3.1-pro-preview", {"thinkingLevel": "LOW"}), + ], +) +def test_gemini_thinking_config_for_model(model: str, expected: dict[str, object] | None) -> None: + assert gemini_thinking_config_for_model(model) == expected + + +def test_apply_gemini_thinking_config_raises_output_cap_when_thinking_stays_on() -> None: + flash = apply_gemini_thinking_config("gemini-2.5-flash-lite", {"maxOutputTokens": 512}) + assert flash["maxOutputTokens"] == 512 + assert flash["thinkingConfig"] == {"thinkingBudget": 0} + + pro = apply_gemini_thinking_config("gemini-3.1-pro-preview", {"maxOutputTokens": 512}) + assert pro["maxOutputTokens"] == 2048 + assert pro["thinkingConfig"] == {"thinkingLevel": "LOW"} + + +def test_is_text_to_text_gemini_model_uses_output_modalities_when_present() -> None: + assert ( + is_text_to_text_gemini_model( + "gemini-2.5-flash", + input_modalities=["TEXT", "IMAGE"], + output_modalities=["TEXT"], + ) + is True + ) + assert ( + is_text_to_text_gemini_model( + "gemini-3-pro-image", + display_name="Nano Banana Pro", + input_modalities=["TEXT"], + output_modalities=["IMAGE"], + ) + is False + ) + assert ( + is_text_to_text_gemini_model( + "gemini-2.5-flash", + input_modalities=["TEXT"], + output_modalities=["TEXT"], + ) + is True + ) + assert ( + is_text_to_text_gemini_model( + "gemini-2.5-pro", + input_modalities=["TEXT", "IMAGE"], + output_modalities=["TEXT"], + ) + is True + ) + assert ( + is_text_to_text_gemini_model( + "gemini-3-flash-preview", + input_modalities=["TEXT"], + output_modalities=["TEXT"], + ) + is True + ) + assert ( + is_text_to_text_gemini_model( + "gemma-4-31b-it", + input_modalities=["TEXT"], + output_modalities=["TEXT"], + ) + is False + ) + + +class TestListGeminiModels: + def test_missing_key_400(self, client): + r = client.get("/api/settings/gemini-models") + assert_http_error(r, status_code=400, code="GEMINI_API_KEY_MISSING") + + def test_filters_generate_content_and_sorts_flash_lite_first(self, client, test_state): + test_state.state.app_settings.gemini_api_key = "key" + test_state.http.queue( + "get", + FakeResponse( + status_code=200, + json_payload={ + "models": [ + _gemini_listed_model("models/gemini-pro", display_name="Gemini Pro"), + _gemini_listed_model( + "models/text-embedding-004", + display_name="Embedding 004", + methods=["embedContent"], + ), + _gemini_listed_model( + "models/gemini-2.5-flash", + display_name="Gemini 2.5 Flash", + description="Multimodal model that understands images and video", + ), + _gemini_listed_model( + "models/gemini-2.0-flash-lite", + display_name="Gemini 2.0 Flash-Lite", + ), + _gemini_listed_model("models/gemini-2.5-flash-lite"), + _gemini_listed_model("models/gemini-1.5-pro", display_name="Gemini 1.5 Pro"), + _gemini_listed_model( + "models/gemini-2.5-flash-preview-tts", + display_name="Gemini 2.5 Flash Preview TTS", + ), + _gemini_listed_model( + "models/gemini-2.5-flash-image", + display_name="Gemini 2.5 Flash Image", + description="Image generation model", + ), + _gemini_listed_model( + "models/imagen-3.0-generate-002", + display_name="Imagen 3", + ), + _gemini_listed_model( + "models/gemini-2.5-flash-native-audio-dialog", + display_name="Native Audio Dialog", + ), + _gemini_listed_model( + "models/nano-banana-pro", + display_name="Nano Banana Pro", + ), + _gemini_listed_model( + "models/gemma-4-31b-it", + display_name="Gemma 4 31B IT", + ), + _gemini_listed_model( + "models/gemini-3-pro-image", + display_name="Nano Banana Pro", + ), + _gemini_listed_model( + "models/lyria-3-clip-preview", + display_name="Lyria 3 Clip Preview", + ), + _gemini_listed_model( + "models/antigravity-preview-05-2026", + display_name="Antigravity Agent Preview", + ), + _gemini_listed_model( + "models/deep-research-preview-04-2026", + display_name="Deep Research Preview (Apr-21-2026)", + ), + _gemini_listed_model( + "models/gemini-omni-flash-preview", + display_name="Gemini Omni Flash Preview", + ), + _gemini_listed_model( + "models/gemini-2.5-pro", + display_name="Gemini 2.5 Pro", + ), + _gemini_listed_model( + "models/gemini-3-flash-preview", + display_name="Gemini 3 Flash Preview", + ), + ] + }, + ), + ) + + r = client.get("/api/settings/gemini-models") + assert r.status_code == 200 + data = r.json() + ids = [model["id"] for model in data["models"]] + assert ids == [ + "gemini-2.0-flash-lite", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + "gemini-3-flash-preview", + "gemini-1.5-pro", + "gemini-2.5-pro", + "gemini-pro", + ] + assert "text-embedding-004" not in ids + assert "gemini-2.5-flash-preview-tts" not in ids + assert "gemini-2.5-flash-image" not in ids + assert "imagen-3.0-generate-002" not in ids + assert "gemini-2.5-flash-native-audio-dialog" not in ids + assert "nano-banana-pro" not in ids + assert "gemma-4-31b-it" not in ids + assert "gemini-3-pro-image" not in ids + assert "lyria-3-clip-preview" not in ids + assert "antigravity-preview-05-2026" not in ids + assert "deep-research-preview-04-2026" not in ids + assert "gemini-omni-flash-preview" not in ids + flash = next(model for model in data["models"] if model["id"] == "gemini-2.5-flash") + assert flash["description"] == "Multimodal model that understands images and video" + assert data["resolvedModel"] == DEFAULT_GEMINI_MODEL + + call = test_state.http.calls[-1] + assert call.method == "get" + assert call.url.startswith("https://generativelanguage.googleapis.com/v1beta/models?") + assert "pageSize=1000" in call.url + assert "key=" not in call.url + assert call.headers is not None + assert call.headers["x-goog-api-key"] == "key" + + def test_resolved_model_is_stored_setting_when_present(self, client, test_state): + test_state.state.app_settings.gemini_api_key = "key" + test_state.state.app_settings.gemini_model = "gemini-2.5-flash" + test_state.http.queue( + "get", + FakeResponse( + status_code=200, + json_payload={ + "models": [ + _gemini_listed_model("models/gemini-2.5-flash", display_name="Gemini 2.5 Flash"), + _gemini_listed_model("models/gemini-2.5-flash-lite"), + ] + }, + ), + ) + + r = client.get("/api/settings/gemini-models") + assert r.status_code == 200 + data = r.json() + assert data["resolvedModel"] == "gemini-2.5-flash" + assert [model["id"] for model in data["models"]] == [ + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + ] + + def test_stored_model_missing_from_list_is_still_included(self, client, test_state): + test_state.state.app_settings.gemini_api_key = "key" + test_state.state.app_settings.gemini_model = "gemini-custom-id" + test_state.http.queue( + "get", + FakeResponse( + status_code=200, + json_payload={"models": [_gemini_listed_model("models/gemini-2.5-flash-lite")]}, + ), + ) + + r = client.get("/api/settings/gemini-models") + assert r.status_code == 200 + data = r.json() + assert data["resolvedModel"] == "gemini-custom-id" + ids = [model["id"] for model in data["models"]] + assert "gemini-custom-id" in ids + assert "gemini-2.5-flash-lite" in ids + + def test_stored_non_text_model_is_not_re_injected(self, client, test_state): + test_state.state.app_settings.gemini_api_key = "key" + for stored in ("nano-banana-pro", "gemma-4-31b-it"): + clear_gemini_models_cache() + test_state.state.app_settings.gemini_model = stored + test_state.http.queue( + "get", + FakeResponse( + status_code=200, + json_payload={"models": [_gemini_listed_model("models/gemini-2.5-flash-lite")]}, + ), + ) + + r = client.get("/api/settings/gemini-models") + assert r.status_code == 200 + data = r.json() + assert data["resolvedModel"] == DEFAULT_GEMINI_MODEL + ids = [model["id"] for model in data["models"]] + assert stored not in ids + assert DEFAULT_GEMINI_MODEL in ids + + def test_default_model_included_when_missing_from_upstream_list(self, client, test_state): + test_state.state.app_settings.gemini_api_key = "key" + test_state.http.queue( + "get", + FakeResponse( + status_code=200, + json_payload={"models": [_gemini_listed_model("models/gemini-2.5-flash")]}, + ), + ) + + r = client.get("/api/settings/gemini-models") + assert r.status_code == 200 + data = r.json() + assert data["resolvedModel"] == DEFAULT_GEMINI_MODEL + assert DEFAULT_GEMINI_MODEL in [model["id"] for model in data["models"]] + + def test_paginates_until_next_page_token_is_empty(self, client, test_state): + test_state.state.app_settings.gemini_api_key = "key" + test_state.http.queue( + "get", + FakeResponse( + status_code=200, + json_payload={ + "models": [_gemini_listed_model("models/gemini-2.5-flash")], + "nextPageToken": "page-2", + }, + ), + FakeResponse( + status_code=200, + json_payload={"models": [_gemini_listed_model("models/gemini-2.5-flash-lite")]}, + ), + ) + + r = client.get("/api/settings/gemini-models") + assert r.status_code == 200 + ids = [model["id"] for model in r.json()["models"]] + assert ids == ["gemini-2.5-flash", "gemini-2.5-flash-lite"] + assert len(test_state.http.calls) == 2 + assert "pageToken=page-2" in test_state.http.calls[1].url + + def test_upstream_error_maps_status(self, client, test_state): + test_state.state.app_settings.gemini_api_key = "key" + test_state.http.queue("get", FakeResponse(status_code=403, text="forbidden")) + + r = client.get("/api/settings/gemini-models") + assert r.status_code == 403 + + def test_timeout_504(self, client, test_state): + test_state.state.app_settings.gemini_api_key = "key" + test_state.http.queue("get", HttpTransportError("timeout")) + + r = client.get("/api/settings/gemini-models") + assert r.status_code == 504 + + def test_successful_list_is_cached_per_api_key(self, client, test_state): + test_state.state.app_settings.gemini_api_key = "key" + test_state.http.queue( + "get", + FakeResponse( + status_code=200, + json_payload={"models": [_gemini_listed_model("models/gemini-2.5-flash-lite")]}, + ), + ) + + first = client.get("/api/settings/gemini-models") + second = client.get("/api/settings/gemini-models") + assert first.status_code == 200 + assert second.status_code == 200 + assert first.json()["models"] == second.json()["models"] + assert len(test_state.http.calls) == 1 + + def test_cache_misses_when_api_key_changes(self, client, test_state): + test_state.state.app_settings.gemini_api_key = "key-a" + test_state.http.queue( + "get", + FakeResponse( + status_code=200, + json_payload={"models": [_gemini_listed_model("models/gemini-2.5-flash")]}, + ), + FakeResponse( + status_code=200, + json_payload={"models": [_gemini_listed_model("models/gemini-2.5-flash-lite")]}, + ), + ) + + first = client.get("/api/settings/gemini-models") + test_state.state.app_settings.gemini_api_key = "key-b" + second = client.get("/api/settings/gemini-models") + assert first.status_code == 200 + assert second.status_code == 200 + assert [model["id"] for model in first.json()["models"]] == [ + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + ] + assert [model["id"] for model in second.json()["models"]] == ["gemini-2.5-flash-lite"] + assert len(test_state.http.calls) == 2 + + def test_included_missing_id_is_not_written_into_the_cache(self, client, test_state): + test_state.state.app_settings.gemini_api_key = "key" + test_state.state.app_settings.gemini_model = "gemini-custom-id" + test_state.http.queue( + "get", + FakeResponse( + status_code=200, + json_payload={"models": [_gemini_listed_model("models/gemini-2.5-flash-lite")]}, + ), + ) + + first = client.get("/api/settings/gemini-models") + test_state.state.app_settings.gemini_model = "" + second = client.get("/api/settings/gemini-models") + assert "gemini-custom-id" in [model["id"] for model in first.json()["models"]] + assert "gemini-custom-id" not in [model["id"] for model in second.json()["models"]] + assert len(test_state.http.calls) == 1 + + def test_errors_are_not_cached(self, client, test_state): + test_state.state.app_settings.gemini_api_key = "key" + test_state.http.queue( + "get", + FakeResponse(status_code=403, text="forbidden"), + FakeResponse( + status_code=200, + json_payload={"models": [_gemini_listed_model("models/gemini-2.5-flash-lite")]}, + ), + ) + + assert client.get("/api/settings/gemini-models").status_code == 403 + ok = client.get("/api/settings/gemini-models") + assert ok.status_code == 200 + assert [model["id"] for model in ok.json()["models"]] == ["gemini-2.5-flash-lite"] + assert len(test_state.http.calls) == 2 diff --git a/backend/tests/test_state_actions.py b/backend/tests/test_state_actions.py index 37559456d..dfc0992f1 100644 --- a/backend/tests/test_state_actions.py +++ b/backend/tests/test_state_actions.py @@ -9,6 +9,7 @@ from _routes._errors import HTTPError from handlers.generation_handler import _RESERVATION_TIMEOUT_S +from services import generation_interrupt from runtime_config.model_download_specs import ( DEPTH_PROCESSOR_CP_ID, get_ltx_model_spec, @@ -69,6 +70,76 @@ def test_try_reserve_generation_start_stale_reservation_expires(test_state): assert test_state.generation.try_reserve_generation_start() is True +def test_cancel_during_reservation_blocks_second_start(test_state): + with test_state.generation.reserved_generation_start(): + cancel = test_state.generation.cancel_generation() + assert cancel.status == "cancelling" + assert generation_interrupt.is_requested() + with pytest.raises(HTTPError) as exc_info: + with test_state.generation.reserved_generation_start(): + pass + assert exc_info.value.status_code == 409 + + assert test_state.generation.try_reserve_generation_start() is True + test_state.generation.release_generation_start_reservation() + + +def test_cancel_after_start_keeps_slot_until_handler_exits(test_state, create_fake_model_files): + create_fake_model_files() + test_state.pipelines.load_gpu_pipeline("fast") + with test_state.generation.reserved_generation_start(): + test_state.generation.start_generation("gen-1") + cancel = test_state.generation.cancel_generation() + assert cancel.status == "cancelling" + assert test_state.generation.try_reserve_generation_start() is False + progress = test_state.generation.get_generation_progress() + assert progress.status == "running" + assert progress.phase == "cancelled" + assert progress.cancellable is True + + progress = test_state.generation.get_generation_progress() + assert progress.status == "cancelled" + assert progress.cancellable is False + idle_cancel = test_state.generation.cancel_generation() + assert idle_cancel.status == "no_active_generation" + assert test_state.generation.try_reserve_generation_start() is True + test_state.generation.release_generation_start_reservation() + + +def test_raise_if_cancelled_ignores_sticky_cancelled_from_previous_job( + test_state, create_fake_model_files +): + # After Stop, AppState stays GenerationCancelled until the next start_generation(). + # A new reservation must not treat that sticky terminal state as "this job was cancelled". + create_fake_model_files() + test_state.pipelines.load_gpu_pipeline("fast") + with test_state.generation.reserved_generation_start(): + test_state.generation.start_generation("gen-1") + test_state.generation.cancel_generation() + assert test_state.generation.get_generation_progress().status == "cancelled" + + with test_state.generation.reserved_generation_start(): + test_state.generation.raise_if_cancelled() + test_state.generation.start_generation("gen-2") + + assert test_state.generation.get_generation_progress().id == "gen-2" + assert test_state.generation.get_generation_progress().status == "running" + + +def test_start_generation_honors_cancel_during_reservation(test_state, create_fake_model_files): + from services.generation_interrupt import GenerationCancelledError + + create_fake_model_files() + test_state.pipelines.load_gpu_pipeline("fast") + with test_state.generation.reserved_generation_start(): + test_state.generation.cancel_generation() + with pytest.raises(GenerationCancelledError): + test_state.generation.start_generation("gen-1") + assert test_state.generation.try_reserve_generation_start() is False + + assert test_state.generation.get_generation_progress().status == "cancelled" + + def test_reserved_generation_start_raises_409_while_reserved(test_state): with test_state.generation.reserved_generation_start(): with pytest.raises(HTTPError) as exc_info: diff --git a/backend/tests/test_win_dll_search.py b/backend/tests/test_win_dll_search.py new file mode 100644 index 000000000..e396093a3 --- /dev/null +++ b/backend/tests/test_win_dll_search.py @@ -0,0 +1,26 @@ +"""Windows DLL search-path hardening.""" + +from __future__ import annotations + +import sys + +import pytest + +from server_utils.win_dll_search import remove_cwd_from_dll_search_path + + +def test_remove_cwd_from_dll_search_path_does_not_raise() -> None: + remove_cwd_from_dll_search_path() + + +@pytest.mark.skipif(sys.platform != "win32", reason="SetDllDirectoryW is Windows-only") +def test_remove_cwd_clears_dll_directory_on_windows() -> None: + import ctypes + + remove_cwd_from_dll_search_path() + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.GetDllDirectoryW.argtypes = [ctypes.c_uint, ctypes.c_wchar_p] + kernel32.GetDllDirectoryW.restype = ctypes.c_uint + buf = ctypes.create_unicode_buffer(1024) + kernel32.GetDllDirectoryW(1024, buf) + assert buf.value == "" diff --git a/backend/uv.lock b/backend/uv.lock index a2e40c719..c0d283408 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -261,8 +261,8 @@ wheels = [ [[package]] name = "diffusers" -version = "0.37.0.dev0" -source = { git = "https://github.com/huggingface/diffusers.git?rev=01de02e8b4f2cc91df4f3e91cb6535ebcbeb490c#01de02e8b4f2cc91df4f3e91cb6535ebcbeb490c" } +version = "0.39.0" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "httpx" }, @@ -274,6 +274,10 @@ dependencies = [ { name = "requests" }, { name = "safetensors" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/1a/81/6095237b86a3116c4789f28c4435d5296c00c0fc74ffde99008fd6b3a36c/diffusers-0.39.0.tar.gz", hash = "sha256:14bb1d98c85a0e463d734c99aaa73b480a7bc9bad22af30fbf730ef8f09c1d67", size = 4651240, upload-time = "2026-07-03T08:48:47.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/3f/7469c46e9d22307ea686bab687d70e6bf328722952f9d10339f5e913e608/diffusers-0.39.0-py3-none-any.whl", hash = "sha256:912aca51b5787365110806e984d5555735bf8a461073bb8459029d0bca7870ef", size = 5631176, upload-time = "2026-07-03T08:48:45.337Z" }, +] [[package]] name = "einops" @@ -286,7 +290,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.129.0" +version = "0.141.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -295,9 +299,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/47/75f6bea02e797abff1bca968d5997793898032d9923c1935ae2efdece642/fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af", size = 375450, upload-time = "2026-02-12T13:54:52.541Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" }, + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, ] [[package]] @@ -555,6 +559,7 @@ dependencies = [ { name = "sageattention", version = "1.0.6", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'darwin' and sys_platform != 'win32'" }, { name = "sageattention", version = "2.2.0+cu128torch2.10.0andhigher.post5", source = { url = "https://github.com/woct0rdho/SageAttention/releases/download/v2.2.0-windows.post5/sageattention-2.2.0+cu128torch2.10.0andhigher.post5-cp310-abi3-win_amd64.whl" }, marker = "sys_platform == 'win32'" }, { name = "sentencepiece" }, + { name = "starlette" }, { name = "torch", version = "2.10.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "torch", version = "2.11.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'darwin'" }, { name = "torch", version = "2.12.1", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'" }, @@ -582,8 +587,8 @@ test = [ [package.metadata] requires-dist = [ { name = "debugpy", marker = "extra == 'dev'", specifier = ">=1.8" }, - { name = "diffusers", git = "https://github.com/huggingface/diffusers.git?rev=01de02e8b4f2cc91df4f3e91cb6535ebcbeb490c" }, - { name = "fastapi", specifier = ">=0.115.0" }, + { name = "diffusers", specifier = ">=0.39.0" }, + { name = "fastapi", specifier = ">=0.141.1" }, { name = "ftfy", specifier = ">=6.0.0" }, { name = "httpx", marker = "extra == 'test'", specifier = ">=0.27" }, { name = "huggingface-hub", specifier = ">=0.23.0" }, @@ -595,25 +600,26 @@ requires-dist = [ { name = "ninja", marker = "sys_platform == 'darwin'", specifier = ">=1.11" }, { name = "opencv-python-headless", specifier = ">=4.8.0" }, { name = "peft", specifier = ">=0.13.2" }, - { name = "pillow", specifier = ">=10.3.0" }, + { name = "pillow", specifier = ">=12.3.0" }, { name = "protobuf", specifier = ">=3.20.0" }, { name = "psutil", specifier = ">=5.9.0" }, { name = "pydantic", specifier = ">=2.7.0" }, { name = "pynvml", marker = "sys_platform != 'darwin'", specifier = ">=11.5.0" }, { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.380" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.0" }, - { name = "python-multipart", specifier = ">=0.0.9" }, - { name = "requests", marker = "extra == 'test'", specifier = ">=2.31" }, + { name = "python-multipart", specifier = ">=0.0.32" }, + { name = "requests", marker = "extra == 'test'", specifier = ">=2.34.2" }, { name = "sageattention", marker = "sys_platform != 'darwin' and sys_platform != 'win32'", specifier = ">=1.0.0" }, { name = "sageattention", marker = "sys_platform == 'win32'", url = "https://github.com/woct0rdho/SageAttention/releases/download/v2.2.0-windows.post5/sageattention-2.2.0+cu128torch2.10.0andhigher.post5-cp310-abi3-win_amd64.whl" }, { name = "sentencepiece", specifier = ">=0.1.99" }, + { name = "starlette", specifier = ">=1.6.0" }, { name = "torch", marker = "sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", specifier = ">=2.3.0" }, { name = "torch", marker = "sys_platform == 'darwin'", specifier = ">=2.3.0,<2.12" }, { name = "torch", marker = "sys_platform == 'linux' or sys_platform == 'win32'", specifier = ">=2.3.0", index = "https://download.pytorch.org/whl/cu128" }, { name = "torchvision", marker = "sys_platform != 'linux' and sys_platform != 'win32'", specifier = ">=0.18.0" }, { name = "torchvision", marker = "sys_platform == 'linux' or sys_platform == 'win32'", specifier = ">=0.18.0", index = "https://download.pytorch.org/whl/cu128" }, { name = "tqdm", specifier = ">=4.66.0" }, - { name = "transformers", specifier = ">=5.8.0,<5.15" }, + { name = "transformers", specifier = ">=5.14.1,<5.15" }, { name = "triton", marker = "sys_platform == 'linux'" }, { name = "triton-windows", marker = "sys_platform == 'win32'" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" }, @@ -1076,71 +1082,73 @@ wheels = [ [[package]] name = "pillow" -version = "12.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, - { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, - { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, - { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, - { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, - { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, - { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, - { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, - { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, - { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, - { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, - { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, - { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, - { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, - { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, - { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, - { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, - { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, - { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, - { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, - { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, - { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, - { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, - { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, - { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, - { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, - { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, - { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, - { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, - { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, - { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, - { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, - { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, - { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, - { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, - { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, - { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, - { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, - { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, - { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, - { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, - { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, ] [[package]] @@ -1426,11 +1434,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.22" +version = "0.0.32" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] [[package]] @@ -1569,7 +1577,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1577,9 +1585,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] @@ -1789,15 +1797,15 @@ wheels = [ [[package]] name = "starlette" -version = "0.52.1" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, ] [[package]] diff --git a/electron-builder.yml b/electron-builder.yml index 5c27292fa..b6d1e0783 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -16,6 +16,7 @@ files: asarUnpack: - dist/splash/** + - node_modules/koffi/** extraResources: - from: backend diff --git a/electron/app-state.ts b/electron/app-state.ts index 231f73e09..b7799fe23 100644 --- a/electron/app-state.ts +++ b/electron/app-state.ts @@ -6,6 +6,8 @@ export interface AppState { analyticsEnabled?: boolean installationId?: string projectAssetsPath?: string + skippedUpdateVersion?: string + autoCheckUpdates?: boolean [key: string]: unknown } @@ -50,3 +52,25 @@ export function setProjectAssetsPath(p: string): void { state.projectAssetsPath = resolvedPath writeAppState(state) } + +export function getSkippedUpdateVersion(): string | undefined { + return readAppState().skippedUpdateVersion +} + +export function setSkippedUpdateVersion(version: string | undefined): void { + const s = readAppState() + if (version) s.skippedUpdateVersion = version + else delete s.skippedUpdateVersion + writeAppState(s) +} + +export function getAutoCheckUpdates(): boolean { + // Default ON: existing installs and fresh installs behave as before. + return readAppState().autoCheckUpdates ?? true +} + +export function setAutoCheckUpdates(enabled: boolean): void { + const s = readAppState() + s.autoCheckUpdates = enabled + writeAppState(s) +} diff --git a/electron/export/ffmpeg-utils.ts b/electron/export/ffmpeg-utils.ts index 7d419134d..4c5d4dd61 100644 --- a/electron/export/ffmpeg-utils.ts +++ b/electron/export/ffmpeg-utils.ts @@ -1,4 +1,4 @@ -import { spawn, spawnSync, ChildProcess, execSync } from 'child_process' +import { spawn, spawnSync, ChildProcess } from 'child_process' import os from 'os' import path from 'path' import fs from 'fs' @@ -35,7 +35,7 @@ export function findFfmpegPath(): string | null { if (bin) return path.join(binDir, bin) } - try { execSync('ffmpeg -version', { stdio: 'ignore' }); return 'ffmpeg' } catch { return null } + return null } /** Check if a video file contains an audio stream using ffprobe/ffmpeg */ diff --git a/electron/gpu.ts b/electron/gpu.ts index 2fe414c20..3ad4a21a3 100644 --- a/electron/gpu.ts +++ b/electron/gpu.ts @@ -1,6 +1,7 @@ import { execSync } from 'child_process' import { logger } from './logger' import { getAuthToken, getBackendUrl, getPythonPath } from './python-backend' +import { PY_REMOVE_CWD_FROM_DLL_SEARCH } from './win-dll-search' // Check if NVIDIA GPU is available export async function checkGPU(): Promise<{ available: boolean; name?: string; vram?: number }> { @@ -31,7 +32,7 @@ export async function checkGPU(): Promise<{ available: boolean; name?: string; v // Fallback: try direct Python check try { const pythonPath = getPythonPath() - const result = execSync(`"${pythonPath}" -c "import torch; cuda=torch.cuda.is_available(); mps=hasattr(torch.backends,'mps') and torch.backends.mps.is_available(); print(cuda or mps); print(torch.cuda.get_device_name(0) if cuda else ('Apple Silicon (MPS)' if mps else '')); print(torch.cuda.get_device_properties(0).total_memory // (1024**3) if cuda else 0)"`, { + const result = execSync(`"${pythonPath}" -c "${PY_REMOVE_CWD_FROM_DLL_SEARCH}import torch; cuda=torch.cuda.is_available(); mps=hasattr(torch.backends,'mps') and torch.backends.mps.is_available(); print(cuda or mps); print(torch.cuda.get_device_name(0) if cuda else ('Apple Silicon (MPS)' if mps else '')); print(torch.cuda.get_device_properties(0).total_memory // (1024**3) if cuda else 0)"`, { encoding: 'utf-8', timeout: 30000, windowsHide: true diff --git a/electron/ipc/app-handlers.ts b/electron/ipc/app-handlers.ts index 4b46f16ae..60ae5814f 100644 --- a/electron/ipc/app-handlers.ts +++ b/electron/ipc/app-handlers.ts @@ -6,6 +6,11 @@ import { isPythonReady, downloadPythonEmbed } from '../python-setup' import { getBackendHealthStatus, getBackendUrl, getAuthToken, getAdminToken, startPythonBackend, setGenerationActive } from '../python-backend' import { getMainWindow } from '../window' import { getAnalyticsState, setAnalyticsEnabled, sendAnalyticsEvent } from '../analytics' +import { + getUpdateState, checkForUpdatesNow, startUpdateDownload, installUpdateAndRestart, + skipUpdateVersion, setAutoCheckUpdatesEnabled, +} from '../updater' +import { getAutoCheckUpdates } from '../app-state' import { handle } from './typed-handle' function getModelsPath(): string { @@ -223,4 +228,52 @@ export function registerAppHandlers(): void { return { success: true } }) + handle('getUpdateState', () => getUpdateState()) + + handle('checkForUpdatesNow', async () => { + try { + await checkForUpdatesNow() + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + }) + + handle('startUpdateDownload', async () => { + try { + await startUpdateDownload() + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + }) + + handle('installUpdateAndRestart', () => { + try { + return installUpdateAndRestart() + } catch (e) { + return { success: false, error: String(e) } + } + }) + + handle('skipUpdateVersion', ({ version }) => { + try { + skipUpdateVersion(version) + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + }) + + handle('getAutoCheckUpdates', () => ({ enabled: getAutoCheckUpdates() })) + + handle('setAutoCheckUpdates', ({ enabled }) => { + try { + setAutoCheckUpdatesEnabled(enabled) + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + }) + } diff --git a/electron/main.ts b/electron/main.ts index 8bd2262d3..68f614eb7 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,3 +1,4 @@ +import './win-dll-search' import './app-paths' import { app } from 'electron' import { setupCSP } from './csp' diff --git a/electron/preload.ts b/electron/preload.ts index a1240cc9d..5e8f6682e 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,4 +1,4 @@ -import { electronAPISchemas, type BackendHealthStatus } from '../shared/electron-api-schema' +import { electronAPISchemas, type BackendHealthStatus, type UpdateStatePayload } from '../shared/electron-api-schema' const { contextBridge, ipcRenderer, webUtils } = require('electron') @@ -24,6 +24,12 @@ api.onBackendHealthStatus = (cb: (data: BackendHealthStatus) => void) => { } } +api.onUpdateEvent = (cb: (data: UpdateStatePayload) => void) => { + const listener = (_: unknown, data: UpdateStatePayload) => cb(data) + ipcRenderer.on('update-event', listener) + return () => ipcRenderer.removeListener('update-event', listener) +} + api.getPathForFile = (file: File) => webUtils.getPathForFile(file) api.platform = process.platform diff --git a/electron/python-backend.ts b/electron/python-backend.ts index 3585cf49b..3b9b7f01d 100644 --- a/electron/python-backend.ts +++ b/electron/python-backend.ts @@ -8,6 +8,7 @@ import { logger, writeLog } from './logger' import { getCurrentLogFilename } from './logging-management' import { getPythonDir } from './python-setup' import { getMainWindow } from './window' +import { PY_REMOVE_CWD_FROM_DLL_SEARCH } from './win-dll-search' let pythonProcess: ChildProcess | null = null let isIntentionalShutdown = false @@ -53,6 +54,10 @@ export function setGenerationActive(active: boolean): void { if (activeGenerationCount === 0) generationActiveSince = null } +export function isGenerationActive(): boolean { + return activeGenerationCount > 0 +} + function isLivenessSuppressed(): boolean { return generationActiveSince != null && Date.now() - generationActiveSince < MAX_SUPPRESSION_MS } @@ -308,7 +313,7 @@ export async function startPythonBackend(): Promise { // can't be found. Use a -c wrapper to fix sys.path before running the server. let pythonArgs: string[] if (!isDev && process.platform === 'win32') { - const preamble = `import sys; sys.path.insert(0, r"${backendPath}"); import runpy; runpy.run_path(r"${mainPy}", run_name="__main__")` + const preamble = `${PY_REMOVE_CWD_FROM_DLL_SEARCH}import sys; sys.path.insert(0, r"${backendPath}"); import runpy; runpy.run_path(r"${mainPy}", run_name="__main__")` pythonArgs = ['-u', '-c', preamble] } else { pythonArgs = isDev ? ['-Xfrozen_modules=off', '-u', mainPy] : ['-u', mainPy] diff --git a/electron/updater.ts b/electron/updater.ts index 510b10871..e0c58a8c7 100644 --- a/electron/updater.ts +++ b/electron/updater.ts @@ -1,48 +1,189 @@ -import { autoUpdater, UpdateDownloadedEvent } from 'electron-updater'; -import { logger } from './logger'; -import { preDownloadPythonForUpdate } from './python-setup'; -import { getMainWindow } from './window'; +import { autoUpdater, UpdateInfo, ProgressInfo } from 'electron-updater' +import { app } from 'electron' +import { logger } from './logger' +import { preDownloadPythonForUpdate } from './python-setup' +import { getMainWindow } from './window' +import { + getSkippedUpdateVersion, setSkippedUpdateVersion, + getAutoCheckUpdates, setAutoCheckUpdates, +} from './app-state' +import { isGenerationActive } from './python-backend' +import type { UpdateStatePayload } from '../shared/electron-api-schema' export type UpdateChannel = 'latest' | 'beta' | 'alpha' -export function initAutoUpdater( - channel: UpdateChannel = 'latest' -): void { +// Cap untrusted feed notes so a huge GitHub body cannot bloat IPC / the modal. +const MAX_RELEASE_NOTES_CHARS = 16_384 + +// The single in-memory value of update state. Broadcast on every change. +let state: UpdateStatePayload = { status: 'idle', currentVersion: app.getVersion() } +let periodicHandle: ReturnType | null = null + +function setState(patch: Partial): void { + state = { ...state, ...patch } + getMainWindow()?.webContents.send('update-event', state) +} + +function releaseNotesFromFeed(info: UpdateInfo): string | undefined { + if (typeof info.releaseNotes !== 'string' || info.releaseNotes.length === 0) return undefined + if (info.releaseNotes.length <= MAX_RELEASE_NOTES_CHARS) return info.releaseNotes + return `${info.releaseNotes.slice(0, MAX_RELEASE_NOTES_CHARS)}\n…` +} + +export function getUpdateState(): UpdateStatePayload { + return state +} + +// True when we must not start or clobber with a new check. +function isBusy(): boolean { + return state.status === 'checking' || state.status === 'downloading' || state.status === 'downloaded' +} + +function hasRestorableOffer(): boolean { + return Boolean(state.version && getSkippedUpdateVersion() !== state.version) +} + +// Network/feed errors must not drop a known offer or a finished download. +function failUpdate(message: string): void { + logger.error(`[updater] ${message}`) + if (state.status === 'downloading') { + setState({ status: 'available', message }) + return + } + if (state.status === 'downloaded') { + setState({ message }) + return + } + if (hasRestorableOffer()) { + setState({ status: 'available', message }) + return + } + setState({ status: 'idle', message }) +} + +function runCheck(): void { + if (isBusy()) return // never interrupt an in-flight download or a downloaded-and-waiting state + logger.info('[updater] Checking for update...') + autoUpdater.checkForUpdates().catch((e) => { + failUpdate(e instanceof Error ? e.message : String(e)) + }) +} + +// Start/stop the 4h timer to match the autoCheckUpdates setting. Safe to call repeatedly. +export function armPeriodicCheck(): void { + if (periodicHandle) { clearInterval(periodicHandle); periodicHandle = null } + if (getAutoCheckUpdates()) { + periodicHandle = setInterval(runCheck, 4 * 60 * 60 * 1000) + } +} + +export function initAutoUpdater(channel: UpdateChannel = 'latest'): void { if (channel !== 'latest') { autoUpdater.channel = channel autoUpdater.allowPrerelease = true } - // Windows/Linux: best-effort pre-download of python-embed so the new version - // doesn't have to download it on first launch. This doesn't block the update — - // autoInstallOnAppQuit stays true (the default) so the update installs whenever - // the user naturally quits, whether or not the pre-download has finished. - autoUpdater.on('update-downloaded', async (info: UpdateDownloadedEvent) => { - if (process.platform === 'darwin') return + // Core change: the user controls download and install. + autoUpdater.autoDownload = false + autoUpdater.autoInstallOnAppQuit = false + + autoUpdater.on('checking-for-update', () => setState({ status: 'checking', message: undefined })) + + autoUpdater.on('update-available', (info: UpdateInfo) => { + const version = info.version + // Skip enforcement: if the user skipped THIS version, stay silent (idle). + if (getSkippedUpdateVersion() === version) { + setState({ status: 'idle', version }) + return + } + setState({ + status: 'available', + version, + // Release notes are untrusted feed content — accept plain strings only, render as text. + releaseNotes: releaseNotesFromFeed(info), + message: undefined, + }) + }) + + autoUpdater.on('update-not-available', () => setState({ status: 'not-available', message: undefined })) - const newVersion = info.version - logger.info( `[updater] Update downloaded: v${newVersion}, pre-downloading python deps...`) + autoUpdater.on('download-progress', (p: ProgressInfo) => + setState({ status: 'downloading', percent: Math.round(p.percent) }), + ) + autoUpdater.on('update-downloaded', async (info: UpdateInfo) => { + setState({ status: 'downloaded', version: info.version }) + if (process.platform === 'darwin') return // macOS: no python pre-download (unchanged) + + logger.info(`[updater] Update downloaded: v${info.version}, pre-downloading python deps...`) try { - const didDownload = await preDownloadPythonForUpdate(newVersion, (progress) => { + const didDownload = await preDownloadPythonForUpdate(info.version, (progress) => { getMainWindow()?.webContents.send('python-update-progress', progress) }) - logger.info( didDownload + logger.info(didDownload ? '[updater] Python pre-download complete' : '[updater] No python changes needed') } catch (err) { - logger.error( `[updater] Python pre-download failed: ${err}`) + logger.error(`[updater] Python pre-download failed: ${err}`) } }) - const update = () => { - logger.info( 'Checking for update...'); - autoUpdater.checkForUpdatesAndNotify().catch((e) => { - logger.error( `Failed checking for updates: ${e}`); - }); + autoUpdater.on('error', (err: Error) => { + failUpdate(err?.message ?? 'Update failed') + }) + + armPeriodicCheck() + // One check shortly after startup, but only if auto-check is on. + setTimeout(() => { if (getAutoCheckUpdates()) runCheck() }, 5_000) +} + +// ---- Actions called from IPC handlers ---- + +// Manual "Check for updates": explicit user intent. Clear any skip and force a check even if +// auto-check is off. +export async function checkForUpdatesNow(): Promise { + if (isBusy()) return + setSkippedUpdateVersion(undefined) // an explicit check overrides a previous skip + logger.info('[updater] Manual check for updates...') + try { + await autoUpdater.checkForUpdates() + } catch (e) { + failUpdate(e instanceof Error ? e.message : String(e)) + throw e + } +} + +export async function startUpdateDownload(): Promise { + if (state.status !== 'available') return + setState({ status: 'downloading', percent: 0, message: undefined }) + await autoUpdater.downloadUpdate() +} + +// Guarded in MAIN: never quit mid-generation. Returns a result the renderer can surface. +export function installUpdateAndRestart(): { success: true } | { success: false; error: string } { + if (state.status !== 'downloaded') { + return { success: false, error: 'No update is ready to install.' } } + if (isGenerationActive()) { + return { success: false, error: 'A generation is running. Please wait for it to finish.' } + } + try { + autoUpdater.quitAndInstall() // quits the app; does not return on success + return { success: true } + } catch (e) { + const error = e instanceof Error ? e.message : String(e) + logger.error(`[updater] install failed: ${error}`) + return { success: false, error } + } +} + +export function skipUpdateVersion(version: string): void { + setSkippedUpdateVersion(version) + setState({ status: 'idle', version }) +} - // Check after startup, then periodically - setTimeout(update, 5_000); - setInterval(update, 4 * 60 * 60 * 1000); +// Persist the auto-check setting AND start/stop the timer immediately (no restart needed). +export function setAutoCheckUpdatesEnabled(enabled: boolean): void { + setAutoCheckUpdates(enabled) + armPeriodicCheck() } diff --git a/electron/win-dll-search.ts b/electron/win-dll-search.ts new file mode 100644 index 000000000..bb17a25a5 --- /dev/null +++ b/electron/win-dll-search.ts @@ -0,0 +1,24 @@ +import { createRequire } from 'node:module' + +/** Python one-liner: strip CWD from the Windows DLL search path. No-op elsewhere. */ +export const PY_REMOVE_CWD_FROM_DLL_SEARCH = + "import sys;(sys.platform=='win32')and __import__('ctypes').WinDLL('kernel32',use_last_error=True).SetDllDirectoryW('');" + +export function removeCwdFromDllSearchPath(): void { + if (process.platform !== 'win32') { + return + } + + const require = createRequire(import.meta.url) + const koffi = require('koffi') as { + load: (name: string) => { func: (sig: string) => (...args: unknown[]) => unknown } + } + const kernel32 = koffi.load('kernel32.dll') + const setDllDirectoryW = kernel32.func('int SetDllDirectoryW(str16)') + const ok = setDllDirectoryW('') + if (!ok) { + console.error('[LTX Desktop] SetDllDirectoryW("") failed') + } +} + +removeCwdFromDllSearchPath() diff --git a/frontend/App.tsx b/frontend/App.tsx index 437529111..491dafbe4 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -21,6 +21,8 @@ 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 { useAppUpdateModal } from './hooks/use-app-update' +import { UpdateAvailableModal } from './components/UpdateAvailableModal' type SetupState = 'loading' | { needsSetup: boolean; needsLicense: boolean } type RequiredModelsGateState = 'checking' | 'missing' | 'ready' @@ -40,6 +42,7 @@ function AppContent() { const [setupState, setSetupState] = useState('loading') const [isSettingsOpen, setIsSettingsOpen] = useState(false) const [settingsInitialTab, setSettingsInitialTab] = useState(undefined) + const { update, isGenerationActive, isModalOpen, openModal, closeModal, checkForUpdates } = useAppUpdateModal() const [isLogViewerOpen, setIsLogViewerOpen] = useState(false) const [isFinalizingFirstRun, setIsFinalizingFirstRun] = useState(false) const [firstRunFinalizeError, setFirstRunFinalizeError] = useState(null) @@ -556,6 +559,9 @@ function AppContent() { setSettingsInitialTab(undefined) }} initialTab={settingsInitialTab} + update={update} + onOpenUpdate={openModal} + onCheckForUpdates={checkForUpdates} /> )} + {isModalOpen && ( + + )} {shouldBlockUntilSettingsLoaded && (
diff --git a/frontend/components/SettingsDropdown.tsx b/frontend/components/SettingsDropdown.tsx index 762e75a00..472aa9dce 100644 --- a/frontend/components/SettingsDropdown.tsx +++ b/frontend/components/SettingsDropdown.tsx @@ -10,6 +10,7 @@ export function SettingsDropdown({ title, tooltip, triggerClassName, + placement = 'above', }: { trigger: React.ReactNode options: { value: string; label: string; disabled?: boolean; tooltip?: string; icon?: React.ReactNode }[] @@ -20,6 +21,8 @@ export function SettingsDropdown({ // Extra classes on the trigger button — e.g. to visually attach it to an adjacent button // as a split-button (rounded-l-none, no left padding, etc). triggerClassName?: string + // Prompt-bar menus open upward; gallery toolbar menus open downward. + placement?: 'above' | 'below' }) { const [isOpen, setIsOpen] = useState(false) const dropdownRef = useRef(null) @@ -50,7 +53,9 @@ export function SettingsDropdown({ {tooltip && !isOpen ? {triggerButton} : triggerButton} {isOpen && ( -
+
{title}
{/* Cap height + scroll so a long option list (e.g. many catalog / custom IC-LoRAs) doesn't clip off-screen — matches the LoRA picker's max-h-80. */} diff --git a/frontend/components/SettingsModal.tsx b/frontend/components/SettingsModal.tsx index 403473383..d40377f48 100644 --- a/frontend/components/SettingsModal.tsx +++ b/frontend/components/SettingsModal.tsx @@ -2,24 +2,30 @@ import { AlertCircle, Check, Download, Film, Folder, HardDrive, Info, KeyRound, import React, { useEffect, useMemo, useRef, useState } from 'react' import { Button } from './ui/button' import { BaseModelSection } from './settings/BaseModelSection' -import { useAppSettings, type AppSettings } from '../contexts/AppSettingsContext' +import { useAppSettings, type AppSettings, DEFAULT_GEMINI_MODEL } from '../contexts/AppSettingsContext' import { ApiClient, type ApiSuccessOf } from '../lib/api-client' import { logger } from '../lib/logger' import { ApiKeyHelperRow, LtxApiKeyInput, LtxApiKeyHelperRow } from './LtxApiKeyInput' import { HfModelAccessGate } from './HfModelAccessGate' import { useHfAuth } from '../hooks/use-hf-auth' import { useHfModelAccess } from '../hooks/use-hf-model-access' +import type { AppUpdate } from '../hooks/use-app-update' +import type { UpdateStatePayload } from '../../shared/electron-api-schema' interface SettingsModalProps { isOpen: boolean onClose: () => void initialTab?: TabId + update: AppUpdate + onOpenUpdate: () => void + onCheckForUpdates: () => void } type TabId = 'general' | 'models' | 'apiKeys' | 'promptEnhancer' | 'about' /** A checkpoint this modal can download: the text encoder or the optional prompt enhancer. */ type TextEncodingCp = NonNullable['cp_to_download']> +type GeminiModelOption = ApiSuccessOf<'listGeminiModels'>['models'][number] /** Focuses an API Keys tab input once the modal has switched to that tab. * Shared by the LTX and FAL key inputs — each call gets its own ref/pending state. */ @@ -96,8 +102,70 @@ function SettingToggle({ title, description, enabled, onToggle, statusOn, status ) } -export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProps) { - const { settings, updateSettings, saveLtxApiKey, saveFalApiKey, saveGeminiApiKey, forceApiGenerations, cudaAvailable, notifyModelsChanged } = useAppSettings() +function GeminiModelSelect({ + disabled, + models, + value, + onChange, +}: { + disabled: boolean + models: GeminiModelOption[] + value: string + onChange: (id: string) => void +}) { + const options = models.some((model) => model.id === value) + ? models + : [...models, { id: value, displayName: value, description: '' }] + const selectedDescription = options.find((model) => model.id === value)?.description?.trim() ?? '' + return ( +
+ +

+ Gemini chat models only — used for Enhance (API) and timeline gap + suggestions. Local Enhance still uses Gemma on-device. +

+ {selectedDescription ? ( +

{selectedDescription}

+ ) : null} +
+ ) +} + +const ABOUT_ACTION_CLASS = 'w-full bg-zinc-700 hover:bg-zinc-600 text-white text-xs' + +function aboutUpdateAction( + state: UpdateStatePayload, + onOpenUpdate: () => void, + onCheckForUpdates: () => void, +): { label: string; onClick?: () => void; disabled?: boolean } { + switch (state.status) { + case 'available': + return { label: `Update available — v${state.version}`, onClick: onOpenUpdate } + case 'downloaded': + return { label: 'Restart to update', onClick: onOpenUpdate } + case 'checking': + return { label: 'Checking…', disabled: true } + case 'downloading': + return { label: `Downloading… ${state.percent ?? 0}%`, disabled: true } + default: + return { label: 'Check for updates', onClick: onCheckForUpdates } + } +} + +export function SettingsModal({ isOpen, onClose, initialTab, update, onOpenUpdate, onCheckForUpdates }: SettingsModalProps) { + const { settings, updateSettings, saveLtxApiKey, saveFalApiKey, saveGeminiApiKey, refreshSettings, forceApiGenerations, cudaAvailable, notifyModelsChanged } = useAppSettings() const onSettingsChange = (next: AppSettings) => updateSettings(next) const [activeTab, setActiveTab] = useState('general') const ltxApiKey = useApiKeyFocus(isOpen, activeTab, setActiveTab) @@ -106,6 +174,9 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp const [falApiKeyInput, setFalApiKeyInput] = useState('') const [geminiApiKeyInput, setGeminiApiKeyInput] = useState('') const geminiApiKeyInputRef = useRef(null) + const [geminiModelOptions, setGeminiModelOptions] = useState([]) + const [resolvedGeminiModel, setResolvedGeminiModel] = useState(DEFAULT_GEMINI_MODEL) + const geminiModelSaveSeq = useRef(0) const [textEncoderRecommendation, setTextEncoderRecommendation] = useState | null>(null) // Which checkpoint is downloading, not just whether one is — the encoder and the optional // prompt enhancer each have their own card and must show progress only on their own. @@ -144,7 +215,9 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp const [modelLicenseLoading, setModelLicenseLoading] = useState(false) const [showModelLicense, setShowModelLicense] = useState(false) const [analyticsEnabled, setAnalyticsEnabled] = useState(false) + const [autoCheckUpdates, setAutoCheckUpdatesState] = useState(true) const [projectAssetsPath, setProjectAssetsPath] = useState('') + const updateAction = aboutUpdateAction(update.state, onOpenUpdate, onCheckForUpdates) // Sync active tab with initialTab prop when modal opens useEffect(() => { @@ -176,8 +249,41 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp window.electronAPI.getProjectAssetsPath() .then((p: string) => setProjectAssetsPath(p)) .catch(() => {}) + window.electronAPI.getAutoCheckUpdates() + .then((s: { enabled: boolean }) => setAutoCheckUpdatesState(s.enabled)) + .catch(() => {}) }, [isOpen]) + useEffect(() => { + const fallbackId = settings.geminiModel.trim() || DEFAULT_GEMINI_MODEL + if (!isOpen) return + if (!settings.hasGeminiApiKey) { + setGeminiModelOptions([{ id: fallbackId, displayName: fallbackId, description: '' }]) + setResolvedGeminiModel(fallbackId) + return + } + + let cancelled = false + const loadGeminiModels = async () => { + const result = await ApiClient.listGeminiModels() + if (cancelled) return + if (!result.ok) { + setGeminiModelOptions([{ id: fallbackId, displayName: fallbackId, description: '' }]) + setResolvedGeminiModel(fallbackId) + return + } + setGeminiModelOptions(result.data.models) + setResolvedGeminiModel(result.data.resolvedModel) + } + void loadGeminiModels() + return () => { + cancelled = true + } + // Selecting a model calls refreshSettings(), which updates settings.geminiModel. Relisting + // on that change would hit the backend on every pick and wipe the dropdown if the GET failed. + // eslint-disable-next-line react-hooks/exhaustive-deps -- fallbackId is only used when the key is missing or the list fails + }, [isOpen, settings.hasGeminiApiKey]) + // Fetch text encoder recommendation when modal opens useEffect(() => { if (!isOpen || forceApiGenerations) return @@ -302,6 +408,12 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp window.electronAPI.setAnalyticsEnabled({ enabled: next }).catch(() => {}) } + const handleToggleAutoCheck = () => { + const next = !autoCheckUpdates + setAutoCheckUpdatesState(next) + window.electronAPI.setAutoCheckUpdates({ enabled: next }).catch(() => {}) + } + // Seed handlers const handleToggleSeedLock = () => { onSettingsChange({ @@ -1122,6 +1234,25 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp )}
+ { + const previous = resolvedGeminiModel + const requestId = ++geminiModelSaveSeq.current + setResolvedGeminiModel(geminiModel) + void (async () => { + const result = await ApiClient.updateSettings({ geminiModel }) + if (requestId !== geminiModelSaveSeq.current) return + if (!result.ok) { + setResolvedGeminiModel(previous) + return + } + await refreshSettings() + })() + }} + /> + {/* Updates */} +
+
+ + Updates +
+

+ {update.state.status === 'available' && `Version ${update.state.version} is available.`} + {update.state.status === 'downloading' && `Downloading… ${update.state.percent ?? 0}%`} + {update.state.status === 'downloaded' && 'Download complete. Restart to apply the update.'} + {update.state.status === 'checking' && 'Checking for updates…'} + {update.state.status === 'not-available' && "You're up to date."} + {update.state.status === 'idle' && 'Check for a newer version, or let the app check automatically.'} +

+ {update.state.message && ( +

{update.state.message}

+ )} + +
+
+ +

+ Periodically check for new versions. You can always check manually above. +

+
+ +
+
+ {/* License */}
diff --git a/frontend/components/UpdateAvailableModal.css b/frontend/components/UpdateAvailableModal.css new file mode 100644 index 000000000..78b524a32 --- /dev/null +++ b/frontend/components/UpdateAvailableModal.css @@ -0,0 +1,165 @@ +.update-modal-backdrop { + position: fixed; + inset: 0; + z-index: 70; + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + background: rgba(0, 0, 0, 0.72); + backdrop-filter: blur(4px); + animation: update-modal-backdrop-in 180ms ease-out; +} + +.update-modal { + position: relative; + width: 100%; + max-width: 440px; + padding: 1.75rem 1.5rem 1.5rem; + border-radius: 16px; + border: 1px solid rgb(63 63 70); + background: rgb(24 24 27); + color: white; + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6); + animation: update-modal-card-in 260ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.update-modal h2 { + margin: 0 1.5rem 0.25rem 0; + font-size: 1.25rem; + font-weight: 600; + letter-spacing: -0.01em; +} + +.update-modal-close { + position: absolute; + top: 0.75rem; + right: 0.75rem; + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border: 0; + border-radius: 0.5rem; + background: transparent; + color: rgb(161 161 170); + cursor: pointer; +} + +.update-modal-close:hover { + background: rgb(39 39 42); + color: white; +} + +.update-modal-version { + margin: 0; + font-size: 0.875rem; + color: rgb(161 161 170); +} + +.update-modal-notes { + margin-top: 0.75rem; + max-height: 10rem; + overflow-y: auto; + padding: 0.75rem; + border-radius: 0.5rem; + background: rgb(9 9 11 / 0.6); + border: 1px solid rgb(63 63 70 / 0.6); + color: rgb(212 212 216); + font-size: 0.8125rem; + line-height: 1.45; + white-space: pre-wrap; +} + +.update-modal-error, +.update-modal-warning { + display: flex; + align-items: flex-start; + gap: 0.5rem; + margin: 0.75rem 0 0; + font-size: 0.8125rem; + line-height: 1.4; +} + +.update-modal-error { + color: rgb(248 113 113); +} + +.update-modal-warning { + color: rgb(251 191 36); +} + +.update-modal-progress { + position: relative; + margin-top: 1.25rem; + padding-bottom: 1.5rem; +} + +.update-modal-progress::before { + content: ''; + display: block; + height: 8px; + border-radius: 999px; + background: rgb(39 39 42); +} + +.update-modal-bar { + position: absolute; + top: 0; + left: 0; + height: 8px; + border-radius: 999px; + background: rgb(43 97 255); + transition: width 150ms ease; +} + +.update-modal-progress span { + position: absolute; + right: 0; + top: 14px; + font-size: 0.75rem; + color: rgb(161 161 170); +} + +.update-modal-skip { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 1rem; + font-size: 0.875rem; + color: rgb(212 212 216); + cursor: pointer; +} + +.update-modal-skip input { + width: 1rem; + height: 1rem; +} + +.update-modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 1.25rem; +} + +.update-modal-actions button { + gap: 0.5rem; +} + +@keyframes update-modal-backdrop-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes update-modal-card-in { + from { + opacity: 0; + transform: translateY(16px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} diff --git a/frontend/components/UpdateAvailableModal.tsx b/frontend/components/UpdateAvailableModal.tsx new file mode 100644 index 000000000..74b3b49d9 --- /dev/null +++ b/frontend/components/UpdateAvailableModal.tsx @@ -0,0 +1,133 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { AlertCircle, Download, RefreshCw, X } from 'lucide-react' +import { Button } from './ui/button' +import type { AppUpdate } from '../hooks/use-app-update' +import './UpdateAvailableModal.css' + +interface Props { + update: AppUpdate + isGenerationActive: boolean + // onClose receives whether the user ticked "Skip this version". + onClose: (skipThisVersion: boolean) => void +} + +function titleForStatus(status: AppUpdate['state']['status']): string { + if (status === 'downloaded') return 'Ready to install' + if (status === 'downloading') return 'Downloading update' + if (status === 'checking') return 'Checking for updates' + return 'Update available' +} + +export function UpdateAvailableModal({ update, isGenerationActive, onClose }: Props) { + const { state, startDownload, installAndRestart } = update + const [skipChecked, setSkipChecked] = useState(false) + const [installError, setInstallError] = useState(null) + const dialogRef = useRef(null) + + const downloading = state.status === 'downloading' + const downloaded = state.status === 'downloaded' + const canDownload = state.status === 'available' + const canDismiss = !downloaded + + const handleClose = useCallback(() => { + if (!canDismiss) return + onClose(skipChecked && state.status === 'available') + }, [canDismiss, onClose, skipChecked, state.status]) + + useEffect(() => { + dialogRef.current?.focus() + }, []) + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && canDismiss) handleClose() + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [canDismiss, handleClose]) + + const handleInstall = async () => { + try { + const res = await installAndRestart() + if (!res.success) setInstallError(res.error ?? 'Could not install the update.') + } catch (e) { + setInstallError(e instanceof Error ? e.message : 'Could not install the update.') + } + } + + return ( +
+
e.stopPropagation()} + > + {canDismiss && ( + + )} + +

{titleForStatus(state.status)}

+

+ {state.currentVersion} → {state.version} +

+ + {state.releaseNotes &&
{state.releaseNotes}
} + + {state.message && ( +

{state.message}

+ )} + {installError && ( +

{installError}

+ )} + + {downloading && ( +
+
+ {state.percent ?? 0}% +
+ )} + + {downloaded && isGenerationActive && ( +

+ A generation is running. Installing will restart the app — it will be enabled when the + generation finishes. +

+ )} + + {state.status === 'available' && ( + + )} + +
+ {downloaded ? ( + + ) : downloading ? ( + + ) : ( + <> + + + + )} +
+
+
+ ) +} diff --git a/frontend/contexts/AppSettingsContext.tsx b/frontend/contexts/AppSettingsContext.tsx index b0bf24df0..0db8838ae 100644 --- a/frontend/contexts/AppSettingsContext.tsx +++ b/frontend/contexts/AppSettingsContext.tsx @@ -10,6 +10,7 @@ export interface AppSettings { hasFalApiKey: boolean userPrefersFalApiImageGenerations: boolean hasGeminiApiKey: boolean + geminiModel: string useLocalTextEncoder: boolean promptCacheSize: number promptEnhancerEnabledT2V: boolean @@ -25,6 +26,8 @@ export interface AppSettings { useConvVae: boolean } +export const DEFAULT_GEMINI_MODEL = 'gemini-2.5-flash-lite' + export const DEFAULT_APP_SETTINGS: AppSettings = { useTorchCompile: false, diffusionStageCacheEnabled: false, @@ -33,6 +36,7 @@ export const DEFAULT_APP_SETTINGS: AppSettings = { hasFalApiKey: false, userPrefersFalApiImageGenerations: false, hasGeminiApiKey: false, + geminiModel: '', useLocalTextEncoder: false, promptCacheSize: 1, promptEnhancerEnabledT2V: false, @@ -89,6 +93,7 @@ function normalizeAppSettings(data: Partial): AppSettings { hasFalApiKey: data.hasFalApiKey ?? DEFAULT_APP_SETTINGS.hasFalApiKey, userPrefersFalApiImageGenerations: data.userPrefersFalApiImageGenerations ?? DEFAULT_APP_SETTINGS.userPrefersFalApiImageGenerations, hasGeminiApiKey: data.hasGeminiApiKey ?? DEFAULT_APP_SETTINGS.hasGeminiApiKey, + geminiModel: data.geminiModel ?? DEFAULT_APP_SETTINGS.geminiModel, useLocalTextEncoder: data.useLocalTextEncoder ?? DEFAULT_APP_SETTINGS.useLocalTextEncoder, promptCacheSize: data.promptCacheSize ?? DEFAULT_APP_SETTINGS.promptCacheSize, promptEnhancerEnabledT2V: data.promptEnhancerEnabledT2V ?? DEFAULT_APP_SETTINGS.promptEnhancerEnabledT2V, diff --git a/frontend/generated/backend-openapi.json b/frontend/generated/backend-openapi.json index d16b14378..7d8da34f2 100644 --- a/frontend/generated/backend-openapi.json +++ b/frontend/generated/backend-openapi.json @@ -101,6 +101,17 @@ ], "title": "Geminiapikey" }, + "geminiModel": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Geminimodel" + }, "lockedSeed": { "anyOf": [ { @@ -859,6 +870,50 @@ "title": "ExtendRequest", "type": "object" }, + "GeminiModelOptionPayload": { + "properties": { + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "displayName": { + "title": "Displayname", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + } + }, + "required": [ + "id", + "displayName" + ], + "title": "GeminiModelOptionPayload", + "type": "object" + }, + "GeminiModelsResponsePayload": { + "properties": { + "models": { + "items": { + "$ref": "#/components/schemas/GeminiModelOptionPayload" + }, + "title": "Models", + "type": "array" + }, + "resolvedModel": { + "title": "Resolvedmodel", + "type": "string" + } + }, + "required": [ + "models", + "resolvedModel" + ], + "title": "GeminiModelsResponsePayload", + "type": "object" + }, "GenerateImageCancelledResponse": { "properties": { "status": { @@ -1156,6 +1211,10 @@ }, "GenerationProgressResponse": { "properties": { + "cancellable": { + "title": "Cancellable", + "type": "boolean" + }, "currentStep": { "anyOf": [ { @@ -1231,7 +1290,8 @@ "phase", "progress", "currentStep", - "totalSteps" + "totalSteps", + "cancellable" ], "title": "GenerationProgressResponse", "type": "object" @@ -3745,6 +3805,11 @@ "title": "Diffusionstagecacheenabled", "type": "boolean" }, + "geminiModel": { + "default": "", + "title": "Geminimodel", + "type": "string" + }, "hasFalApiKey": { "default": false, "title": "Hasfalapikey", @@ -5982,6 +6047,47 @@ ] } }, + "/api/settings/gemini-models": { + "get": { + "operationId": "route_list_gemini_models_api_settings_gemini_models_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GeminiModelsResponsePayload" + } + } + }, + "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 List Gemini Models", + "tags": [ + "settings" + ] + } + }, "/api/suggest-gap-prompt": { "post": { "operationId": "route_suggest_gap_prompt_api_suggest_gap_prompt_post", diff --git a/frontend/generated/backend-openapi.ts b/frontend/generated/backend-openapi.ts index 84eec01ab..8cd1ef63b 100644 --- a/frontend/generated/backend-openapi.ts +++ b/frontend/generated/backend-openapi.ts @@ -656,6 +656,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/settings/gemini-models": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Route List Gemini Models */ + get: operations["route_list_gemini_models_api_settings_gemini_models_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/suggest-gap-prompt": { parameters: { query?: never; @@ -728,6 +745,8 @@ export interface components { falApiKey?: string | null; /** Geminiapikey */ geminiApiKey?: string | null; + /** Geminimodel */ + geminiModel?: string | null; /** Lockedseed */ lockedSeed?: number | null; /** Ltxapikey */ @@ -965,6 +984,25 @@ export interface components { /** Video Path */ video_path: string; }; + /** GeminiModelOptionPayload */ + GeminiModelOptionPayload: { + /** + * Description + * @default + */ + description: string; + /** Displayname */ + displayName: string; + /** Id */ + id: string; + }; + /** GeminiModelsResponsePayload */ + GeminiModelsResponsePayload: { + /** Models */ + models: components["schemas"]["GeminiModelOptionPayload"][]; + /** Resolvedmodel */ + resolvedModel: string; + }; /** GenerateImageCancelledResponse */ GenerateImageCancelledResponse: { /** @@ -1100,6 +1138,8 @@ export interface components { }; /** GenerationProgressResponse */ GenerationProgressResponse: { + /** Cancellable */ + cancellable: boolean; /** Currentstep */ currentStep: number | null; /** Id */ @@ -2006,6 +2046,11 @@ export interface components { * @default false */ diffusionStageCacheEnabled: boolean; + /** + * Geminimodel + * @default + */ + geminiModel: string; /** * Hasfalapikey * @default false @@ -3691,6 +3736,44 @@ export interface operations { }; }; }; + route_list_gemini_models_api_settings_gemini_models_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"]["GeminiModelsResponsePayload"]; + }; + }; + /** @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_suggest_gap_prompt_api_suggest_gap_prompt_post: { parameters: { query?: never; diff --git a/frontend/hooks/use-app-update.ts b/frontend/hooks/use-app-update.ts new file mode 100644 index 000000000..945964cf1 --- /dev/null +++ b/frontend/hooks/use-app-update.ts @@ -0,0 +1,111 @@ +import { useCallback, useEffect, useState } from 'react' +import type { UpdateStatePayload } from '../../shared/electron-api-schema' +import { useIsGenerationActive } from '../lib/generation-active' + +export type AppUpdate = { + state: UpdateStatePayload + checkForUpdates: () => Promise + startDownload: () => Promise + installAndRestart: () => Promise<{ success: true } | { success: false; error: string }> + skipVersion: (version: string) => Promise +} + +const INITIAL: UpdateStatePayload = { status: 'idle', currentVersion: '' } + +const MODAL_STATUSES: ReadonlySet = new Set([ + 'available', + 'downloading', + 'downloaded', +]) + +export function useAppUpdate(): AppUpdate { + const [state, setState] = useState(INITIAL) + + useEffect(() => { + let alive = true + let fromEvent = false + // Subscribe first so a check that starts during getUpdateState cannot be missed, + // then ignore the snapshot if an event already applied a newer value. + const unsubscribe = window.electronAPI.onUpdateEvent((data) => { + fromEvent = true + if (alive) setState(data) + }) + void window.electronAPI.getUpdateState() + .then((s) => { if (alive && !fromEvent) setState(s) }) + .catch(() => {}) + return () => { alive = false; unsubscribe() } + }, []) + + const checkForUpdates = useCallback(async () => { + await window.electronAPI.checkForUpdatesNow() + }, []) + const startDownload = useCallback(async () => { + await window.electronAPI.startUpdateDownload() + }, []) + const installAndRestart = useCallback(async () => { + return window.electronAPI.installUpdateAndRestart() + }, []) + const skipVersion = useCallback(async (version: string) => { + await window.electronAPI.skipUpdateVersion({ version }) + }, []) + + return { state, checkForUpdates, startDownload, installAndRestart, skipVersion } +} + +/** Session Later / skip / manual-check intent. App only mounts the modal. */ +export function useAppUpdateModal() { + const update = useAppUpdate() + const isGenerationActive = useIsGenerationActive() + const [modalOpen, setModalOpen] = useState(false) + const [manualCheckPending, setManualCheckPending] = useState(false) + const [laterVersion, setLaterVersion] = useState(null) + + useEffect(() => { + const s = update.state + if (s.status === 'available') { + if (manualCheckPending || s.version !== laterVersion) { + setModalOpen(true) + } + if (manualCheckPending) setManualCheckPending(false) + } else if (s.status === 'downloaded') { + // Bits are on disk: the modal is the only install path and must stay up, + // including after Hide-during-download. + setModalOpen(true) + } else if (s.status === 'not-available') { + if (manualCheckPending) setManualCheckPending(false) + } + }, [update.state.status, update.state.version, manualCheckPending, laterVersion]) + + const requestCheck = update.checkForUpdates + const skipVersion = update.skipVersion + const version = update.state.version + const status = update.state.status + + const checkForUpdates = useCallback(() => { + setManualCheckPending(true) + void requestCheck() + }, [requestCheck]) + + const closeModal = useCallback((skipThisVersion: boolean) => { + if (status === 'downloaded') return + if (skipThisVersion && version) void skipVersion(version) + // Hide during download is not Later — keep the session prompt so a failed + // download can reopen the modal. Later/skip only apply when dismissing the offer. + else if (version && status !== 'downloading') setLaterVersion(version) + setModalOpen(false) + }, [skipVersion, version, status]) + + const openModal = useCallback(() => setModalOpen(true), []) + + return { + update, + isGenerationActive, + // Keep the modal mounted across a periodic re-check (`checking`) so it does not + // unmount/remount. Do not treat `available` as busy in main — that would hide a + // newer version after the user clicked Later. + isModalOpen: modalOpen && (MODAL_STATUSES.has(status) || status === 'checking'), + openModal, + closeModal, + checkForUpdates, + } +} diff --git a/frontend/hooks/use-extend.ts b/frontend/hooks/use-extend.ts index 967a3b70d..9dfec002c 100644 --- a/frontend/hooks/use-extend.ts +++ b/frontend/hooks/use-extend.ts @@ -1,7 +1,8 @@ import { useCallback, useState } from 'react' import { ApiClient } from '../lib/api-client' -import { withGenerationActive } from '../lib/generation-active' +import { canCancelLocalJob, withGenerationActive } from '../lib/generation-active' import { logger } from '../lib/logger' +import { useAppSettings } from '../contexts/AppSettingsContext' import type { RetakeExtendModel } from './use-retake' export type ExtendDirection = 'start' | 'end' @@ -25,14 +26,17 @@ export interface ExtendResult { interface UseExtendState { isExtending: boolean + canCancel: boolean extendStatus: string extendError: string | null result: ExtendResult | null } export function useExtend() { + const { shouldVideoGenerateWithLtxApi, shouldImageGenerateWithFalApi } = useAppSettings() const [state, setState] = useState({ isExtending: false, + canCancel: false, extendStatus: '', extendError: null, result: null, @@ -41,7 +45,13 @@ export function useExtend() { const submitExtend = useCallback(async (params: ExtendSubmitParams) => { if (!params.videoPath) return - setState({ isExtending: true, extendStatus: 'Generating', extendError: null, result: null }) + setState({ + isExtending: true, + canCancel: canCancelLocalJob('video', shouldVideoGenerateWithLtxApi, shouldImageGenerateWithFalApi), + extendStatus: 'Generating', + extendError: null, + result: null, + }) await withGenerationActive(async () => { const result = await ApiClient.extend({ @@ -55,20 +65,21 @@ export function useExtend() { if (!result.ok) { logger.error(`Extend error: ${result.error.message}`) - setState({ isExtending: false, extendStatus: '', extendError: result.error.message, result: null }) + setState({ isExtending: false, canCancel: false, extendStatus: '', extendError: result.error.message, result: null }) return } const payload = result.data if (payload.status === 'cancelled') { - setState({ isExtending: false, extendStatus: 'Cancelled', extendError: null, result: null }) + setState({ isExtending: false, canCancel: false, extendStatus: 'Cancelled', extendError: null, result: null }) return } if ('video_path' in payload) { setState({ isExtending: false, + canCancel: false, extendStatus: 'Extend complete!', extendError: null, result: { videoPath: payload.video_path }, @@ -82,21 +93,23 @@ export function useExtend() { logger.warn(`Extend completed with a remote payload and no local file: ${JSON.stringify(payload.result)}`) setState({ isExtending: false, + canCancel: false, extendStatus: 'Extend complete!', extendError: null, result: null, }) }) - }, []) + }, [shouldImageGenerateWithFalApi, shouldVideoGenerateWithLtxApi]) const resetExtend = useCallback(() => { - setState({ isExtending: false, extendStatus: '', extendError: null, result: null }) + setState({ isExtending: false, canCancel: false, extendStatus: '', extendError: null, result: null }) }, []) return { submitExtend, resetExtend, isExtending: state.isExtending, + canCancel: state.canCancel, extendStatus: state.extendStatus, extendError: state.extendError, extendResult: state.result, diff --git a/frontend/hooks/use-generation.ts b/frontend/hooks/use-generation.ts index ae96159a2..389db9ace 100644 --- a/frontend/hooks/use-generation.ts +++ b/frontend/hooks/use-generation.ts @@ -2,7 +2,7 @@ import { useState, useCallback, useRef, useEffect } from 'react' import type { GenerationSettings } from '../components/SettingsPanel' import { ApiClient, type ApiRequestBodyOf, type ApiSuccessOf } from '../lib/api-client' import { createLocalGenerationError, type GenerationError } from '../lib/generation-errors' -import { withGenerationActive } from '../lib/generation-active' +import { canCancelLocalJob, withGenerationActive } from '../lib/generation-active' import { useAppSettings } from '../contexts/AppSettingsContext' const POLLING_INTERVAL_MS = 2000 @@ -21,6 +21,10 @@ export interface GenerationRecoveryContext { inputImageUrl?: string inputAudioUrl?: string genType?: 'image' | 'enhance' + // Frozen at marker write (job start) — same rule as hook canCancel. Lets Stop survive a + // UI refresh before the first progress poll returns (local GPU can starve that poll). + // Absent on older markers: treat as not cancellable until the poll reports it. + canCancel?: boolean // Whatever generation id the backend reported at the moment this marker was written — i.e. // immediately BEFORE this generation started. The handler that starts a generation loads its // pipeline (can take many seconds — worse for image models loading checkpoint shards) before @@ -36,8 +40,21 @@ export interface GenerationRecoveryContext { generationId?: string } +export function readRecoveryMarkerCanCancel(): boolean { + const saved = localStorage.getItem(GENERATION_RECOVERY_KEY) + if (!saved) return false + try { + return (JSON.parse(saved) as GenerationRecoveryContext).canCancel === true + } catch { + return false + } +} + interface GenerationState { isGenerating: boolean + isCancelling: boolean + /** Frozen at job start — Stop stays hidden if this POST was an LTX/FAL cloud job. */ + canCancel: boolean progress: number statusMessage: string videoPath: string | null @@ -110,15 +127,19 @@ function getPhaseMessage(phase: string): string { return 'Decoding video...' case 'complete': return 'Complete!' + case 'cancelled': + return 'Cancelling…' default: return 'Generating...' } } export function useGeneration(): UseGenerationReturn { - const { settings: appSettings, shouldImageGenerateWithFalApi, refreshSettings } = useAppSettings() + const { settings: appSettings, shouldImageGenerateWithFalApi, shouldVideoGenerateWithLtxApi, refreshSettings } = useAppSettings() const [state, setState] = useState({ isGenerating: false, + isCancelling: false, + canCancel: false, progress: 0, statusMessage: '', videoPath: null, @@ -127,7 +148,6 @@ export function useGeneration(): UseGenerationReturn { error: null, }) - const abortControllerRef = useRef(null) const recoveryIntervalRef = useRef | null>(null) const clearRecoveryPolling = () => { @@ -150,19 +170,23 @@ export function useGeneration(): UseGenerationReturn { const vp = typeof data.result === 'string' ? data.result : null const ips = Array.isArray(data.result) ? data.result : [] setState({ - isGenerating: false, progress: 100, statusMessage: 'Complete!', + isGenerating: false, isCancelling: false, canCancel: false, progress: 100, statusMessage: 'Complete!', videoPath: vp, imagePath: ips[0] ?? null, imagePaths: ips, error: null, }) return 'complete' } if (data.status === 'running') { setState(prev => ({ - ...prev, isGenerating: true, progress: data.progress, + ...prev, + isGenerating: true, + isCancelling: data.phase === 'cancelled', + canCancel: data.cancellable, + progress: data.progress, statusMessage: getPhaseMessage(data.phase), })) return 'running' } - setState(prev => ({ ...prev, isGenerating: false, statusMessage: '' })) + setState(prev => ({ ...prev, isGenerating: false, isCancelling: false, canCancel: false, statusMessage: '' })) return 'other' } @@ -193,6 +217,8 @@ export function useGeneration(): UseGenerationReturn { setState({ isGenerating: true, + isCancelling: false, + canCancel: canCancelLocalJob('video', shouldVideoGenerateWithLtxApi, shouldImageGenerateWithFalApi), progress: 0, statusMessage: statusMsg, videoPath: null, @@ -201,8 +227,6 @@ export function useGeneration(): UseGenerationReturn { error: null, }) - const abortController = new AbortController() - abortControllerRef.current = abortController let progressInterval: ReturnType | null = null let shouldApplyPollingUpdates = true @@ -265,24 +289,31 @@ export function useGeneration(): UseGenerationReturn { lastPhase = data.phase - setState(prev => ({ - ...prev, - progress: displayProgress, - statusMessage, - })) + setState(prev => { + if (prev.isCancelling) { + return { ...prev, statusMessage: 'Cancelling…' } + } + return { + ...prev, + progress: displayProgress, + statusMessage, + } + }) } progressInterval = setInterval(pollProgress, 500) // Start generation (HTTP POST - synchronous, returns when done) - const result = await ApiClient.generateVideo(body as unknown as GenerateVideoRequest, { - signal: abortController.signal, - }) + // Do not abort this POST: liveness suppression stays up until the + // backend returns {status: "cancelled"} and the GPU job unwinds. + const result = await ApiClient.generateVideo(body as unknown as GenerateVideoRequest) shouldApplyPollingUpdates = false if (!result.ok) { setState(prev => ({ ...prev, isGenerating: false, + isCancelling: false, + canCancel: false, error: result, })) return @@ -292,6 +323,8 @@ export function useGeneration(): UseGenerationReturn { if (payload.status === 'complete') { setState({ isGenerating: false, + isCancelling: false, + canCancel: false, progress: 100, statusMessage: 'Complete!', videoPath: payload.video_path, @@ -303,6 +336,8 @@ export function useGeneration(): UseGenerationReturn { setState(prev => ({ ...prev, isGenerating: false, + isCancelling: false, + canCancel: false, statusMessage: 'Cancelled', })) } else { @@ -310,19 +345,13 @@ export function useGeneration(): UseGenerationReturn { } } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - setState(prev => ({ - ...prev, - isGenerating: false, - statusMessage: 'Cancelled', - })) - } else { - setState(prev => ({ - ...prev, - isGenerating: false, - error: createLocalGenerationError(error instanceof Error ? error.message : 'Unknown error'), - })) - } + setState(prev => ({ + ...prev, + isGenerating: false, + isCancelling: false, + canCancel: false, + error: createLocalGenerationError(error instanceof Error ? error.message : 'Unknown error'), + })) } finally { shouldApplyPollingUpdates = false if (progressInterval) { @@ -330,20 +359,29 @@ export function useGeneration(): UseGenerationReturn { } } }) - }, []) - - const cancel = useCallback(async () => { - // Abort the fetch request - abortControllerRef.current?.abort() - - // Also tell the backend to cancel - void ApiClient.cancelGeneration() - - setState(prev => ({ - ...prev, - isGenerating: false, - statusMessage: 'Cancelled', - })) + }, [shouldImageGenerateWithFalApi, shouldVideoGenerateWithLtxApi]) + + const cancel = useCallback(() => { + let claimedCancelling = false + setState(prev => { + if (!prev.isGenerating || prev.isCancelling) return prev + claimedCancelling = true + return { + ...prev, + isCancelling: true, + statusMessage: 'Cancelling…', + } + }) + // Always POST — retake/extend/IC-LoRA Stop reuse this while this hook is idle. + void (async () => { + const result = await ApiClient.cancelGeneration() + const accepted = result.ok && result.data.status === 'cancelling' + if (accepted || !claimedCancelling) return + setState(prev => { + if (!prev.isCancelling) return prev + return { ...prev, isCancelling: false } + }) + })() }, []) const generateImage = useCallback(async ( @@ -378,6 +416,8 @@ export function useGeneration(): UseGenerationReturn { setState({ isGenerating: true, + isCancelling: false, + canCancel: canCancelLocalJob('image', shouldVideoGenerateWithLtxApi, shouldImageGenerateWithFalApi), progress: 0, statusMessage: isEditing ? 'Editing image...' @@ -388,9 +428,6 @@ export function useGeneration(): UseGenerationReturn { error: null, }) - const abortController = new AbortController() - abortControllerRef.current = abortController - await withGenerationActive(async () => { let progressInterval: ReturnType | null = null try { @@ -409,21 +446,26 @@ export function useGeneration(): UseGenerationReturn { const data = result.data const currentImage = data.currentStep || 0 const totalImages = data.totalSteps || numImages - setState(prev => ({ - ...prev, - progress: data.progress, - statusMessage: data.phase === 'loading_model' - ? 'Loading Z-Image Turbo model...' - : data.phase === 'inference' - ? isEditing - ? 'Editing image...' - : numImages > 1 - ? `Generating image ${currentImage + 1}/${totalImages}...` - : 'Generating image...' - : data.phase === 'complete' - ? 'Complete!' - : 'Generating...', - })) + setState(prev => { + if (prev.isCancelling) { + return { ...prev, statusMessage: 'Cancelling…' } + } + return { + ...prev, + progress: data.progress, + statusMessage: data.phase === 'loading_model' + ? 'Loading Z-Image Turbo model...' + : data.phase === 'inference' + ? isEditing + ? 'Editing image...' + : numImages > 1 + ? `Generating image ${currentImage + 1}/${totalImages}...` + : 'Generating image...' + : data.phase === 'complete' + ? 'Complete!' + : 'Generating...', + } + }) } progressInterval = setInterval(pollProgress, 500) @@ -439,14 +481,14 @@ export function useGeneration(): UseGenerationReturn { strength: isEditing ? (settings.imageEditStrength ?? 0.6) : 0.6, ...(isEditing ? { imagePath: editSource } : {}), } - const result = await ApiClient.generateImage(imageRequest, { - signal: abortController.signal, - }) + const result = await ApiClient.generateImage(imageRequest) if (!result.ok) { setState(prev => ({ ...prev, isGenerating: false, + isCancelling: false, + canCancel: false, error: result, })) return @@ -461,6 +503,8 @@ export function useGeneration(): UseGenerationReturn { setState({ isGenerating: false, + isCancelling: false, + canCancel: false, progress: 100, statusMessage: 'Complete!', videoPath: null, @@ -472,6 +516,8 @@ export function useGeneration(): UseGenerationReturn { setState(prev => ({ ...prev, isGenerating: false, + isCancelling: false, + canCancel: false, statusMessage: 'Cancelled', })) } else { @@ -479,32 +525,28 @@ export function useGeneration(): UseGenerationReturn { } } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - setState(prev => ({ - ...prev, - isGenerating: false, - statusMessage: 'Cancelled', - })) - } else { - setState(prev => ({ - ...prev, - isGenerating: false, - error: createLocalGenerationError(error instanceof Error ? error.message : 'Unknown error'), - })) - } + setState(prev => ({ + ...prev, + isGenerating: false, + isCancelling: false, + canCancel: false, + error: createLocalGenerationError(error instanceof Error ? error.message : 'Unknown error'), + })) } finally { if (progressInterval) { clearInterval(progressInterval) } } }) - }, [appSettings.hasFalApiKey, shouldImageGenerateWithFalApi, refreshSettings]) + }, [appSettings.hasFalApiKey, shouldImageGenerateWithFalApi, shouldVideoGenerateWithLtxApi, refreshSettings]) const reset = useCallback(() => { clearRecoveryPolling() localStorage.removeItem(GENERATION_RECOVERY_KEY) setState({ isGenerating: false, + isCancelling: false, + canCancel: false, progress: 0, statusMessage: '', videoPath: null, diff --git a/frontend/hooks/use-global-generation-lock.ts b/frontend/hooks/use-global-generation-lock.ts index 32d511133..ade8b067d 100644 --- a/frontend/hooks/use-global-generation-lock.ts +++ b/frontend/hooks/use-global-generation-lock.ts @@ -1,6 +1,17 @@ import { useEffect, useState } from 'react' import { subscribeWhileGenerationMayBeActive } from '../lib/generation-progress-poll' -import { GENERATION_RECOVERY_KEY } from './use-generation' +import { GENERATION_RECOVERY_KEY, readRecoveryMarkerCanCancel } from './use-generation' + +export interface GlobalGenerationLock { + // Fail-closed Generate disable: a recovery marker, a running poll, or an unconfirmed + // poll error all mean the single global slot must be treated as busy. + isRunning: boolean + // Same poll as isRunning, gated on the backend's frozen local-GPU vs API slot. + // Never derived from live Settings — switching to local mid-API-job must not reveal Stop. + canCancel: boolean + // Slot still busy after Stop (`status=running`, `phase=cancelled`) until generate() unwinds. + isCancelling: boolean +} // Only one generation can run at a time across the whole app (single global backend slot), but // each project's GenSpace only tracks its OWN local isGenerating-style state — it has no idea a @@ -8,6 +19,9 @@ import { GENERATION_RECOVERY_KEY } from './use-generation' // slot. Without this, Generate stays clickable in project B while project A is mid-generation; // the request 409s, but only after writeRecoveryContext already overwrote A's in-flight recovery // marker with B's (now-failed) one. Polling here lets Generate disable proactively instead. +// Stop uses the same sources: the recovery marker (immediate, frozen at job start) and +// GET /generation/progress.cancellable (authoritative once a poll lands). Hook-local canCancel +// dies on UI refresh; these do not. Live Settings must not flip Stop mid-job. // No marker anywhere means nothing CAN be running (see subscribeWhileGenerationMayBeActive), so // idle starts unlocked and costs no network call; once a marker exists and we're actually // polling, an unconfirmed/failed poll is treated as locked rather than silently trusting "not @@ -17,12 +31,32 @@ import { GENERATION_RECOVERY_KEY } from './use-generation' // project's marker (and its still-running backend generation) survives in localStorage, and the // first poll takes a network round trip to resolve — that gap is otherwise the same unconfirmed // window all over again, just re-opened on every reload instead of only at first app launch. -export function useGlobalGenerationLock(): boolean { - const [isRunning, setIsRunning] = useState(() => localStorage.getItem(GENERATION_RECOVERY_KEY) != null) +export function useGlobalGenerationLock(): GlobalGenerationLock { + const [lock, setLock] = useState(() => ({ + isRunning: localStorage.getItem(GENERATION_RECOVERY_KEY) != null, + canCancel: readRecoveryMarkerCanCancel(), + isCancelling: false, + })) useEffect(() => subscribeWhileGenerationMayBeActive(result => { - setIsRunning(result.ok ? result.data.status === 'running' : true) + if (!result.ok) { + // Same fail-closed Generate lock as before. Stop keeps the frozen marker bit rather + // than guessing — a starved poll during local GPU work must not hide Stop, and a + // failed poll during an API job must not reveal it. + setLock({ + isRunning: true, + canCancel: readRecoveryMarkerCanCancel(), + isCancelling: false, + }) + return + } + const isRunning = result.data.status === 'running' + setLock({ + isRunning, + canCancel: isRunning && result.data.cancellable, + isCancelling: isRunning && result.data.phase === 'cancelled', + }) }), []) - return isRunning + return lock } diff --git a/frontend/hooks/use-ic-lora.ts b/frontend/hooks/use-ic-lora.ts index cfa2177a1..45b5e3319 100644 --- a/frontend/hooks/use-ic-lora.ts +++ b/frontend/hooks/use-ic-lora.ts @@ -55,6 +55,7 @@ export interface IcLoraResult { interface UseIcLoraState { isGenerating: boolean + canCancel: boolean status: string error: string | null result: IcLoraResult | null @@ -65,6 +66,7 @@ type GenerateIcLoraBody = ApiRequestBodyOf<'generateIcLora'> export function useIcLora() { const [state, setState] = useState({ isGenerating: false, + canCancel: false, status: '', error: null, result: null, @@ -78,6 +80,9 @@ export function useIcLora() { setState({ isGenerating: true, + // IC-LoRA is always local GPU (the tab is hidden when forceApiGenerations). + // Do not gate on shouldVideoGenerateWithLtxApi — that flag is for t2v/i2v. + canCancel: true, status: 'Generating', error: null, result: null, @@ -111,6 +116,7 @@ export function useIcLora() { logger.error(`IC-LoRA error: ${result.error.message}`) setState({ isGenerating: false, + canCancel: false, status: '', error: result.error.message, result: null, @@ -122,6 +128,7 @@ export function useIcLora() { if (payload.status === 'cancelled') { setState({ isGenerating: false, + canCancel: false, status: 'Cancelled', error: null, result: null, @@ -132,6 +139,7 @@ export function useIcLora() { if (payload.status === 'complete') { setState({ isGenerating: false, + canCancel: false, status: 'Generation complete!', error: null, result: { @@ -146,6 +154,7 @@ export function useIcLora() { const reset = useCallback(() => { setState({ isGenerating: false, + canCancel: false, status: '', error: null, result: null, @@ -156,6 +165,7 @@ export function useIcLora() { submitIcLora, resetIcLora: reset, isIcLoraGenerating: state.isGenerating, + canCancel: state.canCancel, icLoraStatus: state.status, icLoraError: state.error, icLoraResult: state.result, diff --git a/frontend/hooks/use-retake.ts b/frontend/hooks/use-retake.ts index 43121d179..4a07dd215 100644 --- a/frontend/hooks/use-retake.ts +++ b/frontend/hooks/use-retake.ts @@ -1,8 +1,9 @@ import { useCallback, useState } from 'react' import type { components } from '../generated/backend-openapi' import { ApiClient } from '../lib/api-client' -import { withGenerationActive } from '../lib/generation-active' +import { canCancelLocalJob, withGenerationActive } from '../lib/generation-active' import { logger } from '../lib/logger' +import { useAppSettings } from '../contexts/AppSettingsContext' export type RetakeMode = 'replace_audio_and_video' | 'replace_video' | 'replace_audio' @@ -37,14 +38,17 @@ export interface RetakeResult { interface UseRetakeState { isRetaking: boolean + canCancel: boolean retakeStatus: string retakeError: string | null result: RetakeResult | null } export function useRetake() { + const { shouldVideoGenerateWithLtxApi, shouldImageGenerateWithFalApi } = useAppSettings() const [state, setState] = useState({ isRetaking: false, + canCancel: false, retakeStatus: '', retakeError: null, result: null, @@ -55,6 +59,7 @@ export function useRetake() { setState({ isRetaking: true, + canCancel: canCancelLocalJob('video', shouldVideoGenerateWithLtxApi, shouldImageGenerateWithFalApi), retakeStatus: 'Generating', retakeError: null, result: null, @@ -75,6 +80,7 @@ export function useRetake() { logger.error(`Retake error: ${result.error.message}`) setState({ isRetaking: false, + canCancel: false, retakeStatus: '', retakeError: result.error.message, result: null, @@ -87,6 +93,7 @@ export function useRetake() { if (payload.status === 'cancelled') { setState({ isRetaking: false, + canCancel: false, retakeStatus: 'Cancelled', retakeError: null, result: null, @@ -97,6 +104,7 @@ export function useRetake() { if ('video_path' in payload) { setState({ isRetaking: false, + canCancel: false, retakeStatus: 'Retake complete!', retakeError: null, result: { @@ -110,16 +118,18 @@ export function useRetake() { const errorMsg = 'Retake completed but no local video file was returned' setState({ isRetaking: false, + canCancel: false, retakeStatus: '', retakeError: errorMsg, result: null, }) }) - }, []) + }, [shouldImageGenerateWithFalApi, shouldVideoGenerateWithLtxApi]) const resetRetake = useCallback(() => { setState({ isRetaking: false, + canCancel: false, retakeStatus: '', retakeError: null, result: null, @@ -130,6 +140,7 @@ export function useRetake() { submitRetake, resetRetake, isRetaking: state.isRetaking, + canCancel: state.canCancel, retakeStatus: state.retakeStatus, retakeError: state.retakeError, retakeResult: state.result, diff --git a/frontend/lib/api-client.ts b/frontend/lib/api-client.ts index 86a13458e..3d86bd011 100644 --- a/frontend/lib/api-client.ts +++ b/frontend/lib/api-client.ts @@ -363,6 +363,8 @@ export class ApiClient { static getSettings = makeEndpointClient('/api/settings', 'get') + static listGeminiModels = makeEndpointClient('/api/settings/gemini-models', 'get') + static updateSettings = makeEndpointClient('/api/settings', 'post') static suggestGapPrompt = makeEndpointClient('/api/suggest-gap-prompt', 'post', { diff --git a/frontend/lib/generation-active.ts b/frontend/lib/generation-active.ts index 8637e2d0a..bf6a3b30c 100644 --- a/frontend/lib/generation-active.ts +++ b/frontend/lib/generation-active.ts @@ -1,11 +1,43 @@ -// Local generation can starve the backend's own event loop for tens of seconds (see -// electron/python-backend.ts) — this stops the liveness monitor from mistaking "busy" for -// "hung" and killing the process mid-generation. +import { useSyncExternalStore } from 'react' + +// Local, live "is a generation running?" signal for the renderer UI. +// Ref-counted because generations can overlap. +let activeCount = 0 +const listeners = new Set<() => void>() +function emit() { for (const l of listeners) l() } + +function subscribe(cb: () => void): () => void { + listeners.add(cb) + return () => { listeners.delete(cb) } +} +function getSnapshot(): boolean { + return activeCount > 0 +} + +export function useIsGenerationActive(): boolean { + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot) +} + +// Local generation can starve the backend's event loop; withGenerationActive tells main so the +// liveness monitor doesn't kill a busy backend. We also keep a local count for the UI signal above. export async function withGenerationActive(fn: () => Promise): Promise { + activeCount += 1; emit() void window.electronAPI.notifyGenerationActive({ active: true }) try { return await fn() } finally { + activeCount = Math.max(0, activeCount - 1); emit() void window.electronAPI.notifyGenerationActive({ active: false }) } } + +/** Evaluate at job start and freeze — live Settings must not flip Stop mid-request. */ +export function canCancelLocalJob( + kind: 'video' | 'image', + videoUsesLtxApi: boolean, + imageUsesFalApi: boolean, +): boolean { + if (kind === 'image') return !imageUsesFalApi + if (kind === 'video') return !videoUsesLtxApi + return false +} diff --git a/frontend/lib/genspace-gallery.ts b/frontend/lib/genspace-gallery.ts new file mode 100644 index 000000000..271a6c4c8 --- /dev/null +++ b/frontend/lib/genspace-gallery.ts @@ -0,0 +1,112 @@ +import type { Asset } from '../types/project-model' + +export type GenSpaceTypeFilter = 'all' | 'video' | 'image' +export type GenSpaceSortKey = 'createdAt' | 'type' | 'duration' | 'resolution' | 'ratio' +export type GenSpaceSortDir = 'asc' | 'desc' + +export const GENSPACE_TYPE_FILTER_OPTIONS: { value: GenSpaceTypeFilter; label: string }[] = [ + { value: 'all', label: 'All' }, + { value: 'video', label: 'Videos' }, + { value: 'image', label: 'Images' }, +] + +export const GENSPACE_SORT_OPTIONS: { value: GenSpaceSortKey; label: string }[] = [ + { value: 'createdAt', label: 'Date' }, + { value: 'type', label: 'Type' }, + { value: 'duration', label: 'Duration' }, + { value: 'resolution', label: 'Resolution' }, + { value: 'ratio', label: 'Ratio' }, +] + +const VISUAL_TYPES = new Set(['video', 'image']) + +export function defaultSortDir(key: GenSpaceSortKey): GenSpaceSortDir { + switch (key) { + case 'createdAt': + case 'duration': + case 'resolution': + case 'ratio': + case 'type': + return 'desc' + } +} + +export function filterGenSpaceAssets( + assets: Asset[], + typeFilter: GenSpaceTypeFilter, + favoritesOnly: boolean, +): Asset[] { + let result = assets.filter(asset => VISUAL_TYPES.has(asset.type)) + if (typeFilter !== 'all') { + result = result.filter(asset => asset.type === typeFilter) + } + if (favoritesOnly) { + result = result.filter(asset => asset.favorite) + } + return result +} + +function resolutionRank(asset: Asset): number { + if (asset.width && asset.height) return Math.min(asset.width, asset.height) + const match = asset.resolution?.match(/(\d+)/) + return match ? parseInt(match[1], 10) : 0 +} + +function durationRank(asset: Asset): number { + return asset.duration ?? 0 +} + +function parseAspectRatioString(value?: string): number { + if (!value) return 0 + const match = value.match(/^(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)$/) + if (!match) return 0 + const height = Number(match[2]) + return height === 0 ? 0 : Number(match[1]) / height +} + +function ratioRank(asset: Asset): number { + if (asset.width && asset.height) return asset.width / asset.height + return parseAspectRatioString(asset.generationParams?.imageAspectRatio) +} + +function compareTieBreak(a: Asset, b: Asset): number { + if (a.createdAt !== b.createdAt) return b.createdAt - a.createdAt + return a.id.localeCompare(b.id) +} + +function comparePrimary(a: Asset, b: Asset, key: GenSpaceSortKey): number { + switch (key) { + case 'type': + return a.type.localeCompare(b.type) + case 'duration': + return durationRank(a) - durationRank(b) + case 'resolution': + return resolutionRank(a) - resolutionRank(b) + case 'ratio': + return ratioRank(a) - ratioRank(b) + case 'createdAt': + default: + return a.createdAt - b.createdAt + } +} + +export function sortGenSpaceAssets( + assets: Asset[], + key: GenSpaceSortKey, + direction: GenSpaceSortDir, +): Asset[] { + const dir = direction === 'desc' ? -1 : 1 + return [...assets].sort((a, b) => { + const primary = comparePrimary(a, b, key) + if (primary !== 0) return dir * primary + return compareTieBreak(a, b) + }) +} + +export function shouldShowGeneratingTile( + typeFilter: GenSpaceTypeFilter, + mode: 'image' | 'video', +): boolean { + if (typeFilter === 'all') return true + return typeFilter === mode +} diff --git a/frontend/views/GenSpace.tsx b/frontend/views/GenSpace.tsx index 274b30f34..63b5e70a6 100644 --- a/frontend/views/GenSpace.tsx +++ b/frontend/views/GenSpace.tsx @@ -3,14 +3,14 @@ import { Trash2, Download, Image, Video, X, Heart, Film, Volume2, VolumeX, Sparkles, Sparkle, Clock, Monitor, ChevronUp, Scissors, Music, Undo2, Redo2, Loader2, - MoveHorizontal, Wand2 + MoveHorizontal, Wand2, Square } from 'lucide-react' import { useProjects } from '../contexts/ProjectContext' import type { GenSpaceRetakeSource } from '../contexts/ProjectContext' import { useAppSettings } from '../contexts/AppSettingsContext' import { useGeneration, GENERATION_RECOVERY_KEY, type GenerationRecoveryContext } from '../hooks/use-generation' import { setActiveGenerationOwner, hasValidBaselineId } from '../lib/generation-recovery' -import { withGenerationActive } from '../lib/generation-active' +import { withGenerationActive, canCancelLocalJob } from '../lib/generation-active' import { useVideoGenerationModelSpecs } from '../hooks/use-video-generation-model-specs' import { createLocalGenerationError, type GenerationError } from '../lib/generation-errors' import { @@ -60,6 +60,11 @@ import { SettingsDropdown } from '../components/SettingsDropdown' import { IcLoraSettingsControls, type IcLoraControlsProps } from '../components/IcLoraSettingsControls' import { IcLoraAdvancedPanel } from '../components/IcLoraAdvancedPanel' import { FreeApiKeyBubble } from '../components/FreeApiKeyBubble' +import { shouldShowGeneratingTile } from '../lib/genspace-gallery' +import { GenSpaceFilterEmptyState } from './genspace/GenSpaceFilterEmptyState' +import { GenSpaceGalleryToolbar } from './genspace/GenSpaceGalleryToolbar' +import { gallerySizeClasses, type GallerySize } from './genspace/GenSpaceGallerySizeMenu' +import { useGenSpaceGallery } from './genspace/useGenSpaceGallery' // Asset card with hover overlays function AssetCard({ @@ -466,7 +471,9 @@ function PromptBar({ prompt, onPromptChange, onGenerate, + onStop, isGenerating, + isCancelling, inputImage, onInputImageChange, inputAudio, @@ -518,7 +525,9 @@ function PromptBar({ prompt: string onPromptChange: (prompt: string) => void onGenerate: () => void + onStop?: () => void isGenerating: boolean + isCancelling?: boolean canGenerate: boolean buttonLabel: string buttonIcon: React.ReactNode @@ -717,6 +726,10 @@ function PromptBar({ } } + const showStop = Boolean(isGenerating && onStop) + const stopDisabled = Boolean(isCancelling) + const generateDisabled = isGenerating || !canGenerate || isEnhancingPrompt + return (
{/* Top row: Image ref | Prompt | Generate */} @@ -1148,79 +1161,37 @@ function PromptBar({ )} - {/* Generate button */} + {/* Generate / Stop button */}
) } -// Gallery size icon components -function GridSmallIcon({ className }: { className?: string }) { - return ( - - - - - - - - - - - - - - - ) -} - -function GridMediumIcon({ className }: { className?: string }) { - return ( - - - - - - - - - - - - ) -} - -function GridLargeIcon({ className }: { className?: string }) { - return ( - - - - - - - ) -} - -type GallerySize = 'small' | 'medium' | 'large' - -const gallerySizeClasses: Record = { - small: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7', - medium: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5', - large: 'grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3', -} - const DEFAULT_VIDEO_SETTINGS = { model: 'fast', duration: 5 as number | null, @@ -1273,10 +1244,7 @@ export function GenSpace() { const [inputAudio, setInputAudio] = useState(null) const [localError, setLocalError] = useState(null) const [selectedAsset, setSelectedAsset] = useState(null) - const [showFavorites, setShowFavorites] = useState(false) const [gallerySize, setGallerySize] = useState('medium') - const [showSizeMenu, setShowSizeMenu] = useState(false) - const sizeMenuRef = useRef(null) const persistedVideoKeyRef = useRef(null) const retakeSubmissionRef = useRef<{ prompt: string @@ -1330,6 +1298,8 @@ export function GenSpace() { error, reset, resumeIfRunning, + cancel, + canCancel: generationCanCancel, } = useGeneration() // Locally installed LoRAs are only usable in local generation mode. @@ -1473,6 +1443,7 @@ export function GenSpace() { submitRetake, resetRetake, isRetaking, + canCancel: retakeCanCancel, retakeStatus, retakeError, retakeResult, @@ -1495,6 +1466,7 @@ export function GenSpace() { submitExtend, resetExtend, isExtending, + canCancel: extendCanCancel, extendStatus, extendError, extendResult, @@ -1601,6 +1573,7 @@ export function GenSpace() { submitIcLora, resetIcLora, isIcLoraGenerating, + canCancel: icLoraCanCancel, icLoraStatus, icLoraError, icLoraResult, @@ -1706,6 +1679,19 @@ export function GenSpace() { // Only show assets that were generated (have generationParams), not imported files const assets = (activeProject?.assets || []).filter(a => a.generationParams) + const { + typeFilter, + setTypeFilter, + sortKey, + sortDir, + onSortKeyChange, + onToggleSortDir, + showFavorites, + onToggleFavorites, + filteredAssets, + favoriteCount, + hasTypeMatches, + } = useGenSpaceGallery(assets) const [lastPrompt, setLastPrompt] = useState('') // On mount: recover any generation that was still running when the frontend reloaded. @@ -2173,7 +2159,9 @@ export function GenSpace() { // pipeline (can take many seconds) before it ever reports a new id, so without a baseline // captured up front, a poll can't tell "my generation hasn't started reporting yet" apart from // "a stale, unrelated result predates this marker entirely". - const writeRecoveryContext = async (ctx: Omit) => { + const writeRecoveryContext = async ( + ctx: Omit, + ) => { if (!currentProjectId) return const before = await ApiClient.getGenerationProgress() if (!before.ok) { @@ -2186,10 +2174,24 @@ export function GenSpace() { return } const baselineId = before.data.id ?? null - logger.info(`Writing recovery marker for ${currentProjectId} (genType=${ctx.genType ?? 'video'}, baselineId=${baselineId})`) + const canCancel = ctx.genType === 'enhance' + ? false + : mode === 'ic-lora' + ? true + : canCancelLocalJob( + ctx.genType === 'image' ? 'image' : 'video', + shouldVideoGenerateWithLtxApi, + shouldImageGenerateWithFalApi, + ) + logger.info(`Writing recovery marker for ${currentProjectId} (genType=${ctx.genType ?? 'video'}, baselineId=${baselineId}, canCancel=${canCancel})`) localStorage.setItem( GENERATION_RECOVERY_KEY, - JSON.stringify({ projectId: currentProjectId, baselineId, ...ctx } satisfies GenerationRecoveryContext), + JSON.stringify({ + projectId: currentProjectId, + baselineId, + ...ctx, + canCancel, + } satisfies GenerationRecoveryContext), ) } @@ -2201,7 +2203,11 @@ export function GenSpace() { // Only one generation can run at a time across the whole app — this catches the case where // it's a DIFFERENT project's, which this instance's own isGenerating/isRetaking/etc (all local // state) can't see. Without it, Enhance/Generate stayed clickable and the request just 409'd. - const isOtherGenerationRunning = useGlobalGenerationLock() + const { + isRunning: isOtherGenerationRunning, + canCancel: globalCanCancel, + isCancelling: globalIsCancelling, + } = useGlobalGenerationLock() const isGenerationInProgressForEnhance = mode === 'ic-lora' ? isIcLoraGenerating : isGenerating const canEnhancePrompt = enhanceAvailableForMode && isEnhancerProviderAvailable && prompt.trim().length > 0 && !isGenerationInProgressForEnhance && !isOtherGenerationRunning @@ -2637,12 +2643,18 @@ export function GenSpace() { hasAudio: Boolean(inputAudio), }).hasCompatibleOptions ) - const canSubmit = !isOtherGenerationRunning && (isRetakeMode - ? retakeInput.ready && !!retakeInput.videoPath && !isRetaking + // One global backend slot: Stop / Generate-disable must follow the in-flight job, not the + // GenSpace mode tab. Retake/extend/IC-LoRA live in different hooks than video/image. + // After a UI refresh those hooks remount at false; the same progress poll that disables + // Generate (isOtherGenerationRunning) is the SSOT for "slot busy" / Stop. + const slotBusyLocally = isGenerating || isRetaking || isExtending || isIcLoraGenerating + const slotBusy = slotBusyLocally || isOtherGenerationRunning + const canSubmit = !isOtherGenerationRunning && !slotBusyLocally && (isRetakeMode + ? retakeInput.ready && !!retakeInput.videoPath : isExtendMode - ? extendInput.ready && !!extendInput.videoPath && !isExtending + ? extendInput.ready && !!extendInput.videoPath : isIcLoraMode - ? (!!prompt.trim() || promptOptional) && icLoraInput.ready && !!icLoraInput.videoPath && !isIcLoraGenerating + ? (!!prompt.trim() || promptOptional) && icLoraInput.ready && !!icLoraInput.videoPath && (isCatalogIcLora || icLoraCondType !== 'custom' || !!icLoraCustomRef) : !!prompt.trim() && hasCompatibleVideoSettings) const promptButtonLabel = isRetakeMode ? 'Retake' : isExtendMode ? 'Extend' : isIcLoraMode ? 'Generate' : 'Generate' @@ -2652,25 +2664,22 @@ export function GenSpace() { ? : isIcLoraMode ? - : - const promptGenerating = isRetakeMode ? isRetaking : isExtendMode ? isExtending : isIcLoraMode ? isIcLoraGenerating : isGenerating - - // Close size menu on click outside + : + const promptGenerating = slotBusy + const [isStopping, setIsStopping] = useState(false) useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (sizeMenuRef.current && !sizeMenuRef.current.contains(e.target as Node)) { - setShowSizeMenu(false) - } - } - if (showSizeMenu) { - document.addEventListener('mousedown', handleClickOutside) - } - return () => document.removeEventListener('mousedown', handleClickOutside) - }, [showSizeMenu]) - - const filteredAssets = showFavorites ? assets.filter(a => a.favorite) : assets - const favoriteCount = assets.filter(a => a.favorite).length + if (!promptGenerating) setIsStopping(false) + }, [promptGenerating]) + const handleStop = useCallback(() => { + setIsStopping(true) + cancel() + }, [cancel]) + const inFlightCanStop = globalCanCancel + || generationCanCancel || retakeCanCancel || extendCanCancel || icLoraCanCancel const isLibraryMode = mode === 'video' || mode === 'image' + const showGeneratingTile = isGenerating + && (mode === 'image' || mode === 'video') + && shouldShowGeneratingTile(typeFilter, mode) // Navigation for the asset preview modal const selectedIndex = selectedAsset ? filteredAssets.findIndex(a => a.id === selectedAsset.id) : -1 @@ -2685,6 +2694,13 @@ export function GenSpace() { if (canGoNext) setSelectedAsset(filteredAssets[selectedIndex + 1]) }, [canGoNext, filteredAssets, selectedIndex]) + useEffect(() => { + if (!selectedAsset) return + if (!filteredAssets.some(asset => asset.id === selectedAsset.id)) { + setSelectedAsset(null) + } + }, [filteredAssets, selectedAsset]) + // Shared IC-LoRA control props — consumed by the bottom-row settings (PromptBar) and the // advanced side panel beside the prompt. const icLoraControlsProps: IcLoraControlsProps = { @@ -2736,100 +2752,39 @@ export function GenSpace() {
)} - {/* No favorites empty state */} - {isLibraryMode && showFavorites && filteredAssets.length === 0 && assets.length > 0 && ( -
- -

No favorites yet

-

- Click the heart icon on any asset to add it to your favorites. -

-
+ {/* Filter / favorites empty state */} + {isLibraryMode && filteredAssets.length === 0 && assets.length > 0 && ( + )} {/* Assets area — full width, no background, above the prompt bar */} {/* Kept mounted even with no assets so the Browse LoRAs / Favorites / size toolbar survives the empty state. */} {isLibraryMode && (
- {/* Top bar */} -
-
- {mode === 'video' && canUseUserLoras && ( - - )} -
-
- - -
- - - {showSizeMenu && ( -
- {([ - { value: 'small' as GallerySize, label: 'Small', icon: GridSmallIcon }, - { value: 'medium' as GallerySize, label: 'Medium', icon: GridMediumIcon }, - { value: 'large' as GallerySize, label: 'Large', icon: GridLargeIcon }, - ]).map(option => ( - - ))} -
- )} -
-
-
+ loraLibrary.setModalOpen(true)} + typeFilter={typeFilter} + onTypeFilterChange={setTypeFilter} + sortKey={sortKey} + sortDir={sortDir} + onSortKeyChange={onSortKeyChange} + onToggleSortDir={onToggleSortDir} + showFavorites={showFavorites} + favoriteCount={favoriteCount} + onToggleFavorites={onToggleFavorites} + gallerySize={gallerySize} + onGallerySizeChange={setGallerySize} + /> {/* Assets grid — fills remaining space, scrollable */}
- {isGenerating && ( + {showGeneratingTile && (
@@ -2991,7 +2946,9 @@ export function GenSpace() { prompt={prompt} onPromptChange={setPrompt} onGenerate={handleGenerate} + onStop={inFlightCanStop ? handleStop : undefined} isGenerating={promptGenerating} + isCancelling={isStopping || globalIsCancelling} canGenerate={canSubmit} buttonLabel={promptButtonLabel} buttonIcon={promptButtonIcon} diff --git a/frontend/views/VideoEditor.tsx b/frontend/views/VideoEditor.tsx index 45ab04b1a..305cc7730 100644 --- a/frontend/views/VideoEditor.tsx +++ b/frontend/views/VideoEditor.tsx @@ -143,6 +143,7 @@ function VideoEditorWithStore({ error: regenError, cancel: regenCancel, reset: regenReset, + canCancel: regenCanCancel, } = useGeneration() const gapGenerationApi = useMemo(() => ({ @@ -156,9 +157,11 @@ function VideoEditorWithStore({ cancel: regenCancel, reset: regenReset, error: regenError, + canCancel: regenCanCancel, }), [ isRegenerating, regenCancel, + regenCanCancel, regenError, regenGenerate, regenGenerateImage, @@ -505,6 +508,7 @@ function VideoEditorWithStore({ regenVideoPath, regenImagePath, isRegenerating, regenCancel, regenReset, regenError, + canCancelInFlight: regenCanCancel, shouldVideoGenerateWithLtxApi, }) const canUseIcLora = !forceApiGenerations @@ -780,6 +784,7 @@ function VideoEditorWithStore({ handleImportFile={handleImportFile} handleRegenerate={handleRegenerate} handleCancelRegeneration={handleCancelRegeneration} + canCancelInFlight={regenCanCancel} isRegenerating={isRegenerating} regeneratingAssetId={regeneratingAssetId} regenProgress={regenProgress} @@ -905,6 +910,7 @@ function VideoEditorWithStore({ handleRegenerate={handleRegenerate} handleRetakeClip={handleRetakeClip} handleCancelRegeneration={handleCancelRegeneration} + canCancelInFlight={regenCanCancel} isRegenerating={isRegenerating} regenProgress={regenProgress} /> diff --git a/frontend/views/editor/AssetContextMenu.tsx b/frontend/views/editor/AssetContextMenu.tsx index 8dcf6d81c..1d7c2b3d4 100644 --- a/frontend/views/editor/AssetContextMenu.tsx +++ b/frontend/views/editor/AssetContextMenu.tsx @@ -16,6 +16,7 @@ export interface AssetContextMenuProps { addClipToTimeline: (asset: Asset, trackIndex?: number, startTime?: number) => void handleRegenerate: (assetId: string) => void handleCancelRegeneration: () => void + canCancelInFlight: boolean setTakesViewAssetId: (assetId: string | null) => void setSelectedAssetIds: React.Dispatch>> setAssetContextMenu: React.Dispatch> @@ -31,6 +32,7 @@ export function AssetContextMenu({ addClipToTimeline, handleRegenerate, handleCancelRegeneration, + canCancelInFlight, setTakesViewAssetId, setSelectedAssetIds, setAssetContextMenu, @@ -112,7 +114,7 @@ export function AssetContextMenu({ {!isMulti && asset.generationParams && ( <> - {isRegenerating && regeneratingAssetId === asset.id ? ( + {isRegenerating && regeneratingAssetId === asset.id && canCancelInFlight ? (
{takesAsset.generationParams && ( - isRegenerating && regeneratingAssetId === takesAsset.id ? ( + isRegenerating && regeneratingAssetId === takesAsset.id && canCancelInFlight ? ( + )}
)}
@@ -897,12 +901,14 @@ export const VideoEditorAssetsPanel = forwardRef {regenProgress}% {regenStatusMessage} + {canCancelInFlight && ( + )}
)} {asset.takes && asset.takes.length > 1 && ( @@ -1135,6 +1141,7 @@ export const VideoEditorAssetsPanel = forwardRef void reset: () => void error: GenerationError | null + canCancel: boolean } export interface VideoEditorTimelineEditingPanelProps { @@ -139,6 +140,7 @@ export interface VideoEditorTimelineEditingPanelProps { handleRegenerate: (assetId: string, clipId: string) => void handleRetakeClip: (clip: TimelineClip) => void handleCancelRegeneration: () => void + canCancelInFlight: boolean isRegenerating: boolean regenProgress: number } @@ -177,6 +179,7 @@ export function VideoEditorTimelineEditingPanel(props: VideoEditorTimelineEditin handleRegenerate, handleRetakeClip, handleCancelRegeneration, + canCancelInFlight, isRegenerating, regenProgress, } = props @@ -992,9 +995,10 @@ export function VideoEditorTimelineEditingPanel(props: VideoEditorTimelineEditin }, [actions, currentProjectId, gapGenerationApi, generatingGap]) const cancelGapGeneration = useCallback(() => { + if (!gapGenerationApi.canCancel) return gapGenerationApi.cancel() - gapGenerationApi.reset() - setGeneratingGap(null) + // Keep gap UI until the generate POST returns cancelled so liveness + // suppression stays up while the GPU job unwinds. }, [gapGenerationApi]) const handleCloseGap = useCallback(() => { @@ -2620,12 +2624,14 @@ export function VideoEditorTimelineEditingPanel(props: VideoEditorTimelineEditin {regenProgress > 0 ? `${regenProgress}%` : 'Regenerating...'} + {canCancelInFlight && ( + )}
)} @@ -2779,7 +2785,7 @@ export function VideoEditorTimelineEditingPanel(props: VideoEditorTimelineEditin />
)} - {/* Cancel button */} + {gapGenerationApi.canCancel && ( + )}
) : (
void regenReset: () => void regenError: GenerationError | null + canCancelInFlight: boolean shouldVideoGenerateWithLtxApi: boolean } @@ -100,6 +101,7 @@ export function useRegeneration(params: UseRegenerationParams) { regenCancel, regenReset, regenError, + canCancelInFlight, shouldVideoGenerateWithLtxApi, } = params const { @@ -115,6 +117,7 @@ export function useRegeneration(params: UseRegenerationParams) { const regeneratingAssetId = useEditorStore(selectRegeneratingAssetId) const regeneratingClipId = useEditorStore(selectRegeneratingClipId) const regenerationPreError = useEditorStore(selectRegenerationPreError) + const cancelRequestedRef = useRef(false) const dismissRegenerationPreError = useCallback(() => { setRegenerationPreError(null) @@ -271,10 +274,10 @@ export function useRegeneration(params: UseRegenerationParams) { ]) const handleCancelRegeneration = useCallback(() => { + if (!canCancelInFlight) return + cancelRequestedRef.current = true regenCancel() - cancelClipRegeneration() - regenReset() - }, [cancelClipRegeneration, regenCancel, regenReset]) + }, [canCancelInFlight, regenCancel]) const persistGeneratedTake = useCallback(async ( generatedPath: string, @@ -304,6 +307,7 @@ export function useRegeneration(params: UseRegenerationParams) { }, [applyGeneratedTake, cancelClipRegeneration, projectId, regenReset]) useEffect(() => { + if (cancelRequestedRef.current) return if (!regenVideoPath || !regeneratingAssetId || !projectId || isRegenerating) return void persistGeneratedTake(regenVideoPath, 'video', regeneratingAssetId, regeneratingClipId) }, [ @@ -316,6 +320,7 @@ export function useRegeneration(params: UseRegenerationParams) { ]) useEffect(() => { + if (cancelRequestedRef.current) return if (!regenImagePath || !regeneratingAssetId || !projectId || isRegenerating) return void persistGeneratedTake(regenImagePath, 'image', regeneratingAssetId, regeneratingClipId) }, [ @@ -330,10 +335,18 @@ export function useRegeneration(params: UseRegenerationParams) { // Keep UI clip state in sync if generation fails. // Do not reset generation error here; dialog owns that lifecycle. useEffect(() => { + if (cancelRequestedRef.current) return if (!regeneratingAssetId || isRegenerating || !regenError) return cancelClipRegeneration() }, [cancelClipRegeneration, isRegenerating, regeneratingAssetId, regenError]) + useEffect(() => { + if (!cancelRequestedRef.current || isRegenerating) return + cancelRequestedRef.current = false + cancelClipRegeneration() + regenReset() + }, [cancelClipRegeneration, isRegenerating, regenReset]) + return { regeneratingAssetId, regenerationPreError, diff --git a/frontend/views/genspace/GenSpaceFilterEmptyState.tsx b/frontend/views/genspace/GenSpaceFilterEmptyState.tsx new file mode 100644 index 000000000..f5e9b9075 --- /dev/null +++ b/frontend/views/genspace/GenSpaceFilterEmptyState.tsx @@ -0,0 +1,50 @@ +import { Heart, Image, Video } from 'lucide-react' +import type { GenSpaceTypeFilter } from '../../lib/genspace-gallery' + +export function GenSpaceFilterEmptyState({ + typeFilter, + showFavorites, + hasTypeMatches, +}: { + typeFilter: GenSpaceTypeFilter + showFavorites: boolean + hasTypeMatches: boolean +}) { + if (typeFilter !== 'all' && !hasTypeMatches) { + if (typeFilter === 'video') { + return ( +
+
+ ) + } + + return ( +
+ +

No images yet

+

+ Generate an image or switch the filter to see other media. +

+
+ ) + } + + if (showFavorites) { + return ( +
+ +

No favorites yet

+

+ Click the heart icon on any asset to add it to your favorites. +

+
+ ) + } + + return null +} diff --git a/frontend/views/genspace/GenSpaceGallerySizeMenu.tsx b/frontend/views/genspace/GenSpaceGallerySizeMenu.tsx new file mode 100644 index 000000000..84f901012 --- /dev/null +++ b/frontend/views/genspace/GenSpaceGallerySizeMenu.tsx @@ -0,0 +1,130 @@ +import { useEffect, useRef, useState } from 'react' + +export type GallerySize = 'small' | 'medium' | 'large' + +export const gallerySizeClasses: Record = { + small: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7', + medium: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5', + large: 'grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3', +} + +function GridSmallIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + + ) +} + +function GridMediumIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + ) +} + +function GridLargeIcon({ className }: { className?: string }) { + return ( + + + + + + + ) +} + +export function GenSpaceGallerySizeMenu({ + gallerySize, + onGallerySizeChange, +}: { + gallerySize: GallerySize + onGallerySizeChange: (size: GallerySize) => void +}) { + const [open, setOpen] = useState(false) + const menuRef = useRef(null) + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setOpen(false) + } + } + if (open) { + document.addEventListener('mousedown', handleClickOutside) + } + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [open]) + + const CurrentIcon = gallerySize === 'small' + ? GridSmallIcon + : gallerySize === 'medium' + ? GridMediumIcon + : GridLargeIcon + + return ( +
+ + + {open && ( +
+ {([ + { value: 'small' as const, label: 'Small', icon: GridSmallIcon }, + { value: 'medium' as const, label: 'Medium', icon: GridMediumIcon }, + { value: 'large' as const, label: 'Large', icon: GridLargeIcon }, + ]).map(option => ( + + ))} +
+ )} +
+ ) +} diff --git a/frontend/views/genspace/GenSpaceGalleryToolbar.tsx b/frontend/views/genspace/GenSpaceGalleryToolbar.tsx new file mode 100644 index 000000000..eb4ed81a8 --- /dev/null +++ b/frontend/views/genspace/GenSpaceGalleryToolbar.tsx @@ -0,0 +1,83 @@ +import { Heart, Sparkles } from 'lucide-react' +import type { GenSpaceSortDir, GenSpaceSortKey, GenSpaceTypeFilter } from '../../lib/genspace-gallery' +import { GenSpaceGallerySizeMenu, type GallerySize } from './GenSpaceGallerySizeMenu' +import { GenSpaceSortMenu } from './GenSpaceSortMenu' +import { GenSpaceTypeFilter as TypeFilter } from './GenSpaceTypeFilter' + +export function GenSpaceGalleryToolbar({ + showBrowseLoras, + onBrowseLoras, + typeFilter, + onTypeFilterChange, + sortKey, + sortDir, + onSortKeyChange, + onToggleSortDir, + showFavorites, + favoriteCount, + onToggleFavorites, + gallerySize, + onGallerySizeChange, +}: { + showBrowseLoras: boolean + onBrowseLoras: () => void + typeFilter: GenSpaceTypeFilter + onTypeFilterChange: (value: GenSpaceTypeFilter) => void + sortKey: GenSpaceSortKey + sortDir: GenSpaceSortDir + onSortKeyChange: (key: GenSpaceSortKey) => void + onToggleSortDir: () => void + showFavorites: boolean + favoriteCount: number + onToggleFavorites: () => void + gallerySize: GallerySize + onGallerySizeChange: (size: GallerySize) => void +}) { + return ( +
+
+ {showBrowseLoras && ( + + )} +
+
+ + + + +
+
+ ) +} diff --git a/frontend/views/genspace/GenSpaceSortMenu.tsx b/frontend/views/genspace/GenSpaceSortMenu.tsx new file mode 100644 index 000000000..8fcee4147 --- /dev/null +++ b/frontend/views/genspace/GenSpaceSortMenu.tsx @@ -0,0 +1,53 @@ +import { ChevronDown, ChevronUp } from 'lucide-react' +import { SettingsDropdown } from '../../components/SettingsDropdown' +import { + GENSPACE_SORT_OPTIONS, + type GenSpaceSortDir, + type GenSpaceSortKey, +} from '../../lib/genspace-gallery' + +const toolbarTriggerClass = 'px-3 py-1.5 text-sm font-medium text-zinc-400 hover:text-white rounded-r-none' + +export function GenSpaceSortMenu({ + sortKey, + sortDir, + onSortKeyChange, + onToggleSortDir, +}: { + sortKey: GenSpaceSortKey + sortDir: GenSpaceSortDir + onSortKeyChange: (key: GenSpaceSortKey) => void + onToggleSortDir: () => void +}) { + const currentLabel = GENSPACE_SORT_OPTIONS.find(option => option.value === sortKey)?.label ?? 'Date' + const DirectionIcon = sortDir === 'desc' ? ChevronDown : ChevronUp + const directionLabel = sortDir === 'desc' ? 'Descending' : 'Ascending' + + return ( +
+ onSortKeyChange(next as GenSpaceSortKey)} + triggerClassName={toolbarTriggerClass} + trigger={ + <> + {currentLabel} + + + } + options={GENSPACE_SORT_OPTIONS} + /> + +
+ ) +} diff --git a/frontend/views/genspace/GenSpaceTypeFilter.tsx b/frontend/views/genspace/GenSpaceTypeFilter.tsx new file mode 100644 index 000000000..e22968e44 --- /dev/null +++ b/frontend/views/genspace/GenSpaceTypeFilter.tsx @@ -0,0 +1,35 @@ +import { ChevronDown } from 'lucide-react' +import { SettingsDropdown } from '../../components/SettingsDropdown' +import { + GENSPACE_TYPE_FILTER_OPTIONS, + type GenSpaceTypeFilter, +} from '../../lib/genspace-gallery' + +const toolbarTriggerClass = 'px-3 py-1.5 text-sm font-medium text-zinc-400 hover:text-white' + +export function GenSpaceTypeFilter({ + value, + onChange, +}: { + value: GenSpaceTypeFilter + onChange: (value: GenSpaceTypeFilter) => void +}) { + const currentLabel = GENSPACE_TYPE_FILTER_OPTIONS.find(option => option.value === value)?.label ?? 'All' + + return ( + onChange(next as GenSpaceTypeFilter)} + triggerClassName={toolbarTriggerClass} + trigger={ + <> + {currentLabel} + + + } + options={GENSPACE_TYPE_FILTER_OPTIONS} + /> + ) +} diff --git a/frontend/views/genspace/useGenSpaceGallery.ts b/frontend/views/genspace/useGenSpaceGallery.ts new file mode 100644 index 000000000..e91dd4469 --- /dev/null +++ b/frontend/views/genspace/useGenSpaceGallery.ts @@ -0,0 +1,67 @@ +import { useCallback, useMemo, useState } from 'react' +import type { Asset } from '../../types/project-model' +import { + defaultSortDir, + filterGenSpaceAssets, + sortGenSpaceAssets, + type GenSpaceSortDir, + type GenSpaceSortKey, + type GenSpaceTypeFilter, +} from '../../lib/genspace-gallery' + +export function useGenSpaceGallery(assets: Asset[]) { + const [typeFilter, setTypeFilter] = useState('all') + const [sortKey, setSortKey] = useState('createdAt') + const [sortDir, setSortDir] = useState(() => defaultSortDir('createdAt')) + const [showFavorites, setShowFavorites] = useState(false) + + const filteredAssets = useMemo( + () => sortGenSpaceAssets( + filterGenSpaceAssets(assets, typeFilter, showFavorites), + sortKey, + sortDir, + ), + [assets, typeFilter, showFavorites, sortKey, sortDir], + ) + + const favoriteCount = useMemo( + () => filterGenSpaceAssets(assets, 'all', true).length, + [assets], + ) + + const hasTypeMatches = useMemo( + () => filterGenSpaceAssets(assets, typeFilter, false).length > 0, + [assets, typeFilter], + ) + + const onSortKeyChange = useCallback((key: GenSpaceSortKey) => { + if (key === sortKey) { + setSortDir(direction => (direction === 'asc' ? 'desc' : 'asc')) + return + } + setSortKey(key) + setSortDir(defaultSortDir(key)) + }, [sortKey]) + + const onToggleSortDir = useCallback(() => { + setSortDir(direction => (direction === 'asc' ? 'desc' : 'asc')) + }, []) + + const onToggleFavorites = useCallback(() => { + setShowFavorites(value => !value) + }, []) + + return { + typeFilter, + setTypeFilter, + sortKey, + sortDir, + onSortKeyChange, + onToggleSortDir, + showFavorites, + onToggleFavorites, + filteredAssets, + favoriteCount, + hasTypeMatches, + } +} diff --git a/package.json b/package.json index c6b5d7ff5..b60c0685d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ltx-desktop", - "version": "1.2.0", + "version": "1.2.1", "description": "LTX-2 Video Generation - Desktop App", "type": "module", "main": "dist-electron/main.js", @@ -39,6 +39,7 @@ "clsx": "^2.1.1", "electron-updater": "^6.8.3", "js-yaml": "^4.1.1", + "koffi": "3.1.4", "lucide-react": "^0.400.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9eaeb6f0..350e19fa4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,6 +25,9 @@ importers: js-yaml: specifier: ^4.1.1 version: 4.1.1 + koffi: + specifier: 3.1.4 + version: 3.1.4 lucide-react: specifier: ^0.400.0 version: 0.400.0(react@18.3.1) @@ -425,6 +428,81 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@koromix/koffi-darwin-arm64@3.1.4': + resolution: {integrity: sha512-/9o0uahf25sNXz7CczfMAsgdHrrrkDK3/d1W5ygJUC7QnpWo80103yTYpYahWP3vTABK5yjzKtURgssv1paskA==} + cpu: [arm64] + os: [darwin] + + '@koromix/koffi-darwin-x64@3.1.4': + resolution: {integrity: sha512-6IOhfAHbrySr6lYRU720Hg+IMQvtMpN08k9Ppf9WF8NxYRdHLnW1FJm7zCbClfrwudtjhS/piwDYwgAkO5u8cg==} + cpu: [x64] + os: [darwin] + + '@koromix/koffi-freebsd-arm64@3.1.4': + resolution: {integrity: sha512-JKCWC0awdVvq7Nd/etn4PXFTa7uvyHn7IzqtaOZ3r4dJRdwQVby7Ai/wsQo8UUrJfAYlALkLYgFgU8wgsnAE/A==} + cpu: [arm64] + os: [freebsd] + + '@koromix/koffi-freebsd-ia32@3.1.4': + resolution: {integrity: sha512-gU9pShDRLMZzftdGW+mTzyL8Cpa/7nzHPHe5vFakjGgtIzVFzdFBqwli4oB+tFsx44W1VqMMlvMMVlnz54ERiQ==} + cpu: [ia32] + os: [freebsd] + + '@koromix/koffi-freebsd-x64@3.1.4': + resolution: {integrity: sha512-2kppLX97xBM3WoQET6noN4W02zT2fkFRXHYluAwcCcmkEax8AVJ1CYs6hxcZ3kaNPc+5P7yMw3V/b1lg2v3aMw==} + cpu: [x64] + os: [freebsd] + + '@koromix/koffi-linux-arm64@3.1.4': + resolution: {integrity: sha512-yYbypuGVGqrNchkAMY59kj+7TZ1c1u9lXRG1+74X9T8G4rOaushoVONNYLuu+ygpbwsKzz/NvEDtRioRU/dQlQ==} + cpu: [arm64] + os: [linux] + + '@koromix/koffi-linux-ia32@3.1.4': + resolution: {integrity: sha512-IoA/8Qfc6ZEmwMw2Nf4aSp9RfJnxh0UHhdqD4FsVXm0vC797kLMuzj744vv5tll+waVfjrU10jREqjtnMVFoQw==} + cpu: [ia32] + os: [linux] + + '@koromix/koffi-linux-loong64@3.1.4': + resolution: {integrity: sha512-ZUTdea+9dg6CV9J9CIGbhTh0FtSBgvcGKqDrlp9BVQF71jEDKOri1by/TrDe8yQUyC5kzWN8vWnkzES5wT0xDg==} + cpu: [loong64] + os: [linux] + + '@koromix/koffi-linux-riscv64@3.1.4': + resolution: {integrity: sha512-CINyyhNYV/8MX52MGhYcik2G6PXH+KEU2JEO7dOONlsGol4lSGyW40RvYA4RQgNYk8q8imGSEScL08X8eOXnaA==} + cpu: [riscv64] + os: [linux] + + '@koromix/koffi-linux-x64@3.1.4': + resolution: {integrity: sha512-x3XnAy/tUTTCX/gMpV7VJNpOQIVQvzNhNYDrpyIeS9Q8/f1qLsE0vp0tj7A/YEDIfMVLqoJtyamfRJc04+vk4w==} + cpu: [x64] + os: [linux] + + '@koromix/koffi-openbsd-ia32@3.1.4': + resolution: {integrity: sha512-r9p/fffvmBm7+iT5BZ+c17gZJ280jvmbinrPZqjG14rF9I4lk7xrlV79YfsexkeN4mcPjF2hSPtbMNFBoQU3Dw==} + cpu: [ia32] + os: [openbsd] + + '@koromix/koffi-openbsd-x64@3.1.4': + resolution: {integrity: sha512-SNp5AxOzheC2YaWPu3Y86wxRHHWf6V9NMl5Ot5nu9OpnP61Yinzug7JwsCeXtcZZTbKLsfsWoT7y4n17UYpOVA==} + cpu: [x64] + os: [openbsd] + + '@koromix/koffi-win32-arm64@3.1.4': + resolution: {integrity: sha512-oS8ETU35AelOD6DY7xmmz9qq26Xl38upXWiZbsdxbtH9UEIY0QpenQOuCK/0+q4CtfiLorRUlglGkO9YgPAIeA==} + cpu: [arm64] + os: [win32] + + '@koromix/koffi-win32-ia32@3.1.4': + resolution: {integrity: sha512-zd7Qh8s4fzblD9zzuDf44XCbujYg3QrffhgcNJg79/YC6ABT2m0CUtX4yFic9EWm2ps8NPAM25kCTCXPpt3eaw==} + cpu: [ia32] + os: [win32] + + '@koromix/koffi-win32-x64@3.1.4': + resolution: {integrity: sha512-BPeQXc1bRd0QBOklvsP+AjoRnUzKbPNE6rfx7VNxrebhh09MKld2ibstgKWn6ejQLEcfKEoUJ+WAWIhX4AOsIg==} + cpu: [x64] + os: [win32] + '@malept/cross-spawn-promise@2.0.0': resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} engines: {node: '>= 12.13.0'} @@ -1532,6 +1610,9 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + koffi@3.1.4: + resolution: {integrity: sha512-KHX39XIg7afe8ds+0MHPoLiKR9dCzsVK4oAmBUSaeJlcX0xur22f15C2DILbZ6GJ9eyqC+e6Sb1cTG7M17z+Tg==} + lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} @@ -2715,6 +2796,51 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@koromix/koffi-darwin-arm64@3.1.4': + optional: true + + '@koromix/koffi-darwin-x64@3.1.4': + optional: true + + '@koromix/koffi-freebsd-arm64@3.1.4': + optional: true + + '@koromix/koffi-freebsd-ia32@3.1.4': + optional: true + + '@koromix/koffi-freebsd-x64@3.1.4': + optional: true + + '@koromix/koffi-linux-arm64@3.1.4': + optional: true + + '@koromix/koffi-linux-ia32@3.1.4': + optional: true + + '@koromix/koffi-linux-loong64@3.1.4': + optional: true + + '@koromix/koffi-linux-riscv64@3.1.4': + optional: true + + '@koromix/koffi-linux-x64@3.1.4': + optional: true + + '@koromix/koffi-openbsd-ia32@3.1.4': + optional: true + + '@koromix/koffi-openbsd-x64@3.1.4': + optional: true + + '@koromix/koffi-win32-arm64@3.1.4': + optional: true + + '@koromix/koffi-win32-ia32@3.1.4': + optional: true + + '@koromix/koffi-win32-x64@3.1.4': + optional: true + '@malept/cross-spawn-promise@2.0.0': dependencies: cross-spawn: 7.0.6 @@ -3916,6 +4042,24 @@ snapshots: dependencies: json-buffer: 3.0.1 + koffi@3.1.4: + optionalDependencies: + '@koromix/koffi-darwin-arm64': 3.1.4 + '@koromix/koffi-darwin-x64': 3.1.4 + '@koromix/koffi-freebsd-arm64': 3.1.4 + '@koromix/koffi-freebsd-ia32': 3.1.4 + '@koromix/koffi-freebsd-x64': 3.1.4 + '@koromix/koffi-linux-arm64': 3.1.4 + '@koromix/koffi-linux-ia32': 3.1.4 + '@koromix/koffi-linux-loong64': 3.1.4 + '@koromix/koffi-linux-riscv64': 3.1.4 + '@koromix/koffi-linux-x64': 3.1.4 + '@koromix/koffi-openbsd-ia32': 3.1.4 + '@koromix/koffi-openbsd-x64': 3.1.4 + '@koromix/koffi-win32-arm64': 3.1.4 + '@koromix/koffi-win32-ia32': 3.1.4 + '@koromix/koffi-win32-x64': 3.1.4 + lazy-val@1.0.5: {} lilconfig@3.1.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 31261f9fa..75c9bb8af 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,7 @@ allowBuilds: esbuild: true electron-winstaller: true iconv-corefoundation: true + koffi: true minimumReleaseAgeExclude: - "vite-plugin-electron@0.29.1" diff --git a/shared/electron-api-schema.ts b/shared/electron-api-schema.ts index dd8a36d84..06f0e8d89 100644 --- a/shared/electron-api-schema.ts +++ b/shared/electron-api-schema.ts @@ -57,6 +57,16 @@ const backendHealthStatus = z.object({ export type BackendHealthStatus = z.infer +const updateStatePayload = z.object({ + status: z.enum(['idle', 'checking', 'available', 'downloading', 'downloaded', 'not-available']), + currentVersion: z.string(), + version: z.string().optional(), + releaseNotes: z.string().optional(), + percent: z.number().optional(), + message: z.string().optional(), +}) +export type UpdateStatePayload = z.infer + export const electronAPISchemas = { // App info getBackend: { @@ -325,6 +335,36 @@ export const electronAPISchemas = { input: z.object({ eventName: z.string(), extraDetails: z.record(z.string(), z.unknown()).nullable().optional() }), output: z.void(), }, + + // --- App updates --- + getUpdateState: { + input: z.object({}), + output: updateStatePayload, + }, + checkForUpdatesNow: { + input: z.object({}), + output: emptyResult, + }, + startUpdateDownload: { + input: z.object({}), + output: emptyResult, + }, + installUpdateAndRestart: { + input: z.object({}), + output: emptyResult, + }, + skipUpdateVersion: { + input: z.object({ version: z.string() }), + output: emptyResult, + }, + getAutoCheckUpdates: { + input: z.object({}), + output: z.object({ enabled: z.boolean() }), + }, + setAutoCheckUpdates: { + input: z.object({ enabled: z.boolean() }), + output: emptyResult, + }, } as const type Schemas = typeof electronAPISchemas @@ -339,6 +379,7 @@ export type ElectronAPI = InvokeAPI & { onPythonSetupProgress: (cb: (data: unknown) => void) => void removePythonSetupProgress: () => void onBackendHealthStatus: (cb: (data: BackendHealthStatus) => void) => (() => void) + onUpdateEvent: (cb: (data: UpdateStatePayload) => void) => (() => void) getPathForFile: (file: File) => string platform: string } diff --git a/vite.config.ts b/vite.config.ts index ffd4ffb24..66274576e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -23,7 +23,7 @@ export default defineConfig({ outDir: 'dist-electron', sourcemap: true, rollupOptions: { - external: ['electron'] + external: ['electron', 'koffi'] } } }