From 4713fc17edcf0de96e62e30a0afc4ae942ccd7c3 Mon Sep 17 00:00:00 2001 From: Jinzhe Pan <48981407+Eigensystem@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:07:24 -0700 Subject: [PATCH] [feat] Job Runner UI (#1189) Co-authored-by: Darren Sadr --- .gitignore | 1 + .nvimlog | 1 + .python-version | 1 + fastvideo-kernel/pyproject.toml | 2 +- fastvideo/entrypoints/video_generator.py | 61 +- fastvideo/registry.py | 131 +- fastvideo/worker/executor.py | 19 +- fastvideo/worker/multiproc_executor.py | 111 +- pyproject.toml | 6 +- ui/.env.example | 1 + ui/.gitignore | 42 + ui/.prettierrc | 4 + ui/README.md | 60 + ui/__init__.py | 1 + ui/__main__.py | 7 + ui/database.py | 655 ++++ ui/eslint.config.mjs | 2 + ui/job_runner.py | 1019 ++++++ ui/models/__init__.py | 14 + ui/models/create_dataset_request.py | 11 + ui/models/create_job_request.py | 49 + ui/models/settings_update.py | 31 + ui/models/update_caption_request.py | 9 + ui/package-lock.json | 3066 +++++++++++++++++ ui/package.json | 28 + ui/preprocess_runner.py | 142 + ui/public/fastvideo.ico | Bin 0 -> 122112 bytes ui/public/logo.svg | 18 + ui/server.py | 668 ++++ ui/src/app.css | 66 + ui/src/app.html | 13 + ui/src/lib/actions/resizable.ts | 76 + ui/src/lib/api.ts | 404 +++ ui/src/lib/captionParsing.ts | 185 + ui/src/lib/components/AddDatasetButton.svelte | 27 + .../lib/components/CreateDatasetModal.svelte | 514 +++ ui/src/lib/components/CreateJobButton.svelte | 126 + ui/src/lib/components/CreateJobModal.svelte | 989 ++++++ ui/src/lib/components/DatasetCard.svelte | 133 + ui/src/lib/components/DatasetSidebar.svelte | 320 ++ ui/src/lib/components/DownloadCaptions.svelte | 171 + ui/src/lib/components/Header.svelte | 72 + ui/src/lib/components/JobCard.svelte | 318 ++ ui/src/lib/components/JobQueue.svelte | 111 + ui/src/lib/components/PrimarySidebar.svelte | 275 ++ ui/src/lib/components/SecondarySidebar.svelte | 305 ++ ui/src/lib/components/Slider.svelte | 97 + ui/src/lib/components/TabSwitch.svelte | 70 + ui/src/lib/components/Toggle.svelte | 100 + ui/src/lib/components/UploadZone.svelte | 250 ++ ui/src/lib/defaultOptions.ts | 100 + ui/src/lib/jobConfig.ts | 51 + ui/src/lib/stores/activeDataset.ts | 16 + ui/src/lib/stores/activeJob.ts | 16 + ui/src/lib/stores/createDatasetModalOpen.ts | 5 + ui/src/lib/stores/defaultOptions.ts | 60 + ui/src/lib/stores/headerActions.ts | 16 + ui/src/lib/stores/jobsRefresh.ts | 16 + ui/src/lib/types.ts | 28 + ui/src/routes/+layout.svelte | 84 + ui/src/routes/+layout.ts | 4 + ui/src/routes/+page.svelte | 17 + ui/src/routes/datasets/+page.svelte | 91 + ui/src/routes/distillation/+page.svelte | 15 + ui/src/routes/finetuning/+page.svelte | 15 + ui/src/routes/gallery/+page.svelte | 190 + ui/src/routes/inference/+page.svelte | 15 + ui/src/routes/lora/+page.svelte | 15 + ui/src/routes/settings/+page.svelte | 405 +++ ui/static/fastvideo.ico | Bin 0 -> 122112 bytes ui/static/logo.svg | 18 + ui/svelte.config.js | 16 + ui/training_config.py | 219 ++ ui/tsconfig.json | 10 + ui/vite.config.ts | 11 + 75 files changed, 12184 insertions(+), 31 deletions(-) create mode 100644 .nvimlog create mode 100644 .python-version create mode 100644 ui/.env.example create mode 100644 ui/.gitignore create mode 100644 ui/.prettierrc create mode 100644 ui/README.md create mode 100644 ui/__init__.py create mode 100644 ui/__main__.py create mode 100644 ui/database.py create mode 100644 ui/eslint.config.mjs create mode 100644 ui/job_runner.py create mode 100644 ui/models/__init__.py create mode 100644 ui/models/create_dataset_request.py create mode 100644 ui/models/create_job_request.py create mode 100644 ui/models/settings_update.py create mode 100644 ui/models/update_caption_request.py create mode 100644 ui/package-lock.json create mode 100644 ui/package.json create mode 100644 ui/preprocess_runner.py create mode 100644 ui/public/fastvideo.ico create mode 100644 ui/public/logo.svg create mode 100644 ui/server.py create mode 100644 ui/src/app.css create mode 100644 ui/src/app.html create mode 100644 ui/src/lib/actions/resizable.ts create mode 100644 ui/src/lib/api.ts create mode 100644 ui/src/lib/captionParsing.ts create mode 100644 ui/src/lib/components/AddDatasetButton.svelte create mode 100644 ui/src/lib/components/CreateDatasetModal.svelte create mode 100644 ui/src/lib/components/CreateJobButton.svelte create mode 100644 ui/src/lib/components/CreateJobModal.svelte create mode 100644 ui/src/lib/components/DatasetCard.svelte create mode 100644 ui/src/lib/components/DatasetSidebar.svelte create mode 100644 ui/src/lib/components/DownloadCaptions.svelte create mode 100644 ui/src/lib/components/Header.svelte create mode 100644 ui/src/lib/components/JobCard.svelte create mode 100644 ui/src/lib/components/JobQueue.svelte create mode 100644 ui/src/lib/components/PrimarySidebar.svelte create mode 100644 ui/src/lib/components/SecondarySidebar.svelte create mode 100644 ui/src/lib/components/Slider.svelte create mode 100644 ui/src/lib/components/TabSwitch.svelte create mode 100644 ui/src/lib/components/Toggle.svelte create mode 100644 ui/src/lib/components/UploadZone.svelte create mode 100644 ui/src/lib/defaultOptions.ts create mode 100644 ui/src/lib/jobConfig.ts create mode 100644 ui/src/lib/stores/activeDataset.ts create mode 100644 ui/src/lib/stores/activeJob.ts create mode 100644 ui/src/lib/stores/createDatasetModalOpen.ts create mode 100644 ui/src/lib/stores/defaultOptions.ts create mode 100644 ui/src/lib/stores/headerActions.ts create mode 100644 ui/src/lib/stores/jobsRefresh.ts create mode 100644 ui/src/lib/types.ts create mode 100644 ui/src/routes/+layout.svelte create mode 100644 ui/src/routes/+layout.ts create mode 100644 ui/src/routes/+page.svelte create mode 100644 ui/src/routes/datasets/+page.svelte create mode 100644 ui/src/routes/distillation/+page.svelte create mode 100644 ui/src/routes/finetuning/+page.svelte create mode 100644 ui/src/routes/gallery/+page.svelte create mode 100644 ui/src/routes/inference/+page.svelte create mode 100644 ui/src/routes/lora/+page.svelte create mode 100644 ui/src/routes/settings/+page.svelte create mode 100644 ui/static/fastvideo.ico create mode 100644 ui/static/logo.svg create mode 100644 ui/svelte.config.js create mode 100644 ui/training_config.py create mode 100644 ui/tsconfig.json create mode 100644 ui/vite.config.ts 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 0000000000000000000000000000000000000000..bb68ebf4fcabe3af8401347fa934b10d3aaa5af1 GIT binary patch literal 122112 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

qxH=qX?FcE@;28K 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

    Zti=|kz3m%}T!#P9U5f`%*=Z8Fi zzxM+63Dt8jmF7SC$+1BkI~Z>XpHUm+3llLx68<>`ct_g+3dDCDbISWlj$y4>z;lT6 zKr?`MzLNPVXv%e~eh;a4t|S5jVvCf$6Y? zvCo!yI?w+i_TOQ@Xe;=W*>=P>{FM!1)5jV>$^*@G^86T97&qn3^%C5aK?g7UTYvN} z@!j)ne+s#Fuv47Kk;y!fd8|CnrN{qG`ONrtfE{mqjbK`g+~AxuK(v|hub=_`Tp#xL zb6q20?~0}6z@Zm>yc*CC_#8?R8;UyY@4bnfu!Ow6#faA~WS)rnGm~AA-%ZLLKRnij z?s8lhj~~Z$^SmvM%fI{??-G2VjPhl1FSnt*_myt9LJ#~Eam2lXr98H&ERi=!d4_~2Eum4 zwu4gk1NnYMe@m{fT*@@TWy$?#ZD(0_4|^y-spcc{YV-QxsQy z(%hDOqTpfR4}8YZ$T$z2-4sVR5$DqYHs>j3N%c0--HwOGebMa&QE#=t%A zCp!s$KqEJ-10WBBK}T!g&%KLrdIaCz6WGq4!d_VLhk!i8sQ0IU_wm^>}H?3m`M-Ia;6rDK1kJJ|lH%2|lv3eF7cCeyW4;AO8q`@J(hK zb&2p1`3EdPBfXHHc6WzFI*4phFaL(7j0zEpp-=Y9CDpksI&pHVG7i zJr-j8c#Piy8Nhss_wR8$5XS{e_RD8&XL>M(KZkR^vQO6+alv}*H^iJ4F{HvS44*jG z`LVG3r`hUJnw=h{B2QoIjEOiw7NZu0sGk#f)bIEc`nP$a3+~<2N=h>#kGD85@=k+ds z&vb)W5>3RDvHe&Ewkwy^7tc!TM%EoAUjpk7WdX}iNruYn<~O+%F|wk)QoH3CBw3Cs zkLjCr&A4TsfHFwO&om%_?+aRz@GaTL%fh|#y%+5# zFSg_L*5STOV(1E62^ry2p(2&hItH3F&;P>sNU zX#~8{*<`>7fV!G9{^KuM{4f3QEyySG*004>WBkVFtWnVz7=wyVRb!x{0Tm6XXh1~+ zDjHDHfQkkbG$7|K{v?hdZdSX{QR5Re{Pq(i4L_6b|ihY zmGN$hvnM}H_oIm^?l_B{@8P^+Ph0w~70w)J8%6`8T*$>pmloCwqWx_{>4!FQnb2(>`q3(u8epE^1^jGfxo6&bMCpq6R`Wt0!LPHX~$yns%#~IEzYhe%0 z-@weHU~o z_1_#g8lCDzo;a(V$AizCm>BO$Kedh^H)CCKFVW7Ju5@Nv52c+={mBjI87`{jgY)Qc zCYACTKk_xs7kYrRY})(U;Cb%%o7s2nFVE2|oY~w6G%e@?ZLQ~233EP%nz&nvwp)Sk zZ)_MK@O?ciic)Y^NhZ!}&f|00$~h;ixK{+tNpZtDFs1shpgZZeUM~-j+YoDShB@Ft z`U+X-jC1F{2S5KP%^hc(NcrpEi>yMAf$u_`TR$kmiNbM4`MFLCz9-{dxx{?=zF832 z8|Z+~a4z#_Abyv}p)@Gbr8IB;o$;~J*QT*)e&k^+%>iAsYkHb1?Zf%B&XDKa-Xup8 zy55=l8%19?3M5Y>oXuC)7juGXplm+)TUI1h!`Y8Q*Ai!iKqnCNP)6ThpbJU<$~gKb zOU^;xGu)Z#3VN5uype}Fod>NqbhZ%vx5Js1+nWZVzY%m5ye-kzm~ukwa2{3pa)8gR zW!@>}xaTvH(5Dg|Q=S*)mE3>%ER%nZe=u(NcTyqO-i>h+XEX9xJL8PU1JH3MCc27q z*tid);+%0-dj#c!@ApT)VxSvb>V$LKSO+MJ58w>w74>|{R7-BZ+}DcHHOpu|H<-^! z`RDM@@75$ceH|K~;wki69uGb6f{&|ui?iq)#W}YcRK1Eh-N2cKgmW!7)b|nR->#`2 zfb+`NMsYaTsdu;|&a($!P{Kc-YbOJr>3AP! zH|FB(#$259&2SH&Wyr;u&dMO)zlSr^KCbRT27-1NN8Bc7wrO`Fa3_U zsU96_7e()b?)ls*KIdw3yc=Xdv^b}*XOKN5fe&2i!g>IYfy4*pzH=$xd#>dBGL+_> z@_o#yL+wJz18tD{`_K2^33|=w6ko_JKG#_t@=${or2Eh}8KGo@bGo=44V*0LHqQab zffaT9$qO{G8S;RDFS(C$|K-o6%Zhx@W%nG!_nmCf;ZFvr;ldGvN9c;_GP!yePA3;H&kukAO zpnc{Gpab~~TqU0P823I-aiY%t)|BmMP2B)pah(O=vMYZEWC6M;APcmW8DK+xkTY^y z{>f6dSJid5p>9|cX|PU;@d&~Be8-{Z41&(X=N&O$oP+f-LBC;Kw)VE6T9A>ycLlz2 zM!6V+QuDhAb7e#n-U&Pk+r!J^liTvL&-}&uUa7L|itV7=l-2nJ6eU>hdMbAjuj`M)+F|XwFp7`A*!2K%=p6dhj z0OdN*efR3dhu48a65J^YvYqLjb>yBQ4s-(Ry;y6B7JV^B$048DE_A3}Anav2^g*Hv z6$&}{Y~Gj1KFo{XyM|L*Nw5iA?-WkEG6HC)5`2s6Z=1e6?92$Hu}SV^jP}TU<>hJW z#d*W@&S&}#P4uJ?1-!pJ+%q2E8)Az_NH_icCoJhw&I?N zraCRF;{`n+jP7JdQUk1=T6t8Wdwfnk^TyKsFFhyz=4aM5@8CCDcuM*zw_Ac2ar#nY zBlwFtg;^TW$V4wPQLNvsG5@wT4W$3U_GyH^^Ey-m-SO#JY zN}qZEr~NHGXUbVWDUE9(=;#BsQJ~Fi&*ypYs^)?j;16f}x(HsA$-xwpgKEDe*We0F#gR`}n zPrXWB$nWY9SeaV-+CQ5Ej>dGZO9cEQY%kUzbBxg{*mxMy}dwbKo-8-*L8z^Rv_E{pCfG3scH&eDs^Bw2>HB~2bF4E>h;yMju2zEf<#xZilKGqr5^XVm;k7WY4}@J` zam~IXai2fq89vKbKI8dWDb?Y6uNI^;q@}jLsyV>@W?e@D`Q0*)75qlQb6Jm<&U$4( zhs2BjS-Y*=M+I(#e?`9cs>|o`|LSP#Rku_1J{4|MG@zmZ6%D9pKt%&88c@-IiUw3P zprV2QJPq6h4g8k@-BXPO~mpO^Ry^DR047s>I(k_I&;m>~N z11%4a|4hFcc-l}(WoZviT4htjqH{d3u89BIo#96Xh`Zo8(edy*t=?P-^+G79X zlq464hLq@s@$ozMenyMfXNA9C=2!4@vkzQA3GaIK^c84rOzi_W_E?P^fQN0(0;w)y z&G@s_<`|lby^75dd-f1LW3&hB6XcR!a|J)rq#RKkODw%`hJp@ZJK@&|lt5 zg!wYBhP$+X{}sxOXpC)!x5P)>5a+oA{5}!71IImmT8(4DqcN|YsgtiYTV1gKW{>*XY0`QsR>a`UyzOSPF@;l-ku=iEOj;fJ2;v@V>IoYjK-n zX;f?#>VkbgPXrHoUZ1;#*wbY2Q1L!l_Hv1_DH~Xx;k!SqQ{J;L-e)~1JFE(^Uc@nx z|ASn57kgm&H#jc52y<_HhA;M0o6z|VXgkKdg!Y*q;rmh8E2Ry-t5qI=zj2(U6f=kz zLiscLz%)>HKPz^hd1nW#3A`0C{Jghoc|AYuv(=!WN(N$I@2#u|s_kTk{r#Nh<(VuH za~b_P*FJ>iRduJiO7JOU$fx-HhfvL(`eF}(5ytNC$hMR!*lU6qR33*w%!OUeLns8L z0oGYx)@yh>S()B}&Li!|kz_#$9vCMqqhA)lm(F4De;~%`@8F<{ksf7-RuM9u z$Ks1Re$-u|pY;j>4Pnf;H1J2R8B^@9XZkPIepz1qh4K-eY2_{LsbyOFD|leu*(JCN zxhZ1cdxzN5(%L>Ek5V}H30*^*Z*&gFo;Xwb3jFW|eeQ*J10T=Dek;jjQCIOj^8?^) z2J(t%A>NeR{#V-1xU1%3Np+w%@O7BA0ca?Q0zHyuS&o&i$Fx@RJ+6VNvo**2t&d}FeW z$P9bJj3A5H7Nidxa2_H{>=k6&iM5`N7%$GzAb~bYt-lD`WQ+H3ezLOKRq=b$y5NO1 zEqJs{lcI00NbWq}F37Zh908!N=B@UEph=)OLZp)n@UQ;K0F2 z0tYC&P~>8Jnf_Izt{3db(TD?M9ykRyYR17vjQ^UtUVEKzZ)Gh^$NZ%pnJlfkH{$&3Bitr_$N=6idm^*jmb03eqKz2BrVoO6m z_(MHtX0j{g<9$!MBNwB9aFhVVEC}Fh85H;U_h*5pu@9~yW5DNQnCa?>b2S1WYuJ_@ zj4}~&(hE7o>LJcc0&|oA7bW1&GAQoXNBvm%kt_Q965ps;TPxBZRh_c=y=q+k)8q04 z_q2{2kHMPilT?=qJBrrg-)~zpc#oy0)|I9;yfZLw`}QlXd&Lf z7r6-QAvSJkq$91x_!UA<|JclrYG5sqf>;{{jt`HhyvB;en03S&j@vuGstbL_kmf4Nd1)^6 hDXt?CYbihXSIUa$Ie+E7f8m)zJo9$|kN@?*{{zB?#2Wwr literal 0 HcmV?d00001 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 @@ + + + + + + + FastVideo + %sveltekit.head% + + +
    %sveltekit.body%
    + + diff --git a/ui/src/lib/actions/resizable.ts b/ui/src/lib/actions/resizable.ts new file mode 100644 index 000000000..e2d7a3cc0 --- /dev/null +++ b/ui/src/lib/actions/resizable.ts @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Svelte action: attach horizontal resize behavior to a handle element. + * Use on the resize-handle div inside a sidebar; the parent holds width state. + */ + +export type ResizableEdge = "left" | "right"; + +export interface ResizableOptions { + /** Which edge is fixed: "left" = drag right increases width, "right" = drag left increases width */ + edge: ResizableEdge; + minWidth: number; + maxWidth: number; + /** Return current width (e.g. from parent state) */ + getWidth: () => number; + /** Called with new width when user drags */ + onWidth: (width: number) => void; + /** Optional: called when drag starts/stops so parent can show active state */ + onDragChange?: (dragging: boolean) => void; +} + +export function resizable( + node: HTMLElement, + options: ResizableOptions, +): { update?: (opts: ResizableOptions) => void; destroy?: () => void } { + let dragStart = { x: 0, width: 0 }; + let listenersAttached = false; + + function onMouseMove(e: MouseEvent) { + const delta = e.clientX - dragStart.x; + const sign = options.edge === "left" ? 1 : -1; + const newWidth = Math.min( + options.maxWidth, + Math.max(options.minWidth, dragStart.width + sign * delta), + ); + options.onWidth(newWidth); + } + + function onMouseUp() { + listenersAttached = false; + options.onDragChange?.(false); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + } + + function onMouseDown(e: MouseEvent) { + e.preventDefault(); + dragStart = { x: e.clientX, width: options.getWidth() }; + listenersAttached = true; + options.onDragChange?.(true); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + } + + node.addEventListener("mousedown", onMouseDown); + + return { + update(newOptions: ResizableOptions) { + options = newOptions; + }, + destroy() { + node.removeEventListener("mousedown", onMouseDown); + if (listenersAttached) { + options.onDragChange?.(false); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + } + }, + }; +} diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts new file mode 100644 index 000000000..ca057024a --- /dev/null +++ b/ui/src/lib/api.ts @@ -0,0 +1,404 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { env } from "$env/dynamic/public"; +import type { Job, JobType } from "$lib/types"; + +const DEFAULT_API_BASE_URL = "http://localhost:8189/api"; + +export function getApiBaseUrl(): string { + // 1) Check user-configured value from local storage (Settings page) + if (typeof window !== "undefined") { + try { + const stored = window.localStorage.getItem("fastvideo-default-options"); + if (stored) { + const parsed = JSON.parse(stored) as { apiServerBaseUrl?: string }; + return parsed.apiServerBaseUrl!.trim(); + } + } catch { + // Ignore storage / JSON errors and fall back to env/default + } + } + + // 2) Fall back to env var if set, then hardcoded default + const apiUrl = + typeof env.PUBLIC_API_BASE_URL === "string" && env.PUBLIC_API_BASE_URL + ? env.PUBLIC_API_BASE_URL + : DEFAULT_API_BASE_URL; + return apiUrl; +} + +/** Full URL for streaming a job's video/image output (for <video> or <img> src). */ +export function getJobVideoUrl(jobId: string): string { + return `${getApiBaseUrl()}/jobs/${jobId}/video`; +} + +export interface CreateJobRequest { + model_id: string; + prompt: string; + workload_type?: string; + job_type?: JobType; + image_path?: string; + data_path?: string; + max_train_steps?: number; + train_batch_size?: number; + learning_rate?: number; + num_latent_t?: number; + validation_dataset_file?: string; + lora_rank?: number; + negative_prompt?: string; + num_inference_steps?: number; + num_frames?: number; + height?: number; + width?: number; + guidance_scale?: number; + guidance_rescale?: number; + fps?: number; + seed?: number; + num_gpus?: number; + dit_cpu_offload?: boolean | null; + text_encoder_cpu_offload?: boolean | null; + vae_cpu_offload?: boolean | null; + image_encoder_cpu_offload?: boolean | null; + use_fsdp_inference?: boolean | null; + enable_torch_compile?: boolean | null; + vsa_sparsity?: number; + tp_size?: number; + sp_size?: number; +} + +export interface Model { + id: string; + label: string; + type: string; +} + +export interface Settings { + defaultModelId: string; + defaultModelIdT2v: string; + defaultModelIdI2v: string; + defaultModelIdT2i: string; + numInferenceSteps: number; + numFrames: number; + height: number; + width: number; + guidanceScale: number; + guidanceRescale: number; + fps: number; + seed: number; + numGpus: number; + ditCpuOffload: boolean; + textEncoderCpuOffload: boolean; + vaeCpuOffload: boolean; + imageEncoderCpuOffload: boolean; + useFsdpInference: boolean; + enableTorchCompile: boolean; + vsaSparsity: number; + tpSize: number; + spSize: number; + autoStartJob: boolean; + datasetUploadPath: string; +} + +// MARK: - API Functions + +export async function getSettings(): Promise { + const baseApiUrl = getApiBaseUrl(); + const response = await fetch(`${baseApiUrl}/settings`); + if (!response.ok) { + throw new Error("Failed to fetch settings"); + } + return response.json(); +} + +export async function updateSettings( + updates: Partial, +): Promise { + const baseApiUrl = getApiBaseUrl(); + const response = await fetch(`${baseApiUrl}/settings`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(updates), + }); + if (!response.ok) { + throw new Error("Failed to update settings"); + } + return response.json(); +} + +export async function uploadImage(file: File): Promise<{ path: string }> { + const baseApiUrl = getApiBaseUrl(); + const formData = new FormData(); + formData.append("file", file); + const response = await fetch(`${baseApiUrl}/upload-image`, { + method: "POST", + body: formData, + }); + if (!response.ok) { + const err = await response + .json() + .catch(() => ({ detail: "Upload failed" })); + throw new Error(err.detail || "Upload failed"); + } + return response.json(); +} + +export async function uploadRawDataset( + files: File[], +): Promise<{ path: string; upload_id: string; file_names: string[] }> { + const baseApiUrl = getApiBaseUrl(); + const formData = new FormData(); + for (const f of files) { + formData.append("files", f); + } + const response = await fetch(`${baseApiUrl}/upload-raw-dataset`, { + method: "POST", + body: formData, + }); + if (!response.ok) { + const err = await response + .json() + .catch(() => ({ detail: "Upload failed" })); + throw new Error(err.detail || "Upload failed"); + } + return response.json(); +} + +export async function getModels(workloadType?: string): Promise { + const baseApiUrl = getApiBaseUrl(); + const url = workloadType + ? `${baseApiUrl}/models?workload_type=${encodeURIComponent(workloadType)}` + : `${baseApiUrl}/models`; + const response = await fetch(url); + if (!response.ok) { + throw new Error("Failed to fetch models"); + } + return response.json(); +} + +export async function getJobsList(jobType?: JobType): Promise { + const baseApiUrl = getApiBaseUrl(); + const url = jobType + ? `${baseApiUrl}/jobs?job_type=${encodeURIComponent(jobType)}` + : `${baseApiUrl}/jobs`; + const response = await fetch(url); + if (!response.ok) { + throw new Error("Failed to fetch jobs"); + } + return response.json(); +} + +export async function createJob(job: CreateJobRequest): Promise { + const baseApiUrl = getApiBaseUrl(); + const response = await fetch(`${baseApiUrl}/jobs`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(job), + }); + if (!response.ok) { + throw new Error("Failed to create job"); + } + return response.json(); +} + +export async function getJobDetails(id: string): Promise { + const baseApiUrl = getApiBaseUrl(); + const response = await fetch(`${baseApiUrl}/jobs/${id}`); + if (!response.ok) { + throw new Error("Failed to fetch job"); + } + return response.json(); +} + +export async function startJob(id: string): Promise { + const baseApiUrl = getApiBaseUrl(); + const response = await fetch(`${baseApiUrl}/jobs/${id}/start`, { + method: "POST", + }); + if (!response.ok) { + const error = await response + .json() + .catch(() => ({ detail: "Failed to start job" })); + throw new Error(error.detail || "Failed to start job"); + } + return response.json(); +} + +export async function stopJob(id: string): Promise { + const baseApiUrl = getApiBaseUrl(); + const response = await fetch(`${baseApiUrl}/jobs/${id}/stop`, { + method: "POST", + }); + if (!response.ok) { + const error = await response + .json() + .catch(() => ({ detail: "Failed to stop job" })); + throw new Error(error.detail || "Failed to stop job"); + } + return response.json(); +} + +export async function deleteJob(id: string): Promise { + const baseApiUrl = getApiBaseUrl(); + const response = await fetch(`${baseApiUrl}/jobs/${id}`, { + method: "DELETE", + }); + if (!response.ok) { + const error = await response + .json() + .catch(() => ({ detail: "Failed to delete job" })); + throw new Error(error.detail || "Failed to delete job"); + } +} + +export interface JobLogs { + lines: string[]; + total: number; + progress: number; + progress_msg: string; + phase: string; +} + +export async function getJobLogs( + id: string, + after: number = 0, +): Promise { + const baseApiUrl = getApiBaseUrl(); + const response = await fetch( + `${baseApiUrl}/jobs/${id}/logs?after=${after}`, + ); + if (!response.ok) { + throw new Error("Failed to fetch job logs"); + } + return response.json(); +} + +export async function downloadJobLog(id: string): Promise { + const baseApiUrl = getApiBaseUrl(); + const response = await fetch(`${baseApiUrl}/jobs/${id}/download_log`); + if (!response.ok) { + throw new Error("Failed to download job log"); + } + return response.blob(); +} + +export async function downloadJobVideo(id: string): Promise { + const baseApiUrl = getApiBaseUrl(); + const response = await fetch(`${baseApiUrl}/jobs/${id}/video`); + if (!response.ok) { + throw new Error("Failed to download video"); + } + return response.blob(); +} + +// --- Datasets --- + +export interface Dataset { + id: string; + name: string; + created_at: number; + file_count?: number; + size_bytes?: number; +} + +export interface CreateDatasetRequest { + name: string; + upload_path: string; + file_names: string[]; + /** Optional: file_name -> caption from videos2caption.json */ + captions?: Record; +} + +export interface DatasetFilesResponse { + file_names: string[]; + captions: Record; +} + +export async function getDatasets(): Promise { + const baseApiUrl = getApiBaseUrl(); + const response = await fetch(`${baseApiUrl}/datasets`); + if (!response.ok) { + throw new Error("Failed to fetch datasets"); + } + return response.json(); +} + +export async function getDataset(id: string): Promise { + const baseApiUrl = getApiBaseUrl(); + const response = await fetch(`${baseApiUrl}/datasets/${id}`); + if (!response.ok) { + throw new Error("Failed to fetch dataset"); + } + return response.json(); +} + +export async function createDataset( + data: CreateDatasetRequest, +): Promise { + const baseApiUrl = getApiBaseUrl(); + const response = await fetch(`${baseApiUrl}/datasets`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); + if (!response.ok) { + const err = await response + .json() + .catch(() => ({ detail: "Failed to create dataset" })); + throw new Error(err.detail || "Failed to create dataset"); + } + return response.json(); +} + +export async function deleteDataset(id: string): Promise { + const baseApiUrl = getApiBaseUrl(); + const response = await fetch(`${baseApiUrl}/datasets/${id}`, { + method: "DELETE", + }); + if (!response.ok) { + const err = await response + .json() + .catch(() => ({ detail: "Failed to delete dataset" })); + throw new Error(err.detail || "Failed to delete dataset"); + } +} + +export async function getDatasetFiles( + id: string, +): Promise { + const baseApiUrl = getApiBaseUrl(); + const response = await fetch(`${baseApiUrl}/datasets/${id}/files`); + if (!response.ok) { + throw new Error("Failed to fetch dataset files"); + } + return response.json(); +} + +export async function updateDatasetCaption( + id: string, + file_name: string, + caption: string, +): Promise { + const baseApiUrl = getApiBaseUrl(); + const response = await fetch(`${baseApiUrl}/datasets/${id}/captions`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ file_name, caption }), + }); + if (!response.ok) { + throw new Error("Failed to update caption"); + } +} + +export function getDatasetMediaUrl( + datasetId: string, + fileName: string, +): string { + const baseApiUrl = getApiBaseUrl(); + return `${baseApiUrl}/datasets/${datasetId}/media/${encodeURIComponent(fileName)}`; +} diff --git a/ui/src/lib/captionParsing.ts b/ui/src/lib/captionParsing.ts new file mode 100644 index 000000000..5e35f30ab --- /dev/null +++ b/ui/src/lib/captionParsing.ts @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: Apache-2.0 +// Caption file parsing for CreateDatasetModal + +export function parseVideos2Caption( + text: string, + uploadedFileNames: string[], +): { captions: Record; error: string | null } { + let data: unknown; + try { + data = JSON.parse(text); + } catch { + return { captions: {}, error: "Invalid JSON in videos2caption.json." }; + } + const captions: Record = {}; + const uploadedSet = new Set(uploadedFileNames); + + if (Array.isArray(data)) { + for (let i = 0; i < data.length; i++) { + const entry = data[i]; + if (entry === null || typeof entry !== "object") { + return { + captions: {}, + error: `Invalid entry at index ${i}: must be an object with "path" and "cap".`, + }; + } + const path = (entry as Record)["path"]; + const cap = (entry as Record)["cap"]; + if (typeof path !== "string") { + return { + captions: {}, + error: `Invalid entry at index ${i}: "path" must be a string.`, + }; + } + let caption: string; + if (typeof cap === "string") { + caption = cap; + } else if ( + Array.isArray(cap) && + cap.length > 0 && + typeof cap[0] === "string" + ) { + caption = cap[0]; + } else { + return { + captions: {}, + error: `Invalid entry at index ${i}: "cap" must be a string or non-empty array of strings.`, + }; + } + captions[path] = caption; + } + } else if ( + data !== null && + typeof data === "object" && + !Array.isArray(data) + ) { + const obj = data as Record; + for (const [key, value] of Object.entries(obj)) { + if (typeof key !== "string" || typeof value !== "string") { + return { + captions: {}, + error: `Invalid entry for "${String(key)}": keys and values must be strings.`, + }; + } + captions[key] = value; + } + } else { + return { + captions: {}, + error: + "videos2caption.json must be an array of { path, cap } or an object mapping file names to captions.", + }; + } + + if (uploadedFileNames.length > 0) { + const unknownRefs = Object.keys(captions).filter( + (k) => !uploadedSet.has(k), + ); + if (unknownRefs.length > 0) { + return { + captions: {}, + error: `videos2caption.json references file(s) not in the uploaded videos: ${unknownRefs.slice(0, 5).join(", ")}${unknownRefs.length > 5 ? "…" : ""}.`, + }; + } + } + return { captions, error: null }; +} + +export function parseVideosCaptionsTxt( + videosLines: string[] | null, + captionsLines: string[], + uploadedFileNames: string[], +): { captions: Record; error: string | null } { + const captions: Record = {}; + const useAlphabetical = + !videosLines || + videosLines.length === 0 || + videosLines.every((s) => !s.trim()); + const paths: string[] = useAlphabetical + ? [...uploadedFileNames].sort() + : videosLines!.map((s) => s.trim()).filter(Boolean); + const len = Math.min(paths.length, captionsLines.length); + for (let i = 0; i < len; i++) { + const path = paths[i]; + if (path) captions[path] = captionsLines[i].trim(); + } + if (!useAlphabetical && uploadedFileNames.length > 0) { + const uploadedSet = new Set(uploadedFileNames); + const unknownRefs = Object.keys(captions).filter( + (k) => !uploadedSet.has(k), + ); + if (unknownRefs.length > 0) { + return { + captions: {}, + error: `videos.txt references file(s) not in the uploaded videos: ${unknownRefs.slice(0, 5).join(", ")}${unknownRefs.length > 5 ? "…" : ""}.`, + }; + } + } + return { captions, error: null }; +} + +function parseCsvLine(line: string): string[] { + const out: string[] = []; + let cur = ""; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (c === '"') { + if (inQuotes && line[i + 1] === '"') { + cur += '"'; + i++; + } else { + inQuotes = !inQuotes; + } + } else if ((c === "," && !inQuotes) || c === "\n") { + out.push(cur); + cur = ""; + } else { + cur += c; + } + } + out.push(cur); + return out; +} + +export function parseCaptionCsv( + text: string, + uploadedFileNames: string[], +): { captions: Record; error: string | null } { + const lines = text.split(/\r?\n/).filter((s) => s.trim()); + if (lines.length < 2) { + return { + captions: {}, + error: "CSV must have a header row and at least one data row.", + }; + } + const headerParts = parseCsvLine(lines[0]).map((s) => + s.trim().toLowerCase(), + ); + const vidIdx = headerParts.includes("video_name") + ? headerParts.indexOf("video_name") + : 0; + const capIdx = headerParts.includes("caption") + ? headerParts.indexOf("caption") + : 1; + const captions: Record = {}; + for (let i = 1; i < lines.length; i++) { + const row = parseCsvLine(lines[i]); + const path = row[vidIdx]?.trim(); + const cap = row[capIdx]?.trim() ?? ""; + if (path) captions[path] = cap; + } + if (uploadedFileNames.length > 0) { + const uploadedSet = new Set(uploadedFileNames); + const unknownRefs = Object.keys(captions).filter( + (k) => !uploadedSet.has(k), + ); + if (unknownRefs.length > 0) { + return { + captions: {}, + error: `CSV references file(s) not in the uploaded videos: ${unknownRefs.slice(0, 5).join(", ")}${unknownRefs.length > 5 ? "…" : ""}.`, + }; + } + } + return { captions, error: null }; +} diff --git a/ui/src/lib/components/AddDatasetButton.svelte b/ui/src/lib/components/AddDatasetButton.svelte new file mode 100644 index 000000000..a26b66ba0 --- /dev/null +++ b/ui/src/lib/components/AddDatasetButton.svelte @@ -0,0 +1,27 @@ + + + + + diff --git a/ui/src/lib/components/CreateDatasetModal.svelte b/ui/src/lib/components/CreateDatasetModal.svelte new file mode 100644 index 000000000..e3a138609 --- /dev/null +++ b/ui/src/lib/components/CreateDatasetModal.svelte @@ -0,0 +1,514 @@ + + +{#if isOpen} + +{/if} + + diff --git a/ui/src/lib/components/CreateJobButton.svelte b/ui/src/lib/components/CreateJobButton.svelte new file mode 100644 index 000000000..706368034 --- /dev/null +++ b/ui/src/lib/components/CreateJobButton.svelte @@ -0,0 +1,126 @@ + + +
    + + +
    + (modalOpen = false)} + onSuccess={handleSuccess} + {jobType} + {workloadType} +/> + + diff --git a/ui/src/lib/components/CreateJobModal.svelte b/ui/src/lib/components/CreateJobModal.svelte new file mode 100644 index 000000000..4f35dfedf --- /dev/null +++ b/ui/src/lib/components/CreateJobModal.svelte @@ -0,0 +1,989 @@ + + +{#if isOpen} + +{/if} + + diff --git a/ui/src/lib/components/DatasetCard.svelte b/ui/src/lib/components/DatasetCard.svelte new file mode 100644 index 000000000..ad53a8ae4 --- /dev/null +++ b/ui/src/lib/components/DatasetCard.svelte @@ -0,0 +1,133 @@ + + +
    { + if ((e.target as HTMLElement).closest("button")) return; + onSelect(); + }} + onkeydown={handleKeyDown} + role="button" + tabindex="0" +> +
    + {dataset.name} + +
    +
    + {fileCount} {fileCount === 1 ? "file" : "files"} · {sizeLabel} +
    +
    + + diff --git a/ui/src/lib/components/DatasetSidebar.svelte b/ui/src/lib/components/DatasetSidebar.svelte new file mode 100644 index 000000000..0c43945f3 --- /dev/null +++ b/ui/src/lib/components/DatasetSidebar.svelte @@ -0,0 +1,320 @@ + + + + + diff --git a/ui/src/lib/components/DownloadCaptions.svelte b/ui/src/lib/components/DownloadCaptions.svelte new file mode 100644 index 000000000..faaa46e0c --- /dev/null +++ b/ui/src/lib/components/DownloadCaptions.svelte @@ -0,0 +1,171 @@ + + +
    + + +
    + + diff --git a/ui/src/lib/components/Header.svelte b/ui/src/lib/components/Header.svelte new file mode 100644 index 000000000..25cfd2146 --- /dev/null +++ b/ui/src/lib/components/Header.svelte @@ -0,0 +1,72 @@ + + +
    + +

    {title}

    + {#if actions.length > 0} +
    + {#each actions as action, i (i)} + + {/each} +
    + {/if} +
    + + diff --git a/ui/src/lib/components/JobCard.svelte b/ui/src/lib/components/JobCard.svelte new file mode 100644 index 000000000..d6da02891 --- /dev/null +++ b/ui/src/lib/components/JobCard.svelte @@ -0,0 +1,318 @@ + + +
    +
    + {job.model_id} + {job.status} +
    +

    {job.prompt}

    +
    + {#if job.job_type === "inference"} + {job.num_frames} frames + {job.height}×{job.width} + {:else} + {job.workload_type?.replace(/_/g, " ") ?? job.job_type} + {/if} + {#if elapsedTime} + ⏱ {elapsedTime} + {/if} +
    +
    + {#if job.status === "running"} + + {:else if job.status === "failed"} + + {:else if job.status === "pending" || job.status === "stopped"} + + {/if} + {#if job.status === "completed" && job.output_path && job.job_type === "inference"} + + {/if} + +
    +
    + + diff --git a/ui/src/lib/components/JobQueue.svelte b/ui/src/lib/components/JobQueue.svelte new file mode 100644 index 000000000..355dcd2ce --- /dev/null +++ b/ui/src/lib/components/JobQueue.svelte @@ -0,0 +1,111 @@ + + +
    +
    +
    + {#if jobs.length === 0} +

    + No {typesToFetch.length > 1 ? "jobs" : jobType + " jobs"} yet. Create + one above. +

    + {:else} + {#each jobs as job (job.id)} + + {/each} + {/if} +
    +
    +
    + + diff --git a/ui/src/lib/components/PrimarySidebar.svelte b/ui/src/lib/components/PrimarySidebar.svelte new file mode 100644 index 000000000..e05cfeabb --- /dev/null +++ b/ui/src/lib/components/PrimarySidebar.svelte @@ -0,0 +1,275 @@ + + + + + diff --git a/ui/src/lib/components/SecondarySidebar.svelte b/ui/src/lib/components/SecondarySidebar.svelte new file mode 100644 index 000000000..a059c6b01 --- /dev/null +++ b/ui/src/lib/components/SecondarySidebar.svelte @@ -0,0 +1,305 @@ + + + + + diff --git a/ui/src/lib/components/Slider.svelte b/ui/src/lib/components/Slider.svelte new file mode 100644 index 000000000..ffe46678c --- /dev/null +++ b/ui/src/lib/components/Slider.svelte @@ -0,0 +1,97 @@ + + +
    + onChange(parseFloat((e.target as HTMLInputElement).value))} + {disabled} + aria-label={ariaLabel} + /> + {#if showValue} + + {/if} +
    + + diff --git a/ui/src/lib/components/TabSwitch.svelte b/ui/src/lib/components/TabSwitch.svelte new file mode 100644 index 000000000..adaa2d385 --- /dev/null +++ b/ui/src/lib/components/TabSwitch.svelte @@ -0,0 +1,70 @@ + + +
    + {#each options as opt} + + {/each} +
    + + diff --git a/ui/src/lib/components/Toggle.svelte b/ui/src/lib/components/Toggle.svelte new file mode 100644 index 000000000..596a95488 --- /dev/null +++ b/ui/src/lib/components/Toggle.svelte @@ -0,0 +1,100 @@ + + + + + diff --git a/ui/src/lib/components/UploadZone.svelte b/ui/src/lib/components/UploadZone.svelte new file mode 100644 index 000000000..93660161d --- /dev/null +++ b/ui/src/lib/components/UploadZone.svelte @@ -0,0 +1,250 @@ + + +
    { + if (!textInput && !useBoth && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + handleClick(); + } + }} +> + + {#if useBoth} + + {/if} +
    {label}
    + {#if textInput} + onTextChange?.((e.target as HTMLInputElement).value)} + placeholder={textPlaceholder} + {disabled} + onclick={(e) => e.stopPropagation()} + /> + {:else} + {#if !hasContent} + + {#if uploading} + Uploading… + {:else if useBoth} + { + e.stopPropagation(); + handleClick(); + }} + onkeydown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleClick(); + } + }} + > + Select files + + · + + {:else if directory} + Click or drop folder + {:else} + Click or drop file(s) + {/if} + + {/if} + {#if fileName} +
    + {fileName} + {#if onClear} + · + + {/if} +
    + {/if} + {/if} + {#if hint} +
    {hint}
    + {/if} +
    + + diff --git a/ui/src/lib/defaultOptions.ts b/ui/src/lib/defaultOptions.ts new file mode 100644 index 000000000..4ada342e0 --- /dev/null +++ b/ui/src/lib/defaultOptions.ts @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: Apache-2.0 + +export interface DefaultOptions { + apiServerBaseUrl: string; + defaultModelId: string; + defaultModelIdT2v: string; + defaultModelIdI2v: string; + defaultModelIdT2i: string; + numInferenceSteps: number; + numFrames: number; + height: number; + width: number; + guidanceScale: number; + guidanceRescale: number; + fps: number; + seed: number; + numGpus: number; + ditCpuOffload: boolean; + textEncoderCpuOffload: boolean; + vaeCpuOffload: boolean; + imageEncoderCpuOffload: boolean; + useFsdpInference: boolean; + enableTorchCompile: boolean; + vsaSparsity: number; + tpSize: number; + spSize: number; + autoStartJob: boolean; + datasetUploadPath: string; +} + +export const DEFAULT_OPTIONS: DefaultOptions = { + apiServerBaseUrl: "", + defaultModelId: "", + defaultModelIdT2v: "", + defaultModelIdI2v: "", + defaultModelIdT2i: "", + numInferenceSteps: 50, + numFrames: 81, + height: 480, + width: 832, + guidanceScale: 5.0, + guidanceRescale: 0, + fps: 24, + seed: 1024, + numGpus: 1, + ditCpuOffload: false, + textEncoderCpuOffload: false, + vaeCpuOffload: false, + imageEncoderCpuOffload: false, + useFsdpInference: false, + enableTorchCompile: false, + vsaSparsity: 0, + tpSize: -1, + spSize: -1, + autoStartJob: false, + datasetUploadPath: "", +}; + +const STORAGE_KEY = "fastvideo-default-options"; + +export type WorkloadType = "t2v" | "i2v" | "t2i"; + +export function getDefaultModelForWorkload( + options: DefaultOptions, + workloadType: WorkloadType, +): string { + switch (workloadType) { + case "t2v": + return options.defaultModelIdT2v || options.defaultModelId || ""; + case "i2v": + return options.defaultModelIdI2v || ""; + case "t2i": + return options.defaultModelIdT2i || ""; + default: + return options.defaultModelId || ""; + } +} + +export function loadDefaultOptions(): DefaultOptions { + if (typeof window === "undefined") { + return DEFAULT_OPTIONS; + } + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (!stored) return DEFAULT_OPTIONS; + const parsed = JSON.parse(stored) as Partial; + return { ...DEFAULT_OPTIONS, ...parsed }; + } catch { + return DEFAULT_OPTIONS; + } +} + +export function saveDefaultOptions(options: DefaultOptions): void { + if (typeof window === "undefined") return; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(options)); + } catch { + // Ignore storage errors + } +} diff --git a/ui/src/lib/jobConfig.ts b/ui/src/lib/jobConfig.ts new file mode 100644 index 000000000..91ad565c1 --- /dev/null +++ b/ui/src/lib/jobConfig.ts @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { JobType } from "./types"; + +export interface WorkloadOption { + type: string; + label: string; + desc: string; +} + +export const WORKLOAD_OPTIONS: Record = { + inference: [ + { type: "t2v", label: "T2V", desc: "Text to Video" }, + { type: "i2v", label: "I2V", desc: "Image to Video" }, + { type: "t2i", label: "T2I", desc: "Text to Image" }, + ], + finetuning: [ + { + type: "full_t2v", + label: "Full", + desc: "Full finetune Text to Video", + }, + { + type: "vsa_t2v", + label: "VSA", + desc: "Video Sparse Attention", + }, + { + type: "ode_init", + label: "ODE Init", + desc: "ODE-init consistency finetune", + }, + { + type: "lora_t2v", + label: "LoRA", + desc: "Low Rank Adaptation", + }, + ], + distillation: [ + { + type: "dmd_t2v", + label: "DMD", + desc: "Distribution Matching Distillation", + }, + { + type: "self_forcing_t2v", + label: "Self-forcing", + desc: "Self-forcing distillation", + }, + ] +}; diff --git a/ui/src/lib/stores/activeDataset.ts b/ui/src/lib/stores/activeDataset.ts new file mode 100644 index 000000000..d9e2799bc --- /dev/null +++ b/ui/src/lib/stores/activeDataset.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { writable } from "svelte/store"; +import type { Dataset } from "$lib/api"; + +export const activeDatasetId = writable(null); +export const activeDataset = writable(null); + +export function setActiveDatasetId(id: string | null): void { + activeDatasetId.set(id); + if (!id) activeDataset.set(null); +} + +export function setActiveDataset(dataset: Dataset | null): void { + activeDataset.set(dataset); +} diff --git a/ui/src/lib/stores/activeJob.ts b/ui/src/lib/stores/activeJob.ts new file mode 100644 index 000000000..0e38b1cec --- /dev/null +++ b/ui/src/lib/stores/activeJob.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { writable } from "svelte/store"; +import type { Job } from "$lib/types"; + +export const activeJobId = writable(null); +export const activeJob = writable(null); + +export function setActiveJobId(id: string | null): void { + activeJobId.set(id); + if (!id) activeJob.set(null); +} + +export function setActiveJob(job: Job | null): void { + activeJob.set(job); +} diff --git a/ui/src/lib/stores/createDatasetModalOpen.ts b/ui/src/lib/stores/createDatasetModalOpen.ts new file mode 100644 index 000000000..530cc5700 --- /dev/null +++ b/ui/src/lib/stores/createDatasetModalOpen.ts @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { writable } from "svelte/store"; + +export const createDatasetModalOpen = writable(false); diff --git a/ui/src/lib/stores/defaultOptions.ts b/ui/src/lib/stores/defaultOptions.ts new file mode 100644 index 000000000..bcb190e22 --- /dev/null +++ b/ui/src/lib/stores/defaultOptions.ts @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { writable } from "svelte/store"; +import { + DEFAULT_OPTIONS, + loadDefaultOptions, + saveDefaultOptions, +} from "$lib/defaultOptions"; +import { getSettings, updateSettings } from "$lib/api"; +import type { DefaultOptions } from "$lib/defaultOptions"; + +export const defaultOptions = writable(loadDefaultOptions()); + +export function initDefaultOptions(): void { + getSettings() + .then((opts) => { + // Merge server settings into existing local options, but keep + // apiServerBaseUrl purely local (do not let the server overwrite it). + defaultOptions.update((prev) => { + const merged: DefaultOptions = { + ...DEFAULT_OPTIONS, + ...prev, + ...opts, + apiServerBaseUrl: prev.apiServerBaseUrl, + }; + saveDefaultOptions(merged); + return merged; + }); + }) + .catch(() => { + // Fall back to whatever is in local storage (or DEFAULT_OPTIONS) + defaultOptions.set(loadDefaultOptions()); + }); +} + +export function updateOption( + key: K, + value: DefaultOptions[K], +): void { + defaultOptions.update((prev) => { + const next = { ...prev, [key]: value }; + // API Server Base URL is a purely local (per-browser) setting. + // Do not persist it to the backend; just update local storage. + if (key === "apiServerBaseUrl") { + saveDefaultOptions(next); + } else { + updateSettings({ [key]: value }).catch(() => + saveDefaultOptions(next), + ); + } + return next; + }); +} + +export function resetToDefaults(): void { + defaultOptions.set(DEFAULT_OPTIONS); + updateSettings(DEFAULT_OPTIONS).catch(() => + saveDefaultOptions(DEFAULT_OPTIONS), + ); +} diff --git a/ui/src/lib/stores/headerActions.ts b/ui/src/lib/stores/headerActions.ts new file mode 100644 index 000000000..be1a3a785 --- /dev/null +++ b/ui/src/lib/stores/headerActions.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { writable } from "svelte/store"; +import type { Component } from "svelte"; + +export interface HeaderAction { + component: Component; + props?: Record; +} + +/** Components to render in the header actions slot. */ +export const headerActions = writable([]); + +export function setHeaderActions(actions: HeaderAction[]): void { + headerActions.set(actions); +} diff --git a/ui/src/lib/stores/jobsRefresh.ts b/ui/src/lib/stores/jobsRefresh.ts new file mode 100644 index 000000000..9049c693e --- /dev/null +++ b/ui/src/lib/stores/jobsRefresh.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 + +type RefreshCallback = () => void; + +let refreshCallback: RefreshCallback | null = null; + +export function registerRefresh(cb: RefreshCallback): () => void { + refreshCallback = cb; + return () => { + refreshCallback = null; + }; +} + +export function triggerRefresh(): void { + refreshCallback?.(); +} diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts new file mode 100644 index 000000000..a2dbeebaf --- /dev/null +++ b/ui/src/lib/types.ts @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: Apache-2.0 + +export type JobType = "inference" | "finetuning" | "distillation"; + +export interface Job { + id: string; + model_id: string; + prompt: string; + job_type?: JobType; + workload_type?: string; + status: string; + created_at: number; + started_at: number | null; + finished_at: number | null; + error: string | null; + output_path: string | null; + log_file_path: string | null; + num_inference_steps: number; + num_frames: number; + height: number; + width: number; + guidance_scale: number; + seed: number; + num_gpus: number; + progress: number; + progress_msg: string; + phase: string; +} diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte new file mode 100644 index 000000000..2be44d1ca --- /dev/null +++ b/ui/src/routes/+layout.svelte @@ -0,0 +1,84 @@ + + +
    +
    + (primaryWidth = w)} /> +
    + +
    + {#if jobSidebarOpen && $activeJob} + setActiveJobId(null)} + onWidthChange={(w) => (secondaryWidth = w)} + /> + {/if} + {#if datasetSidebarOpen && $activeDataset} + setActiveDatasetId(null)} + onWidthChange={(w) => (secondaryWidth = w)} + /> + {/if} +
    + + diff --git a/ui/src/routes/+layout.ts b/ui/src/routes/+layout.ts new file mode 100644 index 000000000..64bbcfb07 --- /dev/null +++ b/ui/src/routes/+layout.ts @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: Apache-2.0 +// Layout load: no server data needed; default options are inited in +layout.svelte onMount + +export const ssr = false; diff --git a/ui/src/routes/+page.svelte b/ui/src/routes/+page.svelte new file mode 100644 index 000000000..0743faa12 --- /dev/null +++ b/ui/src/routes/+page.svelte @@ -0,0 +1,17 @@ + + +

    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 @@ + + +
    +
    +
    + {#if datasets.length === 0} +

    No datasets yet.

    + {:else} + {#each datasets as ds (ds.id)} + handleSelectDataset(ds)} + /> + {/each} + {/if} +
    +
    +
    + + + diff --git a/ui/src/routes/distillation/+page.svelte b/ui/src/routes/distillation/+page.svelte new file mode 100644 index 000000000..5c47997f6 --- /dev/null +++ b/ui/src/routes/distillation/+page.svelte @@ -0,0 +1,15 @@ + + + diff --git a/ui/src/routes/finetuning/+page.svelte b/ui/src/routes/finetuning/+page.svelte new file mode 100644 index 000000000..3d3fe720e --- /dev/null +++ b/ui/src/routes/finetuning/+page.svelte @@ -0,0 +1,15 @@ + + + diff --git a/ui/src/routes/gallery/+page.svelte b/ui/src/routes/gallery/+page.svelte new file mode 100644 index 000000000..9437715ec --- /dev/null +++ b/ui/src/routes/gallery/+page.svelte @@ -0,0 +1,190 @@ + + +
    +
    +

    Gallery

    +

    + Generated videos from completed inference jobs. Captions show the prompt + used for each generation. +

    + {#if isLoading} +
    +
    + Loading gallery… +
    + {:else if error} +

    {error}

    + {:else if galleryJobs.length === 0} +

    + No generated videos yet. Complete some + inference jobs to see them here. +

    + {:else} +
    + {#each galleryJobs as job (job.id)} +
    +
    + {#if isImage(job)} + {job.prompt} + {:else} + + {/if} +
    +

    {job.prompt || "—"}

    +
    + {/each} +
    + {/if} +
    +
    + + diff --git a/ui/src/routes/inference/+page.svelte b/ui/src/routes/inference/+page.svelte new file mode 100644 index 000000000..22aacf709 --- /dev/null +++ b/ui/src/routes/inference/+page.svelte @@ -0,0 +1,15 @@ + + + diff --git a/ui/src/routes/lora/+page.svelte b/ui/src/routes/lora/+page.svelte new file mode 100644 index 000000000..c86d1f74a --- /dev/null +++ b/ui/src/routes/lora/+page.svelte @@ -0,0 +1,15 @@ + + +

    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 @@ + + +
    +
    +

    Behavior

    +
    + + updateOption("autoStartJob", v)} + /> +
    +
    +

    Paths

    +
    + + + updateOption( + "apiServerBaseUrl", + (e.target as HTMLInputElement).value, + )} + placeholder="http://localhost:8189/api" + style="font-family: monospace; font-size: 0.9rem" + /> +
    +
    + + + updateOption("datasetUploadPath", (e.target as HTMLInputElement).value)} + placeholder="outputs/ui_data/uploads/datasets" + style="font-family: monospace; font-size: 0.9rem" + /> +
    +
    +
    +

    Default Options

    + +
    +

    + These values are used as defaults when creating new jobs. +

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + updateOption("numFrames", v)} + /> +
    +
    + + updateOption("height", v)} + /> +
    +
    + + updateOption("width", v)} + /> +
    +
    + + updateOption("numInferenceSteps", v)} + /> +
    +
    + + updateOption("vsaSparsity", v)} + formatValue={(v) => v.toFixed(2)} + /> +
    +
    + + updateOption("guidanceScale", v)} + formatValue={(v) => v.toFixed(1)} + /> +
    +
    + + updateOption("guidanceRescale", v)} + formatValue={(v) => v.toFixed(2)} + /> +
    +
    + + updateOption("tpSize", v)} + formatValue={(v) => (v === -1 ? "Auto" : String(v))} + /> +
    +
    + + updateOption("spSize", v)} + formatValue={(v) => (v === -1 ? "Auto" : String(v))} + /> +
    +
    + + updateOption("fps", v)} + /> +
    +
    + + updateOption("ditCpuOffload", v)} + /> +
    +
    + + updateOption("textEncoderCpuOffload", v)} + /> +
    +
    + + updateOption("useFsdpInference", v)} + /> +
    +
    + + updateOption("vaeCpuOffload", v)} + /> +
    +
    + + updateOption("imageEncoderCpuOffload", v)} + /> +
    +
    + + updateOption("enableTorchCompile", v)} + /> +
    +
    + + updateOption("numGpus", v)} + /> +
    +
    + + + updateOption( + "seed", + parseInt((e.target as HTMLInputElement).value, 10), + )} + min={0} + /> +
    +
    +
    +
    + + diff --git a/ui/static/fastvideo.ico b/ui/static/fastvideo.ico new file mode 100644 index 0000000000000000000000000000000000000000..bb68ebf4fcabe3af8401347fa934b10d3aaa5af1 GIT binary patch literal 122112 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

    qxH=qX?FcE@;28K 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

      Zti=|kz3m%}T!#P9U5f`%*=Z8Fi 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 literal 0 HcmV?d00001 diff --git a/ui/static/logo.svg b/ui/static/logo.svg new file mode 100644 index 000000000..0b8b058b5 --- /dev/null +++ b/ui/static/logo.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/ui/svelte.config.js b/ui/svelte.config.js new file mode 100644 index 000000000..77e17c7d6 --- /dev/null +++ b/ui/svelte.config.js @@ -0,0 +1,16 @@ +// @ts-check +import adapter from "@sveltejs/adapter-node"; +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter(), + alias: { + $lib: "src/lib", + }, + }, +}; + +export default config; diff --git a/ui/training_config.py b/ui/training_config.py new file mode 100644 index 000000000..52b88bfd4 --- /dev/null +++ b/ui/training_config.py @@ -0,0 +1,219 @@ +# SPDX-License-Identifier: Apache-2.0 +""" +Maps workload types to FastVideo training modules and builds CLI arguments. +""" + +from __future__ import annotations + +from typing import Any + +# (script_path for torchrun, relative to repo root; workload_type; use_vsa; is_lora) +# Only T2V workflows are supported for finetuning and distillation. +# full_t2v and lora_t2v route to LTX2 pipeline when model is LTX-2 (see get_training_module_info). +WORKLOAD_TO_MODULE: dict[str, tuple[str, str, bool, bool]] = { + # Finetuning + "full_t2v": ("fastvideo/training/wan_training_pipeline.py", "t2v", False, False), + "vsa_t2v": ("fastvideo/training/wan_training_pipeline.py", "t2v", True, False), + "ode_init": ("fastvideo/training/ode_causal_pipeline.py", "t2v", False, False), + # Distillation + "dmd_t2v": ("fastvideo/training/wan_distillation_pipeline.py", "t2v", False, False), + "self_forcing_t2v": ("fastvideo/training/wan_self_forcing_distillation_pipeline.py", "t2v", False, False), + # LoRA + "lora_t2v": ("fastvideo/training/wan_training_pipeline.py", "t2v", False, True), +} + +LTX2_TRAINING_MODULE: tuple[str, str, bool, bool] = ( + "fastvideo/training/ltx2_training_pipeline.py", + "t2v", + False, + False, +) + + +def is_ltx2_model(model_path: str) -> bool: + """True if the model path identifies an LTX-2 model (for training pipeline routing).""" + lower = (model_path or "").lower() + return "ltx2" in lower or "ltx-2" in lower + + +def get_training_module_info(workload_type: str, model_id: str) -> tuple[str, str, bool, bool] | None: + """Resolve (script_path, workload_type, use_vsa, is_lora). Routes full_t2v and lora_t2v to LTX2 pipeline when model is LTX-2.""" + base = WORKLOAD_TO_MODULE.get(workload_type) + if base is None: + return None + if workload_type in ("full_t2v", "lora_t2v") and is_ltx2_model(model_id): + return LTX2_TRAINING_MODULE + return base + + +def get_training_env(use_vsa: bool) -> dict[str, str]: + """Environment variables for training subprocess.""" + env: dict[str, str] = { + "TOKENIZERS_PARALLELISM": "false", + "WANDB_MODE": "offline", + } + if use_vsa: + env["FASTVIDEO_ATTENTION_BACKEND"] = "VIDEO_SPARSE_ATTN" + return env + + +def build_training_args(job: dict[str, Any], output_dir: str) -> list[str]: + """Build CLI arguments for the training subprocess.""" + model_id = job.get("model_id", "") + data_path = job.get("data_path", "") + workload_type = job.get("workload_type", "full_t2v") + num_gpus = job.get("num_gpus", 1) + max_train_steps = job.get("max_train_steps", 1000) + train_batch_size = job.get("train_batch_size", 1) + learning_rate = job.get("learning_rate", 5e-5) + num_latent_t = job.get("num_latent_t", 20) + num_height = job.get("num_height") or job.get("height", 480) + num_width = job.get("num_width") or job.get("width", 832) + num_frames = job.get("num_frames", 77) + validation_dataset_file = job.get("validation_dataset_file", "") + lora_rank = job.get("lora_rank", 32) + + module_info = WORKLOAD_TO_MODULE.get(workload_type) + if not module_info: + raise ValueError(f"Unknown workload type: {workload_type}") + + _module_path, pipeline_workload, use_vsa, is_lora = module_info + + args = [ + "--model-path", + model_id, + "--pretrained-model-name-or-path", + model_id, + "--data-path", + data_path, + "--output-dir", + output_dir, + "--inference-mode", + "False", + "--max-train-steps", + str(max_train_steps), + "--train-batch-size", + str(train_batch_size), + "--train-sp-batch-size", + str(train_batch_size), + "--learning-rate", + str(learning_rate), + "--num-gpus", + str(num_gpus), + "--num-latent-t", + str(num_latent_t), + "--num-height", + str(num_height), + "--num-width", + str(num_width), + "--num-frames", + str(num_frames), + "--mixed-precision", + "bf16", + "--gradient-accumulation-steps", + "8", + "--enable-gradient-checkpointing-type", + "full", + "--weight-decay", + "1e-4", + "--max-grad-norm", + "1.0", + "--checkpoints-total-limit", + "3", + "--training-cfg-rate", + "0.1", + "--multi-phased-distill-schedule", + "4000-1", + "--not-apply-cfg-solver", + "--dit-precision", + "fp32", + "--num-euler-timesteps", + "50", + "--ema-start-step", + "0", + "--weight-only-checkpointing-steps", + "500", + "--training-state-checkpointing-steps", + "500", + "--tracker-project-name", + "fastvideo_ui_training", + "--workload-type", + pipeline_workload, + "--dataloader-num-workers", + "1", + ] + + if validation_dataset_file: + args.extend([ + "--log-validation", + "--validation-dataset-file", + validation_dataset_file, + "--validation-steps", + "200", + "--validation-sampling-steps", + "50", + "--validation-guidance-scale", + "3.0", + ]) + + if is_lora: + args.extend(["--lora-training", "True", "--lora-rank", str(lora_rank)]) + + # Distillation-specific + if workload_type.startswith("dmd_") or workload_type.startswith("self_forcing_"): + real_score = job.get("real_score_model_path", "") or model_id + fake_score = job.get("fake_score_model_path", "") or model_id + args.extend([ + "--real-score-model-path", + real_score, + "--fake-score-model-path", + fake_score, + "--training-cfg-rate", + "0.0", + "--dmd-denoising-steps", + job.get("dmd_denoising_steps", "1000,757,522") or "1000,757,522", + "--min-timestep-ratio", + str(job.get("min_timestep_ratio", 0.02)), + "--max-timestep-ratio", + str(job.get("max_timestep_ratio", 0.98)), + "--generator-update-interval", + str(job.get("generator_update_interval", 5)), + "--real-score-guidance-scale", + str(job.get("real_score_guidance_scale", 3.5)), + ]) + if job.get("dmd_use_vsa"): + args.extend([ + "--VSA-sparsity", + str(job.get("dmd_vsa_sparsity", 0.8)), + ]) + + # ODE init specific + if workload_type == "ode_init": + args.extend([ + "--warp-denoising-step", + "--multi-phased-distill-schedule", + "4000-1", + ]) + + if use_vsa: + args.extend(["--VSA-sparsity", "0.8"]) + + # LTX-2 specific + if is_ltx2_model(model_id): + p = job.get("ltx2_first_frame_conditioning_p") + if p is not None: + args.extend(["--ltx2-first-frame-conditioning-p", str(float(p))]) + + # HSDP / parallelism + args.extend([ + "--sp-size", + str(min(num_gpus, 8)), + "--tp-size", + "1", + "--hsdp-replicate-dim", + "1", + "--hsdp-shard-dim", + str(num_gpus), + ]) + + return args diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 000000000..81bda7dc3 --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "strict": true + }, + "include": ["src/**/*.ts", "src/**/*.svelte"], + "exclude": ["node_modules"] +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 000000000..3b0f3c428 --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,11 @@ +import { sveltekit } from "@sveltejs/kit/vite"; +import type { UserConfig } from "vite"; + +const config: UserConfig = { + plugins: [sveltekit()], + server: { + port: 3000 + }, +}; + +export default config;