{job.prompt || "—"}
++ No {typesToFetch.length > 1 ? "jobs" : jobType + " jobs"} yet. Create + one above. +
+ {:else} + {#each jobs as job (job.id)} +diff --git a/.gitignore b/.gitignore index dadc1b67e..222c3c3a8 100644 --- a/.gitignore +++ b/.gitignore @@ -85,6 +85,7 @@ docs/distillation/examples/ dmd_t2v_output/ preprocess_output_text/ +# Next.js / Node artifacts under ui/: see ui/.gitignore .claude/ .codex/ diff --git a/.nvimlog b/.nvimlog new file mode 100644 index 000000000..f394bf16f --- /dev/null +++ b/.nvimlog @@ -0,0 +1 @@ +WRN 2026-03-26T13:46:33.469 ?.19646 server_start:193: Failed to start server: operation not permitted: /var/folders/z_/h_6myyk14d1b7z87z3vy4mjh0000gn/T/nvim.dsynkd/iSe0el/nvim.19646.0 diff --git a/.python-version b/.python-version new file mode 100644 index 000000000..e4fba2183 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/fastvideo-kernel/pyproject.toml b/fastvideo-kernel/pyproject.toml index 9518be79b..155f9811d 100644 --- a/fastvideo-kernel/pyproject.toml +++ b/fastvideo-kernel/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ ] dependencies = [ "torch>=2.5.0", - "triton>=2.0.0", + "triton>=2.0.0; sys_platform == 'linux'", ] [project.urls] diff --git a/fastvideo/entrypoints/video_generator.py b/fastvideo/entrypoints/video_generator.py index 35709c10b..51c6d54af 100644 --- a/fastvideo/entrypoints/video_generator.py +++ b/fastvideo/entrypoints/video_generator.py @@ -52,16 +52,25 @@ class VideoGenerator: customization options, similar to popular frameworks like HF Diffusers. """ - def __init__(self, fastvideo_args: FastVideoArgs, executor_class: type[Executor], log_stats: bool): + def __init__( + self, + fastvideo_args: FastVideoArgs, + executor_class: type[Executor], + log_stats: bool, + *, + log_queue=None, + ): """ Initialize the video generator. - + Args: fastvideo_args: The inference arguments executor_class: The executor class to use for inference + log_stats: Whether to log statistics + log_queue: Optional multiprocessing.Queue to forward worker logs to """ self.fastvideo_args = fastvideo_args - self.executor = executor_class(fastvideo_args) + self.executor = executor_class(fastvideo_args, log_queue=log_queue) @classmethod def from_pretrained(cls, model_path: str, **kwargs) -> "VideoGenerator": @@ -80,18 +89,25 @@ class VideoGenerator: """ # If users also provide some kwargs, it will override the FastVideoArgs and PipelineConfig. kwargs['model_path'] = model_path + log_queue = kwargs.pop("log_queue", None) fastvideo_args = FastVideoArgs.from_kwargs(**kwargs) - return cls.from_fastvideo_args(fastvideo_args) + return cls.from_fastvideo_args(fastvideo_args, log_queue=log_queue) @classmethod - def from_fastvideo_args(cls, fastvideo_args: FastVideoArgs) -> "VideoGenerator": + def from_fastvideo_args( + cls, + fastvideo_args: FastVideoArgs, + *, + log_queue=None, + ) -> "VideoGenerator": """ Create a video generator with the specified arguments. - + Args: fastvideo_args: The inference arguments - + log_queue: Optional multiprocessing.Queue to forward worker logs to + Returns: The created video generator """ @@ -103,6 +119,7 @@ class VideoGenerator: fastvideo_args=fastvideo_args, executor_class=executor_class, log_stats=False, # TODO: implement + log_queue=log_queue, ) def generate_video( @@ -140,6 +157,34 @@ class VideoGenerator: A metadata dictionary for single-prompt generation, or a list of metadata dictionaries for prompt-file batch generation. """ + log_queue = kwargs.pop("log_queue", None) + if log_queue: + self.executor.set_log_queue(log_queue) + + try: + return self._generate_video_impl( + prompt=prompt, + sampling_param=sampling_param, + mouse_cond=mouse_cond, + keyboard_cond=keyboard_cond, + grid_sizes=grid_sizes, + **kwargs, + ) + finally: + if log_queue: + self.executor.clear_log_queue() + + def _generate_video_impl( + self, + prompt: str | None = None, + sampling_param: SamplingParam | None = None, + mouse_cond: torch.Tensor | None = None, + keyboard_cond: torch.Tensor | None = None, + grid_sizes: tuple[int, int, int] | list[int] | torch.Tensor + | None = None, + **kwargs, + ) -> dict[str, Any] | list[np.ndarray] | list[dict[str, Any]]: + """Internal implementation of generate_video.""" # Handle batch processing from text file if sampling_param is None: sampling_param = SamplingParam.from_pretrained(self.fastvideo_args.model_path) @@ -156,7 +201,7 @@ class VideoGenerator: if self.fastvideo_args.prompt_txt is not None or sampling_param.prompt_path is not None: prompt_txt_path = sampling_param.prompt_path or self.fastvideo_args.prompt_txt - if not os.path.exists(prompt_txt_path): + if not prompt_txt_path or not os.path.exists(prompt_txt_path): raise FileNotFoundError(f"Prompt text file not found: {prompt_txt_path}") # Read prompts from file diff --git a/fastvideo/registry.py b/fastvideo/registry.py index 58608c2dd..d37a4d639 100644 --- a/fastvideo/registry.py +++ b/fastvideo/registry.py @@ -137,6 +137,7 @@ class ConfigInfo: sampling_param_cls: type[SamplingParam] | None pipeline_config_cls: type[PipelineConfig] + workload_types: tuple[WorkloadType, ...] # The central registry mapping a model name to its configuration information @@ -152,15 +153,21 @@ _MODEL_NAME_DETECTORS: list[tuple[str, Callable[[str], bool]]] = [] def register_configs( sampling_param_cls: type[SamplingParam] | None, pipeline_config_cls: type[PipelineConfig], + workload_types: tuple[WorkloadType, ...], hf_model_paths: list[str] | None = None, model_detectors: list[Callable[[str], bool]] | None = None, ) -> None: - """Register config classes for a model family.""" + """Register config classes for a model family. + + workload_types declares which UI workload options this config supports. + Use () for configs not exposed as workload options. + """ model_id = str(len(_CONFIG_REGISTRY)) _CONFIG_REGISTRY[model_id] = ConfigInfo( sampling_param_cls=sampling_param_cls, pipeline_config_cls=pipeline_config_cls, + workload_types=workload_types, ) if hf_model_paths: @@ -237,6 +244,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=LTX2BaseSamplingParam, pipeline_config_cls=LTX2T2VConfig, + workload_types=(WorkloadType.T2V, ), hf_model_paths=[ "Lightricks/LTX-2", "FastVideo/LTX2-base", @@ -250,6 +258,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=LTX2DistilledSamplingParam, pipeline_config_cls=LTX2T2VConfig, + workload_types=(WorkloadType.T2V, ), hf_model_paths=[ "FastVideo/LTX2-Distilled-Diffusers", ], @@ -262,6 +271,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=Hunyuan15_480P_SamplingParam, pipeline_config_cls=Hunyuan15T2V480PConfig, + workload_types=(WorkloadType.T2V, ), hf_model_paths=[ "hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v", ], @@ -277,6 +287,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=Hunyuan15_480P_StepDistilled_I2V_SamplingParam, pipeline_config_cls=Hunyuan15I2V480PStepDistilledConfig, + workload_types=(WorkloadType.I2V, ), hf_model_paths=[ "hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_i2v_step_distilled", ], @@ -284,6 +295,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=Hunyuan15_720P_SamplingParam, pipeline_config_cls=Hunyuan15T2V720PConfig, + workload_types=(WorkloadType.T2V, ), hf_model_paths=[ "hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_t2v", ], @@ -291,6 +303,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=Hunyuan15_720P_Distilled_I2V_SamplingParam, pipeline_config_cls=Hunyuan15I2V720PConfig, + workload_types=(WorkloadType.I2V, ), hf_model_paths=[ "hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_i2v_distilled", ], @@ -298,6 +311,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=Hunyuan15_SR_1080P_SamplingParam, pipeline_config_cls=Hunyuan15SR1080PConfig, + workload_types=(), hf_model_paths=["weizhou03/HunyuanVideo-1.5-Diffusers-1080p", "weizhou03/HunyuanVideo-1.5-Diffusers-1080p-2SR"], ) @@ -305,6 +319,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=HunyuanSamplingParam, pipeline_config_cls=HunyuanConfig, + workload_types=(WorkloadType.T2V, ), hf_model_paths=[ "hunyuanvideo-community/HunyuanVideo", ], @@ -316,6 +331,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=FastHunyuanSamplingParam, pipeline_config_cls=FastHunyuanConfig, + workload_types=(WorkloadType.T2V, ), hf_model_paths=[ "FastVideo/FastHunyuan-diffusers", ], @@ -325,6 +341,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=HYWorld_SamplingParam, pipeline_config_cls=HYWorldConfig, + workload_types=(), hf_model_paths=[ "FastVideo/HY-WorldPlay-Bidirectional-Diffusers", ], @@ -335,6 +352,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=HunyuanGameCraftSamplingParam, pipeline_config_cls=HunyuanGameCraftPipelineConfig, + workload_types=(WorkloadType.I2V, ), hf_model_paths=[ "FastVideo/HunyuanGameCraft-Diffusers", ], @@ -344,6 +362,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=LingBotWorld_SamplingParam, pipeline_config_cls=LingBotWorldI2V480PConfig, + workload_types=(WorkloadType.I2V, ), hf_model_paths=[ "FastVideo/LingBot-World-Base-Cam-Diffusers", ], @@ -354,6 +373,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=None, pipeline_config_cls=PipelineConfig, + workload_types=(WorkloadType.T2V, ), hf_model_paths=[ "kandinskylab/Kandinsky-5.0-T2V-Lite-sft-5s-Diffusers", ], @@ -362,19 +382,34 @@ def _register_configs() -> None: ], ) - # LongCat + # LongCat (T2V, I2V, VC use same config; workload varies by path) register_configs( sampling_param_cls=None, pipeline_config_cls=LongCatT2V480PConfig, - hf_model_paths=[ - "FastVideo/LongCat-Video-T2V-Diffusers", - "FastVideo/LongCat-Video-I2V-Diffusers", - "FastVideo/LongCat-Video-VC-Diffusers", - ], + workload_types=(WorkloadType.T2V, ), + hf_model_paths=["FastVideo/LongCat-Video-T2V-Diffusers"], model_detectors=[ - lambda path: "longcatimagetovideo" in path.lower(), - lambda path: "longcatvideocontinuation" in path.lower(), - lambda path: "longcat" in path.lower(), + lambda path: "longcat" in path.lower() and "i2v" not in path.lower() and "imagetovideo" not in path.lower() + and "vc" not in path.lower() and "videocontinuation" not in path.lower(), + ], + ) + register_configs( + sampling_param_cls=None, + pipeline_config_cls=LongCatT2V480PConfig, + workload_types=(WorkloadType.I2V, ), + hf_model_paths=["FastVideo/LongCat-Video-I2V-Diffusers"], + model_detectors=[ + lambda path: "longcatimagetovideo" in path.lower() or ("longcat" in path.lower() and "i2v" in path.lower()), + ], + ) + register_configs( + sampling_param_cls=None, + pipeline_config_cls=LongCatT2V480PConfig, + workload_types=(), + hf_model_paths=["FastVideo/LongCat-Video-VC-Diffusers"], + model_detectors=[ + lambda path: "longcatvideocontinuation" in path.lower() or + ("longcat" in path.lower() and "vc" in path.lower()), ], ) @@ -382,6 +417,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=MatrixGame2_SamplingParam, pipeline_config_cls=MatrixGameI2V480PConfig, + workload_types=(WorkloadType.I2V, ), hf_model_paths=[ "FastVideo/Matrix-Game-2.0-Base-Diffusers", "FastVideo/Matrix-Game-2.0-GTA-Diffusers", @@ -396,6 +432,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=Gen3C_Cosmos_7B_SamplingParam, pipeline_config_cls=Gen3CConfig, + workload_types=(WorkloadType.T2V, ), hf_model_paths=[ "FastVideo/GEN3C-Cosmos-7B-Diffusers", ], @@ -408,6 +445,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=Cosmos25SamplingParamBase, pipeline_config_cls=Cosmos25Config, + workload_types=(WorkloadType.T2V, ), hf_model_paths=[ "KyleShao/Cosmos-Predict2.5-2B-Diffusers", ], @@ -424,6 +462,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=Cosmos_Predict2_2B_Video2World_SamplingParam, pipeline_config_cls=CosmosConfig, + workload_types=(WorkloadType.T2V, ), hf_model_paths=[ "nvidia/Cosmos-Predict2-2B-Video2World", ], @@ -437,6 +476,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=TurboDiffusionT2V_1_3B_SamplingParam, pipeline_config_cls=TurboDiffusionT2V_1_3B_Config, + workload_types=(WorkloadType.T2V, ), hf_model_paths=[ "loayrashid/TurboWan2.1-T2V-1.3B-Diffusers", ], @@ -445,6 +485,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=TurboDiffusionT2V_14B_SamplingParam, pipeline_config_cls=TurboDiffusionT2V_14B_Config, + workload_types=(WorkloadType.T2V, ), hf_model_paths=[ "loayrashid/TurboWan2.1-T2V-14B-Diffusers", ], @@ -452,6 +493,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=TurboDiffusionI2V_A14B_SamplingParam, pipeline_config_cls=TurboDiffusionI2V_A14B_Config, + workload_types=(WorkloadType.I2V, ), hf_model_paths=[ "loayrashid/TurboWan2.2-I2V-A14B-Diffusers", ], @@ -461,6 +503,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=WanT2V_1_3B_SamplingParam, pipeline_config_cls=WanT2V480PConfig, + workload_types=(WorkloadType.T2V, ), hf_model_paths=[ "Wan-AI/Wan2.1-T2V-1.3B-Diffusers", ], @@ -469,6 +512,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=WanT2V_14B_SamplingParam, pipeline_config_cls=WanT2V720PConfig, + workload_types=(WorkloadType.T2V, ), hf_model_paths=[ "Wan-AI/Wan2.1-T2V-14B-Diffusers", "FastVideo/Wan2.1-VSA-T2V-14B-720P-Diffusers", @@ -477,6 +521,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=WanI2V_14B_480P_SamplingParam, pipeline_config_cls=WanI2V480PConfig, + workload_types=(WorkloadType.I2V, ), hf_model_paths=[ "Wan-AI/Wan2.1-I2V-14B-480P-Diffusers", ], @@ -485,6 +530,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=WanI2V_14B_720P_SamplingParam, pipeline_config_cls=WanI2V720PConfig, + workload_types=(WorkloadType.I2V, ), hf_model_paths=[ "Wan-AI/Wan2.1-I2V-14B-720P-Diffusers", ], @@ -492,6 +538,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=Wan2_1_Fun_1_3B_InP_SamplingParam, pipeline_config_cls=WanI2V480PConfig, + workload_types=(WorkloadType.I2V, ), hf_model_paths=[ "weizhou03/Wan2.1-Fun-1.3B-InP-Diffusers", ], @@ -499,6 +546,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=Wan2_1_Fun_1_3B_Control_SamplingParam, pipeline_config_cls=WANV2VConfig, + workload_types=(), hf_model_paths=[ "IRMChen/Wan2.1-Fun-1.3B-Control-Diffusers", ], @@ -506,6 +554,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=FastWanT2V480P_SamplingParam, pipeline_config_cls=FastWan2_1_T2V_480P_Config, + workload_types=(WorkloadType.T2V, ), hf_model_paths=[ "FastVideo/FastWan2.1-T2V-1.3B-Diffusers", "FastVideo/FastWan2.1-T2V-14B-480P-Diffusers", @@ -515,6 +564,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=Wan2_2_TI2V_5B_SamplingParam, pipeline_config_cls=Wan2_2_TI2V_5B_Config, + workload_types=(WorkloadType.T2V, WorkloadType.I2V), hf_model_paths=[ "Wan-AI/Wan2.2-TI2V-5B-Diffusers", ], @@ -522,6 +572,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=Wan2_2_TI2V_5B_SamplingParam, pipeline_config_cls=FastWan2_2_TI2V_5B_Config, + workload_types=(WorkloadType.T2V, WorkloadType.I2V), hf_model_paths=[ "FastVideo/FastWan2.2-TI2V-5B-FullAttn-Diffusers", "FastVideo/FastWan2.2-TI2V-5B-Diffusers", @@ -530,6 +581,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=Wan2_2_T2V_A14B_SamplingParam, pipeline_config_cls=Wan2_2_T2V_A14B_Config, + workload_types=(WorkloadType.T2V, ), hf_model_paths=[ "Wan-AI/Wan2.2-T2V-A14B-Diffusers", ], @@ -537,6 +589,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=Wan2_2_I2V_A14B_SamplingParam, pipeline_config_cls=Wan2_2_I2V_A14B_Config, + workload_types=(WorkloadType.I2V, ), hf_model_paths=[ "Wan-AI/Wan2.2-I2V-A14B-Diffusers", ], @@ -544,17 +597,29 @@ def _register_configs() -> None: register_configs( sampling_param_cls=SelfForcingWan2_1_T2V_1_3B_480P_SamplingParam, pipeline_config_cls=SelfForcingWanT2V480PConfig, + workload_types=(WorkloadType.T2V, ), hf_model_paths=[ "wlsaidhi/SFWan2.1-T2V-1.3B-Diffusers", ], model_detectors=[lambda path: "wancausaldmdpipeline" in path.lower()], ) + # SFWan2.2: T2V and I2V variants by path register_configs( sampling_param_cls=SelfForcingWan2_2_T2V_A14B_480P_SamplingParam, pipeline_config_cls=SelfForcingWan2_2_T2V480PConfig, - hf_model_paths=[ - "rand0nmr/SFWan2.2-T2V-A14B-Diffusers", - "FastVideo/SFWan2.2-I2V-A14B-Preview-Diffusers", + workload_types=(WorkloadType.T2V, ), + hf_model_paths=["rand0nmr/SFWan2.2-T2V-A14B-Diffusers"], + model_detectors=[ + lambda path: ("sfwan2.2" in path.lower() or "sfwan2_2" in path.lower()) and "i2v" not in path.lower(), + ], + ) + register_configs( + sampling_param_cls=SelfForcingWan2_2_T2V_A14B_480P_SamplingParam, + pipeline_config_cls=SelfForcingWan2_2_T2V480PConfig, + workload_types=(WorkloadType.I2V, ), + hf_model_paths=["FastVideo/SFWan2.2-I2V-A14B-Preview-Diffusers"], + model_detectors=[ + lambda path: ("sfwan2.2" in path.lower() or "sfwan2_2" in path.lower()) and "i2v" in path.lower(), ], ) @@ -562,6 +627,7 @@ def _register_configs() -> None: register_configs( sampling_param_cls=SD35SamplingParam, pipeline_config_cls=SD35Config, + workload_types=(WorkloadType.T2I, ), hf_model_paths=[ "stabilityai/stable-diffusion-3.5-medium", ], @@ -649,11 +715,50 @@ def get_sampling_param_cls_for_name(pipeline_name_or_path: str) -> Any | None: _register_configs() + +def get_registered_model_paths() -> list[str]: + """Return all registered HuggingFace model paths. + + Useful for UIs and tooling that need to enumerate supported models. + """ + return sorted(_MODEL_HF_PATH_TO_NAME.keys()) + + +def get_registered_models_with_workloads(workload_type: str | None = None, ) -> list[dict[str, Any]]: + """Return models with workload metadata, optionally filtered by workload. + + Args: + workload_type: If set (e.g. "t2v", "i2v", "t2i"), only return models + that support this workload. If None, return all with workload_types. + + Returns: + List of dicts with keys: id, label, workload_types. + """ + result: list[dict[str, Any]] = [] + for path in sorted(_MODEL_HF_PATH_TO_NAME.keys()): + model_id = _MODEL_HF_PATH_TO_NAME[path] + config_info = _CONFIG_REGISTRY.get(model_id) + if config_info is None: + continue + workload_values = [w.value for w in config_info.workload_types] + if workload_type is not None and workload_type.lower() not in workload_values: + continue + label = path.split("/")[-1].replace("-", " ").replace("_", " ") + result.append({ + "id": path, + "label": label, + "workload_types": workload_values, + }) + return result + + __all__ = [ "ConfigInfo", "ModelInfo", "get_model_info", "get_pipeline_config_cls_from_name", + "get_registered_model_paths", + "get_registered_models_with_workloads", "get_sampling_param_cls_for_name", "get_pipeline_config_classes", ] diff --git a/fastvideo/worker/executor.py b/fastvideo/worker/executor.py index ad4d207d8..964c099e1 100644 --- a/fastvideo/worker/executor.py +++ b/fastvideo/worker/executor.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 from abc import ABC, abstractmethod from collections.abc import Callable +from queue import Queue from typing import Any, TypeVar, cast from fastvideo.fastvideo_args import FastVideoArgs @@ -14,8 +15,14 @@ _R = TypeVar("_R") class Executor(ABC): - def __init__(self, fastvideo_args: FastVideoArgs): + def __init__( + self, + fastvideo_args: FastVideoArgs, + *, + log_queue=None, + ): self.fastvideo_args = fastvideo_args + self._log_queue = log_queue self._init_executor() @@ -97,6 +104,16 @@ class Executor(ABC): """ raise NotImplementedError + @abstractmethod + def set_log_queue(self, log_queue: Queue | None) -> None: + """Forward worker logs to the given queue. Call before generate_video.""" + self.collective_rpc("set_log_queue", kwargs={"log_queue": log_queue}) + + @abstractmethod + def clear_log_queue(self) -> None: + """Stop forwarding worker logs to the queue. Call after generate_video.""" + self.collective_rpc("clear_log_queue") + @abstractmethod def shutdown(self) -> None: """ diff --git a/fastvideo/worker/multiproc_executor.py b/fastvideo/worker/multiproc_executor.py index 13aef8892..4393a1b58 100644 --- a/fastvideo/worker/multiproc_executor.py +++ b/fastvideo/worker/multiproc_executor.py @@ -7,6 +7,8 @@ import contextlib from dataclasses import dataclass from enum import Enum import faulthandler +import logging +import logging.handlers import multiprocessing as mp from multiprocessing.connection import Connection from multiprocessing.queues import Queue @@ -34,6 +36,11 @@ from fastvideo.worker.worker_base import WorkerWrapperBase logger = init_logger(__name__) +def _make_queue_log_handler(log_queue: Queue) -> logging.Handler: + """Create a QueueHandler that forwards fastvideo logs to a multiprocessing queue.""" + return logging.handlers.QueueHandler(log_queue) + + class StreamingTaskType(str, Enum): """ Enumeration for different streaming task types. @@ -97,6 +104,7 @@ class MultiprocExecutor(Executor): distributed_init_method=distributed_init_method, streaming_input_queue=self._streaming_input_queue, streaming_output_queue=self._streaming_output_queue, + log_queue=self._log_queue, )) # Workers must be created before wait_for_ready to avoid @@ -246,6 +254,14 @@ class MultiprocExecutor(Executor): if response["status"] != "lora_adapter_merged": raise RuntimeError(f"Worker {i} failed to merge LoRA weights") + def set_log_queue(self, log_queue: Queue | None) -> None: + """Forward worker logs to the given queue. Call before generate_video.""" + self.collective_rpc("set_log_queue", kwargs={"log_queue": log_queue}) + + def clear_log_queue(self) -> None: + """Stop forwarding worker logs to the queue. Call after generate_video.""" + self.collective_rpc("clear_log_queue") + def collective_rpc(self, method: str | Callable, timeout: float | None = None, @@ -298,6 +314,12 @@ class MultiprocExecutor(Executor): return # Prevent multiple shutdown calls logger.info("Shutting down MultiprocExecutor...") + + # Check if workers were initialized (they might not be if initialization failed) + if not hasattr(self, 'workers') or not self.workers: + logger.info("No workers to shut down.") + return + self.shutting_down = True # First try gentle termination @@ -427,11 +449,14 @@ class WorkerMultiprocProc: pipe: Connection, streaming_input_queue: Queue | None = None, streaming_output_queue: Queue | None = None, + _initial_log_handler: logging.Handler | None = None, + **kwargs: Any, ): self.rank = rank self.pipe = pipe self.streaming_input_queue = streaming_input_queue self.streaming_output_queue = streaming_output_queue + self._initial_log_handler = _initial_log_handler wrapper = WorkerWrapperBase(fastvideo_args=fastvideo_args, rpc_rank=rank) all_kwargs: list[dict] = [{} for _ in range(fastvideo_args.num_gpus)] @@ -458,6 +483,7 @@ class WorkerMultiprocProc: distributed_init_method: str, streaming_input_queue: Queue | None = None, streaming_output_queue: Queue | None = None, + log_queue: Queue | None = None, ) -> UnreadyWorkerProcHandle: context = get_mp_context() executor_pipe, worker_pipe = context.Pipe(duplex=True) @@ -472,6 +498,7 @@ class WorkerMultiprocProc: "ready_pipe": writer, "streaming_input_queue": streaming_input_queue, "streaming_output_queue": streaming_output_queue, + "log_queue": log_queue, } # Run EngineCore busy loop in background process. proc = context.Process(target=WorkerMultiprocProc.worker_main, @@ -488,6 +515,13 @@ class WorkerMultiprocProc: """ Worker initialization and execution loops. This runs a background process """ + log_queue = kwargs.pop("log_queue", None) + # Add log handler before model loading so we capture fsdp_load, cuda, etc. + if log_queue is not None: + _handler = _make_queue_log_handler(log_queue) + logging.getLogger("fastvideo").addHandler(_handler) + kwargs["_initial_log_handler"] = _handler + # Signal handler used for graceful termination. # SystemExit exception is only raised once to allow this and worker # processes to terminate without error @@ -523,9 +557,21 @@ class WorkerMultiprocProc: worker.worker_busy_loop() - except Exception: + except Exception as exc: if ready_pipe is not None: logger.exception("WorkerMultiprocProc failed to start.") + # Send error status to parent before closing pipe + try: + traceback_str = get_exception_traceback() + ready_pipe.send({ + "status": "ERROR", + "error": str(exc), + "traceback": traceback_str, + "rank": rank, + }) + except Exception: + # If sending fails, at least log it + pass else: logger.exception("WorkerMultiprocProc failed.") @@ -535,7 +581,8 @@ class WorkerMultiprocProc: shutdown_requested = True traceback = get_exception_traceback() logger.error("Worker %d hit an exception: %s", rank, traceback) - parent_process.send_signal(signal.SIGQUIT) + if parent_process: + parent_process.send_signal(signal.SIGQUIT) finally: if ready_pipe is not None: @@ -552,7 +599,8 @@ class WorkerMultiprocProc: "See stack trace for root cause.") pipes = {handle.ready_pipe: handle for handle in unready_proc_handles} - ready_proc_handles: list[WorkerProcHandle | None] = ([None] * len(unready_proc_handles)) + ready_proc_handles: list[WorkerProcHandle | None] = [None] * len(unready_proc_handles) + worker_errors: list[str] = [] while pipes: ready = mp.connection.wait(pipes.keys()) for pipe in ready: @@ -561,13 +609,28 @@ class WorkerMultiprocProc: # Wait until the WorkerProc is ready. unready_proc_handle = pipes.pop(pipe) response: dict[str, Any] = pipe.recv() - if response["status"] != "READY": - raise e + if response["status"] == "ERROR": + # Worker sent error details + error_msg = response.get("error", "Unknown error") + traceback_str = response.get("traceback", "") + rank = response.get("rank", "unknown") + error_info = f"Worker {rank} error: {error_msg}" + if traceback_str: + error_info += f"\n{traceback_str}" + worker_errors.append(error_info) + # Log a concise error message (full traceback will be in the exception) + logger.error("Worker %s initialization failed: %s", rank, error_msg) + # Continue to check other workers, but we'll fail at the end + elif response["status"] != "READY": + worker_errors.append(f"Worker returned unexpected status: {response.get('status', 'unknown')}") - ready_proc_handles[unready_proc_handle.rank] = ( - WorkerProcHandle.from_unready_handle(unready_proc_handle)) + if response["status"] == "READY": + ready_proc_handles[unready_proc_handle.rank] = ( + WorkerProcHandle.from_unready_handle(unready_proc_handle)) except EOFError: + # Pipe closed without sending status - worker crashed + worker_errors.append("Worker process crashed (pipe closed unexpectedly)") e.__suppress_context__ = True raise e from None @@ -575,6 +638,12 @@ class WorkerMultiprocProc: # Close connection. pipe.close() + # If any workers failed, raise exception with details + if worker_errors: + error_msg = "WorkerMultiprocProc initialization failed due to exceptions in background processes:\n" + error_msg += "\n".join(f" - {err}" for err in worker_errors) + raise Exception(error_msg) from None + logger.info("%d workers ready", len(ready_proc_handles)) return cast(list[WorkerProcHandle], ready_proc_handles) @@ -597,6 +666,14 @@ class WorkerMultiprocProc: with contextlib.suppress(Exception): self.pipe.send(response) break + if method == "set_log_queue": + self._set_log_queue(kwargs.get("log_queue")) + self.pipe.send({"status": "ok"}) + continue + if method == "clear_log_queue": + self._clear_log_queue() + self.pipe.send({"status": "ok"}) + continue if method == "start_streaming_queue_loop": self.pipe.send({"status": "streaming_queue_loop_started"}) self.streaming_queue_loop() @@ -665,6 +742,26 @@ class WorkerMultiprocProc: logger.error("Worker %d queue loop error: %s", self.rank, e) self.streaming_output_queue.put(StreamingResult(task_type=StreamingTaskType.STEP, error=e)) + _log_queue_handler: logging.Handler | None = None + + def _set_log_queue(self, log_queue: Queue | None) -> None: + """Add a handler that forwards fastvideo logs to the given queue.""" + self._clear_log_queue() + if log_queue is None: + return + # Remove initial handler if present (from worker_main) to avoid duplicates + if self._initial_log_handler is not None: + logging.getLogger("fastvideo").removeHandler(self._initial_log_handler) + self._initial_log_handler = None + self._log_queue_handler = _make_queue_log_handler(log_queue) + logging.getLogger("fastvideo").addHandler(self._log_queue_handler) + + def _clear_log_queue(self) -> None: + """Remove the log queue handler.""" + if self._log_queue_handler is not None: + logging.getLogger("fastvideo").removeHandler(self._log_queue_handler) + self._log_queue_handler = None + @staticmethod def setup_proc_title_and_log_prefix() -> None: dp_size = get_dp_group().world_size diff --git a/pyproject.toml b/pyproject.toml index 2462178d6..5479aff4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,10 @@ dependencies = [ "torchcodec", "ray>=2.49.1", "ftfy==6.3.1", + + # Job Runner + "fastapi==0.129.0", + "uvicorn==0.41.0" ] [tool.uv] @@ -146,7 +150,7 @@ check_untyped_defs = true follow_imports = "silent" [tool.codespell] -skip ="./data,./wandb" +skip = "./data,./wandb,ui/package-lock.json" [tool.ruff] # Allow lines to be as long as 120. diff --git a/ui/.env.example b/ui/.env.example new file mode 100644 index 000000000..70daf8ca8 --- /dev/null +++ b/ui/.env.example @@ -0,0 +1 @@ +PUBLIC_API_BASE_URL=http://localhost:8189/api \ No newline at end of file diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 000000000..51dc5c300 --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts +.svelte-kit diff --git a/ui/.prettierrc b/ui/.prettierrc new file mode 100644 index 000000000..6a48eb4a3 --- /dev/null +++ b/ui/.prettierrc @@ -0,0 +1,4 @@ +{ + "useTabs": true, + "tabWidth": 4 +} diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 000000000..0626a4352 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,60 @@ +# FastVideo UI + +A lightweight web-based UI for interacting with FastVideo. + +## Features + +The UI currently supports: + +- Inference +- Finetuning & Distillation +- Datasets +- Gallery View + +## Quick Start + +The easiest way to run the application is: + +```bash +cd ui +npm install +npm run build +npm run start +``` + +You can then access the app at [http://localhost:3000](http://localhost:3000). + +### Running API and Web Separately + +The UI is composed of two separate components: + +- API Server +- Web Server + +To run each component separately, you can use the commands `npm run start:web` and `npm run start:api`. + +You can also run the API server with this command from the root directory: + +```bash +python -m ui.server --output-dir /path/to/videos --log-dir /path/to/logs +``` + +Running it this way allows you to pass command line parameters. + +The API server defaults to port 8189. To configure this, you can edit `.env.local` file. +Refer to `.env.example` for reference. + +### API Endpoints + +| Method | Path | Description | +| -------- | ----------------------------- | ------------------------------------------ | +| `GET` | `/api/models` | List available models | +| `GET` | `/api/jobs` | List all jobs (newest first) | +| `GET` | `/api/jobs/{id}` | Get a single job's details | +| `POST` | `/api/jobs` | Create a new job | +| `POST` | `/api/jobs/{id}/start` | Start a pending/stopped/failed job | +| `POST` | `/api/jobs/{id}/stop` | Request a running job to stop | +| `DELETE` | `/api/jobs/{id}` | Delete a job | +| `GET` | `/api/jobs/{id}/video` | Stream the generated video/image | +| `GET` | `/api/jobs/{id}/logs` | Get job logs (polling, supports `?after=`) | +| `GET` | `/api/jobs/{id}/download_log` | Download the job's log file | diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 000000000..988131360 --- /dev/null +++ b/ui/__init__.py @@ -0,0 +1 @@ +# SPDX-License-Identifier: Apache-2.0 diff --git a/ui/__main__.py b/ui/__main__.py new file mode 100644 index 000000000..e4163b7cf --- /dev/null +++ b/ui/__main__.py @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Allow ``python -m ui`` to start the server.""" + +from ui.server import main + +if __name__ == "__main__": + main() diff --git a/ui/database.py b/ui/database.py new file mode 100644 index 000000000..47c7ec3e0 --- /dev/null +++ b/ui/database.py @@ -0,0 +1,655 @@ +# SPDX-License-Identifier: Apache-2.0 +""" +SQLite persistence for FastVideo UI. + +Stores jobs and default settings. Uses Python's built-in sqlite3. +""" + +from __future__ import annotations + +import contextlib +import logging +import sqlite3 +import threading +from pathlib import Path +from typing import Any + +logger = logging.getLogger("fastvideo.ui.database") + +# Default options schema - used for settings table defaults +DEFAULT_SETTINGS: dict[str, Any] = { + "default_model_id": "", # legacy; migrated to default_model_id_t2v + "default_model_id_t2v": "", + "default_model_id_i2v": "", + "default_model_id_t2i": "", + "num_inference_steps": 50, + "num_frames": 81, + "height": 480, + "width": 832, + "guidance_scale": 5.0, + "guidance_rescale": 0.0, + "fps": 24, + "seed": 1024, + "num_gpus": 1, + "dit_cpu_offload": 0, + "text_encoder_cpu_offload": 0, + "vae_cpu_offload": 0, + "image_encoder_cpu_offload": 0, + "use_fsdp_inference": 0, + "enable_torch_compile": 0, + "vsa_sparsity": 0.0, + "tp_size": -1, + "sp_size": -1, + "auto_start_job": 0, + "dataset_upload_path": "", +} + + +def _sqlite_row_get(row: sqlite3.Row, key: str, default: Any) -> Any: + """Like dict.get for sqlite3.Row (Row has no .get on Python 3.10).""" + return row[key] if key in row else default # noqa: SIM401 + + +def _get_db_path(data_dir: Path) -> Path: + """Return path to SQLite database file.""" + data_dir.mkdir(parents=True, exist_ok=True) + return data_dir / "fastvideo_ui.db" + + +def _get_table_columns(conn: sqlite3.Connection, table: str) -> set[str]: + """Return set of column names for a table.""" + cur = conn.execute(f"PRAGMA table_info({table})") + return {row[1] for row in cur.fetchall()} + + +def _add_column_if_missing(conn: sqlite3.Connection, table: str, col: str, sql_type: str, default: str) -> None: + """Add a column to the table if it does not exist.""" + cols = _get_table_columns(conn, table) + if col not in cols: + conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {sql_type} DEFAULT {default}") + + +def _migrate_db(conn: sqlite3.Connection) -> None: + """Add new columns to existing tables for schema migrations.""" + # Jobs table + _add_column_if_missing(conn, "jobs", "vae_cpu_offload", "INTEGER", "0") + _add_column_if_missing(conn, "jobs", "image_encoder_cpu_offload", "INTEGER", "0") + _add_column_if_missing(conn, "jobs", "enable_torch_compile", "INTEGER", "0") + _add_column_if_missing(conn, "jobs", "vsa_sparsity", "REAL", "0.0") + _add_column_if_missing(conn, "jobs", "tp_size", "INTEGER", "-1") + _add_column_if_missing(conn, "jobs", "sp_size", "INTEGER", "-1") + _add_column_if_missing(conn, "jobs", "negative_prompt", "TEXT", "''") + _add_column_if_missing(conn, "jobs", "guidance_rescale", "REAL", "0.0") + _add_column_if_missing(conn, "jobs", "fps", "INTEGER", "24") + _add_column_if_missing(conn, "jobs", "workload_type", "TEXT", "'t2v'") + _add_column_if_missing(conn, "jobs", "image_path", "TEXT", "''") + _add_column_if_missing(conn, "jobs", "job_type", "TEXT", "'inference'") + _add_column_if_missing(conn, "jobs", "data_path", "TEXT", "''") + _add_column_if_missing(conn, "jobs", "max_train_steps", "INTEGER", "1000") + _add_column_if_missing(conn, "jobs", "train_batch_size", "INTEGER", "1") + _add_column_if_missing(conn, "jobs", "learning_rate", "REAL", "5e-5") + _add_column_if_missing(conn, "jobs", "num_latent_t", "INTEGER", "20") + _add_column_if_missing(conn, "jobs", "validation_dataset_file", "TEXT", "''") + _add_column_if_missing(conn, "jobs", "lora_rank", "INTEGER", "32") + _add_column_if_missing(conn, "jobs", "ltx2_first_frame_conditioning_p", "REAL", "NULL") + _add_column_if_missing(conn, "jobs", "dmd_use_vsa", "INTEGER", "0") + _add_column_if_missing(conn, "jobs", "dmd_vsa_sparsity", "REAL", "0.8") + _add_column_if_missing(conn, "jobs", "dmd_denoising_steps", "TEXT", "'1000,757,522'") + _add_column_if_missing(conn, "jobs", "min_timestep_ratio", "REAL", "0.02") + _add_column_if_missing(conn, "jobs", "max_timestep_ratio", "REAL", "0.98") + _add_column_if_missing(conn, "jobs", "real_score_guidance_scale", "REAL", "3.5") + _add_column_if_missing(conn, "jobs", "generator_update_interval", "INTEGER", "5") + _add_column_if_missing(conn, "jobs", "real_score_model_path", "TEXT", "''") + _add_column_if_missing(conn, "jobs", "fake_score_model_path", "TEXT", "''") + # Settings table + _add_column_if_missing(conn, "settings", "vae_cpu_offload", "INTEGER", "0") + _add_column_if_missing(conn, "settings", "image_encoder_cpu_offload", "INTEGER", "0") + _add_column_if_missing(conn, "settings", "enable_torch_compile", "INTEGER", "0") + _add_column_if_missing(conn, "settings", "vsa_sparsity", "REAL", "0.0") + _add_column_if_missing(conn, "settings", "tp_size", "INTEGER", "-1") + _add_column_if_missing(conn, "settings", "sp_size", "INTEGER", "-1") + _add_column_if_missing(conn, "settings", "guidance_rescale", "REAL", "0.0") + _add_column_if_missing(conn, "settings", "fps", "INTEGER", "24") + _add_column_if_missing(conn, "settings", "default_model_id_t2v", "TEXT", "''") + _add_column_if_missing(conn, "settings", "default_model_id_i2v", "TEXT", "''") + _add_column_if_missing(conn, "settings", "default_model_id_t2i", "TEXT", "''") + _add_column_if_missing(conn, "settings", "auto_start_job", "INTEGER", "0") + _add_column_if_missing(conn, "settings", "dataset_upload_path", "TEXT", "''") + # Drop removed columns from datasets (SQLite 3.35+) + for col in ("workload_type", "output_path", "model_path", "raw_path", "dataset_type", "num_gpus", "media_type", + "status", "error", "log_file_path"): + if col in _get_table_columns(conn, "datasets"): + with contextlib.suppress(sqlite3.OperationalError): + # SQLite < 3.35 or column already dropped + conn.execute(f"ALTER TABLE datasets DROP COLUMN {col}") + # Migrate legacy default_model_id to default_model_id_t2v + if "default_model_id_t2v" in _get_table_columns(conn, "settings"): + conn.execute(""" + UPDATE settings SET default_model_id_t2v = default_model_id + WHERE id = 1 AND default_model_id != '' + AND (default_model_id_t2v IS NULL OR default_model_id_t2v = '') + """) + + +def init_db(db_path: Path) -> None: + """Create database and tables if they do not exist.""" + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + try: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY, + model_id TEXT NOT NULL, + prompt TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + created_at REAL NOT NULL, + started_at REAL, + finished_at REAL, + error TEXT, + output_path TEXT, + log_file_path TEXT, + num_inference_steps INTEGER NOT NULL DEFAULT 50, + num_frames INTEGER NOT NULL DEFAULT 81, + height INTEGER NOT NULL DEFAULT 480, + width INTEGER NOT NULL DEFAULT 832, + guidance_scale REAL NOT NULL DEFAULT 5.0, + seed INTEGER NOT NULL DEFAULT 1024, + num_gpus INTEGER NOT NULL DEFAULT 1, + dit_cpu_offload INTEGER NOT NULL DEFAULT 0, + text_encoder_cpu_offload INTEGER NOT NULL DEFAULT 0, + vae_cpu_offload INTEGER NOT NULL DEFAULT 0, + image_encoder_cpu_offload INTEGER NOT NULL DEFAULT 0, + use_fsdp_inference INTEGER NOT NULL DEFAULT 0, + enable_torch_compile INTEGER NOT NULL DEFAULT 0, + vsa_sparsity REAL NOT NULL DEFAULT 0.0, + tp_size INTEGER NOT NULL DEFAULT -1, + sp_size INTEGER NOT NULL DEFAULT -1 + ); + + CREATE TABLE IF NOT EXISTS settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + default_model_id TEXT NOT NULL DEFAULT '', + default_model_id_t2v TEXT NOT NULL DEFAULT '', + default_model_id_i2v TEXT NOT NULL DEFAULT '', + default_model_id_t2i TEXT NOT NULL DEFAULT '', + num_inference_steps INTEGER NOT NULL DEFAULT 50, + num_frames INTEGER NOT NULL DEFAULT 81, + height INTEGER NOT NULL DEFAULT 480, + width INTEGER NOT NULL DEFAULT 832, + guidance_scale REAL NOT NULL DEFAULT 5.0, + seed INTEGER NOT NULL DEFAULT 1024, + num_gpus INTEGER NOT NULL DEFAULT 1, + dit_cpu_offload INTEGER NOT NULL DEFAULT 0, + text_encoder_cpu_offload INTEGER NOT NULL DEFAULT 0, + vae_cpu_offload INTEGER NOT NULL DEFAULT 0, + image_encoder_cpu_offload INTEGER NOT NULL DEFAULT 0, + use_fsdp_inference INTEGER NOT NULL DEFAULT 0, + enable_torch_compile INTEGER NOT NULL DEFAULT 0, + vsa_sparsity REAL NOT NULL DEFAULT 0.0, + tp_size INTEGER NOT NULL DEFAULT -1, + sp_size INTEGER NOT NULL DEFAULT -1, + auto_start_job INTEGER NOT NULL DEFAULT 0, + dataset_upload_path TEXT NOT NULL DEFAULT '' + ); + + INSERT OR IGNORE INTO settings (id) VALUES (1); + + CREATE TABLE IF NOT EXISTS datasets ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at REAL NOT NULL + ); + + CREATE TABLE IF NOT EXISTS dataset_captions ( + dataset_id TEXT NOT NULL, + file_name TEXT NOT NULL, + caption TEXT NOT NULL DEFAULT '', + PRIMARY KEY (dataset_id, file_name), + FOREIGN KEY (dataset_id) REFERENCES datasets(id) ON DELETE CASCADE + ); + """) + conn.commit() + _migrate_db(conn) + conn.commit() + finally: + conn.close() + + +class Database: + """Thread-safe SQLite access for jobs and settings.""" + + def __init__(self, db_path: Path): + self._path = db_path + self._local = threading.local() + init_db(db_path) + + def _conn(self) -> sqlite3.Connection: + if not hasattr(self._local, "conn") or self._local.conn is None: + self._local.conn = sqlite3.connect(self._path) + self._local.conn.row_factory = sqlite3.Row + return self._local.conn + + def _execute(self, sql: str, params: tuple[Any, ...] = ()) -> sqlite3.Cursor: + return self._conn().execute(sql, params) + + def _commit(self) -> None: + self._conn().commit() + + # --- Jobs --- + + def insert_job(self, job: dict[str, Any]) -> None: + """Insert a new job.""" + self._execute( + """ + INSERT INTO jobs ( + id, model_id, prompt, workload_type, image_path, job_type, status, + created_at, started_at, finished_at, error, output_path, log_file_path, + num_inference_steps, num_frames, height, width, guidance_scale, + guidance_rescale, fps, seed, num_gpus, dit_cpu_offload, + text_encoder_cpu_offload, vae_cpu_offload, image_encoder_cpu_offload, + use_fsdp_inference, enable_torch_compile, vsa_sparsity, tp_size, + sp_size, negative_prompt, + data_path, max_train_steps, train_batch_size, learning_rate, + num_latent_t, validation_dataset_file, lora_rank, + ltx2_first_frame_conditioning_p, + dmd_use_vsa, dmd_vsa_sparsity, dmd_denoising_steps, + min_timestep_ratio, max_timestep_ratio, real_score_guidance_scale, + generator_update_interval, real_score_model_path, fake_score_model_path + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + job["id"], + job["model_id"], + job["prompt"], + job.get("workload_type", "t2v"), + job.get("image_path", ""), + job.get("job_type", "inference"), + job["status"], + job["created_at"], + job.get("started_at"), + job.get("finished_at"), + job.get("error"), + job.get("output_path"), + job.get("log_file_path"), + job.get("num_inference_steps", 50), + job.get("num_frames", 81), + job.get("height", 480), + job.get("width", 832), + job.get("guidance_scale", 5.0), + job.get("guidance_rescale", 0.0), + job.get("fps", 24), + job.get("seed", 1024), + job.get("num_gpus", 1), + 1 if job.get("dit_cpu_offload") else 0, + 1 if job.get("text_encoder_cpu_offload") else 0, + 1 if job.get("vae_cpu_offload") else 0, + 1 if job.get("image_encoder_cpu_offload") else 0, + 1 if job.get("use_fsdp_inference") else 0, + 1 if job.get("enable_torch_compile") else 0, + job.get("vsa_sparsity", 0.0), + job.get("tp_size", -1), + job.get("sp_size", -1), + job.get("negative_prompt", ""), + job.get("data_path", ""), + job.get("max_train_steps", 1000), + job.get("train_batch_size", 1), + job.get("learning_rate", 5e-5), + job.get("num_latent_t", 20), + job.get("validation_dataset_file", ""), + job.get("lora_rank", 32), + job.get("ltx2_first_frame_conditioning_p"), + 1 if job.get("dmd_use_vsa") else 0, + job.get("dmd_vsa_sparsity", 0.8), + job.get("dmd_denoising_steps", "1000,757,522"), + job.get("min_timestep_ratio", 0.02), + job.get("max_timestep_ratio", 0.98), + job.get("real_score_guidance_scale", 3.5), + job.get("generator_update_interval", 5), + job.get("real_score_model_path", ""), + job.get("fake_score_model_path", ""), + ), + ) + self._commit() + + def update_job(self, job_id: str, updates: dict[str, Any]) -> None: + """Update job fields. Only provided keys are updated.""" + if not updates: + return + allowed = { + "status", + "started_at", + "finished_at", + "error", + "output_path", + "log_file_path", + } + cols = [] + vals = [] + for k, v in updates.items(): + if k in allowed: + cols.append(f"{k} = ?") + vals.append(v) + if not cols: + return + vals.append(job_id) + sql = f"UPDATE jobs SET {', '.join(cols)} WHERE id = ?" + self._execute(sql, tuple(vals)) + self._commit() + + def delete_job(self, job_id: str) -> bool: + """Delete a job. Returns True if a row was deleted.""" + cur = self._execute("DELETE FROM jobs WHERE id = ?", (job_id, )) + self._commit() + return cur.rowcount > 0 + + def get_all_jobs(self, job_type: str | None = None) -> list[dict[str, Any]]: + """Return all jobs, newest first. Optionally filter by job_type.""" + if job_type: + cur = self._execute( + "SELECT * FROM jobs WHERE job_type = ? ORDER BY created_at DESC", + (job_type, ), + ) + else: + cur = self._execute("SELECT * FROM jobs ORDER BY created_at DESC") + return [_row_to_job(row) for row in cur.fetchall()] + + def get_job(self, job_id: str) -> dict[str, Any] | None: + """Get a single job by ID.""" + cur = self._execute("SELECT * FROM jobs WHERE id = ?", (job_id, )) + row = cur.fetchone() + return _row_to_job(row) if row else None + + # --- Datasets --- + + def insert_dataset(self, dataset: dict[str, Any]) -> None: + """Insert a new dataset.""" + self._execute( + """ + INSERT INTO datasets (id, name, created_at) + VALUES (?, ?, ?) + """, + ( + dataset["id"], + dataset["name"], + dataset["created_at"], + ), + ) + self._commit() + + def get_dataset_captions(self, dataset_id: str) -> dict[str, str]: + """Return caption map: file_name -> caption.""" + cur = self._execute( + "SELECT file_name, caption FROM dataset_captions WHERE dataset_id = ?", + (dataset_id, ), + ) + return {row["file_name"]: row["caption"] or "" for row in cur.fetchall()} + + def upsert_dataset_caption(self, dataset_id: str, file_name: str, caption: str) -> None: + """Insert or update a caption for a file in a dataset.""" + self._execute( + """ + INSERT INTO dataset_captions (dataset_id, file_name, caption) + VALUES (?, ?, ?) + ON CONFLICT(dataset_id, file_name) DO UPDATE SET caption = ? + """, + (dataset_id, file_name, caption, caption), + ) + self._commit() + + def upsert_dataset_captions(self, dataset_id: str, captions: dict[str, str]) -> None: + """Insert or update multiple captions for a dataset.""" + for file_name, caption in captions.items(): + self.upsert_dataset_caption(dataset_id, file_name, caption) + + def update_dataset(self, dataset_id: str, updates: dict[str, Any]) -> None: + """Update dataset fields. Only provided keys are updated.""" + if not updates: + return + allowed: set[str] = set() + cols = [] + vals = [] + for k, v in updates.items(): + if k in allowed: + cols.append(f"{k} = ?") + vals.append(v) + if not cols: + return + vals.append(dataset_id) + sql = f"UPDATE datasets SET {', '.join(cols)} WHERE id = ?" + self._execute(sql, tuple(vals)) + self._commit() + + def delete_dataset(self, dataset_id: str) -> bool: + """Delete a dataset. Returns True if a row was deleted.""" + cur = self._execute("DELETE FROM datasets WHERE id = ?", (dataset_id, )) + self._commit() + return cur.rowcount > 0 + + def get_all_datasets(self) -> list[dict[str, Any]]: + """Return all datasets, newest first.""" + cur = self._execute("SELECT * FROM datasets ORDER BY created_at DESC") + return [_row_to_dataset(row) for row in cur.fetchall()] + + def get_dataset(self, dataset_id: str) -> dict[str, Any] | None: + """Get a single dataset by ID.""" + cur = self._execute("SELECT * FROM datasets WHERE id = ?", (dataset_id, )) + row = cur.fetchone() + return _row_to_dataset(row) if row else None + + # --- Settings --- + + def get_settings(self) -> dict[str, Any]: + """Return current settings as a dict (camelCase keys for API).""" + cur = self._execute("SELECT * FROM settings WHERE id = 1") + row = cur.fetchone() + if not row: + return _default_settings_dict() + t2v = ((row["default_model_id_t2v"] or row["default_model_id"] or "") if "default_model_id_t2v" in row else + (row["default_model_id"] or "")) + i2v = ((row["default_model_id_i2v"] or "") if "default_model_id_i2v" in row else "") + t2i = ((row["default_model_id_t2i"] or "") if "default_model_id_t2i" in row else "") + result = { + "defaultModelId": row["default_model_id"] or "", + "defaultModelIdT2v": t2v, + "defaultModelIdI2v": i2v, + "defaultModelIdT2i": t2i, + "numInferenceSteps": int(row["num_inference_steps"]), + "numFrames": int(row["num_frames"]), + "height": int(row["height"]), + "width": int(row["width"]), + "guidanceScale": float(row["guidance_scale"]), + "seed": int(row["seed"]), + "numGpus": int(row["num_gpus"]), + "ditCpuOffload": bool(row["dit_cpu_offload"]), + "textEncoderCpuOffload": bool(row["text_encoder_cpu_offload"]), + "useFsdpInference": bool(row["use_fsdp_inference"]), + } + # New columns (may not exist in older DBs) + for col, key, default in [ + ("vae_cpu_offload", "vaeCpuOffload", False), + ("image_encoder_cpu_offload", "imageEncoderCpuOffload", False), + ("enable_torch_compile", "enableTorchCompile", False), + ("vsa_sparsity", "vsaSparsity", 0.0), + ("tp_size", "tpSize", -1), + ("sp_size", "spSize", -1), + ("guidance_rescale", "guidanceRescale", 0.0), + ("fps", "fps", 24), + ("auto_start_job", "autoStartJob", False), + ("dataset_upload_path", "datasetUploadPath", ""), + ]: + if col in row: + v = row[col] + if isinstance(default, bool): + result[key] = bool(v) + elif isinstance(default, float): + result[key] = float(v) + elif isinstance(default, str): + result[key] = v or "" + else: + result[key] = int(v) + else: + result[key] = default + return result + + def save_settings(self, settings: dict[str, Any]) -> None: + """Save settings. Accepts camelCase keys from API.""" + # Map camelCase -> snake_case + mapping = { + "defaultModelId": "default_model_id", + "defaultModelIdT2v": "default_model_id_t2v", + "defaultModelIdI2v": "default_model_id_i2v", + "defaultModelIdT2i": "default_model_id_t2i", + "numInferenceSteps": "num_inference_steps", + "numFrames": "num_frames", + "height": "height", + "width": "width", + "guidanceScale": "guidance_scale", + "guidanceRescale": "guidance_rescale", + "fps": "fps", + "seed": "seed", + "numGpus": "num_gpus", + "ditCpuOffload": "dit_cpu_offload", + "textEncoderCpuOffload": "text_encoder_cpu_offload", + "vaeCpuOffload": "vae_cpu_offload", + "imageEncoderCpuOffload": "image_encoder_cpu_offload", + "useFsdpInference": "use_fsdp_inference", + "enableTorchCompile": "enable_torch_compile", + "vsaSparsity": "vsa_sparsity", + "tpSize": "tp_size", + "spSize": "sp_size", + "autoStartJob": "auto_start_job", + "datasetUploadPath": "dataset_upload_path", + } + updates = [] + params = [] + for api_key, db_col in mapping.items(): + if api_key in settings: + v = settings[api_key] + if isinstance(v, bool): + v = 1 if v else 0 + updates.append(f"{db_col} = ?") + params.append(v) + if not updates: + return + params.append(1) + sql = f"UPDATE settings SET {', '.join(updates)} WHERE id = ?" + self._execute(sql, tuple(params)) + self._commit() + + +def _row_to_dataset(row: sqlite3.Row) -> dict[str, Any]: + """Convert a DB row to dataset dict.""" + return { + "id": row["id"], + "name": row["name"], + "created_at": row["created_at"], + } + + +def _row_to_job(row: sqlite3.Row) -> dict[str, Any]: + """Convert a DB row to job dict (snake_case, matching Job.to_dict).""" + int_defaults = { + "max_train_steps": 1000, + "train_batch_size": 1, + "num_latent_t": 20, + "lora_rank": 32, + "generator_update_interval": 5, + } + float_defaults = { + "dmd_vsa_sparsity": 0.8, + "min_timestep_ratio": 0.02, + "max_timestep_ratio": 0.98, + "real_score_guidance_scale": 3.5, + } + result = { + "id": row["id"], + "model_id": row["model_id"], + "prompt": row["prompt"], + "workload_type": _sqlite_row_get(row, "workload_type", "t2v"), + "image_path": _sqlite_row_get(row, "image_path", "") or "", + "job_type": _sqlite_row_get(row, "job_type", "inference"), + "status": row["status"], + "created_at": row["created_at"], + "started_at": row["started_at"], + "finished_at": row["finished_at"], + "error": row["error"], + "output_path": row["output_path"], + "log_file_path": row["log_file_path"], + "num_inference_steps": row["num_inference_steps"], + "num_frames": row["num_frames"], + "height": row["height"], + "width": row["width"], + "guidance_scale": row["guidance_scale"], + "guidance_rescale": (float(row["guidance_rescale"]) if "guidance_rescale" in row else 0.0), + "fps": int(row["fps"]) if "fps" in row else 24, + "seed": row["seed"], + "num_gpus": row["num_gpus"], + "dit_cpu_offload": bool(row["dit_cpu_offload"]), + "text_encoder_cpu_offload": bool(row["text_encoder_cpu_offload"]), + "use_fsdp_inference": bool(row["use_fsdp_inference"]), + "negative_prompt": ((row["negative_prompt"] or "") if "negative_prompt" in row else ""), + "progress": 0.0, + "progress_msg": "", + "phase": "initializing", + } + for col in ("vae_cpu_offload", "image_encoder_cpu_offload", "enable_torch_compile"): + if col in row: + result[col] = bool(row[col]) + for col in ("vsa_sparsity", ): + if col in row: + result[col] = float(row[col]) + for col in ("tp_size", "sp_size"): + if col in row: + result[col] = int(row[col]) + for col in ( + "data_path", + "validation_dataset_file", + ): + if col in row: + result[col] = (row[col] or "") or "" + for col in int_defaults: + if col in row: + int_val_raw: Any = row[col] + result[col] = (int(int_val_raw) if int_val_raw is not None else int_defaults[col]) + if "learning_rate" in row: + result["learning_rate"] = float(row["learning_rate"] or 5e-5) + if "dmd_use_vsa" in row: + result["dmd_use_vsa"] = bool(row["dmd_use_vsa"]) + for col in float_defaults: + if col in row: + float_val_raw: Any = row[col] + result[col] = (float(float_val_raw) if float_val_raw is not None else float_defaults[col]) + for col in ("dmd_denoising_steps", "real_score_model_path", "fake_score_model_path"): + if col in row: + result[col] = (row[col] or "") or "" + return result + + +def _default_settings_dict() -> dict[str, Any]: + """Return default settings as API-style dict.""" + return { + "defaultModelId": DEFAULT_SETTINGS["default_model_id"], + "defaultModelIdT2v": DEFAULT_SETTINGS["default_model_id_t2v"], + "defaultModelIdI2v": DEFAULT_SETTINGS["default_model_id_i2v"], + "defaultModelIdT2i": DEFAULT_SETTINGS["default_model_id_t2i"], + "numInferenceSteps": DEFAULT_SETTINGS["num_inference_steps"], + "numFrames": DEFAULT_SETTINGS["num_frames"], + "height": DEFAULT_SETTINGS["height"], + "width": DEFAULT_SETTINGS["width"], + "guidanceScale": DEFAULT_SETTINGS["guidance_scale"], + "seed": DEFAULT_SETTINGS["seed"], + "numGpus": DEFAULT_SETTINGS["num_gpus"], + "ditCpuOffload": bool(DEFAULT_SETTINGS["dit_cpu_offload"]), + "textEncoderCpuOffload": bool(DEFAULT_SETTINGS["text_encoder_cpu_offload"]), + "vaeCpuOffload": bool(DEFAULT_SETTINGS["vae_cpu_offload"]), + "imageEncoderCpuOffload": bool(DEFAULT_SETTINGS["image_encoder_cpu_offload"]), + "useFsdpInference": bool(DEFAULT_SETTINGS["use_fsdp_inference"]), + "enableTorchCompile": bool(DEFAULT_SETTINGS["enable_torch_compile"]), + "vsaSparsity": float(DEFAULT_SETTINGS["vsa_sparsity"]), + "tpSize": int(DEFAULT_SETTINGS["tp_size"]), + "spSize": int(DEFAULT_SETTINGS["sp_size"]), + "guidanceRescale": float(DEFAULT_SETTINGS["guidance_rescale"]), + "fps": int(DEFAULT_SETTINGS["fps"]), + "autoStartJob": bool(DEFAULT_SETTINGS["auto_start_job"]), + "datasetUploadPath": DEFAULT_SETTINGS["dataset_upload_path"] or "", + } diff --git a/ui/eslint.config.mjs b/ui/eslint.config.mjs new file mode 100644 index 000000000..e6c72f026 --- /dev/null +++ b/ui/eslint.config.mjs @@ -0,0 +1,2 @@ +// Minimal ESLint config for SvelteKit (add typescript-eslint / eslint-plugin-svelte as needed) +export default []; diff --git a/ui/job_runner.py b/ui/job_runner.py new file mode 100644 index 000000000..725730d93 --- /dev/null +++ b/ui/job_runner.py @@ -0,0 +1,1019 @@ +# SPDX-License-Identifier: Apache-2.0 +""" +Job Runner for FastVideo video generation jobs. + +Manages job lifecycle, execution, logging, and generator caching. +""" + +from __future__ import annotations + +import atexit +import collections +import contextlib +import enum +import logging +import logging.handlers +import multiprocessing as mp +import os +import re +import subprocess +import sys +import threading +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any +from fastvideo.utils import get_mp_context +from ui.database import Database +from ui.training_config import ( + build_training_args, + get_training_env, + get_training_module_info, +) + +logger = logging.getLogger("fastvideo.ui.job_runner") + +# Regex patterns for parsing tqdm-style progress output. +# Matches e.g. " 40%|████ | 20/50 " or " 20/50 " +_TQDM_PCT_RE = re.compile(r"(\d+)%\|") +_TQDM_FRAC_RE = re.compile(r"\b(\d+)/(\d+)\b") + +_MAX_LOG_LINES = 2000 # ring-buffer cap per job + + +class JobStatus(str, enum.Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + STOPPED = "stopped" + + +class JobLogBuffer: + """Ring buffer for storing job log lines with progress tracking.""" + + def __init__(self, maxlen: int = _MAX_LOG_LINES): + self._lines: collections.deque[str] = collections.deque(maxlen=maxlen) + self._lock = threading.Lock() + self.progress: float = 0.0 # 0 – 100 + self.progress_msg: str = "" # e.g. "20/50 steps" + self.phase: str = "initializing" # human-readable phase + + def write(self, text: str): + """Append *text* (may contain embedded newlines).""" + for line in text.splitlines(): + stripped = line.strip() + if not stripped: + continue + with self._lock: + self._lines.append(stripped) + self._parse_progress(stripped) + + def get_lines(self, after: int = 0) -> tuple[list[str], int]: + """Return ``(lines[after:], total_len)``.""" + with self._lock: + all_lines = list(self._lines) + return all_lines[after:], len(all_lines) + + # -- internal helpers --------------------------------------------------- + + def _parse_progress(self, line: str): + """Try to extract a percentage / fraction from a tqdm line.""" + m = _TQDM_PCT_RE.search(line) + if m: + self.progress = min(float(m.group(1)), 100.0) + m2 = _TQDM_FRAC_RE.search(line) + if m2: + cur, total = int(m2.group(1)), int(m2.group(2)) + if total > 0: + self.progress = min(cur / total * 100.0, 100.0) + self.progress_msg = f"{cur}/{total} steps" + # Detect high-level phases from FastVideo log messages + low = line.lower() + if "loading model" in low or "loading" in low and "checkpoint" in low: + self.phase = "loading model" + elif "denoising" in low or "timestep" in low: + self.phase = "denoising" + elif "saving" in low or "saved" in low or "checkpoint" in low: + self.phase = "saving" + elif "encoding" in low or "vae" in low: + self.phase = "VAE encoding" + elif "training" in low or "step" in low or "loss" in low: + self.phase = "training" + + +class LogBufferHandler(logging.Handler): + """Logging handler that writes to a JobLogBuffer.""" + + def __init__(self, buffer: JobLogBuffer): + super().__init__() + self.buffer = buffer + self.setFormatter(logging.Formatter("%(levelname)s %(asctime)s [%(name)s] %(message)s")) + + def emit(self, record): + try: + msg = self.format(record) + self.buffer.write(msg) + except Exception: + self.handleError(record) + + +@dataclass +class Job: + id: str + model_id: str + prompt: str + workload_type: str = "t2v" + job_type: str = "inference" + image_path: str = "" + status: JobStatus = JobStatus.PENDING + created_at: float = field(default_factory=time.time) + started_at: float | None = None + finished_at: float | None = None + error: str | None = None + output_path: str | None = None + log_file_path: str | None = None # Path to the job's log file + num_inference_steps: int = 50 + num_frames: int = 81 + height: int = 480 + width: int = 832 + guidance_scale: float = 5.0 + guidance_rescale: float = 0.0 + fps: int = 24 + seed: int = 1024 + negative_prompt: str = "" + num_gpus: int = 1 + dit_cpu_offload: bool = False + text_encoder_cpu_offload: bool = False + vae_cpu_offload: bool = False + image_encoder_cpu_offload: bool = False + use_fsdp_inference: bool = False + enable_torch_compile: bool = False + vsa_sparsity: float = 0.0 + tp_size: int = -1 + sp_size: int = -1 + # Training-specific (for finetuning, distillation, LoRA) + data_path: str = "" + max_train_steps: int = 1000 + train_batch_size: int = 1 + learning_rate: float = 5e-5 + num_latent_t: int = 20 + validation_dataset_file: str = "" + lora_rank: int = 32 + ltx2_first_frame_conditioning_p: float | None = None + # DMD options + dmd_use_vsa: bool = False + dmd_vsa_sparsity: float = 0.8 + dmd_denoising_steps: str = "1000,757,522" + min_timestep_ratio: float = 0.02 + max_timestep_ratio: float = 0.98 + real_score_guidance_scale: float = 3.5 + generator_update_interval: int = 5 + real_score_model_path: str = "" + fake_score_model_path: str = "" + # Internal + _thread: threading.Thread | None = field(default=None, repr=False) + _stop_event: threading.Event = field(default_factory=threading.Event, repr=False) + _log_buf: JobLogBuffer = field(default_factory=JobLogBuffer, repr=False) + log_file_handler: logging.FileHandler | None = field(default=None, repr=False) + _process: subprocess.Popen | None = field(default=None, repr=False) + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "model_id": self.model_id, + "prompt": self.prompt, + "workload_type": self.workload_type, + "job_type": self.job_type, + "image_path": self.image_path, + "status": self.status.value, + "created_at": self.created_at, + "started_at": self.started_at, + "finished_at": self.finished_at, + "error": self.error, + "output_path": self.output_path, + "log_file_path": self.log_file_path, + "num_inference_steps": self.num_inference_steps, + "num_frames": self.num_frames, + "height": self.height, + "width": self.width, + "guidance_scale": self.guidance_scale, + "guidance_rescale": self.guidance_rescale, + "fps": self.fps, + "seed": self.seed, + "negative_prompt": self.negative_prompt, + "num_gpus": self.num_gpus, + "dit_cpu_offload": self.dit_cpu_offload, + "text_encoder_cpu_offload": self.text_encoder_cpu_offload, + "vae_cpu_offload": self.vae_cpu_offload, + "image_encoder_cpu_offload": self.image_encoder_cpu_offload, + "use_fsdp_inference": self.use_fsdp_inference, + "enable_torch_compile": self.enable_torch_compile, + "vsa_sparsity": self.vsa_sparsity, + "tp_size": self.tp_size, + "sp_size": self.sp_size, + "data_path": self.data_path, + "max_train_steps": self.max_train_steps, + "train_batch_size": self.train_batch_size, + "learning_rate": self.learning_rate, + "num_latent_t": self.num_latent_t, + "num_height": self.height, + "num_width": self.width, + "validation_dataset_file": self.validation_dataset_file, + "lora_rank": self.lora_rank, + "ltx2_first_frame_conditioning_p": self.ltx2_first_frame_conditioning_p, + "dmd_use_vsa": self.dmd_use_vsa, + "dmd_vsa_sparsity": self.dmd_vsa_sparsity, + "dmd_denoising_steps": self.dmd_denoising_steps, + "min_timestep_ratio": self.min_timestep_ratio, + "max_timestep_ratio": self.max_timestep_ratio, + "real_score_guidance_scale": self.real_score_guidance_scale, + "generator_update_interval": self.generator_update_interval, + "real_score_model_path": self.real_score_model_path or "", + "fake_score_model_path": self.fake_score_model_path or "", + "progress": self._log_buf.progress, + "progress_msg": self._log_buf.progress_msg, + "phase": self._log_buf.phase, + } + + +class JobRunner: + """Manages video generation jobs, their execution, and generator caching.""" + + def __init__( + self, + output_dir: str, + log_dir: str, + database: Database, + verbose: bool = False, + ): + """Initialize the job runner. + + Args: + output_dir: Directory where generated videos are saved + log_dir: Directory where job log files are saved + verbose: Whether to print full tracebacks in error messages + database: Optional SQLite database for job persistence + """ + self.output_dir = output_dir + self.log_dir = log_dir + self.verbose = verbose + self._db = database + + self._jobs: dict[str, Job] = {} + self._jobs_lock = threading.Lock() + self._load_jobs() + + # Cache of loaded generators keyed by model config so that we only pay + # the model-loading cost once per model configuration. + self._generators: dict[tuple, Any] = {} + self._generators_lock = threading.Lock() + + # Shared Manager for log queues (avoids spawning a new process per job) + self._mp_manager = get_mp_context().Manager() + atexit.register(self._shutdown) + + # Ensure directories exist + os.makedirs(self.output_dir, exist_ok=True) + os.makedirs(self.log_dir, exist_ok=True) + + def _load_logs(self, job: Job) -> None: + """Populate job's log buffer from its log file if it exists.""" + path = job.log_file_path + if not path: + path = os.path.join(self.log_dir, f"{job.id}.log") + if not os.path.isfile(path): + return + try: + with open(path, encoding="utf-8", errors="replace") as f: + for line in f: + stripped = line.rstrip("\n\r") + if stripped: + job._log_buf.write(stripped) + if not job.log_file_path: + job.log_file_path = path + except Exception as exc: + logger.warning( + "Failed to load logs from %s for job %s: %s", + path, + job.id, + exc, + ) + + def _load_jobs(self) -> None: + """Load jobs from database.""" + try: + for row in self._db.get_all_jobs(): + status = row["status"] + if status == "running": + status = JobStatus.FAILED + row["error"] = "Server restarted (job was running)" + row["finished_at"] = time.time() + self._db.update_job(row["id"], { + "status": "failed", + "error": row["error"], + "finished_at": row["finished_at"], + }) + elif status == "pending": + status = JobStatus.PENDING + job = Job( + id=row["id"], + model_id=row["model_id"], + prompt=row["prompt"], + workload_type=row.get("workload_type", "t2v"), + job_type=row.get("job_type", "inference"), + image_path=row.get("image_path", "") or "", + data_path=row.get("data_path", "") or "", + max_train_steps=row.get("max_train_steps", 1000), + train_batch_size=row.get("train_batch_size", 1), + learning_rate=float(row.get("learning_rate", 5e-5)), + num_latent_t=row.get("num_latent_t", 20), + validation_dataset_file=row.get("validation_dataset_file", "") or "", + lora_rank=row.get("lora_rank", 32), + ltx2_first_frame_conditioning_p=row.get("ltx2_first_frame_conditioning_p"), + dmd_use_vsa=row.get("dmd_use_vsa", False), + dmd_vsa_sparsity=float(row.get("dmd_vsa_sparsity", 0.8)), + dmd_denoising_steps=row.get("dmd_denoising_steps", "1000,757,522") or "1000,757,522", + min_timestep_ratio=float(row.get("min_timestep_ratio", 0.02)), + max_timestep_ratio=float(row.get("max_timestep_ratio", 0.98)), + real_score_guidance_scale=float(row.get("real_score_guidance_scale", 3.5)), + generator_update_interval=int(row.get("generator_update_interval", 5)), + real_score_model_path=row.get("real_score_model_path", "") or "", + fake_score_model_path=row.get("fake_score_model_path", "") or "", + status=JobStatus(status), + created_at=row["created_at"], + started_at=row.get("started_at"), + finished_at=row.get("finished_at"), + error=row.get("error"), + output_path=row.get("output_path"), + log_file_path=row.get("log_file_path"), + num_inference_steps=row.get("num_inference_steps", 50), + num_frames=row.get("num_frames", 81), + height=row.get("height", 480), + width=row.get("width", 832), + guidance_scale=row.get("guidance_scale", 5.0), + guidance_rescale=row.get("guidance_rescale", 0.0), + fps=row.get("fps", 24), + seed=row.get("seed", 1024), + negative_prompt=row.get("negative_prompt", "") or "", + num_gpus=row.get("num_gpus", 1), + dit_cpu_offload=row.get("dit_cpu_offload", False), + text_encoder_cpu_offload=row.get("text_encoder_cpu_offload", False), + vae_cpu_offload=row.get("vae_cpu_offload", False), + image_encoder_cpu_offload=row.get("image_encoder_cpu_offload", False), + use_fsdp_inference=row.get("use_fsdp_inference", False), + enable_torch_compile=row.get("enable_torch_compile", False), + vsa_sparsity=row.get("vsa_sparsity", 0.0), + tp_size=row.get("tp_size", -1), + sp_size=row.get("sp_size", -1), + ) + self._load_logs(job) + with self._jobs_lock: + self._jobs[job.id] = job + if self._jobs: + logger.info("Loaded %d jobs from database", len(self._jobs)) + except Exception as exc: + logger.warning("Failed to load jobs from database: %s", exc) + + def _save_job(self, job: Job) -> None: + """Persist job to database.""" + try: + self._db.update_job( + job.id, { + "status": job.status.value, + "started_at": job.started_at, + "finished_at": job.finished_at, + "error": job.error, + "output_path": job.output_path, + "log_file_path": job.log_file_path, + }) + except Exception as exc: + logger.warning("Failed to persist job %s: %s", job.id, exc) + + def _shutdown(self) -> None: + """Shutdown the shared multiprocessing manager on exit.""" + try: + self._mp_manager.shutdown() + except Exception as exc: + logger.warning("Failed to shutdown mp manager: %s", exc) + + def create_job( + self, + job_id: str, + model_id: str, + prompt: str, + workload_type: str = "t2v", + job_type: str = "inference", + image_path: str = "", + data_path: str = "", + max_train_steps: int = 1000, + train_batch_size: int = 1, + learning_rate: float = 5e-5, + num_latent_t: int = 20, + validation_dataset_file: str = "", + lora_rank: int = 32, + ltx2_first_frame_conditioning_p: float | None = None, + dmd_use_vsa: bool = False, + dmd_vsa_sparsity: float = 0.8, + dmd_denoising_steps: str = "1000,757,522", + min_timestep_ratio: float = 0.02, + max_timestep_ratio: float = 0.98, + real_score_guidance_scale: float = 3.5, + generator_update_interval: int = 5, + real_score_model_path: str = "", + fake_score_model_path: str = "", + num_inference_steps: int = 50, + num_frames: int = 81, + height: int = 480, + width: int = 832, + guidance_scale: float = 5.0, + guidance_rescale: float = 0.0, + fps: int = 24, + seed: int = 1024, + num_gpus: int = 1, + negative_prompt: str = "", + dit_cpu_offload: bool = False, + text_encoder_cpu_offload: bool = False, + vae_cpu_offload: bool = False, + image_encoder_cpu_offload: bool = False, + use_fsdp_inference: bool = False, + enable_torch_compile: bool = False, + vsa_sparsity: float = 0.0, + tp_size: int = -1, + sp_size: int = -1, + ) -> Job: + """Create a new job (does not start it automatically).""" + job = Job( + id=job_id, + model_id=model_id, + prompt=prompt.strip(), + workload_type=workload_type or "t2v", + job_type=job_type or "inference", + image_path=image_path or "", + data_path=data_path or "", + max_train_steps=max_train_steps, + train_batch_size=train_batch_size, + learning_rate=learning_rate, + num_latent_t=num_latent_t, + validation_dataset_file=validation_dataset_file or "", + lora_rank=lora_rank, + ltx2_first_frame_conditioning_p=ltx2_first_frame_conditioning_p, + dmd_use_vsa=dmd_use_vsa, + dmd_vsa_sparsity=dmd_vsa_sparsity, + dmd_denoising_steps=dmd_denoising_steps, + min_timestep_ratio=min_timestep_ratio, + max_timestep_ratio=max_timestep_ratio, + real_score_guidance_scale=real_score_guidance_scale, + generator_update_interval=generator_update_interval, + real_score_model_path=real_score_model_path or "", + fake_score_model_path=fake_score_model_path or "", + num_inference_steps=num_inference_steps, + num_frames=num_frames, + height=height, + width=width, + guidance_scale=guidance_scale, + guidance_rescale=guidance_rescale, + fps=fps, + seed=seed, + negative_prompt=negative_prompt or "", + num_gpus=num_gpus, + dit_cpu_offload=dit_cpu_offload, + text_encoder_cpu_offload=text_encoder_cpu_offload, + vae_cpu_offload=vae_cpu_offload, + image_encoder_cpu_offload=image_encoder_cpu_offload, + use_fsdp_inference=use_fsdp_inference, + enable_torch_compile=enable_torch_compile, + vsa_sparsity=vsa_sparsity, + tp_size=tp_size, + sp_size=sp_size, + ) + with self._jobs_lock: + self._jobs[job.id] = job + try: + self._db.insert_job(job.to_dict()) + except Exception as exc: + logger.warning("Failed to persist new job %s: %s", job.id, exc) + logger.info( + "Created job %s (model=%s, prompt=%s…)", + job.id, + job.model_id, + job.prompt[:60], + ) + return job + + def get_job(self, job_id: str) -> Job | None: + """Get a job by ID.""" + with self._jobs_lock: + return self._jobs.get(job_id) + + def list_jobs(self, job_type: str | None = None) -> list[Job]: + """Return jobs, sorted by creation time (newest first). + If job_type is set, filter to that type only.""" + with self._jobs_lock: + jobs = list(self._jobs.values()) + if job_type: + jobs = [j for j in jobs if j.job_type == job_type] + return sorted(jobs, key=lambda j: j.created_at, reverse=True) + + def delete_job(self, job_id: str) -> bool: + """Delete a job. Running jobs are stopped first. + + Returns: + True if job was found and deleted, False otherwise + """ + with self._jobs_lock: + job = self._jobs.pop(job_id, None) + if job is None: + return False + + # Best-effort stop + job._stop_event.set() + try: + self._db.delete_job(job_id) + except Exception as exc: + logger.warning("Failed to delete job %s from database: %s", job_id, exc) + logger.info("Deleted job %s", job.id) + return True + + def start_job(self, job_id: str) -> Job: + """Start (or restart) a pending / stopped / failed job. + + Raises: + ValueError: If job not found or cannot be started + """ + with self._jobs_lock: + job = self._jobs.get(job_id) + if job is None: + raise ValueError(f"Job {job_id} not found") + if job.status == JobStatus.RUNNING: + raise ValueError("Job is already running") + if job.status == JobStatus.COMPLETED: + raise ValueError("Job already completed. Delete and re-create to run again.") + # Reset state for re-run + job.status = JobStatus.PENDING + job.error = None + job.output_path = None + job.log_file_path = None # Will be set when job starts + job.started_at = None + job.finished_at = None + job._stop_event.clear() + job._log_buf = JobLogBuffer() # fresh log buffer + job.log_file_handler = None # Reset file handler + job._process = None # Reset subprocess handle + + # Wrap _run_job in an additional safety layer to catch any exceptions + # that might escape (though they shouldn't with our comprehensive handling) + def safe_run_job(job: Job): + """Wrapper to ensure _run_job never raises an unhandled exception.""" + try: + self._run_job(job) + except BaseException as exc: + # This should never happen, but if it does, we catch it here + logger.critical("Unhandled exception escaped from _run_job for job %s: %s", job.id, exc, exc_info=True) + with contextlib.suppress(Exception): + job.status = JobStatus.FAILED + job.error = (f"Unhandled exception: {type(exc).__name__}: {str(exc)}") + job.finished_at = time.time() + + thread = threading.Thread(target=safe_run_job, args=(job, ), daemon=True) + job._thread = thread + thread.start() + logger.info("Started job %s", job.id) + return job + + def stop_job(self, job_id: str) -> Job: + """Request a running job to stop. + + For inference: cooperative stop between phases. + For training: terminates the subprocess. + + Raises: + ValueError: If job not found or not running + """ + with self._jobs_lock: + job = self._jobs.get(job_id) + if job is None: + raise ValueError(f"Job {job_id} not found") + if job.status != JobStatus.RUNNING: + raise ValueError(f"Job is not running (status={job.status.value})") + + job._stop_event.set() + if job._process is not None: + with contextlib.suppress(Exception): + job._process.terminate() + logger.info("Stop requested for job %s", job.id) + return job + + def get_job_logs(self, job_id: str, after: int = 0) -> dict[str, Any]: + """Return log lines for a job. + + Args: + job_id: The job ID + after: Return only lines after this index (for incremental polling) + + Returns: + Dictionary with 'lines', 'total', 'progress', 'progress_msg', 'phase' + + Raises: + ValueError: If job not found + """ + with self._jobs_lock: + job = self._jobs.get(job_id) + if job is None: + raise ValueError(f"Job {job_id} not found") + + lines, total = job._log_buf.get_lines(after=after) + return { + "lines": lines, + "total": total, + "progress": job._log_buf.progress, + "progress_msg": job._log_buf.progress_msg, + "phase": job._log_buf.phase, + } + + def _get_or_create_generator( + self, + model_id: str, + workload_type: str, + num_gpus: int, + dit_cpu_offload: bool = False, + text_encoder_cpu_offload: bool = False, + vae_cpu_offload: bool = False, + image_encoder_cpu_offload: bool = False, + use_fsdp_inference: bool = False, + enable_torch_compile: bool = False, + vsa_sparsity: float = 0.0, + tp_size: int = -1, + sp_size: int = -1, + log_queue: mp.Queue | None = None, + ) -> Any: + cache_key = ( + model_id, + workload_type, + num_gpus, + dit_cpu_offload, + text_encoder_cpu_offload, + vae_cpu_offload, + image_encoder_cpu_offload, + use_fsdp_inference, + enable_torch_compile, + vsa_sparsity, + tp_size, + sp_size, + ) + + # Generators are cached by model_id and configuration parameters + with self._generators_lock: + if cache_key in self._generators: + return self._generators[cache_key] + + # Import lazily so starting the server is fast even without a GPU. + from fastvideo import VideoGenerator + + logger.info( + "Loading model %s (workload=%s, num_gpus=%d, offloads: " + "dit=%s text_encoder=%s vae=%s image_encoder=%s, fsdp=%s, " + "torch_compile=%s, vsa_sparsity=%.2f, tp=%d sp=%d) …", + model_id, + workload_type, + num_gpus, + dit_cpu_offload, + text_encoder_cpu_offload, + vae_cpu_offload, + image_encoder_cpu_offload, + use_fsdp_inference, + enable_torch_compile, + vsa_sparsity, + tp_size, + sp_size, + ) + + gen = VideoGenerator.from_pretrained( + model_id, + workload_type=workload_type, + dit_cpu_offload=dit_cpu_offload, + text_encoder_cpu_offload=text_encoder_cpu_offload, + vae_cpu_offload=vae_cpu_offload, + image_encoder_cpu_offload=image_encoder_cpu_offload, + use_fsdp_inference=use_fsdp_inference, + enable_torch_compile=enable_torch_compile, + VSA_sparsity=vsa_sparsity, + tp_size=tp_size, + sp_size=sp_size, + log_queue=log_queue, + ) + + with self._generators_lock: + if cache_key not in self._generators: + self._generators[cache_key] = gen + else: # Another thread may have created it while we were loading. + gen.shutdown() + gen = self._generators[cache_key] + return gen + + def _run_job(self, job: Job): + if job.job_type == "inference": + self._run_inference_job(job) + else: + self._run_training_job(job) + + def _run_training_job(self, job: Job): + """Run a finetuning, distillation, or LoRA job via subprocess.""" + buf = job._log_buf + os.makedirs(self.log_dir, exist_ok=True) + job.log_file_path = os.path.join(self.log_dir, f"{job.id}.log") + job_output_dir = os.path.join(self.output_dir, job.id) + os.makedirs(job_output_dir, exist_ok=True) + + if not job.data_path or not os.path.isdir(job.data_path): + job.status = JobStatus.FAILED + job.error = (f"Data path '{job.data_path}' is required and must be an " + "existing directory. Preprocess your dataset first.") + job.finished_at = time.time() + self._save_job(job) + return + + module_info = get_training_module_info(job.workload_type, job.model_id) + if not module_info: + job.status = JobStatus.FAILED + job.error = f"Unknown workload type: {job.workload_type}" + job.finished_at = time.time() + self._save_job(job) + return + + module_path, _pipeline_workload, use_vsa, _is_lora = module_info + dmd_use_vsa = (job.workload_type.startswith("dmd_") and getattr(job, "dmd_use_vsa", False)) + env = os.environ.copy() + env.update(get_training_env(use_vsa or dmd_use_vsa)) + + job_dict = { + "model_id": job.model_id, + "data_path": job.data_path, + "workload_type": job.workload_type, + "num_gpus": job.num_gpus, + "max_train_steps": job.max_train_steps, + "train_batch_size": job.train_batch_size, + "learning_rate": job.learning_rate, + "num_latent_t": job.num_latent_t, + "num_height": job.height, + "num_width": job.width, + "num_frames": job.num_frames, + "validation_dataset_file": job.validation_dataset_file, + "lora_rank": job.lora_rank, + "ltx2_first_frame_conditioning_p": job.ltx2_first_frame_conditioning_p, + } + if job.workload_type.startswith("dmd_") or job.workload_type.startswith("self_forcing_"): + job_dict["dmd_use_vsa"] = getattr(job, "dmd_use_vsa", False) + job_dict["dmd_vsa_sparsity"] = getattr(job, "dmd_vsa_sparsity", 0.8) + job_dict["dmd_denoising_steps"] = getattr(job, "dmd_denoising_steps", "1000,757,522") + job_dict["min_timestep_ratio"] = getattr(job, "min_timestep_ratio", 0.02) + job_dict["max_timestep_ratio"] = getattr(job, "max_timestep_ratio", 0.98) + job_dict["real_score_guidance_scale"] = getattr(job, "real_score_guidance_scale", 3.5) + job_dict["generator_update_interval"] = getattr(job, "generator_update_interval", 5) + job_dict["real_score_model_path"] = (getattr(job, "real_score_model_path", "") or job.model_id) + job_dict["fake_score_model_path"] = (getattr(job, "fake_score_model_path", "") or job.model_id) + train_args = build_training_args(job_dict, job_output_dir) + + repo_root = Path(__file__).resolve().parent.parent + torchrun_cmd = [ + sys.executable, + "-m", + "torch.distributed.run", + "--nproc_per_node", + str(job.num_gpus), + "--nnodes", + "1", + str(repo_root / module_path), + ] + train_args + buf.write(f"Starting training: {' '.join(torchrun_cmd[:12])}...") + buf.phase = "starting" + + try: + job.status = JobStatus.RUNNING + job.started_at = time.time() + self._save_job(job) + + with open(job.log_file_path, "w", encoding="utf-8") as log_file: + job._process = subprocess.Popen( + torchrun_cmd, + cwd=str(repo_root), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + assert job._process.stdout is not None + for line in iter(job._process.stdout.readline, ""): + if job._stop_event.is_set(): + job._process.terminate() + try: + job._process.wait(timeout=30) + except subprocess.TimeoutExpired: + job._process.kill() + job.status = JobStatus.STOPPED + job.finished_at = time.time() + self._save_job(job) + buf.phase = "stopped" + return + line = line.rstrip() + if line: + buf.write(line) + log_file.write(line + "\n") + log_file.flush() + + job._process.wait() + exit_code = job._process.returncode or 0 + + if exit_code == 0: + job.status = JobStatus.COMPLETED + buf.progress = 100.0 + buf.phase = "done" + # Training outputs checkpoints, not video + ckpt_dirs = sorted(Path(job_output_dir).glob("checkpoint-*")) + if ckpt_dirs: + job.output_path = str(ckpt_dirs[-1]) + else: + job.status = JobStatus.FAILED + job.error = f"Training exited with code {exit_code}" + buf.phase = "failed" + job.finished_at = time.time() + self._save_job(job) + + except Exception as exc: + job.status = JobStatus.FAILED + job.error = f"{type(exc).__name__}: {exc}" + job.finished_at = time.time() + self._save_job(job) + buf.phase = "failed" + logger.exception("Training job %s failed", job.id) + finally: + job._process = None + + def _run_inference_job(self, job: Job): + buf = job._log_buf + os.makedirs(self.log_dir, exist_ok=True) + job.log_file_path = os.path.join(self.log_dir, f"{job.id}.log") + + # Add file handler to persist logs + file_handler = logging.FileHandler(job.log_file_path, mode='w', encoding='utf-8') + file_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")) + job.log_file_handler = file_handler + + # Hook logger output into job log buffer (main process logs) + fastvideo_logger = logging.getLogger("fastvideo") + buffer_handler = LogBufferHandler(buf) + fastvideo_logger.addHandler(buffer_handler) + fastvideo_logger.addHandler(file_handler) + + # Queue for worker process logs (fsdp_load, cuda, etc.) + # Use Manager().Queue() so it can be shared with spawned workers (spawn + # does not inherit memory; mp.Queue only works through inheritance). + log_queue = self._mp_manager.Queue() + queue_listener = logging.handlers.QueueListener(log_queue, + buffer_handler, + file_handler, + respect_handler_level=True) + queue_listener.start() + + # Set output directory, create if it doesn't exist + job_output_dir = os.path.join(self.output_dir, job.id) + os.makedirs(job_output_dir, exist_ok=True) + + try: + job.status = JobStatus.RUNNING + job.started_at = time.time() + self._save_job(job) + buf.phase = "starting" + started = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(job.started_at)) + logger.info("Job %s started at %s", job.id, started) + logger.info("Model: %s", job.model_id) + logger.info("Prompt: %s", job.prompt) + + if job._stop_event.is_set(): + job.status = JobStatus.STOPPED + job.finished_at = time.time() + self._save_job(job) + logger.warning("Job stopped before execution started") + return + + buf.phase = "loading model" + logger.info("Loading model...") + + # Run generator creation in a background thread so we + # can poll _stop_event while the (potentially slow) + # model download / load is in progress. + _gen_result: list[Any] = [] + _gen_error: list[BaseException] = [] + + def _load_generator() -> None: + try: + gen = self._get_or_create_generator( + job.model_id, + job.workload_type, + job.num_gpus, + dit_cpu_offload=job.dit_cpu_offload, + text_encoder_cpu_offload=(job.text_encoder_cpu_offload), + vae_cpu_offload=job.vae_cpu_offload, + image_encoder_cpu_offload=(job.image_encoder_cpu_offload), + use_fsdp_inference=job.use_fsdp_inference, + enable_torch_compile=(job.enable_torch_compile), + vsa_sparsity=job.vsa_sparsity, + tp_size=job.tp_size, + sp_size=job.sp_size, + log_queue=log_queue, + ) + _gen_result.append(gen) + except BaseException as exc: + _gen_error.append(exc) + + loader = threading.Thread( + target=_load_generator, + daemon=True, + ) + loader.start() + + while loader.is_alive(): + if job._stop_event.is_set(): + job.status = JobStatus.STOPPED + job.finished_at = time.time() + self._save_job(job) + logger.warning( + "Job %s stopped during model loading", + job.id, + ) + buf.phase = "stopped" + return + loader.join(timeout=0.5) + + if _gen_error: + raise _gen_error[0] + + generator = _gen_result[0] + buf.phase = "generating" + logger.info("Starting generation for job %s (model=%s)", job.id, job.model_id) + + gen_kwargs: dict[str, Any] = { + "prompt": job.prompt, + "output_path": job_output_dir, + "save_video": True, + "num_inference_steps": job.num_inference_steps, + "num_frames": job.num_frames, + "height": job.height, + "width": job.width, + "guidance_scale": job.guidance_scale, + "guidance_rescale": job.guidance_rescale, + "fps": job.fps, + "seed": job.seed, + "negative_prompt": job.negative_prompt or "", + "log_queue": log_queue, + } + if job.image_path: + gen_kwargs["image_path"] = job.image_path + generator.generate_video(**gen_kwargs) + + buf.phase = "saving" + logger.info("Generation completed, searching for output file...") + + # Find the generated video file + video_files = sorted(Path(job_output_dir).glob("*.mp4")) + if video_files: + job.output_path = str(video_files[0]) + logger.info("Found video output: %s", job.output_path) + else: + # Could be an image workload + image_files = sorted(Path(job_output_dir).glob("*.png")) + if image_files: + job.output_path = str(image_files[0]) + logger.info("Found image output: %s", job.output_path) + else: + logger.warning("No output file found in job directory") + + if job._stop_event.is_set(): + job.status = JobStatus.STOPPED + logger.warning("Job was stopped during execution") + else: + job.status = JobStatus.COMPLETED + buf.progress = 100.0 + logger.info("Job completed successfully") + job.finished_at = time.time() + self._save_job(job) + buf.phase = "done" + + except Exception as exception: + error_msg = str(exception) + logger.error("Critical error in job thread: %s", error_msg) + job.status = JobStatus.FAILED + job.error = f"Critical error ({type(exception).__name__}): {error_msg}" + job.finished_at = time.time() + self._save_job(job) + buf.phase = "failed" + + finally: + queue_listener.stop() + # Remove handlers and close file + fastvideo_logger.removeHandler(buffer_handler) + fastvideo_logger.removeHandler(file_handler) + file_handler.flush() + file_handler.close() + job.log_file_handler = None diff --git a/ui/models/__init__.py b/ui/models/__init__.py new file mode 100644 index 000000000..ee985c4bb --- /dev/null +++ b/ui/models/__init__.py @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Pydantic request/response models for the API.""" + +from ui.models.create_job_request import CreateJobRequest +from ui.models.settings_update import SettingsUpdate +from ui.models.create_dataset_request import CreateDatasetRequest +from ui.models.update_caption_request import UpdateCaptionRequest + +__all__ = [ + "CreateJobRequest", + "SettingsUpdate", + "CreateDatasetRequest", + "UpdateCaptionRequest", +] diff --git a/ui/models/create_dataset_request.py b/ui/models/create_dataset_request.py new file mode 100644 index 000000000..166c509d8 --- /dev/null +++ b/ui/models/create_dataset_request.py @@ -0,0 +1,11 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Request model for creating a dataset.""" + +from pydantic import BaseModel + + +class CreateDatasetRequest(BaseModel): + name: str + upload_path: str = "" # One-time path from upload; moved to datasets_upload_dir/{id} + file_names: list[str] = [] + captions: dict[str, str] = {} # optional: file_name -> caption from videos2caption.json diff --git a/ui/models/create_job_request.py b/ui/models/create_job_request.py new file mode 100644 index 000000000..c364c81d6 --- /dev/null +++ b/ui/models/create_job_request.py @@ -0,0 +1,49 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Request model for creating a job.""" + +from pydantic import BaseModel + + +class CreateJobRequest(BaseModel): + model_id: str + prompt: str + workload_type: str = "t2v" + job_type: str = "inference" + image_path: str = "" + data_path: str = "" + max_train_steps: int = 1000 + train_batch_size: int = 1 + learning_rate: float = 5e-5 + num_latent_t: int = 20 + validation_dataset_file: str = "" + lora_rank: int = 32 + ltx2_first_frame_conditioning_p: float | None = None + negative_prompt: str = "" + num_inference_steps: int = 50 + num_frames: int = 81 + height: int = 480 + width: int = 832 + guidance_scale: float = 5.0 + guidance_rescale: float = 0.0 + fps: int = 24 + seed: int = 1024 + num_gpus: int = 1 + dit_cpu_offload: bool = False + text_encoder_cpu_offload: bool = False + vae_cpu_offload: bool = False + image_encoder_cpu_offload: bool = False + use_fsdp_inference: bool = False + enable_torch_compile: bool = False + vsa_sparsity: float = 0.0 + tp_size: int = -1 + sp_size: int = -1 + # DMD (Distribution Matching Distillation) options + dmd_use_vsa: bool = False + dmd_vsa_sparsity: float = 0.8 + dmd_denoising_steps: str = "1000,757,522" + min_timestep_ratio: float = 0.02 + max_timestep_ratio: float = 0.98 + real_score_guidance_scale: float = 3.5 + generator_update_interval: int = 5 + real_score_model_path: str = "" + fake_score_model_path: str = "" diff --git a/ui/models/settings_update.py b/ui/models/settings_update.py new file mode 100644 index 000000000..447e4c415 --- /dev/null +++ b/ui/models/settings_update.py @@ -0,0 +1,31 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Request model for updating settings.""" + +from pydantic import BaseModel + + +class SettingsUpdate(BaseModel): + defaultModelId: str | None = None + defaultModelIdT2v: str | None = None + defaultModelIdI2v: str | None = None + defaultModelIdT2i: str | None = None + numInferenceSteps: int | None = None + numFrames: int | None = None + height: int | None = None + width: int | None = None + guidanceScale: float | None = None + guidanceRescale: float | None = None + fps: int | None = None + seed: int | None = None + numGpus: int | None = None + ditCpuOffload: bool | None = None + textEncoderCpuOffload: bool | None = None + vaeCpuOffload: bool | None = None + imageEncoderCpuOffload: bool | None = None + useFsdpInference: bool | None = None + enableTorchCompile: bool | None = None + vsaSparsity: float | None = None + tpSize: int | None = None + spSize: int | None = None + autoStartJob: bool | None = None + datasetUploadPath: str | None = None diff --git a/ui/models/update_caption_request.py b/ui/models/update_caption_request.py new file mode 100644 index 000000000..ee2f7e4b9 --- /dev/null +++ b/ui/models/update_caption_request.py @@ -0,0 +1,9 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Request model for updating a dataset file caption.""" + +from pydantic import BaseModel + + +class UpdateCaptionRequest(BaseModel): + file_name: str + caption: str diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 000000000..6b9cd71fd --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,3066 @@ +{ + "name": "fastvideo-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fastvideo-ui", + "version": "0.1.0", + "dependencies": { + "@sveltejs/kit": "^2.0.0", + "svelte": "^5.0.0" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^5.5.4", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@types/node": "^20", + "concurrently": "^9", + "eslint": "^9", + "typescript": "^5", + "vite": "^6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==" + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.2.tgz", + "integrity": "sha512-S/ggWH1LU7jTyi9DxZOKyxpVd4hF/OZ0JrEbeLjXk/DFXwRny0tjD2c992zOUYQobLrVkRVMDdmHP16HKP7GRg==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-node": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.4.tgz", + "integrity": "sha512-45X92CXW+2J8ZUzPv3eLlKWEzINKiiGeFWTjyER4ZN4sGgNoaoeSkCY/QYNxHpPXy71QPsctwccBo9jJs0ySPQ==", + "dev": true, + "dependencies": { + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.0", + "rollup": "^4.59.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.4.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.53.4", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.53.4.tgz", + "integrity": "sha512-iAIPEahFgDJJyvz8g0jP08KvqnM6JvdW8YfsygZ+pMeMvyM2zssWMltcsotETvjSZ82G3VlitgDtBIvpQSZrTA==", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.6.3", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", + "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", + "debug": "^4.4.1", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.17", + "vitefu": "^1.0.6" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", + "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.19.33", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", + "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", + "devOptional": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz", + "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==" + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrap": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.3.tgz", + "integrity": "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "optional": true, + "peer": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "optional": true, + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", + "integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "5.53.7", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.7.tgz", + "integrity": "sha512-uxck1KI7JWtlfP3H6HOWi/94soAl23jsGJkBzN2BAWcQng0+lTrRNhxActFqORgnO9BHVd1hKJhG+ljRuIUWfQ==", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.3", + "esm-env": "^1.2.1", + "esrap": "^2.2.2", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte/node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/svelte/node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitefu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", + "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==" + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 000000000..d41c4b0d6 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,28 @@ +{ + "name": "fastvideo-ui", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "start": "concurrently --kill-others-on-fail \"npm:start:api\" \"npm:start:web\"", + "start:api": "cd .. && python -m ui.server", + "start:web": "node build", + "lint": "eslint ." + }, + "dependencies": { + "@sveltejs/kit": "^2.0.0", + "svelte": "^5.0.0" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^5.5.4", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@types/node": "^20", + "concurrently": "^9", + "eslint": "^9", + "typescript": "^5", + "vite": "^6.0.0" + } +} diff --git a/ui/preprocess_runner.py b/ui/preprocess_runner.py new file mode 100644 index 000000000..54ed77a2a --- /dev/null +++ b/ui/preprocess_runner.py @@ -0,0 +1,142 @@ +# SPDX-License-Identifier: Apache-2.0 +""" +Runs preprocessing subprocess for datasets. +""" + +from __future__ import annotations + +import logging +import os +import subprocess +import sys +import threading +from pathlib import Path +from collections.abc import Callable + +logger = logging.getLogger("fastvideo.ui.preprocess_runner") + + +def build_preprocess_args( + dataset_id: str, + raw_path: str, + output_dir: str, + workload_type: str, + model_path: str, + dataset_type: str = "merged", + num_gpus: int = 1, +) -> list[str]: + """Build CLI args for v1_preprocessing_new.""" + return [ + "--model-path", + model_path, + "--mode", + "preprocess", + "--workload-type", + workload_type, + "--preprocess.video_loader_type", + "torchvision", + "--preprocess.dataset_type", + dataset_type, + "--preprocess.dataset_path", + raw_path, + "--preprocess.dataset_output_dir", + output_dir, + "--preprocess.preprocess_video_batch_size", + "2", + "--preprocess.dataloader_num_workers", + "0", + "--preprocess.max_height", + "480", + "--preprocess.max_width", + "832", + "--preprocess.num_frames", + "77", + "--preprocess.train_fps", + "16", + "--preprocess.samples_per_file", + "8", + "--preprocess.flush_frequency", + "8", + "--preprocess.video_length_tolerance_range", + "5", + ] + + +def run_preprocess( + dataset_id: str, + raw_path: str, + output_dir: str, + workload_type: str, + model_path: str, + dataset_type: str, + num_gpus: int, + log_file_path: str, + on_status_change: Callable[[str, str | None], None], + stop_event: threading.Event, +) -> None: + """Run preprocessing subprocess. Calls on_status_change(status, error).""" + repo_root = Path(__file__).resolve().parent.parent + preprocess_module = "fastvideo.pipelines.preprocess.v1_preprocessing_new" + + args = build_preprocess_args( + dataset_id=dataset_id, + raw_path=raw_path, + output_dir=output_dir, + workload_type=workload_type, + model_path=model_path, + dataset_type=dataset_type, + num_gpus=num_gpus, + ) + + cmd = [ + sys.executable, + "-m", + "torch.distributed.run", + "--nproc_per_node", + str(num_gpus), + "--nnodes", + "1", + "-m", + preprocess_module, + ] + args + + env = os.environ.copy() + env["TOKENIZERS_PARALLELISM"] = "false" + + try: + on_status_change("preprocessing", None) + with open(log_file_path, "w", encoding="utf-8") as log_file: + proc = subprocess.Popen( + cmd, + cwd=str(repo_root), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + assert proc.stdout is not None + for line in iter(proc.stdout.readline, ""): + if stop_event.is_set(): + proc.terminate() + try: + proc.wait(timeout=30) + except subprocess.TimeoutExpired: + proc.kill() + on_status_change("stopped", "Preprocessing stopped by user") + return + line = line.rstrip() + if line: + log_file.write(line + "\n") + log_file.flush() + + proc.wait() + exit_code = proc.returncode or 0 + + if exit_code == 0: + on_status_change("ready", None) + else: + on_status_change("failed", f"Preprocessing exited with code {exit_code}") + except Exception as exc: + on_status_change("failed", f"{type(exc).__name__}: {exc}") + logger.exception("Preprocessing failed for dataset %s", dataset_id) diff --git a/ui/public/fastvideo.ico b/ui/public/fastvideo.ico new file mode 100644 index 000000000..bb68ebf4f Binary files /dev/null and b/ui/public/fastvideo.ico differ diff --git a/ui/public/logo.svg b/ui/public/logo.svg new file mode 100644 index 000000000..0b8b058b5 --- /dev/null +++ b/ui/public/logo.svg @@ -0,0 +1,18 @@ + diff --git a/ui/server.py b/ui/server.py new file mode 100644 index 000000000..9e0422e24 --- /dev/null +++ b/ui/server.py @@ -0,0 +1,668 @@ +# SPDX-License-Identifier: Apache-2.0 +""" +FastVideo Job Runner — API server only. + +Provides REST endpoints for creating, starting, stopping, and deleting +video-generation jobs powered by the FastVideo library. + +This is the API-only server. Use web_server.py to serve the frontend. + +Usage: + python -m ui.api_server # from repo root + python ui/api_server.py --output-dir ./videos # explicit output dir +""" + +from __future__ import annotations + +import argparse +import contextlib +import logging +import os +import shutil +import signal +import time +import uuid +from pathlib import Path +import uvicorn +from typing import Annotated, Any + +from fastapi import FastAPI, File, HTTPException, UploadFile +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse + +from fastvideo.registry import (get_registered_model_paths, get_registered_models_with_workloads) +from ui.database import Database, _get_db_path +from ui.job_runner import JobRunner, JobStatus +from ui.models import (CreateDatasetRequest, CreateJobRequest, SettingsUpdate, UpdateCaptionRequest) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger("fastvideo.ui.api") + +DEFAULT_OUTPUT_DIR = os.path.join(os.path.dirname(__file__), "..", "outputs", "ui_jobs") + + +def _get_model_label(model_path: str) -> str: + """Derive a readable label from a HF model path.""" + return model_path.split("/")[-1].replace("-", " ").replace("_", " ") + + +_available_models: list[dict[str, str]] = [{ + "id": path, + "label": _get_model_label(path) +} for path in get_registered_model_paths()] + +job_runner: JobRunner +database: Database | None = None +upload_dir: str = "" +datasets_upload_dir: str = "" +verbose = 0 + +app = FastAPI( + title="FastVideo Job Runner API", + version="0.1.0", + description="REST API for FastVideo job management", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/api/settings") +def get_settings() -> dict[str, Any]: + """Return persisted default options (for new job creation).""" + if database is None: + raise HTTPException( + status_code=503, + detail="Database not initialized (persistence disabled)", + ) + return database.get_settings() + + +@app.put("/api/settings") +def update_settings(settings: SettingsUpdate) -> dict[str, Any]: + """Update persisted default options. Only provided fields are updated.""" + if database is None: + raise HTTPException( + status_code=503, + detail="Database not initialized (persistence disabled)", + ) + updates = settings.model_dump(exclude_unset=True) + if not updates: + return database.get_settings() + database.save_settings(updates) + return database.get_settings() + + +@app.get("/api/models") +def list_models(workload_type: str | None = None) -> list[dict[str, Any]]: + """Return the catalogue of available video-generation models. + + Query params: + workload_type: If set (t2v, i2v, t2i), only return models that + support this workload. Otherwise return all models. + """ + if workload_type: + models = get_registered_models_with_workloads(workload_type=workload_type) + return [{"id": m["id"], "label": m["label"]} for m in models] + return _available_models + + +ALLOWED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"} + + +@app.post("/api/upload-image") +async def upload_image(file: Annotated[UploadFile, File()], ) -> dict[str, str]: + """Upload an image file for I2V jobs. Returns the absolute path.""" + global upload_dir # noqa: PLW0603 + if not upload_dir: + raise HTTPException( + status_code=503, + detail="Upload directory not configured", + ) + ext = Path(file.filename or "").suffix.lower() + if ext not in ALLOWED_IMAGE_EXTENSIONS: + raise HTTPException( + status_code=400, + detail=(f"Invalid file type. Allowed: " + f"{', '.join(ALLOWED_IMAGE_EXTENSIONS)}"), + ) + os.makedirs(upload_dir, exist_ok=True) + unique_name = f"{uuid.uuid4().hex}{ext}" + dest_path = os.path.join(upload_dir, unique_name) + try: + contents = await file.read() + with open(dest_path, "wb") as f: + f.write(contents) + except OSError as e: + raise HTTPException( + status_code=500, + detail=f"Failed to save upload: {e}", + ) from e + return {"path": os.path.abspath(dest_path)} + + +ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".webm", ".avi", ".mov"} + + +def _filter_video_files(files: list[UploadFile]) -> list[UploadFile]: + """Filter uploaded files to only include videos.""" + return [f for f in files if Path(f.filename or "").suffix.lower() in ALLOWED_VIDEO_EXTENSIONS] + + +@app.post("/api/upload-raw-dataset") +async def upload_raw_dataset(files: Annotated[list[UploadFile], File()], ) -> dict[str, Any]: + """ + Upload raw video dataset. Returns path and file list. + Filters files to only include videos. For folder upload, only adds videos. + """ + if database is None: + raise HTTPException( + status_code=503, + detail="Database not initialized", + ) + settings = database.get_settings() + raw_path = (settings.get("datasetUploadPath") or settings.get("dataset_upload_path") or "") + base_path = (raw_path.strip() if raw_path and isinstance(raw_path, str) else "") + base_path = (datasets_upload_dir if not base_path else os.path.abspath(base_path)) + if not base_path: + raise HTTPException( + status_code=503, + detail="Dataset upload directory not configured. Set it in Settings.", + ) + filtered = _filter_video_files(files) + if not filtered: + raise HTTPException( + status_code=400, + detail=("No video files found. " + f"Allowed: {', '.join(ALLOWED_VIDEO_EXTENSIONS)}"), + ) + upload_id = uuid.uuid4().hex + media_folder = os.path.join(base_path, upload_id) + os.makedirs(media_folder, exist_ok=True) + file_names = [] + for uf in filtered: + name = uf.filename or f"{uuid.uuid4().hex}" + if "/" in name or "\\" in name: + name = os.path.basename(name) + if not Path(name).suffix: + name += ".mp4" + dest = os.path.join(media_folder, name) + try: + contents = await uf.read() + # Detect Git LFS pointer files (they look like tiny text files, not real videos) + if contents.startswith(b"version https://git-lfs.github.com/spec/v1"): + raise HTTPException( + status_code=400, + detail=(f"File {uf.filename} appears to be a Git LFS pointer, not an actual video. " + "Please run `git lfs pull` (or otherwise download the real video files) " + "and upload the resolved videos instead."), + ) + with open(dest, "wb") as f: + f.write(contents) + file_names.append(name) + except HTTPException: + # Re-raise HTTPExceptions (e.g. LFS pointer detection) as-is + raise + except OSError as e: + raise HTTPException( + status_code=500, + detail=f"Failed to save {uf.filename}: {e}", + ) from e + return { + "path": os.path.abspath(media_folder), + "upload_id": upload_id, + "file_names": file_names, + } + + +@app.get("/api/jobs") +def list_jobs(job_type: str | None = None) -> list[dict[str, Any]]: + """Return jobs (newest first). Optionally filter by job_type.""" + return [j.to_dict() for j in job_runner.list_jobs(job_type=job_type)] + + +@app.get("/api/jobs/{job_id}") +def get_job(job_id: str) -> dict[str, Any]: + """Get details for a single job.""" + job = job_runner.get_job(job_id) + if job is None: + raise HTTPException(status_code=404, detail="Job not found") + return job.to_dict() + + +@app.post("/api/jobs", status_code=201) +def create_job(req: CreateJobRequest) -> dict[str, Any]: + """Create a new job (does **not** start it automatically).""" + job_type = req.job_type or "inference" + if job_type == "inference": + valid_ids = {m["id"] for m in _available_models} + if req.model_id not in valid_ids: + raise HTTPException( + status_code=400, + detail=(f"Unknown model_id '{req.model_id}'. " + f"Valid options: {sorted(valid_ids)}"), + ) + + job = job_runner.create_job( + job_id=str(uuid.uuid4()), + model_id=req.model_id, + prompt=req.prompt, + workload_type=req.workload_type or "t2v", + job_type=job_type, + image_path=req.image_path or "", + data_path=req.data_path or "", + max_train_steps=req.max_train_steps, + train_batch_size=req.train_batch_size, + learning_rate=req.learning_rate, + num_latent_t=req.num_latent_t, + validation_dataset_file=req.validation_dataset_file or "", + lora_rank=req.lora_rank, + ltx2_first_frame_conditioning_p=req.ltx2_first_frame_conditioning_p, + negative_prompt=req.negative_prompt, + num_inference_steps=req.num_inference_steps, + num_frames=req.num_frames, + height=req.height, + width=req.width, + guidance_scale=req.guidance_scale, + guidance_rescale=req.guidance_rescale, + fps=req.fps, + seed=req.seed, + num_gpus=req.num_gpus, + dit_cpu_offload=req.dit_cpu_offload, + text_encoder_cpu_offload=req.text_encoder_cpu_offload, + vae_cpu_offload=req.vae_cpu_offload, + image_encoder_cpu_offload=req.image_encoder_cpu_offload, + use_fsdp_inference=req.use_fsdp_inference, + enable_torch_compile=req.enable_torch_compile, + vsa_sparsity=req.vsa_sparsity, + tp_size=req.tp_size, + sp_size=req.sp_size, + dmd_use_vsa=req.dmd_use_vsa, + dmd_vsa_sparsity=req.dmd_vsa_sparsity, + dmd_denoising_steps=req.dmd_denoising_steps or "1000,757,522", + min_timestep_ratio=req.min_timestep_ratio, + max_timestep_ratio=req.max_timestep_ratio, + real_score_guidance_scale=req.real_score_guidance_scale, + generator_update_interval=req.generator_update_interval, + real_score_model_path=req.real_score_model_path or "", + fake_score_model_path=req.fake_score_model_path or "", + ) + + if database is not None: + settings = database.get_settings() + if settings.get("autoStartJob"): + try: + job = job_runner.start_job(job.id) + except ValueError as exc: + logger.warning( + "Auto-start failed for job %s: %s", + job.id, + exc, + ) + + return job.to_dict() + + +@app.post("/api/jobs/{job_id}/start") +def start_job(job_id: str) -> dict[str, Any]: + """Start (or restart) a pending / stopped / failed job.""" + try: + job = job_runner.start_job(job_id) + return job.to_dict() + except ValueError as e: + raise HTTPException( + status_code=404 if "not found" in str(e) else 409, + detail=str(e), + ) from e + + +@app.post("/api/jobs/{job_id}/stop") +def stop_job(job_id: str) -> dict[str, Any]: + """Request a running job to stop. + + Because video generation is a single atomic call to the FastVideo + library, the stop is *cooperative*: the flag is checked between major + phases (model loading ↔ generation ↔ saving). If the model is + already mid-forward-pass, it will complete before the stop takes + effect. + """ + try: + job = job_runner.stop_job(job_id) + return job.to_dict() + except ValueError as e: + raise HTTPException( + status_code=404 if "not found" in str(e) else 409, + detail=str(e), + ) from e + + +@app.delete("/api/jobs/{job_id}") +def delete_job(job_id: str) -> dict[str, str]: + """Delete a job. Running jobs are stopped first.""" + if not job_runner.delete_job(job_id): + raise HTTPException(status_code=404, detail="Job not found") + return {"detail": f"Job {job_id} deleted"} + + +# --- Datasets --- + + +@app.get("/api/datasets") +def list_datasets() -> list[dict[str, Any]]: + """Return all datasets, newest first, with file_count and size_bytes.""" + if database is None: + raise HTTPException( + status_code=503, + detail="Database not initialized", + ) + out = [] + for ds in database.get_all_datasets(): + count, size = _dataset_media_stats(ds["id"]) + out.append({**ds, "file_count": count, "size_bytes": size}) + return out + + +@app.get("/api/datasets/{dataset_id}") +def get_dataset(dataset_id: str) -> dict[str, Any]: + """Get a single dataset by ID, with file_count and size_bytes.""" + if database is None: + raise HTTPException(status_code=503, detail="Database not initialized") + ds = database.get_dataset(dataset_id) + if ds is None: + raise HTTPException(status_code=404, detail="Dataset not found") + count, size = _dataset_media_stats(dataset_id) + return {**ds, "file_count": count, "size_bytes": size} + + +def _dataset_media_dir(dataset_id: str) -> str: + """Return the path to a dataset's media directory.""" + return os.path.join(datasets_upload_dir, dataset_id) + + +def _dataset_media_stats(dataset_id: str) -> tuple[int, int]: + """Return (file_count, total_size_bytes) for a dataset's media directory.""" + media_dir = _dataset_media_dir(dataset_id) + video_exts = {".mp4", ".webm", ".mov", ".avi", ".mkv"} + count = 0 + total = 0 + if os.path.isdir(media_dir): + for name in os.listdir(media_dir): + ext = os.path.splitext(name)[1].lower() + path = os.path.join(media_dir, name) + if os.path.isfile(path) and ext in video_exts: + count += 1 + with contextlib.suppress(OSError): + total += os.path.getsize(path) + return (count, total) + + +@app.post("/api/datasets", status_code=201) +def create_dataset(req: CreateDatasetRequest) -> dict[str, Any]: + """Create a new dataset. Moves upload into datasets_upload_dir/{id}, persists dataset and captions.""" + if database is None: + raise HTTPException(status_code=503, detail="Database not initialized") + if not req.upload_path: + raise HTTPException( + status_code=400, + detail="upload_path is required. Upload media files first.", + ) + if not req.file_names: + raise HTTPException( + status_code=400, + detail="No media files found. Ensure at least one image or video.", + ) + dataset_id = str(uuid.uuid4()) + created_at = time.time() + dest_dir = os.path.join(datasets_upload_dir, dataset_id) + if os.path.exists(dest_dir): + raise HTTPException( + status_code=409, + detail="Dataset directory already exists", + ) + try: + shutil.copytree(req.upload_path, dest_dir) + except OSError as e: + raise HTTPException( + status_code=500, + detail=f"Failed to move upload: {e}", + ) from e + with contextlib.suppress(OSError): + shutil.rmtree(req.upload_path) # Best-effort cleanup of upload folder + dataset = { + "id": dataset_id, + "name": req.name, + "created_at": created_at, + } + database.insert_dataset(dataset) + for fn in req.file_names: + caption = (req.captions.get(fn, "") if req.captions else "") or "" + database.upsert_dataset_caption(dataset_id, fn, caption) + return database.get_dataset(dataset_id) or dataset + + +@app.get("/api/datasets/{dataset_id}/files") +def get_dataset_files(dataset_id: str) -> dict[str, Any]: + """List media files in dataset with their captions.""" + if database is None: + raise HTTPException(status_code=503, detail="Database not initialized") + ds = database.get_dataset(dataset_id) + if ds is None: + raise HTTPException(status_code=404, detail="Dataset not found") + captions = database.get_dataset_captions(dataset_id) + media_dir = _dataset_media_dir(dataset_id) + video_exts = {".mp4", ".webm", ".mov", ".avi", ".mkv"} + file_names = [] + if os.path.isdir(media_dir): + for name in sorted(os.listdir(media_dir)): + ext = os.path.splitext(name)[1].lower() + path = os.path.join(media_dir, name) + if os.path.isfile(path) and ext in video_exts: + file_names.append(name) + return { + "file_names": file_names, + "captions": captions, + } + + +@app.put("/api/datasets/{dataset_id}/captions") +def update_dataset_caption(dataset_id: str, req: UpdateCaptionRequest) -> dict[str, str]: + """Update a single caption for a file. Persists to database.""" + if database is None: + raise HTTPException(status_code=503, detail="Database not initialized") + ds = database.get_dataset(dataset_id) + if ds is None: + raise HTTPException(status_code=404, detail="Dataset not found") + database.upsert_dataset_caption(dataset_id, req.file_name, req.caption) + return {"detail": "Caption updated"} + + +@app.get("/api/datasets/{dataset_id}/media/{file_name:path}") +def serve_dataset_media(dataset_id: str, file_name: str) -> FileResponse: + """Serve a media file from a dataset.""" + if database is None: + raise HTTPException(status_code=503, detail="Database not initialized") + ds = database.get_dataset(dataset_id) + if ds is None: + raise HTTPException(status_code=404, detail="Dataset not found") + media_path = os.path.join(_dataset_media_dir(dataset_id), file_name) + if not os.path.isfile(media_path): + raise HTTPException(status_code=404, detail="File not found") + import mimetypes + mime, _ = mimetypes.guess_type(media_path) + return FileResponse(media_path, media_type=mime or "video/mp4") + + +@app.delete("/api/datasets/{dataset_id}") +def delete_dataset(dataset_id: str) -> dict[str, str]: + """Delete a dataset and its media directory.""" + if database is None: + raise HTTPException(status_code=503, detail="Database not initialized") + if not database.delete_dataset(dataset_id): + raise HTTPException(status_code=404, detail="Dataset not found") + dest_dir = os.path.join(datasets_upload_dir, dataset_id) + if os.path.isdir(dest_dir): + with contextlib.suppress(OSError): + shutil.rmtree(dest_dir) # Best-effort cleanup + return {"detail": f"Dataset {dataset_id} deleted"} + + +@app.get("/api/jobs/{job_id}/logs") +def get_job_logs(job_id: str, after: int = 0) -> dict[str, Any]: + """Return log lines for a job. + + Query params: + after: return only lines after this index (for incremental polling). + """ + try: + return job_runner.get_job_logs(job_id, after=after) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + + +@app.get("/api/jobs/{job_id}/video") +def get_video(job_id: str) -> FileResponse: + """Stream the generated video/image for a completed job.""" + job = job_runner.get_job(job_id) + if job is None: + raise HTTPException(status_code=404, detail="Job not found") + if job.status != JobStatus.COMPLETED or not job.output_path: + raise HTTPException(status_code=404, detail="No output available for this job") + if not os.path.isfile(job.output_path): + raise HTTPException(status_code=404, detail="Output file not found on disk") + + media_type = ("video/mp4" if job.output_path.endswith(".mp4") else "image/png") + return FileResponse(job.output_path, media_type=media_type) + + +@app.get("/api/jobs/{job_id}/download_log") +def get_job_log_file(job_id: str) -> FileResponse: + """Download the log file for a job.""" + job = job_runner.get_job(job_id) + if job is None: + raise HTTPException(status_code=404, detail="Job not found") + if not job.log_file_path: + raise HTTPException(status_code=404, detail="Log file not available for this job") + if not os.path.isfile(job.log_file_path): + raise HTTPException(status_code=404, detail="Log file not found on disk") + + return FileResponse(job.log_file_path, media_type="text/plain", filename=f"job_{job_id}.log") + + +def _setup_signal_handlers() -> None: + + def handle_sigquit(signum, frame): + logger.warning("Received SIGQUIT (likely from a crashed worker process). " + "Ignoring to keep server running.") + + def handle_sigterm(signum, frame): + logger.info("Received SIGTERM. Shutting down gracefully...") + raise SystemExit(0) + + signal.signal(signal.SIGTERM, handle_sigterm) + if hasattr(signal, "SIGQUIT"): # SIGQUIT might not be available on all platforms (e.g., Windows) + signal.signal(signal.SIGQUIT, handle_sigquit) + + +def create_local_env(host: str, port: int) -> None: + """Check if .env.local exists in the ui directory, and create it if not.""" + ui_dir = os.path.dirname(__file__) + env_local_path = os.path.join(ui_dir, ".env.local") + + # Use localhost for the API URL since browsers can't connect to 0.0.0.0 + api_host = "localhost" if host == "0.0.0.0" else host + api_url = f"http://{api_host}:{port}/api" + + if not os.path.exists(env_local_path): + logger.info("Creating .env.local with API URL: %s", api_url) + with open(env_local_path, "w", encoding="utf-8") as f: + f.write(f"NEXT_PUBLIC_API_BASE_URL={api_url}\n") + else: + logger.debug(".env.local already exists at %s", env_local_path) + + +def main() -> None: + global job_runner, database, upload_dir, verbose, datasets_upload_dir # noqa: PLW0603 + + # Set up signal handlers to prevent worker crashes from killing the server + _setup_signal_handlers() + + default_log_dir = os.path.join(os.path.dirname(__file__), "..", "outputs", "ui_logs") + default_data_dir = Path(os.path.dirname(__file__), "..", "outputs", "ui_data") + + parser = argparse.ArgumentParser(description="FastVideo Job Runner API server") + parser.add_argument( + "--host", + default="0.0.0.0", + help="Bind address (default: 0.0.0.0)", + ) + parser.add_argument( + "--port", + type=int, + default=8189, + help="Port number (default: 8189)", + ) + parser.add_argument( + "--output-dir", + default=DEFAULT_OUTPUT_DIR, + help=("Directory where generated videos are saved " + f"(default: {DEFAULT_OUTPUT_DIR})"), + ) + parser.add_argument( + "--log-dir", + default=default_log_dir, + help=("Directory where job log files are saved " + f"(default: {default_log_dir})"), + ) + parser.add_argument( + "--data-dir", + default=str(default_data_dir), + help=("Directory for SQLite database (jobs + settings persistence) " + f"(default: {default_data_dir})"), + ) + parser.add_argument( + "--verbose", + action="store_true", + help="Print full tracebacks in error messages (default: False)", + ) + args = parser.parse_args() + + output_dir = os.path.abspath(args.output_dir) + log_dir = os.path.abspath(args.log_dir) + data_dir = Path(args.data_dir).resolve() + upload_dir = str(data_dir / "uploads") + datasets_upload_dir = str(data_dir / "uploads" / "datasets") + + create_local_env(args.host, args.port) + + db_path = _get_db_path(data_dir) + database = Database(db_path) + verbose = args.verbose + logger.info("Database: %s", db_path) + + job_runner = JobRunner( + output_dir=output_dir, + log_dir=log_dir, + verbose=args.verbose, + database=database, + ) + + logger.info("Output directory: %s", output_dir) + logger.info("Log directory: %s", log_dir) + + uvicorn.run( + app, + host=args.host, + port=args.port, + log_level="info", + ) + + +if __name__ == "__main__": + main() diff --git a/ui/src/app.css b/ui/src/app.css new file mode 100644 index 000000000..cc9139059 --- /dev/null +++ b/ui/src/app.css @@ -0,0 +1,66 @@ +/* CSS Variables */ +:root { + --header-height: 74px; + --bg: #0f1117; + --surface: #1a1d27; + --border: #2a2d3a; + --text: #e4e4e7; + --text-dim: #9ca3af; + --accent: #356cff; + --accent-h: #818cf8; + --green: #22c55e; + --red: #ef4444; + --yellow: #eab308; + --blue: #3b82f6; + --radius: 8px; + --shadow: 0 2px 8px rgba(0, 0, 0, 0.35); +} + +/* Reset */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* Base body styles */ +body { + font-family: + "Inter", + system-ui, + -apple-system, + sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.6; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* Base form input styles (used globally) */ +select, +input, +textarea { + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.55rem 0.75rem; + font-size: 0.9rem; + font-family: inherit; + outline: none; + transition: border-color 0.15s; +} + +select:focus, +input:focus, +textarea:focus { + border-color: var(--accent); +} + +textarea { + resize: vertical; +} diff --git a/ui/src/app.html b/ui/src/app.html new file mode 100644 index 000000000..c61dec857 --- /dev/null +++ b/ui/src/app.html @@ -0,0 +1,13 @@ + + +
+ + + +{job.prompt}
++ No {typesToFetch.length > 1 ? "jobs" : jobType + " jobs"} yet. Create + one above. +
+ {:else} + {#each jobs as job (job.id)} +Redirecting to Inference…
+ + diff --git a/ui/src/routes/datasets/+page.svelte b/ui/src/routes/datasets/+page.svelte new file mode 100644 index 000000000..87026a19b --- /dev/null +++ b/ui/src/routes/datasets/+page.svelte @@ -0,0 +1,91 @@ + + +No datasets yet.
+ {:else} + {#each datasets as ds (ds.id)} ++ Generated videos from completed inference jobs. Captions show the prompt + used for each generation. +
+ {#if isLoading} +{error}
+ {:else if galleryJobs.length === 0} ++ No generated videos yet. Complete some + inference jobs to see them here. +
+ {:else} +{job.prompt || "—"}
+Redirecting to Finetuning…
+ + diff --git a/ui/src/routes/settings/+page.svelte b/ui/src/routes/settings/+page.svelte new file mode 100644 index 000000000..cec1bf007 --- /dev/null +++ b/ui/src/routes/settings/+page.svelte @@ -0,0 +1,405 @@ + + ++ These values are used as defaults when creating new jobs. +
+