Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2a12fd087 |
@@ -85,8 +85,6 @@ docs/distillation/examples/
|
||||
dmd_t2v_output/
|
||||
preprocess_output_text/
|
||||
|
||||
# Next.js / Node artifacts under ui/: see ui/.gitignore
|
||||
|
||||
.claude/
|
||||
.codex/
|
||||
openspec/
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
3.12
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"torch>=2.5.0",
|
||||
"triton>=2.0.0; sys_platform == 'linux'",
|
||||
"triton>=2.0.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -52,27 +52,16 @@ class VideoGenerator:
|
||||
customization options, similar to popular frameworks like HF Diffusers.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fastvideo_args: FastVideoArgs,
|
||||
executor_class: type[Executor],
|
||||
log_stats: bool,
|
||||
*,
|
||||
log_queue=None,
|
||||
):
|
||||
def __init__(self, fastvideo_args: FastVideoArgs, executor_class: type[Executor], log_stats: bool):
|
||||
"""
|
||||
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, log_queue=log_queue
|
||||
)
|
||||
self.executor = executor_class(fastvideo_args)
|
||||
|
||||
@classmethod
|
||||
def from_pretrained(cls, model_path: str, **kwargs) -> "VideoGenerator":
|
||||
@@ -91,25 +80,18 @@ 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, log_queue=log_queue)
|
||||
return cls.from_fastvideo_args(fastvideo_args)
|
||||
|
||||
@classmethod
|
||||
def from_fastvideo_args(
|
||||
cls,
|
||||
fastvideo_args: FastVideoArgs,
|
||||
*,
|
||||
log_queue=None,
|
||||
) -> "VideoGenerator":
|
||||
def from_fastvideo_args(cls, fastvideo_args: FastVideoArgs) -> "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
|
||||
"""
|
||||
@@ -121,7 +103,6 @@ class VideoGenerator:
|
||||
fastvideo_args=fastvideo_args,
|
||||
executor_class=executor_class,
|
||||
log_stats=False, # TODO: implement
|
||||
log_queue=log_queue,
|
||||
)
|
||||
|
||||
def generate_video(
|
||||
@@ -159,34 +140,6 @@ 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)
|
||||
@@ -203,9 +156,8 @@ 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 prompt_txt_path or not os.path.exists(prompt_txt_path):
|
||||
raise FileNotFoundError(
|
||||
f"Prompt text file not found: {prompt_txt_path}")
|
||||
if not os.path.exists(prompt_txt_path):
|
||||
raise FileNotFoundError(f"Prompt text file not found: {prompt_txt_path}")
|
||||
|
||||
# Read prompts from file
|
||||
with open(prompt_txt_path, encoding='utf-8') as f:
|
||||
|
||||
+14
-129
@@ -83,7 +83,7 @@ from fastvideo.configs.sample.wan import (
|
||||
)
|
||||
from fastvideo.configs.sample.sd35 import SD35SamplingParam
|
||||
|
||||
from fastvideo.fastvideo_args import FastVideoArgs, WorkloadType
|
||||
from fastvideo.fastvideo_args import WorkloadType
|
||||
from fastvideo.logger import init_logger
|
||||
from fastvideo.utils import (maybe_download_model_index, verify_model_config_and_directory)
|
||||
|
||||
@@ -135,7 +135,6 @@ 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
|
||||
@@ -151,21 +150,15 @@ _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.
|
||||
|
||||
workload_types declares which UI workload options this config supports.
|
||||
Use () for configs not exposed as workload options.
|
||||
"""
|
||||
"""Register config classes for a model family."""
|
||||
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:
|
||||
@@ -242,7 +235,6 @@ 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",
|
||||
@@ -256,7 +248,6 @@ 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",
|
||||
],
|
||||
@@ -269,7 +260,6 @@ 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",
|
||||
],
|
||||
@@ -285,7 +275,6 @@ 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",
|
||||
],
|
||||
@@ -293,7 +282,6 @@ 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",
|
||||
],
|
||||
@@ -301,7 +289,6 @@ 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",
|
||||
],
|
||||
@@ -309,18 +296,13 @@ 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"
|
||||
],
|
||||
hf_model_paths=["weizhou03/HunyuanVideo-1.5-Diffusers-1080p", "weizhou03/HunyuanVideo-1.5-Diffusers-1080p-2SR"],
|
||||
)
|
||||
|
||||
# Hunyuan (excludes gamecraft, hyworld, and versioned models)
|
||||
register_configs(
|
||||
sampling_param_cls=HunyuanSamplingParam,
|
||||
pipeline_config_cls=HunyuanConfig,
|
||||
workload_types=(WorkloadType.T2V,),
|
||||
hf_model_paths=[
|
||||
"hunyuanvideo-community/HunyuanVideo",
|
||||
],
|
||||
@@ -332,7 +314,6 @@ def _register_configs() -> None:
|
||||
register_configs(
|
||||
sampling_param_cls=FastHunyuanSamplingParam,
|
||||
pipeline_config_cls=FastHunyuanConfig,
|
||||
workload_types=(WorkloadType.T2V,),
|
||||
hf_model_paths=[
|
||||
"FastVideo/FastHunyuan-diffusers",
|
||||
],
|
||||
@@ -342,7 +323,6 @@ 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",
|
||||
],
|
||||
@@ -353,7 +333,6 @@ def _register_configs() -> None:
|
||||
register_configs(
|
||||
sampling_param_cls=HunyuanGameCraftSamplingParam,
|
||||
pipeline_config_cls=HunyuanGameCraftPipelineConfig,
|
||||
workload_types=(WorkloadType.I2V,),
|
||||
hf_model_paths=[
|
||||
"FastVideo/HunyuanGameCraft-Diffusers",
|
||||
],
|
||||
@@ -363,7 +342,6 @@ 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",
|
||||
],
|
||||
@@ -374,7 +352,6 @@ 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",
|
||||
],
|
||||
@@ -383,38 +360,19 @@ def _register_configs() -> None:
|
||||
],
|
||||
)
|
||||
|
||||
# LongCat (T2V, I2V, VC use same config; workload varies by path)
|
||||
# LongCat
|
||||
register_configs(
|
||||
sampling_param_cls=None,
|
||||
pipeline_config_cls=LongCatT2V480PConfig,
|
||||
workload_types=(WorkloadType.T2V,),
|
||||
hf_model_paths=["FastVideo/LongCat-Video-T2V-Diffusers"],
|
||||
model_detectors=[
|
||||
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(),
|
||||
hf_model_paths=[
|
||||
"FastVideo/LongCat-Video-T2V-Diffusers",
|
||||
"FastVideo/LongCat-Video-I2V-Diffusers",
|
||||
"FastVideo/LongCat-Video-VC-Diffusers",
|
||||
],
|
||||
)
|
||||
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()),
|
||||
lambda path: "longcatimagetovideo" in path.lower(),
|
||||
lambda path: "longcatvideocontinuation" in path.lower(),
|
||||
lambda path: "longcat" in path.lower(),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -422,7 +380,6 @@ 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",
|
||||
@@ -437,7 +394,6 @@ 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",
|
||||
],
|
||||
@@ -454,7 +410,6 @@ 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",
|
||||
],
|
||||
@@ -468,7 +423,6 @@ 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",
|
||||
],
|
||||
@@ -477,7 +431,6 @@ 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",
|
||||
],
|
||||
@@ -485,7 +438,6 @@ 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",
|
||||
],
|
||||
@@ -495,7 +447,6 @@ 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",
|
||||
],
|
||||
@@ -504,7 +455,6 @@ 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",
|
||||
@@ -513,7 +463,6 @@ 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",
|
||||
],
|
||||
@@ -522,7 +471,6 @@ 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",
|
||||
],
|
||||
@@ -530,7 +478,6 @@ 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",
|
||||
],
|
||||
@@ -538,7 +485,6 @@ 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",
|
||||
],
|
||||
@@ -546,7 +492,6 @@ 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",
|
||||
@@ -556,7 +501,6 @@ 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",
|
||||
],
|
||||
@@ -564,7 +508,6 @@ 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",
|
||||
@@ -573,7 +516,6 @@ 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",
|
||||
],
|
||||
@@ -581,7 +523,6 @@ 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",
|
||||
],
|
||||
@@ -589,31 +530,17 @@ 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,
|
||||
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(),
|
||||
hf_model_paths=[
|
||||
"rand0nmr/SFWan2.2-T2V-A14B-Diffusers",
|
||||
"FastVideo/SFWan2.2-I2V-A14B-Preview-Diffusers",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -621,7 +548,6 @@ 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",
|
||||
],
|
||||
@@ -709,52 +635,11 @@ 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",
|
||||
]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# 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
|
||||
@@ -15,14 +14,8 @@ _R = TypeVar("_R")
|
||||
|
||||
class Executor(ABC):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fastvideo_args: FastVideoArgs,
|
||||
*,
|
||||
log_queue=None,
|
||||
):
|
||||
def __init__(self, fastvideo_args: FastVideoArgs):
|
||||
self.fastvideo_args = fastvideo_args
|
||||
self._log_queue = log_queue
|
||||
|
||||
self._init_executor()
|
||||
|
||||
@@ -104,16 +97,6 @@ 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:
|
||||
"""
|
||||
|
||||
@@ -7,8 +7,6 @@ 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
|
||||
@@ -36,11 +34,6 @@ 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.
|
||||
@@ -104,7 +97,6 @@ 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
|
||||
@@ -254,14 +246,6 @@ 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,
|
||||
@@ -314,12 +298,6 @@ 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
|
||||
@@ -449,14 +427,11 @@ 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)]
|
||||
@@ -483,7 +458,6 @@ 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)
|
||||
@@ -498,7 +472,6 @@ 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,
|
||||
@@ -515,13 +488,6 @@ 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
|
||||
@@ -557,21 +523,9 @@ class WorkerMultiprocProc:
|
||||
|
||||
worker.worker_busy_loop()
|
||||
|
||||
except Exception as exc:
|
||||
except Exception:
|
||||
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.")
|
||||
|
||||
@@ -581,8 +535,7 @@ class WorkerMultiprocProc:
|
||||
shutdown_requested = True
|
||||
traceback = get_exception_traceback()
|
||||
logger.error("Worker %d hit an exception: %s", rank, traceback)
|
||||
if parent_process:
|
||||
parent_process.send_signal(signal.SIGQUIT)
|
||||
parent_process.send_signal(signal.SIGQUIT)
|
||||
|
||||
finally:
|
||||
if ready_pipe is not None:
|
||||
@@ -599,8 +552,7 @@ 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)
|
||||
worker_errors: list[str] = []
|
||||
ready_proc_handles: list[WorkerProcHandle | None] = ([None] * len(unready_proc_handles))
|
||||
while pipes:
|
||||
ready = mp.connection.wait(pipes.keys())
|
||||
for pipe in ready:
|
||||
@@ -609,40 +561,19 @@ class WorkerMultiprocProc:
|
||||
# Wait until the WorkerProc is ready.
|
||||
unready_proc_handle = pipes.pop(pipe)
|
||||
response: dict[str, Any] = pipe.recv()
|
||||
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')}")
|
||||
if response["status"] != "READY":
|
||||
raise e
|
||||
|
||||
if response["status"] == "READY":
|
||||
ready_proc_handles[unready_proc_handle.rank] = (
|
||||
WorkerProcHandle.from_unready_handle(unready_proc_handle))
|
||||
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(f"Worker process crashed (pipe closed unexpectedly)")
|
||||
e.__suppress_context__ = True
|
||||
raise e from None
|
||||
|
||||
finally:
|
||||
# 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)
|
||||
@@ -666,14 +597,6 @@ 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()
|
||||
@@ -742,30 +665,6 @@ 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
|
||||
|
||||
+1
-5
@@ -80,10 +80,6 @@ dependencies = [
|
||||
"torchcodec",
|
||||
"ray>=2.49.1",
|
||||
"ftfy==6.3.1",
|
||||
|
||||
# Job Runner
|
||||
"fastapi==0.129.0",
|
||||
"uvicorn==0.41.0"
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
@@ -150,7 +146,7 @@ check_untyped_defs = true
|
||||
follow_imports = "silent"
|
||||
|
||||
[tool.codespell]
|
||||
skip = "./data,./wandb,./ui/package-lock.json"
|
||||
skip ="./data,./wandb"
|
||||
|
||||
[tool.ruff]
|
||||
# Allow lines to be as long as 120.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
PUBLIC_API_BASE_URL=http://localhost:8189/api
|
||||
@@ -1,42 +0,0 @@
|
||||
# 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
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"tabWidth": 4
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
# 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 |
|
||||
@@ -1 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
@@ -1,7 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Allow ``python -m ui`` to start the server."""
|
||||
|
||||
from ui.server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
-758
@@ -1,758 +0,0 @@
|
||||
# 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 "",
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// Minimal ESLint config for SvelteKit (add typescript-eslint / eslint-plugin-svelte as needed)
|
||||
export default [];
|
||||
-1049
File diff suppressed because it is too large
Load Diff
@@ -1,14 +0,0 @@
|
||||
# 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",
|
||||
]
|
||||
@@ -1,11 +0,0 @@
|
||||
# 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
|
||||
@@ -1,49 +0,0 @@
|
||||
# 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 = ""
|
||||
@@ -1,31 +0,0 @@
|
||||
# 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
|
||||
@@ -1,9 +0,0 @@
|
||||
# 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
|
||||
Generated
-3066
File diff suppressed because it is too large
Load Diff
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
# 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
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, 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)
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 119 KiB |
@@ -1,18 +0,0 @@
|
||||
<svg width="252" height="105" viewBox="0 0 252 105" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M89.4843 55.5457H101.361L87.7028 101H74.638L89.4843 55.5457Z" fill="#356CFF"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M96.0167 1.00057H112.645L118.583 48.273H104.924L103.737 39.7882H79.9827L67.5117 55.5457H85.3273L43.1638 101H28.3174L22.3789 55.5457H33.6621L38.4129 91.3031L58.604 68.2729H44.3515L96.0167 1.00057ZM100.768 13.1217L87.7028 29.4852H103.143L100.768 13.1217Z" fill="#356CFF"/>
|
||||
<path d="M37.2252 1.00057L22.3789 48.273H36.0375L40.7884 30.6974L62.6727 30.6974L69.6727 21.0005L43.7576 21.0004L46.7269 11.9096L77.6727 11.9096L86 1.00057L37.2252 1.00057Z" fill="#356CFF"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M108.488 55.5457L94.2351 101C94.2351 101 105.518 101 120.959 101C136.399 101 144.078 93.0133 148.276 79.788C152.432 68.0157 153.027 55.5457 136.399 55.5457C119.771 55.5457 108.488 55.5457 108.488 55.5457ZM109.081 90.697L116.802 65.8487C116.802 65.8487 120.959 65.8487 132.242 65.8487C143.525 65.8487 137.586 78.5759 135.211 84.0304C133.307 88.4021 127.491 90.697 122.74 90.697C117.989 90.697 109.081 90.697 109.081 90.697Z" fill="#356CFF"/>
|
||||
<path d="M173.188 1.00056L168.625 11.9096C168.625 11.9096 149.386 11.9092 142.525 11.9095C135.664 11.9098 136.586 20.3944 141.337 20.3944H159.747C168.654 20.3944 166.961 33.6899 163.904 38.5761C160.467 44.0675 157.371 48.273 148.463 48.273L125.188 48.273L124 37.97L147.87 37.97C153.808 37.97 156.184 29.4852 151.433 29.4852H131.836C120.142 29.4852 125.897 1.00043 141.337 1.00043L173.188 1.00056Z" fill="#356CFF"/>
|
||||
<path d="M179.938 1.00056L175.688 11.9096L191.221 11.9096L179.938 48.273H192.409L203.692 11.9096L219.132 11.9095L223.289 1.00043L179.938 1.00056Z" fill="#356CFF"/>
|
||||
<path d="M161.341 55.5457H202.845L198.5 65.8487H169.654L167.279 73.7268H188.5L184.749 82.8177H164.31L161.934 90.697H190.251L186.624 101H146.494L161.341 55.5457Z" fill="#356CFF"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M230.821 54.9391C255.169 54.9391 251.776 67.0602 249.231 77.9692C246.686 88.8783 240.917 101 217.757 101C194.596 101 195.606 88.8783 199.347 77.9692C203.089 67.0602 206.473 54.9391 230.821 54.9391ZM237.948 77.9692C239.984 70.6965 240.917 65.242 228.446 65.242C215.975 65.242 211.818 71.9087 210.037 77.9692C208.255 84.0298 208.255 91.3025 219.538 91.3025C230.821 91.3025 235.911 85.2419 237.948 77.9692Z" fill="#356CFF"/>
|
||||
<path d="M173.188 1.00056L168.625 11.9096C168.625 11.9096 149.386 11.9092 142.525 11.9095C135.664 11.9098 136.586 20.3944 141.337 20.3944M173.188 1.00056C173.188 1.00056 156.777 1.00043 141.337 1.00043M173.188 1.00056L141.337 1.00043M141.337 20.3944C146.088 20.3944 150.839 20.3944 159.747 20.3944M141.337 20.3944H159.747M159.747 20.3944C168.654 20.3944 166.961 33.6899 163.904 38.5761C160.467 44.0675 157.371 48.273 148.463 48.273M148.463 48.273C139.556 48.273 125.188 48.273 125.188 48.273M148.463 48.273L125.188 48.273M125.188 48.273L124 37.97M124 37.97C124 37.97 141.931 37.97 147.87 37.97M124 37.97L147.87 37.97M147.87 37.97C153.808 37.97 156.184 29.4852 151.433 29.4852M151.433 29.4852C146.682 29.4852 138.962 29.4852 131.836 29.4852M151.433 29.4852H131.836M131.836 29.4852C120.142 29.4852 125.897 1.00043 141.337 1.00043M37.2252 1.00057L22.3789 48.273H36.0375L40.7884 30.6974L62.6727 30.6974L69.6727 21.0005L43.7576 21.0004L46.7269 11.9096L77.6727 11.9096L86 1.00057L37.2252 1.00057ZM96.0167 1.00057H112.645L118.583 48.273H104.924L103.737 39.7882H79.9827L67.5117 55.5457H85.3273L43.1638 101H28.3174L22.3789 55.5457H33.6621L38.4129 91.3031L58.604 68.2729H44.3515L96.0167 1.00057ZM87.7028 29.4852L100.768 13.1217L103.143 29.4852H87.7028ZM89.4843 55.5457H101.361L87.7028 101H74.638L89.4843 55.5457ZM108.488 55.5457L94.2351 101C94.2351 101 105.518 101 120.959 101C136.399 101 144.078 93.0133 148.276 79.788C152.432 68.0157 153.027 55.5457 136.399 55.5457C119.771 55.5457 108.488 55.5457 108.488 55.5457ZM116.802 65.8487L109.081 90.697C109.081 90.697 117.989 90.697 122.74 90.697C127.491 90.697 133.307 88.4021 135.211 84.0304C137.586 78.5759 143.525 65.8487 132.242 65.8487C120.959 65.8487 116.802 65.8487 116.802 65.8487ZM179.938 1.00056L175.688 11.9096L191.221 11.9096L179.938 48.273H192.409L203.692 11.9096L219.132 11.9095L223.289 1.00043L179.938 1.00056ZM161.341 55.5457H202.845L198.5 65.8487H169.654L167.279 73.7268H188.5L184.749 82.8177H164.31L161.934 90.697H190.251L186.624 101H146.494L161.341 55.5457ZM230.821 54.9391C255.169 54.9391 251.776 67.0602 249.231 77.9692C246.686 88.8783 240.917 101 217.757 101C194.596 101 195.606 88.8783 199.347 77.9692C203.089 67.0602 206.473 54.9391 230.821 54.9391ZM228.446 65.242C240.917 65.242 239.984 70.6965 237.948 77.9692C235.911 85.2419 230.821 91.3025 219.538 91.3025C208.255 91.3025 208.255 84.0298 210.037 77.9692C211.818 71.9087 215.975 65.242 228.446 65.242Z" stroke="#356CFF" stroke-width="1.18771"/>
|
||||
<path d="M15.2524 55.5451L21.191 100.999L24.7541 100.999L18.8156 55.5451L15.2524 55.5451Z" fill="#356CFF" stroke="#356CFF" stroke-width="1.18771"/>
|
||||
<path d="M8.12646 55.5451L14.065 100.999L15.2527 100.999L9.31417 55.5451L8.12646 55.5451Z" fill="#356CFF" stroke="#356CFF" stroke-width="1.18771"/>
|
||||
<path d="M1 55.5451L6.93853 100.999L7.53239 100.999L1.59385 55.5451L1 55.5451Z" fill="#356CFF" stroke="#356CFF" stroke-width="0.593853"/>
|
||||
<path d="M15.2524 48.2724L30.0988 1H33.6619L18.8156 48.2724H15.2524Z" fill="#356CFF" stroke="#356CFF" stroke-width="1.18771"/>
|
||||
<path d="M8.12646 48.2724L22.9728 1H24.1605L9.31417 48.2724H8.12646Z" fill="#356CFF" stroke="#356CFF" stroke-width="1.18771"/>
|
||||
<path d="M1 48.2724L15.8463 1H16.4402L1.59385 48.2724H1Z" fill="#356CFF" stroke="#356CFF" stroke-width="0.593853"/>
|
||||
<path d="M85.3271 55.5457H67.5116L87 12.7363L44.3513 68.2729H58.6038L43.1636 101L85.3271 55.5457Z" fill="#FDC717" stroke="#FDC717" stroke-width="1.18771" stroke-miterlimit="16"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.7 KiB |
-727
@@ -1,727 +0,0 @@
|
||||
# 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()
|
||||
@@ -1,66 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/fastvideo.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>FastVideo</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body class="antialiased" data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,76 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,404 +0,0 @@
|
||||
// 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<Settings> {
|
||||
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<Settings>,
|
||||
): Promise<Settings> {
|
||||
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<Model[]> {
|
||||
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<Job[]> {
|
||||
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<Job> {
|
||||
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<Job> {
|
||||
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<Job> {
|
||||
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<Job> {
|
||||
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<void> {
|
||||
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<JobLogs> {
|
||||
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<Blob> {
|
||||
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<Blob> {
|
||||
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<string, string>;
|
||||
}
|
||||
|
||||
export interface DatasetFilesResponse {
|
||||
file_names: string[];
|
||||
captions: Record<string, string>;
|
||||
}
|
||||
|
||||
export async function getDatasets(): Promise<Dataset[]> {
|
||||
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<Dataset> {
|
||||
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<Dataset> {
|
||||
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<void> {
|
||||
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<DatasetFilesResponse> {
|
||||
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<void> {
|
||||
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)}`;
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Caption file parsing for CreateDatasetModal
|
||||
|
||||
export function parseVideos2Caption(
|
||||
text: string,
|
||||
uploadedFileNames: string[],
|
||||
): { captions: Record<string, string>; error: string | null } {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
return { captions: {}, error: "Invalid JSON in videos2caption.json." };
|
||||
}
|
||||
const captions: Record<string, string> = {};
|
||||
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<string, unknown>)["path"];
|
||||
const cap = (entry as Record<string, unknown>)["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<string, unknown>;
|
||||
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<string, string>; error: string | null } {
|
||||
const captions: Record<string, string> = {};
|
||||
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<string, string>; 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<string, string> = {};
|
||||
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 };
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { createDatasetModalOpen } from "$lib/stores/createDatasetModalOpen";
|
||||
|
||||
function openModal() {
|
||||
createDatasetModalOpen.set(true);
|
||||
}
|
||||
</script>
|
||||
|
||||
<button type="button" class="btn btnPrimary" onclick={openModal}>
|
||||
Add Dataset
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.btn {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.55rem 1.2rem;
|
||||
}
|
||||
.btnPrimary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -1,514 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { createDataset, uploadRawDataset } from "$lib/api";
|
||||
import UploadZone from "./UploadZone.svelte";
|
||||
import TabSwitch from "./TabSwitch.svelte";
|
||||
import {
|
||||
parseVideos2Caption,
|
||||
parseVideosCaptionsTxt,
|
||||
parseCaptionCsv,
|
||||
} from "$lib/captionParsing";
|
||||
|
||||
const ALLOWED_VIDEO_EXT = ".mp4,.webm,.avi,.mov,.mkv";
|
||||
|
||||
let {
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
} = $props();
|
||||
|
||||
let name = $state("");
|
||||
let isSubmitting = $state(false);
|
||||
let rawPath = $state("");
|
||||
let fileNames = $state<string[]>([]);
|
||||
let isUploading = $state(false);
|
||||
let validationError = $state<string | null>(null);
|
||||
let captionFormat = $state<"json" | "txt" | "csv">("json");
|
||||
let captionMap = $state<Record<string, string> | null>(null);
|
||||
let captionFileName = $state<string | null>(null);
|
||||
let videosTxtLines = $state<string[] | null>(null);
|
||||
let videosTxtFileName = $state<string | null>(null);
|
||||
let captionsTxtLines = $state<string[] | null>(null);
|
||||
let captionsTxtFileName = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (captionFormat === "txt" && captionsTxtLines && fileNames.length > 0) {
|
||||
const hasVideosTxt =
|
||||
videosTxtLines &&
|
||||
videosTxtLines.length > 0 &&
|
||||
videosTxtLines.some((s) => s.trim());
|
||||
if (hasVideosTxt && videosTxtLines) {
|
||||
const { error } = parseVideosCaptionsTxt(
|
||||
videosTxtLines,
|
||||
captionsTxtLines,
|
||||
fileNames,
|
||||
);
|
||||
validationError = error;
|
||||
} else {
|
||||
validationError = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const txtCaptionMap = $derived.by(() => {
|
||||
if (!captionsTxtLines || fileNames.length === 0) return null;
|
||||
const { captions, error } = parseVideosCaptionsTxt(
|
||||
videosTxtLines ?? null,
|
||||
captionsTxtLines,
|
||||
fileNames,
|
||||
);
|
||||
if (error) return null;
|
||||
return Object.keys(captions).length > 0 ? captions : null;
|
||||
});
|
||||
|
||||
const effectiveCaptionMap = $derived(
|
||||
captionFormat === "txt" ? txtCaptionMap : captionMap,
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (!isOpen) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && !isSubmitting) handleClose();
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
});
|
||||
|
||||
function handleClose() {
|
||||
if (isSubmitting) return;
|
||||
name = "";
|
||||
rawPath = "";
|
||||
fileNames = [];
|
||||
validationError = null;
|
||||
captionFormat = "json";
|
||||
captionMap = null;
|
||||
captionFileName = null;
|
||||
videosTxtLines = null;
|
||||
videosTxtFileName = null;
|
||||
captionsTxtLines = null;
|
||||
captionsTxtFileName = null;
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function handleCaptionJsonChange(files: File[]) {
|
||||
validationError = null;
|
||||
captionMap = null;
|
||||
captionFileName = null;
|
||||
if (files.length === 0) return;
|
||||
const file = files[0];
|
||||
try {
|
||||
const text = await file.text();
|
||||
const uploaded = fileNames.length > 0 ? fileNames : [];
|
||||
const { captions, error } = parseVideos2Caption(text, uploaded);
|
||||
if (error) {
|
||||
validationError = error;
|
||||
return;
|
||||
}
|
||||
captionMap = captions;
|
||||
captionFileName = file.name;
|
||||
} catch {
|
||||
validationError = "Could not read the file.";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVideosTxtChange(files: File[]) {
|
||||
validationError = null;
|
||||
videosTxtLines = null;
|
||||
videosTxtFileName = null;
|
||||
if (files.length === 0) return;
|
||||
try {
|
||||
const text = await files[0].text();
|
||||
videosTxtLines = text.split(/\r?\n/).map((s) => s.trim());
|
||||
videosTxtFileName = files[0].name;
|
||||
if (captionsTxtLines && fileNames.length > 0) {
|
||||
const { error } = parseVideosCaptionsTxt(
|
||||
videosTxtLines,
|
||||
captionsTxtLines,
|
||||
fileNames,
|
||||
);
|
||||
if (error) validationError = error;
|
||||
}
|
||||
} catch {
|
||||
validationError = "Could not read videos.txt.";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCaptionsTxtChange(files: File[]) {
|
||||
validationError = null;
|
||||
captionsTxtLines = null;
|
||||
captionsTxtFileName = null;
|
||||
if (files.length === 0) return;
|
||||
try {
|
||||
const text = await files[0].text();
|
||||
captionsTxtLines = text.split(/\r?\n/).map((s) => s.trim());
|
||||
captionsTxtFileName = files[0].name;
|
||||
if (videosTxtLines && fileNames.length > 0) {
|
||||
const { error } = parseVideosCaptionsTxt(
|
||||
videosTxtLines,
|
||||
captionsTxtLines,
|
||||
fileNames,
|
||||
);
|
||||
if (error) validationError = error;
|
||||
}
|
||||
} catch {
|
||||
validationError = "Could not read captions.txt.";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCaptionCsvChange(files: File[]) {
|
||||
validationError = null;
|
||||
captionMap = null;
|
||||
captionFileName = null;
|
||||
if (files.length === 0) return;
|
||||
const file = files[0];
|
||||
try {
|
||||
const text = await file.text();
|
||||
const uploaded = fileNames.length > 0 ? fileNames : [];
|
||||
const { captions, error } = parseCaptionCsv(text, uploaded);
|
||||
if (error) {
|
||||
validationError = error;
|
||||
return;
|
||||
}
|
||||
captionMap = captions;
|
||||
captionFileName = file.name;
|
||||
} catch {
|
||||
validationError = "Could not read the file.";
|
||||
}
|
||||
}
|
||||
|
||||
function handleCaptionFormatChange(format: string) {
|
||||
captionFormat = format as "json" | "txt" | "csv";
|
||||
validationError = null;
|
||||
captionMap = null;
|
||||
captionFileName = null;
|
||||
videosTxtLines = null;
|
||||
videosTxtFileName = null;
|
||||
captionsTxtLines = null;
|
||||
captionsTxtFileName = null;
|
||||
}
|
||||
|
||||
async function handleMediaChange(files: File[]) {
|
||||
validationError = null;
|
||||
if (files.length === 0) {
|
||||
rawPath = "";
|
||||
fileNames = [];
|
||||
return;
|
||||
}
|
||||
isUploading = true;
|
||||
try {
|
||||
const res = await uploadRawDataset(files);
|
||||
rawPath = res.path;
|
||||
fileNames = res.file_names;
|
||||
if (res.file_names.length === 0) {
|
||||
validationError = `No video files found. Allowed: ${ALLOWED_VIDEO_EXT}`;
|
||||
}
|
||||
} catch (err) {
|
||||
rawPath = "";
|
||||
fileNames = [];
|
||||
validationError =
|
||||
err instanceof Error ? err.message : "Upload failed";
|
||||
} finally {
|
||||
isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
validationError = null;
|
||||
if (!name.trim()) return;
|
||||
if (!rawPath || fileNames.length === 0) {
|
||||
validationError = "No data was found. Upload at least one video.";
|
||||
return;
|
||||
}
|
||||
if (captionFormat === "json" || captionFormat === "csv") {
|
||||
if (captionFileName && !captionMap) {
|
||||
validationError =
|
||||
"Caption file has errors. Fix or remove it before creating the dataset.";
|
||||
return;
|
||||
}
|
||||
} else if (captionFormat === "txt") {
|
||||
if (videosTxtFileName || captionsTxtFileName) {
|
||||
if (!captionsTxtFileName) {
|
||||
validationError = "Upload captions.txt to use TXT captions.";
|
||||
return;
|
||||
}
|
||||
if (validationError) return;
|
||||
}
|
||||
}
|
||||
|
||||
const finalCaptionMap =
|
||||
effectiveCaptionMap && Object.keys(effectiveCaptionMap).length > 0
|
||||
? effectiveCaptionMap
|
||||
: null;
|
||||
if (finalCaptionMap) {
|
||||
const missing = fileNames.filter((fn) => !(fn in finalCaptionMap));
|
||||
if (missing.length > 0) {
|
||||
const list =
|
||||
missing.length <= 5
|
||||
? missing.join(", ")
|
||||
: `${missing.slice(0, 5).join(", ")} and ${missing.length - 5} more`;
|
||||
const ok = confirm(
|
||||
`The caption file does not include captions for ${missing.length} video(s): ${list}. They will get empty captions. Continue?`,
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
try {
|
||||
await createDataset({
|
||||
name: name.trim(),
|
||||
upload_path: rawPath,
|
||||
file_names: fileNames,
|
||||
...(finalCaptionMap ? { captions: finalCaptionMap } : {}),
|
||||
});
|
||||
onSuccess();
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
validationError =
|
||||
err instanceof Error ? err.message : "Failed to create dataset";
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="modal" data-modal>
|
||||
<div class="modalBackdrop" onclick={handleClose} role="presentation"></div>
|
||||
<div class="modalContent modalForm">
|
||||
<button
|
||||
class="modalClose"
|
||||
onclick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div class="card">
|
||||
<h2>Add Dataset — Raw</h2>
|
||||
<form onsubmit={handleSubmit} autocomplete="off">
|
||||
<div class="formRow">
|
||||
<label for="add-dataset-name">Name</label>
|
||||
<input
|
||||
id="add-dataset-name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder="My dataset"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label>Videos</label>
|
||||
<UploadZone
|
||||
label="Upload video files"
|
||||
hint="Select files or a folder (.mp4, .webm, .avi, .mov, .mkv)"
|
||||
accept={ALLOWED_VIDEO_EXT}
|
||||
multiple={true}
|
||||
directory={true}
|
||||
allowBothFileAndDirectory={true}
|
||||
value={rawPath}
|
||||
fileName={fileNames.length > 0 ? `${fileNames.length} file(s)` : undefined}
|
||||
onFileChange={handleMediaChange}
|
||||
onClear={() => {
|
||||
rawPath = "";
|
||||
fileNames = [];
|
||||
captionMap = null;
|
||||
captionFileName = null;
|
||||
validationError = null;
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
uploading={isUploading}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<div class="captionRow">
|
||||
<label>Captions (optional)</label>
|
||||
<TabSwitch
|
||||
options={[
|
||||
{ id: "json", label: "JSON" },
|
||||
{ id: "txt", label: "TXT" },
|
||||
{ id: "csv", label: "CSV" },
|
||||
]}
|
||||
value={captionFormat}
|
||||
onChange={handleCaptionFormatChange}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
{#if captionFormat === "json"}
|
||||
<UploadZone
|
||||
label="Upload videos2caption.json"
|
||||
hint={'Array of { path, cap } or object mapping file names to captions'}
|
||||
accept=".json,application/json"
|
||||
value={captionFileName ? "1" : ""}
|
||||
fileName={captionFileName ?? undefined}
|
||||
onFileChange={handleCaptionJsonChange}
|
||||
onClear={() => {
|
||||
captionMap = null;
|
||||
captionFileName = null;
|
||||
validationError = null;
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{:else if captionFormat === "txt"}
|
||||
<div class="twoCol">
|
||||
<UploadZone
|
||||
label="Upload videos.txt (optional)"
|
||||
hint="One video path per line, or leave empty to match captions to videos in alphabetical order"
|
||||
accept=".txt,text/plain"
|
||||
value={videosTxtFileName ? "1" : ""}
|
||||
fileName={videosTxtFileName ?? undefined}
|
||||
onFileChange={handleVideosTxtChange}
|
||||
onClear={() => {
|
||||
videosTxtLines = null;
|
||||
videosTxtFileName = null;
|
||||
validationError = null;
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<UploadZone
|
||||
label="Upload captions.txt"
|
||||
hint="One caption per line (same order as videos.txt or alphabetical)"
|
||||
accept=".txt,text/plain"
|
||||
value={captionsTxtFileName ? "1" : ""}
|
||||
fileName={captionsTxtFileName ?? undefined}
|
||||
onFileChange={handleCaptionsTxtChange}
|
||||
onClear={() => {
|
||||
captionsTxtLines = null;
|
||||
captionsTxtFileName = null;
|
||||
validationError = null;
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<UploadZone
|
||||
label="Upload captions CSV"
|
||||
hint="Header: video_name, caption"
|
||||
accept=".csv,text/csv"
|
||||
value={captionFileName ? "1" : ""}
|
||||
fileName={captionFileName ?? undefined}
|
||||
onFileChange={handleCaptionCsvChange}
|
||||
onClear={() => {
|
||||
captionMap = null;
|
||||
captionFileName = null;
|
||||
validationError = null;
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{#if validationError}
|
||||
<p class="validationError">{validationError}</p>
|
||||
{/if}
|
||||
<button type="submit" class="btn btnPrimary" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Creating…" : "Create Dataset"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modalBackdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
.modalContent {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0;
|
||||
max-width: 850px;
|
||||
width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modalClose {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.75rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 1.6rem;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
}
|
||||
.card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.card h2 {
|
||||
font-size: 1.15rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.formRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.formRow label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.captionRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.captionRow label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.twoCol {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.twoCol :global(> *) {
|
||||
flex: 1 1 0;
|
||||
min-width: 200px;
|
||||
}
|
||||
.validationError {
|
||||
color: var(--red);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.btn {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
padding: 0.55rem 1.2rem;
|
||||
}
|
||||
.btnPrimary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -1,126 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { JobType } from "$lib/types";
|
||||
import { WORKLOAD_OPTIONS } from "$lib/jobConfig";
|
||||
import { triggerRefresh } from "$lib/stores/jobsRefresh";
|
||||
import CreateJobModal from "./CreateJobModal.svelte";
|
||||
|
||||
let { jobType }: { jobType: JobType } = $props();
|
||||
|
||||
let modalOpen = $state(false);
|
||||
const options = $derived(WORKLOAD_OPTIONS[jobType] ?? []);
|
||||
let workloadType = $state(options[0]?.type ?? "t2v");
|
||||
|
||||
function openModal(type: string) {
|
||||
workloadType = type;
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function handleSuccess() {
|
||||
triggerRefresh();
|
||||
modalOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="wrapper">
|
||||
<button type="button" class="btn btnPrimary trigger">Create Job</button>
|
||||
<div class="menu" role="menu">
|
||||
{#each options as opt}
|
||||
<button
|
||||
type="button"
|
||||
class="menuItem"
|
||||
role="menuitem"
|
||||
onclick={() => openModal(opt.type)}
|
||||
>
|
||||
{opt.label}
|
||||
<div class="menuItemDesc">{opt.desc}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<CreateJobModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => (modalOpen = false)}
|
||||
onSuccess={handleSuccess}
|
||||
{jobType}
|
||||
{workloadType}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.trigger::after {
|
||||
content: "";
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 5px solid currentColor;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 0.25rem;
|
||||
min-width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
padding: 0.25rem 0;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-4px);
|
||||
transition: opacity 0.15s, visibility 0.15s, transform 0.15s;
|
||||
z-index: 200;
|
||||
}
|
||||
.wrapper:hover .menu {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.menuItem {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.menuItem:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
.menuItemDesc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
font-weight: 400;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
.btn {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.55rem 1.2rem;
|
||||
}
|
||||
.btnPrimary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -1,989 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
createJob,
|
||||
getModels,
|
||||
getDatasets,
|
||||
uploadImage,
|
||||
type Model,
|
||||
} from "$lib/api";
|
||||
import { getDefaultModelForWorkload } from "$lib/defaultOptions";
|
||||
import { defaultOptions } from "$lib/stores/defaultOptions";
|
||||
import { WORKLOAD_OPTIONS } from "$lib/jobConfig";
|
||||
import type { JobType } from "$lib/types";
|
||||
import Toggle from "./Toggle.svelte";
|
||||
import Slider from "./Slider.svelte";
|
||||
|
||||
let {
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
jobType,
|
||||
workloadType,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
jobType: JobType;
|
||||
workloadType: string;
|
||||
} = $props();
|
||||
|
||||
function getModelWorkloadForTraining(_w: string): string {
|
||||
return "t2v";
|
||||
}
|
||||
|
||||
const isInference = $derived(jobType === "inference");
|
||||
const inferenceWorkload = $derived(
|
||||
isInference ? workloadType : getModelWorkloadForTraining(workloadType),
|
||||
);
|
||||
|
||||
let models = $state<Model[]>([]);
|
||||
let modelId = $state("");
|
||||
let prompt = $state("");
|
||||
let imagePath = $state("");
|
||||
let imageFileName = $state("");
|
||||
let isUploadingImage = $state(false);
|
||||
let negativePrompt = $state("");
|
||||
let numInferenceSteps = $state(50);
|
||||
let numFrames = $state(81);
|
||||
let height = $state(480);
|
||||
let width = $state(832);
|
||||
let guidanceScale = $state(5);
|
||||
let guidanceRescale = $state(0);
|
||||
let fps = $state(24);
|
||||
let seed = $state(1024);
|
||||
let numGpus = $state(1);
|
||||
let ditCpuOffload = $state(false);
|
||||
let textEncoderCpuOffload = $state(false);
|
||||
let vaeCpuOffload = $state(false);
|
||||
let imageEncoderCpuOffload = $state(false);
|
||||
let useFsdpInference = $state(false);
|
||||
let enableTorchCompile = $state(false);
|
||||
let vsaSparsity = $state(0);
|
||||
let tpSize = $state(-1);
|
||||
let spSize = $state(-1);
|
||||
let selectedDatasetId = $state("");
|
||||
let readyDatasets = $state<Awaited<ReturnType<typeof getDatasets>>>([]);
|
||||
let maxTrainSteps = $state(1000);
|
||||
let trainBatchSize = $state(1);
|
||||
let learningRate = $state(5e-5);
|
||||
let numLatentT = $state(20);
|
||||
let selectedValidationDatasetId = $state("");
|
||||
let loraRank = $state(32);
|
||||
let ltx2FirstFrameConditioningP = $state(0.1);
|
||||
let dmdUseVsa = $state(false);
|
||||
let dmdVsaSparsity = $state(0.8);
|
||||
let dmdDenoisingSteps = $state("1000,757,522");
|
||||
let minTimestepRatio = $state(0.02);
|
||||
let maxTimestepRatio = $state(0.98);
|
||||
let realScoreGuidanceScale = $state(3.5);
|
||||
let generatorUpdateInterval = $state(5);
|
||||
let realScoreModelPath = $state("");
|
||||
let fakeScoreModelPath = $state("");
|
||||
let isSubmitting = $state(false);
|
||||
let isLoadingModels = $state(false);
|
||||
let imageInputEl: HTMLInputElement;
|
||||
|
||||
$effect(() => {
|
||||
if (!isOpen) return;
|
||||
const opts = $defaultOptions;
|
||||
numInferenceSteps = opts.numInferenceSteps;
|
||||
numFrames = workloadType === "t2i" ? 1 : opts.numFrames;
|
||||
height = opts.height;
|
||||
width = opts.width;
|
||||
guidanceScale = opts.guidanceScale;
|
||||
guidanceRescale = opts.guidanceRescale;
|
||||
fps = opts.fps;
|
||||
seed = opts.seed;
|
||||
numGpus = opts.numGpus;
|
||||
ditCpuOffload = opts.ditCpuOffload;
|
||||
textEncoderCpuOffload = opts.textEncoderCpuOffload;
|
||||
vaeCpuOffload = opts.vaeCpuOffload;
|
||||
imageEncoderCpuOffload = opts.imageEncoderCpuOffload;
|
||||
useFsdpInference = opts.useFsdpInference;
|
||||
enableTorchCompile = opts.enableTorchCompile;
|
||||
vsaSparsity = opts.vsaSparsity;
|
||||
tpSize = opts.tpSize;
|
||||
spSize = opts.spSize;
|
||||
modelId = getDefaultModelForWorkload(
|
||||
opts,
|
||||
inferenceWorkload as "t2v" | "i2v" | "t2i",
|
||||
);
|
||||
imagePath = "";
|
||||
imageFileName = "";
|
||||
selectedDatasetId = "";
|
||||
selectedValidationDatasetId = "";
|
||||
if (workloadType === "dmd_t2v") {
|
||||
dmdUseVsa = false;
|
||||
dmdVsaSparsity = 0.8;
|
||||
dmdDenoisingSteps = "1000,757,522";
|
||||
minTimestepRatio = 0.02;
|
||||
maxTimestepRatio = 0.98;
|
||||
realScoreGuidanceScale = 3.5;
|
||||
generatorUpdateInterval = 5;
|
||||
realScoreModelPath = "";
|
||||
fakeScoreModelPath = "";
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!isOpen) return;
|
||||
isLoadingModels = true;
|
||||
const filter = isInference ? workloadType : getModelWorkloadForTraining(workloadType);
|
||||
getModels(filter)
|
||||
.then((list) => {
|
||||
models = list;
|
||||
const ids = list.map((m) => m.id);
|
||||
const opts = $defaultOptions;
|
||||
const defaultId = getDefaultModelForWorkload(
|
||||
opts,
|
||||
filter as "t2v" | "i2v" | "t2i",
|
||||
);
|
||||
modelId = ids.includes(defaultId) ? defaultId : list[0]?.id ?? "";
|
||||
if (workloadType === "dmd_t2v") {
|
||||
const mid = ids.includes(defaultId) ? defaultId : list[0]?.id ?? "";
|
||||
realScoreModelPath = mid;
|
||||
fakeScoreModelPath = mid;
|
||||
}
|
||||
})
|
||||
.catch((e) => console.error("Failed to load models:", e))
|
||||
.finally(() => (isLoadingModels = false));
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen && !isInference) {
|
||||
getDatasets()
|
||||
.then(setReadyDatasets)
|
||||
.catch(() => (readyDatasets = []));
|
||||
} else {
|
||||
readyDatasets = [];
|
||||
}
|
||||
});
|
||||
|
||||
function setReadyDatasets(list: Awaited<ReturnType<typeof getDatasets>>) {
|
||||
readyDatasets = list;
|
||||
}
|
||||
|
||||
async function handleImageChange(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) {
|
||||
imagePath = "";
|
||||
imageFileName = "";
|
||||
return;
|
||||
}
|
||||
isUploadingImage = true;
|
||||
imageFileName = file.name;
|
||||
try {
|
||||
const { path } = await uploadImage(file);
|
||||
imagePath = path;
|
||||
} catch (err) {
|
||||
imagePath = "";
|
||||
imageFileName = "";
|
||||
} finally {
|
||||
isUploadingImage = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearImage() {
|
||||
imagePath = "";
|
||||
imageFileName = "";
|
||||
if (imageInputEl) imageInputEl.value = "";
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (isInference && workloadType === "i2v" && !imagePath) return;
|
||||
const effectiveDataPath = selectedDatasetId
|
||||
? readyDatasets.find((d) => d.id === selectedDatasetId)?.name ?? ""
|
||||
: "";
|
||||
if (!isInference && !selectedDatasetId) return;
|
||||
const effectiveJobType: JobType =
|
||||
workloadType === "lora_t2v" ? "lora" : jobType;
|
||||
isSubmitting = true;
|
||||
try {
|
||||
const basePayload = {
|
||||
model_id: modelId,
|
||||
prompt,
|
||||
workload_type: workloadType,
|
||||
job_type: effectiveJobType,
|
||||
...(isInference
|
||||
? {}
|
||||
: {
|
||||
data_path: effectiveDataPath.trim(),
|
||||
max_train_steps: maxTrainSteps,
|
||||
train_batch_size: trainBatchSize,
|
||||
learning_rate: learningRate,
|
||||
num_latent_t: numLatentT,
|
||||
validation_dataset_file: selectedValidationDatasetId
|
||||
? (readyDatasets.find(
|
||||
(d) => d.id === selectedValidationDatasetId,
|
||||
)?.name ?? "") || undefined
|
||||
: undefined,
|
||||
lora_rank: loraRank,
|
||||
...(isLtx2Model
|
||||
? { ltx2_first_frame_conditioning_p: ltx2FirstFrameConditioningP }
|
||||
: {}),
|
||||
...(workloadType === "dmd_t2v"
|
||||
? {
|
||||
dmd_use_vsa: dmdUseVsa,
|
||||
dmd_vsa_sparsity: dmdVsaSparsity,
|
||||
dmd_denoising_steps: dmdDenoisingSteps,
|
||||
min_timestep_ratio: minTimestepRatio,
|
||||
max_timestep_ratio: maxTimestepRatio,
|
||||
real_score_guidance_scale: realScoreGuidanceScale,
|
||||
generator_update_interval: generatorUpdateInterval,
|
||||
real_score_model_path: realScoreModelPath || modelId,
|
||||
fake_score_model_path: fakeScoreModelPath || modelId,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
};
|
||||
const inferencePayload = isInference
|
||||
? {
|
||||
...(workloadType === "i2v" && imagePath ? { image_path: imagePath } : {}),
|
||||
negative_prompt: negativePrompt,
|
||||
num_inference_steps: numInferenceSteps,
|
||||
num_frames: numFrames,
|
||||
height,
|
||||
width,
|
||||
guidance_scale: guidanceScale,
|
||||
guidance_rescale: guidanceRescale,
|
||||
fps,
|
||||
seed,
|
||||
num_gpus: numGpus,
|
||||
dit_cpu_offload: ditCpuOffload,
|
||||
text_encoder_cpu_offload: textEncoderCpuOffload,
|
||||
vae_cpu_offload: vaeCpuOffload,
|
||||
image_encoder_cpu_offload: imageEncoderCpuOffload,
|
||||
use_fsdp_inference: useFsdpInference,
|
||||
enable_torch_compile: enableTorchCompile,
|
||||
vsa_sparsity: vsaSparsity,
|
||||
tp_size: tpSize,
|
||||
sp_size: spSize,
|
||||
}
|
||||
: {};
|
||||
await createJob({ ...basePayload, ...inferencePayload });
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to create job:", err);
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
if (isSubmitting) return;
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleBackdropClick() {
|
||||
handleClose();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!isOpen) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && !isSubmitting) handleClose();
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
});
|
||||
|
||||
const isLtx2Model = $derived(
|
||||
(modelId || "").toLowerCase().includes("ltx2") ||
|
||||
(modelId || "").toLowerCase().includes("ltx-2"),
|
||||
);
|
||||
|
||||
const workloadLabel = $derived(
|
||||
WORKLOAD_OPTIONS[jobType]?.find((o) => o.type === workloadType)?.label ?? "",
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="modal" data-modal>
|
||||
<div class="modalBackdrop" onclick={handleBackdropClick} role="presentation"></div>
|
||||
<div class="modalContent modalForm">
|
||||
<button
|
||||
class="modalClose"
|
||||
onclick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div class="card">
|
||||
<h2>
|
||||
New {jobType.charAt(0).toUpperCase() + jobType.slice(1)} Job
|
||||
{workloadLabel ? ` (${workloadLabel})` : ""}
|
||||
</h2>
|
||||
<form onsubmit={handleSubmit} autocomplete="off">
|
||||
<div class="formRow">
|
||||
<label for="modal-modelId">Model</label>
|
||||
<select
|
||||
id="modal-modelId"
|
||||
bind:value={modelId}
|
||||
required
|
||||
disabled={isSubmitting || isLoadingModels}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{isLoadingModels
|
||||
? "Loading models…"
|
||||
: models.length === 0
|
||||
? "No models available for this workload"
|
||||
: "Select a model…"}
|
||||
</option>
|
||||
{#each models as model}
|
||||
<option value={model.id}>{model.label} ({model.id})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{#if isInference && workloadType === "i2v"}
|
||||
<div class="formRow">
|
||||
<label for="modal-image">Image</label>
|
||||
<input
|
||||
bind:this={imageInputEl}
|
||||
id="modal-image"
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.webp,.bmp"
|
||||
onchange={handleImageChange}
|
||||
disabled={isSubmitting || isUploadingImage}
|
||||
required
|
||||
/>
|
||||
{#if imageFileName}
|
||||
<span class="helperText">
|
||||
{isUploadingImage ? "Uploading…" : imageFileName} ·
|
||||
<button
|
||||
type="button"
|
||||
class="clearLink"
|
||||
onclick={clearImage}
|
||||
disabled={isSubmitting || isUploadingImage}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="formRow">
|
||||
<label for="modal-prompt">{isInference ? "Prompt" : "Description"}</label>
|
||||
<textarea
|
||||
id="modal-prompt"
|
||||
bind:value={prompt}
|
||||
rows={isInference ? 3 : 2}
|
||||
placeholder={
|
||||
isInference
|
||||
? "A curious raccoon peers through a vibrant field of yellow sunflowers…"
|
||||
: "Brief description of this training job…"
|
||||
}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
></textarea>
|
||||
</div>
|
||||
{#if isInference}
|
||||
<div class="formRow">
|
||||
<label for="modal-negative-prompt">Negative Prompt</label>
|
||||
<textarea
|
||||
id="modal-negative-prompt"
|
||||
bind:value={negativePrompt}
|
||||
rows={2}
|
||||
placeholder="Optional: things to avoid in the output…"
|
||||
disabled={isSubmitting}
|
||||
></textarea>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !isInference}
|
||||
<div class="datasetRow">
|
||||
<div class="formRow">
|
||||
<label for="modal-dataset">Dataset *</label>
|
||||
<select
|
||||
id="modal-dataset"
|
||||
bind:value={selectedDatasetId}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{readyDatasets.length === 0
|
||||
? "No datasets (add in Datasets tab)"
|
||||
: "Select a dataset…"}
|
||||
</option>
|
||||
{#each readyDatasets as d}
|
||||
<option value={d.id}>{d.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="modal-validation-dataset"
|
||||
>Validation Dataset (optional)</label
|
||||
>
|
||||
<select
|
||||
id="modal-validation-dataset"
|
||||
bind:value={selectedValidationDatasetId}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{#each readyDatasets as d}
|
||||
<option value={d.id}>{d.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<details class="optionsSection">
|
||||
<summary>Options</summary>
|
||||
<div class="settingsGrid">
|
||||
<div class="formRow">
|
||||
<label for="modal-max-train-steps">Max Train Steps</label>
|
||||
<Slider
|
||||
id="modal-max-train-steps"
|
||||
min={100}
|
||||
max={50000}
|
||||
step={100}
|
||||
value={maxTrainSteps}
|
||||
onChange={(v) => (maxTrainSteps = v)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="modal-train-batch-size">Train Batch Size</label>
|
||||
<Slider
|
||||
id="modal-train-batch-size"
|
||||
min={1}
|
||||
max={8}
|
||||
step={1}
|
||||
value={trainBatchSize}
|
||||
onChange={(v) => (trainBatchSize = v)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="modal-learning-rate">Learning Rate</label>
|
||||
<input
|
||||
id="modal-learning-rate"
|
||||
type="number"
|
||||
step="1e-6"
|
||||
min={1e-6}
|
||||
max={1}
|
||||
bind:value={learningRate}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="modal-num-latent-t">Num Latent T</label>
|
||||
<Slider
|
||||
id="modal-num-latent-t"
|
||||
min={8}
|
||||
max={40}
|
||||
step={1}
|
||||
value={numLatentT}
|
||||
onChange={(v) => (numLatentT = v)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
{#if workloadType === "lora_t2v"}
|
||||
<div class="formRow">
|
||||
<label for="modal-lora-rank">LoRA Rank</label>
|
||||
<Slider
|
||||
id="modal-lora-rank"
|
||||
min={8}
|
||||
max={128}
|
||||
step={8}
|
||||
value={loraRank}
|
||||
onChange={(v) => (loraRank = v)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if isLtx2Model}
|
||||
<div class="formRow">
|
||||
<label for="modal-ltx2-first-frame" title="Probability of conditioning on the first frame during LTX-2 training">First Frame Conditioning</label>
|
||||
<Slider
|
||||
id="modal-ltx2-first-frame"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={ltx2FirstFrameConditioningP}
|
||||
onChange={(v) => (ltx2FirstFrameConditioningP = v)}
|
||||
disabled={isSubmitting}
|
||||
formatValue={(v) => v.toFixed(2)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if workloadType === "dmd_t2v"}
|
||||
<div class="formRow">
|
||||
<label for="modal-dmd-use-vsa" title="Use Video Sparse Attention for DMD">Video Sparse Attention (VSA)</label>
|
||||
<Toggle
|
||||
id="modal-dmd-use-vsa"
|
||||
checked={dmdUseVsa}
|
||||
onChange={(v) => (dmdUseVsa = v)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
{#if dmdUseVsa}
|
||||
<div class="formRow">
|
||||
<label for="modal-dmd-vsa-sparsity" title="VSA sparsity (0–1)">VSA Sparsity</label>
|
||||
<Slider
|
||||
id="modal-dmd-vsa-sparsity"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={dmdVsaSparsity}
|
||||
onChange={(v) => (dmdVsaSparsity = v)}
|
||||
disabled={isSubmitting}
|
||||
formatValue={(v) => v.toFixed(2)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="formRow">
|
||||
<label for="modal-dmd-denoising-steps" title="Comma-separated denoising steps, e.g. 1000,757,522">DMD Denoising Steps</label>
|
||||
<input
|
||||
id="modal-dmd-denoising-steps"
|
||||
type="text"
|
||||
bind:value={dmdDenoisingSteps}
|
||||
placeholder="1000,757,522"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="modal-min-timestep-ratio">Min Timestep Ratio</label>
|
||||
<Slider
|
||||
id="modal-min-timestep-ratio"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={minTimestepRatio}
|
||||
onChange={(v) => (minTimestepRatio = v)}
|
||||
disabled={isSubmitting}
|
||||
formatValue={(v) => v.toFixed(2)}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="modal-max-timestep-ratio">Max Timestep Ratio</label>
|
||||
<Slider
|
||||
id="modal-max-timestep-ratio"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={maxTimestepRatio}
|
||||
onChange={(v) => (maxTimestepRatio = v)}
|
||||
disabled={isSubmitting}
|
||||
formatValue={(v) => v.toFixed(2)}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="modal-real-score-guidance-scale">Real Score Guidance Scale</label>
|
||||
<Slider
|
||||
id="modal-real-score-guidance-scale"
|
||||
min={1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={realScoreGuidanceScale}
|
||||
onChange={(v) => (realScoreGuidanceScale = v)}
|
||||
disabled={isSubmitting}
|
||||
formatValue={(v) => v.toFixed(1)}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="modal-generator-update-interval">Generator Update Interval</label>
|
||||
<Slider
|
||||
id="modal-generator-update-interval"
|
||||
min={1}
|
||||
max={20}
|
||||
step={1}
|
||||
value={generatorUpdateInterval}
|
||||
onChange={(v) => (generatorUpdateInterval = v)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="modal-real-score-model">Real Score Model</label>
|
||||
<select
|
||||
id="modal-real-score-model"
|
||||
bind:value={realScoreModelPath}
|
||||
disabled={isSubmitting || isLoadingModels}
|
||||
>
|
||||
<option value="">Same as main model</option>
|
||||
{#each models as model}
|
||||
<option value={model.id}>{model.label} ({model.id})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="modal-fake-score-model">Fake Score Model</label>
|
||||
<select
|
||||
id="modal-fake-score-model"
|
||||
bind:value={fakeScoreModelPath}
|
||||
disabled={isSubmitting || isLoadingModels}
|
||||
>
|
||||
<option value="">Same as main model</option>
|
||||
{#each models as model}
|
||||
<option value={model.id}>{model.label} ({model.id})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
|
||||
{#if isInference}
|
||||
<details class="optionsSection">
|
||||
<summary>Options</summary>
|
||||
<div class="settingsGrid">
|
||||
{#if workloadType !== "t2i"}
|
||||
<div class="formRow">
|
||||
<label for="modal-num-frames">Frames</label>
|
||||
<Slider
|
||||
id="modal-num-frames"
|
||||
min={1}
|
||||
max={500}
|
||||
step={1}
|
||||
value={numFrames}
|
||||
onChange={(v) => (numFrames = v)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="formRow">
|
||||
<label for="modal-height">Height</label>
|
||||
<Slider
|
||||
id="modal-height"
|
||||
min={64}
|
||||
max={1080}
|
||||
step={16}
|
||||
value={height}
|
||||
onChange={(v) => (height = v)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="modal-width">Width</label>
|
||||
<Slider
|
||||
id="modal-width"
|
||||
min={64}
|
||||
max={1920}
|
||||
step={16}
|
||||
value={width}
|
||||
onChange={(v) => (width = v)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="modal-num-steps">Inference Steps</label>
|
||||
<Slider
|
||||
id="modal-num-steps"
|
||||
min={1}
|
||||
max={200}
|
||||
step={1}
|
||||
value={numInferenceSteps}
|
||||
onChange={(v) => (numInferenceSteps = v)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="modal-vsa-sparsity" title="VSA sparsity (0–1)">VSA Sparsity</label>
|
||||
<Slider
|
||||
id="modal-vsa-sparsity"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={vsaSparsity}
|
||||
onChange={(v) => (vsaSparsity = v)}
|
||||
disabled={isSubmitting}
|
||||
formatValue={(v) => v.toFixed(2)}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="modal-guidance">Guidance Scale</label>
|
||||
<Slider
|
||||
id="modal-guidance"
|
||||
min={0}
|
||||
max={20}
|
||||
step={0.1}
|
||||
value={guidanceScale}
|
||||
onChange={(v) => (guidanceScale = v)}
|
||||
disabled={isSubmitting}
|
||||
formatValue={(v) => v.toFixed(1)}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="modal-guidance-rescale" title="0 = disabled">Guidance Rescale</label>
|
||||
<Slider
|
||||
id="modal-guidance-rescale"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={guidanceRescale}
|
||||
onChange={(v) => (guidanceRescale = v)}
|
||||
disabled={isSubmitting}
|
||||
formatValue={(v) => v.toFixed(2)}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="modal-tp-size" title="-1 = auto">TP Size</label>
|
||||
<Slider
|
||||
id="modal-tp-size"
|
||||
min={-1}
|
||||
max={8}
|
||||
step={1}
|
||||
value={tpSize}
|
||||
onChange={(v) => (tpSize = v)}
|
||||
disabled={isSubmitting}
|
||||
formatValue={(v) => (v === -1 ? "Auto" : String(v))}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="modal-sp-size" title="-1 = auto">SP Size</label>
|
||||
<Slider
|
||||
id="modal-sp-size"
|
||||
min={-1}
|
||||
max={8}
|
||||
step={1}
|
||||
value={spSize}
|
||||
onChange={(v) => (spSize = v)}
|
||||
disabled={isSubmitting}
|
||||
formatValue={(v) => (v === -1 ? "Auto" : String(v))}
|
||||
/>
|
||||
</div>
|
||||
{#if workloadType !== "t2i"}
|
||||
<div class="formRow">
|
||||
<label for="modal-fps">FPS</label>
|
||||
<Slider
|
||||
id="modal-fps"
|
||||
min={1}
|
||||
max={60}
|
||||
step={1}
|
||||
value={fps}
|
||||
onChange={(v) => (fps = v)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="formRow">
|
||||
<label for="modal-dit-cpu-offload">DiT CPU Offload</label>
|
||||
<Toggle
|
||||
id="modal-dit-cpu-offload"
|
||||
checked={ditCpuOffload}
|
||||
onChange={(v) => (ditCpuOffload = v)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="modal-text-encoder-cpu-offload">Text Encoder CPU Offload</label>
|
||||
<Toggle
|
||||
id="modal-text-encoder-cpu-offload"
|
||||
checked={textEncoderCpuOffload}
|
||||
onChange={(v) => (textEncoderCpuOffload = v)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="modal-use-fsdp-inference">Use FSDP Inference</label>
|
||||
<Toggle
|
||||
id="modal-use-fsdp-inference"
|
||||
checked={useFsdpInference}
|
||||
onChange={(v) => (useFsdpInference = v)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="modal-vae-cpu-offload">VAE CPU Offload</label>
|
||||
<Toggle
|
||||
id="modal-vae-cpu-offload"
|
||||
checked={vaeCpuOffload}
|
||||
onChange={(v) => (vaeCpuOffload = v)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="modal-image-encoder-cpu-offload">Image Encoder CPU Offload</label>
|
||||
<Toggle
|
||||
id="modal-image-encoder-cpu-offload"
|
||||
checked={imageEncoderCpuOffload}
|
||||
onChange={(v) => (imageEncoderCpuOffload = v)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="modal-enable-torch-compile">Torch Compile</label>
|
||||
<Toggle
|
||||
id="modal-enable-torch-compile"
|
||||
checked={enableTorchCompile}
|
||||
onChange={(v) => (enableTorchCompile = v)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="modal-num-gpus">GPUs</label>
|
||||
<Slider
|
||||
id="modal-num-gpus"
|
||||
min={1}
|
||||
max={8}
|
||||
step={1}
|
||||
value={numGpus}
|
||||
onChange={(v) => (numGpus = v)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="modal-seed">Seed</label>
|
||||
<input
|
||||
id="modal-seed"
|
||||
type="number"
|
||||
bind:value={seed}
|
||||
min={0}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
|
||||
<button type="submit" class="btn btnPrimary" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Creating..." : "Create Job"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: modal-fade-in 0.2s ease-out;
|
||||
}
|
||||
.modalBackdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
.modalContent {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
.modalForm {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0;
|
||||
max-width: 850px;
|
||||
width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modalClose {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.75rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 1.6rem;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
line-height: 1;
|
||||
}
|
||||
.modalClose:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.modalClose:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.card {
|
||||
padding: 1.5rem;
|
||||
margin: 0;
|
||||
border: none;
|
||||
}
|
||||
.card h2 {
|
||||
font-size: 1.15rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.formRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.formRow label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
padding-left: 3px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.datasetRow {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.datasetRow .formRow {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.helperText {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
.clearLink {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--accent-h);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.clearLink:hover:not(:disabled) {
|
||||
color: var(--accent);
|
||||
}
|
||||
.optionsSection {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.optionsSection summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--accent-h);
|
||||
user-select: none;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.settingsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 0.5rem 0.75rem;
|
||||
}
|
||||
.btn {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.55rem 1.2rem;
|
||||
}
|
||||
.btnPrimary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@keyframes modal-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,133 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Dataset } from "$lib/api";
|
||||
import { deleteDataset } from "$lib/api";
|
||||
import { activeDatasetId, setActiveDatasetId } from "$lib/stores/activeDataset";
|
||||
|
||||
let { dataset, onUpdated, onSelect = () => {} }: {
|
||||
dataset: Dataset;
|
||||
onUpdated: () => void;
|
||||
onSelect?: () => void;
|
||||
} = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
|
||||
const isSelected = $derived($activeDatasetId === dataset.id);
|
||||
|
||||
const fileCount = $derived(dataset.file_count ?? 0);
|
||||
const sizeBytes = $derived(dataset.size_bytes ?? 0);
|
||||
const sizeLabel = $derived(
|
||||
sizeBytes < 1024
|
||||
? `${sizeBytes} B`
|
||||
: sizeBytes < 1024 * 1024
|
||||
? `${(sizeBytes / 1024).toFixed(1)} KB`
|
||||
: sizeBytes < 1024 * 1024 * 1024
|
||||
? `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
: `${(sizeBytes / (1024 * 1024 * 1024)).toFixed(1)} GB`,
|
||||
);
|
||||
|
||||
async function handleDelete(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isLoading) return;
|
||||
if (!confirm(`Delete dataset "${dataset.name}"?`)) return;
|
||||
isLoading = true;
|
||||
try {
|
||||
await deleteDataset(dataset.id);
|
||||
if ($activeDatasetId === dataset.id) setActiveDatasetId(null);
|
||||
onUpdated();
|
||||
} catch (err) {
|
||||
alert(
|
||||
err instanceof Error ? err.message : "Failed to delete dataset",
|
||||
);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="jobCard"
|
||||
class:jobCardSelected={isSelected}
|
||||
onclick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) return;
|
||||
onSelect();
|
||||
}}
|
||||
onkeydown={handleKeyDown}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="jobHeader">
|
||||
<span class="jobModel">{dataset.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btnDelete btnSmall"
|
||||
onclick={handleDelete}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<div class="jobPrompt">
|
||||
{fileCount} {fileCount === 1 ? "file" : "files"} · {sizeLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.jobCard {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.15rem;
|
||||
margin-bottom: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.jobCardSelected {
|
||||
border-color: var(--accent);
|
||||
background: rgba(99, 102, 241, 0.06);
|
||||
}
|
||||
.jobHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.jobModel {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.jobPrompt {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.btn {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.55rem 1.2rem;
|
||||
}
|
||||
.btnSmall {
|
||||
background: transparent;
|
||||
color: var(--accent-h);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.3rem 0.7rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.btnDelete {
|
||||
background: var(--red);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -1,320 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Dataset } from "$lib/api";
|
||||
import {
|
||||
getDatasetFiles,
|
||||
updateDatasetCaption,
|
||||
getDatasetMediaUrl,
|
||||
} from "$lib/api";
|
||||
import DownloadCaptions from "./DownloadCaptions.svelte";
|
||||
import { resizable } from "$lib/actions/resizable";
|
||||
|
||||
let { dataset, onClose, onWidthChange }: {
|
||||
dataset: Dataset;
|
||||
onClose: () => void;
|
||||
onWidthChange?: (w: number) => void;
|
||||
} = $props();
|
||||
|
||||
const SIDEBAR_MIN_WIDTH = 320;
|
||||
const SIDEBAR_MAX_WIDTH = 900;
|
||||
const INITIAL_PAGE_SIZE = 24;
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
let width = $state(400);
|
||||
let isDragging = $state(false);
|
||||
let fileNames = $state<string[]>([]);
|
||||
let captions = $state<Record<string, string>>({});
|
||||
let visibleCount = $state(INITIAL_PAGE_SIZE);
|
||||
let isLoading = $state(true);
|
||||
let thumbLoaded = $state<Record<string, boolean>>({});
|
||||
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let scrollEl: HTMLDivElement;
|
||||
|
||||
$effect(() => {
|
||||
onWidthChange?.(width);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
let cancelled = false;
|
||||
isLoading = true;
|
||||
getDatasetFiles(dataset.id)
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
fileNames = data.file_names;
|
||||
captions = data.captions;
|
||||
visibleCount = INITIAL_PAGE_SIZE;
|
||||
thumbLoaded = {};
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error("Failed to load dataset files:", err))
|
||||
.finally(() => {
|
||||
if (!cancelled) isLoading = false;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
function handleCaptionChange(fileName: string, value: string) {
|
||||
captions = { ...captions, [fileName]: value };
|
||||
if (saveTimeout) clearTimeout(saveTimeout);
|
||||
saveTimeout = setTimeout(() => {
|
||||
saveTimeout = null;
|
||||
updateDatasetCaption(dataset.id, fileName, value).catch((err) =>
|
||||
console.error("Failed to save caption:", err),
|
||||
);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function handleLoadMore() {
|
||||
visibleCount = Math.min(visibleCount + PAGE_SIZE, fileNames.length);
|
||||
}
|
||||
|
||||
const resizableOpts = $derived({
|
||||
edge: "right" as const,
|
||||
minWidth: SIDEBAR_MIN_WIDTH,
|
||||
maxWidth: SIDEBAR_MAX_WIDTH,
|
||||
getWidth: () => width,
|
||||
onWidth: (w: number) => (width = w),
|
||||
onDragChange: (dragging: boolean) => (isDragging = dragging),
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
if (saveTimeout) clearTimeout(saveTimeout);
|
||||
};
|
||||
});
|
||||
|
||||
const visibleFiles = $derived(fileNames.slice(0, visibleCount));
|
||||
const hasMore = $derived(visibleCount < fileNames.length);
|
||||
|
||||
function handleScroll() {
|
||||
if (!hasMore || isLoading || !scrollEl) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollEl;
|
||||
const distanceFromBottom = scrollHeight - (scrollTop + clientHeight);
|
||||
if (distanceFromBottom < 200) {
|
||||
visibleCount = Math.min(visibleCount + PAGE_SIZE, fileNames.length);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="sidebar" style="width: {width}px; max-width: {SIDEBAR_MAX_WIDTH}px">
|
||||
<div class="header">
|
||||
<h2 class="title">{dataset.name}</h2>
|
||||
<div class="headerActions">
|
||||
<DownloadCaptions {fileNames} {captions} />
|
||||
<button
|
||||
type="button"
|
||||
class="closeBtn"
|
||||
onclick={onClose}
|
||||
title="Close"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gallerySection">
|
||||
<div
|
||||
class="galleryScroll"
|
||||
bind:this={scrollEl}
|
||||
onscroll={handleScroll}
|
||||
>
|
||||
{#if isLoading}
|
||||
<p class="galleryEmpty">Loading…</p>
|
||||
{:else if fileNames.length === 0}
|
||||
<p class="galleryEmpty">No media files</p>
|
||||
{:else}
|
||||
<div class="galleryGrid">
|
||||
{#each visibleFiles as fileName}
|
||||
<div class="galleryItem">
|
||||
{#if !thumbLoaded[fileName]}
|
||||
<div class="thumbLoading">
|
||||
<div class="thumbSpinner"></div>
|
||||
</div>
|
||||
{/if}
|
||||
<video
|
||||
src={getDatasetMediaUrl(dataset.id, fileName)}
|
||||
class="galleryThumb"
|
||||
muted
|
||||
autoplay
|
||||
loop
|
||||
playsinline
|
||||
onloadeddata={() => {
|
||||
thumbLoaded = { ...thumbLoaded, [fileName]: true };
|
||||
}}
|
||||
onerror={() => {
|
||||
thumbLoaded = { ...thumbLoaded, [fileName]: true };
|
||||
}}
|
||||
></video>
|
||||
<div class="galleryCaption">
|
||||
<textarea
|
||||
value={captions[fileName] ?? ""}
|
||||
oninput={(e) =>
|
||||
handleCaptionChange(
|
||||
fileName,
|
||||
(e.target as HTMLTextAreaElement).value,
|
||||
)}
|
||||
placeholder="Caption"
|
||||
rows={2}
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="resizeHandle"
|
||||
class:resizeHandleActive={isDragging}
|
||||
use:resizable={resizableOpts}
|
||||
role="presentation"
|
||||
></div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: var(--header-height);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - var(--header-height));
|
||||
min-width: 320px;
|
||||
background: var(--surface);
|
||||
border-left: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
.resizeHandle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 6px;
|
||||
bottom: 0;
|
||||
cursor: col-resize;
|
||||
z-index: 1;
|
||||
}
|
||||
.resizeHandle:hover,
|
||||
.resizeHandleActive {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.closeBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.4rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.closeBtn:hover {
|
||||
color: var(--text);
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
.closeBtn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.gallerySection {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.galleryScroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.galleryGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.galleryItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.galleryThumb {
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
background: var(--border);
|
||||
}
|
||||
.galleryCaption {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.galleryCaption textarea {
|
||||
width: 100%;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
resize: vertical;
|
||||
}
|
||||
.thumbLoading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: radial-gradient(
|
||||
circle at center,
|
||||
rgba(15, 17, 23, 0.6),
|
||||
rgba(15, 17, 23, 0.9)
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
.thumbSpinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid rgba(148, 163, 184, 0.4);
|
||||
border-top-color: var(--accent);
|
||||
animation: thumb-spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes thumb-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.galleryEmpty {
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,171 +0,0 @@
|
||||
<script lang="ts">
|
||||
let { fileNames = [], captions = {} }: {
|
||||
fileNames?: string[];
|
||||
captions?: Record<string, string>;
|
||||
} = $props();
|
||||
|
||||
function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const sortedNames = $derived([...fileNames].sort());
|
||||
const disabled = $derived(fileNames.length === 0);
|
||||
|
||||
function handleDownloadJson() {
|
||||
const data = sortedNames.map((path) => ({
|
||||
path,
|
||||
cap: captions[path] ?? "",
|
||||
}));
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
downloadBlob(blob, "videos2caption.json");
|
||||
}
|
||||
|
||||
function handleDownloadTxt() {
|
||||
const videosContent = sortedNames.join("\n");
|
||||
const promptContent = sortedNames.map((fn) => captions[fn] ?? "").join("\n");
|
||||
downloadBlob(new Blob([videosContent], { type: "text/plain" }), "videos.txt");
|
||||
setTimeout(() => {
|
||||
downloadBlob(
|
||||
new Blob([promptContent], { type: "text/plain" }),
|
||||
"captions.txt",
|
||||
);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function handleDownloadCsv() {
|
||||
const escape = (s: string) =>
|
||||
s.includes('"') || s.includes(",") || s.includes("\n")
|
||||
? `"${s.replace(/"/g, '""')}"`
|
||||
: s;
|
||||
const rows = sortedNames.map(
|
||||
(fn) => `${escape(fn)},${escape(captions[fn] ?? "")}`,
|
||||
);
|
||||
const header = "video_name,caption";
|
||||
const csv = [header, ...rows].join("\n");
|
||||
downloadBlob(new Blob([csv], { type: "text/csv" }), "captions.csv");
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="wrapper">
|
||||
<button type="button" class="btn btnSmall trigger" disabled={disabled}>
|
||||
Download Captions
|
||||
</button>
|
||||
<div class="menu" role="menu">
|
||||
<button
|
||||
class="menuItem"
|
||||
role="menuitem"
|
||||
onclick={handleDownloadJson}
|
||||
disabled={disabled}
|
||||
>
|
||||
JSON
|
||||
</button>
|
||||
<button
|
||||
class="menuItem"
|
||||
role="menuitem"
|
||||
onclick={handleDownloadTxt}
|
||||
disabled={disabled}
|
||||
>
|
||||
TXT
|
||||
</button>
|
||||
<button
|
||||
class="menuItem"
|
||||
role="menuitem"
|
||||
onclick={handleDownloadCsv}
|
||||
disabled={disabled}
|
||||
>
|
||||
CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.trigger::after {
|
||||
content: "";
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 5px solid currentColor;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 0.25rem;
|
||||
min-width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
padding: 0.25rem 0;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-4px);
|
||||
transition: opacity 0.15s, visibility 0.15s, transform 0.15s;
|
||||
z-index: 200;
|
||||
}
|
||||
.wrapper:hover .menu {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.menuItem {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.menuItem:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
.menuItem:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.55rem 1.2rem;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btnSmall {
|
||||
background: transparent;
|
||||
color: var(--accent-h);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.3rem 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,72 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/stores";
|
||||
import { headerActions } from "$lib/stores/headerActions";
|
||||
import type { HeaderAction } from "$lib/stores/headerActions";
|
||||
|
||||
const TAB_TITLES: Record<string, string> = {
|
||||
"/inference": "Jobs",
|
||||
"/finetuning": "Jobs",
|
||||
"/distillation": "Jobs",
|
||||
"/datasets": "Datasets",
|
||||
"/gallery": "Gallery",
|
||||
"/settings": "Settings",
|
||||
};
|
||||
|
||||
const title = $derived(TAB_TITLES[$page.url.pathname] ?? "FastVideo");
|
||||
|
||||
let actions = $state<HeaderAction[]>([]);
|
||||
$effect(() => {
|
||||
const unsub = headerActions.subscribe((v) => (actions = v));
|
||||
return unsub;
|
||||
});
|
||||
</script>
|
||||
|
||||
<header class="header">
|
||||
<img src="/logo.svg" alt="FastVideo Logo" width="100" height="42" class="logo" />
|
||||
<h1 class="title">{title}</h1>
|
||||
{#if actions.length > 0}
|
||||
<div class="actions">
|
||||
{#each actions as action, i (i)}
|
||||
<svelte:component this={action.component} {...(action.props ?? {})} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
.title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.logo {
|
||||
width: 100px;
|
||||
height: 42px;
|
||||
display: block;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,318 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Job } from "$lib/types";
|
||||
import {
|
||||
startJob,
|
||||
stopJob,
|
||||
deleteJob,
|
||||
downloadJobVideo,
|
||||
} from "$lib/api";
|
||||
import { activeJobId, setActiveJobId } from "$lib/stores/activeJob";
|
||||
|
||||
let { job, onJobUpdated }: {
|
||||
job: Job;
|
||||
onJobUpdated?: () => void;
|
||||
} = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
let currentTime = $state(Date.now());
|
||||
|
||||
const isSelected = $derived($activeJobId === job.id);
|
||||
const badgeClass =
|
||||
"badge" +
|
||||
job.status.charAt(0).toUpperCase() +
|
||||
job.status.slice(1);
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const roundedSeconds = Math.round(seconds);
|
||||
if (roundedSeconds < 60) return `${roundedSeconds}s`;
|
||||
if (roundedSeconds < 3600) {
|
||||
const mins = Math.floor(roundedSeconds / 60);
|
||||
const secs = roundedSeconds % 60;
|
||||
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
|
||||
}
|
||||
const hours = Math.floor(roundedSeconds / 3600);
|
||||
const mins = Math.floor((roundedSeconds % 3600) / 60);
|
||||
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||
}
|
||||
|
||||
const elapsedTime = $derived((() => {
|
||||
if (!job.started_at) return null;
|
||||
const endTime =
|
||||
job.status === "running" ? currentTime : (job.finished_at ?? 0);
|
||||
if (!endTime && job.status !== "running") return null;
|
||||
const startedAtMs =
|
||||
job.started_at < 1e12 ? job.started_at * 1000 : job.started_at;
|
||||
const endTimeMs = endTime < 1e12 ? endTime * 1000 : endTime;
|
||||
const elapsedSeconds = (endTimeMs - startedAtMs) / 1000;
|
||||
if (elapsedSeconds <= 0) return null;
|
||||
return formatDuration(elapsedSeconds);
|
||||
})());
|
||||
|
||||
$effect(() => {
|
||||
if (job.status !== "running" || !job.started_at) return;
|
||||
const interval = setInterval(() => (currentTime = Date.now()), 1000);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
|
||||
async function handleStart(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isLoading || job.status === "running" || job.status === "completed")
|
||||
return;
|
||||
isLoading = true;
|
||||
try {
|
||||
await startJob(job.id);
|
||||
onJobUpdated?.();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Failed to start job");
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isLoading || job.status !== "running") return;
|
||||
isLoading = true;
|
||||
try {
|
||||
await stopJob(job.id);
|
||||
onJobUpdated?.();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Failed to stop job");
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isLoading) return;
|
||||
if (!confirm("Delete this job?")) return;
|
||||
isLoading = true;
|
||||
try {
|
||||
await deleteJob(job.id);
|
||||
onJobUpdated?.();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Failed to delete job");
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectJob(e: MouseEvent) {
|
||||
if ((e.target as HTMLElement).closest("button")) return;
|
||||
setActiveJobId(isSelected ? null : job.id);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSelectJob(e as unknown as MouseEvent);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadVideo(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isLoading || !job.output_path) return;
|
||||
isLoading = true;
|
||||
try {
|
||||
const blob = await downloadJobVideo(job.id);
|
||||
const ext = job.output_path.endsWith(".png") ? "png" : "mp4";
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `job_${job.id}.${ext}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (err) {
|
||||
alert(
|
||||
err instanceof Error ? err.message : "Failed to download video",
|
||||
);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="jobCard"
|
||||
class:jobCardSelected={isSelected}
|
||||
onclick={handleSelectJob}
|
||||
onkeydown={handleKeyDown}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="jobHeader">
|
||||
<span class="jobModel">{job.model_id}</span>
|
||||
<span class="badge {badgeClass}">{job.status}</span>
|
||||
</div>
|
||||
<p class="jobPrompt">{job.prompt}</p>
|
||||
<div class="jobMeta">
|
||||
{#if job.job_type === "inference"}
|
||||
<span>{job.num_frames} frames</span>
|
||||
<span>{job.height}×{job.width}</span>
|
||||
{:else}
|
||||
<span>{job.workload_type?.replace(/_/g, " ") ?? job.job_type}</span>
|
||||
{/if}
|
||||
{#if elapsedTime}
|
||||
<span class="jobDuration">⏱ {elapsedTime}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="jobActions">
|
||||
{#if job.status === "running"}
|
||||
<button class="btn btnStop btnSmall" onclick={handleStop} disabled={isLoading}>
|
||||
Stop
|
||||
</button>
|
||||
{:else if job.status === "failed"}
|
||||
<button class="btn btnStart btnSmall" onclick={handleStart} disabled={isLoading}>
|
||||
Restart
|
||||
</button>
|
||||
{:else if job.status === "pending" || job.status === "stopped"}
|
||||
<button class="btn btnStart btnSmall" onclick={handleStart} disabled={isLoading}>
|
||||
Start
|
||||
</button>
|
||||
{/if}
|
||||
{#if job.status === "completed" && job.output_path && job.job_type === "inference"}
|
||||
<button
|
||||
class="btn btnSmall"
|
||||
onclick={handleDownloadVideo}
|
||||
disabled={isLoading}
|
||||
title="Download video"
|
||||
>
|
||||
Download Video
|
||||
</button>
|
||||
{/if}
|
||||
<button class="btn btnDelete btnSmall" onclick={handleDelete} disabled={isLoading}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.jobCard {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.15rem;
|
||||
margin-bottom: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.jobCard:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.jobCardSelected {
|
||||
border-color: var(--accent);
|
||||
background: rgba(99, 102, 241, 0.06);
|
||||
}
|
||||
.jobHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.jobModel {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.jobPrompt {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
.jobMeta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.jobActions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.jobDuration {
|
||||
/* use default */
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.badgePending {
|
||||
background: var(--border);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.badgeRunning {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: var(--yellow);
|
||||
}
|
||||
.badgeCompleted,
|
||||
.badgeReady {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: var(--green);
|
||||
}
|
||||
.badgeFailed {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: var(--red);
|
||||
}
|
||||
.badgeStopped {
|
||||
background: rgba(156, 163, 175, 0.2);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.badgePreprocessing {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
color: var(--accent);
|
||||
}
|
||||
.btn {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.55rem 1.2rem;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btnSmall {
|
||||
background: transparent;
|
||||
color: var(--accent-h);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.3rem 0.7rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.btnStart {
|
||||
background: var(--green);
|
||||
color: #fff;
|
||||
}
|
||||
.btnStop {
|
||||
background: var(--yellow);
|
||||
color: #000;
|
||||
}
|
||||
.btnDelete {
|
||||
background: var(--red);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -1,111 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { getJobsList } from "$lib/api";
|
||||
import type { JobType } from "$lib/types";
|
||||
import type { Job } from "$lib/types";
|
||||
import JobCard from "./JobCard.svelte";
|
||||
import { activeJobId, setActiveJobId, setActiveJob } from "$lib/stores/activeJob";
|
||||
import { registerRefresh, triggerRefresh } from "$lib/stores/jobsRefresh";
|
||||
|
||||
let {
|
||||
jobType,
|
||||
jobTypesForList,
|
||||
}: { jobType: JobType; jobTypesForList?: JobType[] } = $props();
|
||||
|
||||
let jobs = $state<Job[]>([]);
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const typesToFetch = $derived(jobTypesForList ?? [jobType]);
|
||||
|
||||
const activeJob = $derived(
|
||||
$activeJobId ? jobs.find((j) => j.id === $activeJobId) ?? null : null,
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
setActiveJob(activeJob);
|
||||
// Void active job ID if deleeted
|
||||
if ($activeJobId && !activeJob) setActiveJobId(null);
|
||||
});
|
||||
|
||||
async function fetchJobs() {
|
||||
try {
|
||||
if (typesToFetch.length === 1) {
|
||||
jobs = await getJobsList(typesToFetch[0]);
|
||||
} else {
|
||||
const results = await Promise.all(
|
||||
typesToFetch.map((t) => getJobsList(t)),
|
||||
);
|
||||
jobs = results
|
||||
.flat()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at ?? 0).getTime() -
|
||||
new Date(a.created_at ?? 0).getTime(),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch jobs:", e);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const hasActive = jobs.some(
|
||||
(j) => j.status === "running" || j.status === "pending",
|
||||
);
|
||||
if (hasActive) {
|
||||
interval = setInterval(fetchJobs, 1000);
|
||||
} else if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
return () => {
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
fetchJobs();
|
||||
return registerRefresh(fetchJobs);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (interval) clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
|
||||
<main class="main">
|
||||
<section class="card">
|
||||
<div id="jobs-container">
|
||||
{#if jobs.length === 0}
|
||||
<p class="placeholder">
|
||||
No {typesToFetch.length > 1 ? "jobs" : jobType + " jobs"} yet. Create
|
||||
one above.
|
||||
</p>
|
||||
{:else}
|
||||
{#each jobs as job (job.id)}
|
||||
<JobCard {job} onJobUpdated={fetchJobs} />
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.main {
|
||||
max-width: 850px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem 3rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
.card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.placeholder {
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
padding: 2rem 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,275 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/stores";
|
||||
import { resizable } from "$lib/actions/resizable";
|
||||
|
||||
const SIDEBAR_MIN_WIDTH = 100;
|
||||
const SIDEBAR_MAX_WIDTH = 300;
|
||||
const SIDEBAR_COLLAPSED_WIDTH = 0;
|
||||
const SIDEBAR_COLLAPSED_VISIBLE_WIDTH = 60;
|
||||
|
||||
const JOB_ROUTES = [
|
||||
{ href: "/inference", label: "Inference" },
|
||||
{ href: "/finetuning", label: "Finetuning" },
|
||||
{ href: "/distillation", label: "Distillation" },
|
||||
] as const;
|
||||
|
||||
let { onWidthChange }: { onWidthChange?: (w: number) => void } = $props();
|
||||
|
||||
let width = $state(220);
|
||||
let isCollapsed = $state(false);
|
||||
let isDragging = $state(false);
|
||||
let jobsOpen = $state(true);
|
||||
|
||||
const effectiveWidth = isCollapsed ? SIDEBAR_COLLAPSED_WIDTH : width;
|
||||
const layoutWidth = isCollapsed ? SIDEBAR_COLLAPSED_VISIBLE_WIDTH : width;
|
||||
const pathname = $derived($page.url.pathname);
|
||||
const isJobsActive = $derived(
|
||||
JOB_ROUTES.some((r) => pathname === r.href),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
onWidthChange?.(layoutWidth);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (JOB_ROUTES.some((r) => pathname === r.href)) {
|
||||
jobsOpen = true;
|
||||
}
|
||||
});
|
||||
|
||||
const resizableOpts = $derived({
|
||||
edge: "left" as const,
|
||||
minWidth: SIDEBAR_MIN_WIDTH,
|
||||
maxWidth: SIDEBAR_MAX_WIDTH,
|
||||
getWidth: () => width,
|
||||
onWidth: (w: number) => (width = w),
|
||||
onDragChange: (dragging: boolean) => (isDragging = dragging),
|
||||
});
|
||||
|
||||
function toggleCollapse() {
|
||||
isCollapsed = !isCollapsed;
|
||||
}
|
||||
|
||||
function toggleJobs() {
|
||||
jobsOpen = !jobsOpen;
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class="sidebar"
|
||||
class:collapsed={isCollapsed}
|
||||
style="width: {effectiveWidth}px"
|
||||
>
|
||||
<nav class="tabs">
|
||||
<div class="jobsGroup">
|
||||
<button
|
||||
type="button"
|
||||
class="tab tabTrigger"
|
||||
class:tabActive={isJobsActive}
|
||||
onclick={toggleJobs}
|
||||
aria-expanded={jobsOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<span>Jobs</span>
|
||||
<svg
|
||||
class="chevron"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class:rotated={jobsOpen}
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
{#if jobsOpen}
|
||||
<div class="jobsSubnav">
|
||||
{#each JOB_ROUTES as route}
|
||||
<a
|
||||
href={route.href}
|
||||
class="tab subTab"
|
||||
class:tabActive={pathname === route.href}
|
||||
>
|
||||
{route.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<a href="/datasets" class="tab" class:tabActive={pathname === "/datasets"}>Datasets</a>
|
||||
<a href="/gallery" class="tab" class:tabActive={pathname === "/gallery"}>Gallery</a>
|
||||
<a href="/settings" class="tab" class:tabActive={pathname === "/settings"}>Settings</a>
|
||||
</nav>
|
||||
<div class="collapseFooter">
|
||||
<button
|
||||
type="button"
|
||||
class="collapseBtn"
|
||||
onclick={toggleCollapse}
|
||||
title={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
{#if isCollapsed}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if !isCollapsed}
|
||||
<div
|
||||
class="resizeHandle"
|
||||
class:resizeHandleActive={isDragging}
|
||||
use:resizable={resizableOpts}
|
||||
role="presentation"
|
||||
></div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: var(--header-height);
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - var(--header-height));
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
.resizeHandle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 6px;
|
||||
bottom: 0;
|
||||
cursor: col-resize;
|
||||
z-index: 1;
|
||||
}
|
||||
.resizeHandle:hover,
|
||||
.resizeHandleActive {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
.resizeHandle::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 2px;
|
||||
height: 24px;
|
||||
background: var(--border);
|
||||
border-radius: 1px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.resizeHandle:hover::after,
|
||||
.resizeHandleActive::after {
|
||||
opacity: 1;
|
||||
}
|
||||
.collapseFooter {
|
||||
padding: 0.5rem;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
.collapseBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
font-family: inherit;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.collapseBtn:hover {
|
||||
color: var(--text);
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
.collapseBtn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.collapsed .tabs {
|
||||
display: none;
|
||||
}
|
||||
.collapsed .collapseFooter {
|
||||
border-top: none;
|
||||
top: 0;
|
||||
right: -60px;
|
||||
}
|
||||
.collapsed .collapseBtn {
|
||||
width: auto;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.tab {
|
||||
display: block;
|
||||
padding: 0.65rem 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
.tabActive {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
.jobsGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
.tabTrigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tabTrigger .chevron {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.85;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.tabTrigger .chevron.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.jobsSubnav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-left: 0.5rem;
|
||||
border-left: 2px solid var(--border);
|
||||
margin-left: 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.subTab {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,305 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Job } from "$lib/types";
|
||||
import { getJobLogs, downloadJobLog } from "$lib/api";
|
||||
import { resizable } from "$lib/actions/resizable";
|
||||
|
||||
let { job, onClose, onWidthChange }: {
|
||||
job: Job;
|
||||
onClose: () => void;
|
||||
onWidthChange?: (w: number) => void;
|
||||
} = $props();
|
||||
|
||||
const SIDEBAR_MIN_WIDTH = 280;
|
||||
const SIDEBAR_MAX_WIDTH = 750;
|
||||
|
||||
let width = $state(360);
|
||||
let isDragging = $state(false);
|
||||
let isLoading = $state(false);
|
||||
let logs = $state<string[]>([]);
|
||||
let logAfter = $state(0);
|
||||
let consoleEl: HTMLPreElement;
|
||||
let previousJobId = $state<string | null>(null);
|
||||
let previousStatus = $state<string | null>(null);
|
||||
const stateRef = { logs: [] as string[], logAfter: 0 };
|
||||
let pollingLock = false;
|
||||
|
||||
$effect(() => {
|
||||
onWidthChange?.(width);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const wasTerminal =
|
||||
previousStatus === "failed" ||
|
||||
previousStatus === "stopped" ||
|
||||
previousStatus === "completed";
|
||||
const isRestarting =
|
||||
previousJobId === job.id &&
|
||||
wasTerminal &&
|
||||
(job.status === "pending" || job.status === "running");
|
||||
|
||||
if (previousJobId !== job.id || isRestarting) {
|
||||
logs = [];
|
||||
logAfter = 0;
|
||||
stateRef.logs = [];
|
||||
stateRef.logAfter = 0;
|
||||
}
|
||||
previousJobId = job.id;
|
||||
previousStatus = job.status;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const shouldPoll = job.status === "running" || job.status === "pending";
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let mounted = true;
|
||||
|
||||
async function pollLogs() {
|
||||
if (!mounted || pollingLock) return;
|
||||
pollingLock = true;
|
||||
try {
|
||||
const logData = await getJobLogs(job.id, stateRef.logAfter);
|
||||
if (mounted && logData.lines.length > 0) {
|
||||
stateRef.logs = [...stateRef.logs, ...logData.lines];
|
||||
stateRef.logAfter = logData.total;
|
||||
logs = stateRef.logs;
|
||||
logAfter = stateRef.logAfter;
|
||||
if (consoleEl) consoleEl.scrollTop = consoleEl.scrollHeight;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch logs:", e);
|
||||
} finally {
|
||||
pollingLock = false;
|
||||
}
|
||||
}
|
||||
|
||||
pollLogs();
|
||||
if (shouldPoll) pollInterval = setInterval(pollLogs, 2000);
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (pollInterval) clearInterval(pollInterval);
|
||||
};
|
||||
});
|
||||
|
||||
const resizableOpts = $derived({
|
||||
edge: "right" as const,
|
||||
minWidth: SIDEBAR_MIN_WIDTH,
|
||||
maxWidth: SIDEBAR_MAX_WIDTH,
|
||||
getWidth: () => width,
|
||||
onWidth: (w: number) => (width = w),
|
||||
onDragChange: (dragging: boolean) => (isDragging = dragging),
|
||||
});
|
||||
|
||||
async function handleDownloadLog(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
if (isLoading) return;
|
||||
isLoading = true;
|
||||
try {
|
||||
const blob = await downloadJobLog(job.id);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `job_${job.id}.log`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (err) {
|
||||
console.error("Failed to download log:", err);
|
||||
alert(err instanceof Error ? err.message : "Failed to download log");
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="sidebar" style="width: {width}px; max-width: {SIDEBAR_MAX_WIDTH}px">
|
||||
<div class="header">
|
||||
<h2 class="title">Job Details</h2>
|
||||
<div class="headerActions">
|
||||
<button
|
||||
class="btn btnSmall"
|
||||
onclick={handleDownloadLog}
|
||||
disabled={isLoading || !job.log_file_path}
|
||||
title="Download log file"
|
||||
>
|
||||
Download Log
|
||||
</button>
|
||||
<button type="button" class="closeBtn" onclick={onClose} title="Close">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="consoleSection">
|
||||
<div class="consoleHeader">
|
||||
<span class="consoleTitle">Console Output</span>
|
||||
{#if job.status === "running"}
|
||||
<span class="consoleStatus">● Live</span>
|
||||
{/if}
|
||||
</div>
|
||||
<pre bind:this={consoleEl} class="consoleOutput">{#if logs.length === 0}<span class="consoleEmpty">{job.status === "running" ? "Waiting for logs..." : "No logs available"}</span>{:else}{logs.join("\n")}{/if}</pre>
|
||||
</div>
|
||||
<div
|
||||
class="resizeHandle"
|
||||
class:resizeHandleActive={isDragging}
|
||||
use:resizable={resizableOpts}
|
||||
role="presentation"
|
||||
></div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: var(--header-height);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - var(--header-height));
|
||||
min-width: 280px;
|
||||
background: var(--surface);
|
||||
border-left: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
.resizeHandle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 6px;
|
||||
bottom: 0;
|
||||
cursor: col-resize;
|
||||
z-index: 1;
|
||||
}
|
||||
.resizeHandle:hover,
|
||||
.resizeHandleActive {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
.resizeHandle::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 2px;
|
||||
height: 24px;
|
||||
background: var(--border);
|
||||
border-radius: 1px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.resizeHandle:hover::after,
|
||||
.resizeHandleActive::after {
|
||||
opacity: 1;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.closeBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.4rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.closeBtn:hover {
|
||||
color: var(--text);
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
.closeBtn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.consoleSection {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
.consoleHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.consoleTitle {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.consoleStatus {
|
||||
font-size: 0.7rem;
|
||||
color: var(--green);
|
||||
font-weight: 500;
|
||||
}
|
||||
.consoleOutput {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem;
|
||||
font-family: Monaco, Menlo, "Ubuntu Mono", Consolas, monospace;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
margin: 0;
|
||||
}
|
||||
.consoleEmpty {
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
.btn {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.55rem 1.2rem;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btnSmall {
|
||||
background: transparent;
|
||||
color: var(--accent-h);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.3rem 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.btnSmall:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
</style>
|
||||
@@ -1,97 +0,0 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
id,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
showValue = true,
|
||||
formatValue = (v: number) => String(v),
|
||||
ariaLabel,
|
||||
}: {
|
||||
id: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
disabled?: boolean;
|
||||
showValue?: boolean;
|
||||
formatValue?: (v: number) => string;
|
||||
ariaLabel?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="sliderWrapper">
|
||||
<input
|
||||
type="range"
|
||||
{id}
|
||||
{min}
|
||||
{max}
|
||||
{step}
|
||||
value={value}
|
||||
oninput={(e) => onChange(parseFloat((e.target as HTMLInputElement).value))}
|
||||
{disabled}
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
{#if showValue}
|
||||
<span class="sliderValue" aria-hidden="true">{formatValue(value)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sliderWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.sliderValue {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim);
|
||||
min-width: 2.5rem;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sliderWrapper input[type="range"] {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
height: 6px;
|
||||
background: var(--border);
|
||||
border-radius: 999px;
|
||||
outline: none;
|
||||
}
|
||||
.sliderWrapper input[type="range"]::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--accent);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, transform 0.15s;
|
||||
}
|
||||
.sliderWrapper input[type="range"]::-webkit-slider-thumb:hover {
|
||||
background: var(--accent-h);
|
||||
}
|
||||
.sliderWrapper input[type="range"]:disabled::-webkit-slider-thumb {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.sliderWrapper input[type="range"]::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sliderWrapper input[type="range"]::-moz-range-track {
|
||||
height: 6px;
|
||||
background: var(--border);
|
||||
border-radius: 999px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,70 +0,0 @@
|
||||
<script lang="ts">
|
||||
export interface TabSwitchOption {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
let {
|
||||
options = [],
|
||||
value = "",
|
||||
onChange = () => {},
|
||||
disabled = false,
|
||||
}: {
|
||||
options?: TabSwitchOption[];
|
||||
value?: string;
|
||||
onChange?: (id: string) => void;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="tabs" role="tablist">
|
||||
{#each options as opt}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={value === opt.id}
|
||||
class="tab"
|
||||
class:tabActive={value === opt.id}
|
||||
onclick={() => onChange(opt.id)}
|
||||
{disabled}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tabs {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
padding: 2px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.tab {
|
||||
padding: 0.4rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-dim);
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.tab:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
.tabActive {
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.tab:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -1,100 +0,0 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
id,
|
||||
checked = false,
|
||||
onChange,
|
||||
disabled = false,
|
||||
label,
|
||||
ariaLabel,
|
||||
}: {
|
||||
id: string;
|
||||
checked?: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
ariaLabel?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<label
|
||||
for={id}
|
||||
class="toggle"
|
||||
class:toggleDisabled={disabled}
|
||||
data-checked={checked}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
{id}
|
||||
checked={checked}
|
||||
onchange={(e) => onChange((e.target as HTMLInputElement).checked)}
|
||||
{disabled}
|
||||
aria-label={ariaLabel ?? label}
|
||||
/>
|
||||
<span class="toggleTrack" aria-hidden="true"></span>
|
||||
{#if label != null}
|
||||
<span class="toggleLabel">{label}</span>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
<style>
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.toggleDisabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.toggle input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
.toggleTrack {
|
||||
display: block;
|
||||
width: 2.25rem;
|
||||
height: 1.25rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toggleTrack::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
margin: 0.1rem 0 0 0.1rem;
|
||||
background: var(--text-dim);
|
||||
border-radius: 50%;
|
||||
transition: transform 0.15s, background 0.15s;
|
||||
}
|
||||
.toggle:hover:not(.toggleDisabled) .toggleTrack {
|
||||
border-color: var(--accent-h);
|
||||
}
|
||||
.toggle[data-checked="true"] .toggleTrack {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.toggle[data-checked="true"] .toggleTrack::after {
|
||||
transform: translateX(1rem);
|
||||
background: #fff;
|
||||
}
|
||||
.toggle:focus-within .toggleTrack {
|
||||
box-shadow: 0 0 0 2px var(--accent);
|
||||
}
|
||||
.toggleLabel {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -1,250 +0,0 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
label,
|
||||
hint,
|
||||
accept,
|
||||
multiple = false,
|
||||
directory = false,
|
||||
allowBothFileAndDirectory = false,
|
||||
value = "",
|
||||
fileName,
|
||||
onFileChange,
|
||||
onClear,
|
||||
disabled = false,
|
||||
uploading = false,
|
||||
textInput = false,
|
||||
textValue = "",
|
||||
onTextChange,
|
||||
textPlaceholder,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
directory?: boolean;
|
||||
allowBothFileAndDirectory?: boolean;
|
||||
value?: string;
|
||||
fileName?: string;
|
||||
onFileChange?: (files: File[]) => void;
|
||||
onClear?: () => void;
|
||||
disabled?: boolean;
|
||||
uploading?: boolean;
|
||||
textInput?: boolean;
|
||||
textValue?: string;
|
||||
onTextChange?: (value: string) => void;
|
||||
textPlaceholder?: string;
|
||||
} = $props();
|
||||
|
||||
let fileInputEl: HTMLInputElement;
|
||||
let directoryInputEl: HTMLInputElement;
|
||||
|
||||
const useBoth = directory && allowBothFileAndDirectory;
|
||||
const hasContent = textInput ? !!textValue.trim() : !!(value || fileName);
|
||||
|
||||
function handleChange(e: Event) {
|
||||
const files = (e.target as HTMLInputElement).files;
|
||||
if (files && files.length > 0) {
|
||||
onFileChange?.(Array.from(files));
|
||||
}
|
||||
(e.target as HTMLInputElement).value = "";
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (!textInput && !disabled) {
|
||||
fileInputEl?.click();
|
||||
}
|
||||
}
|
||||
|
||||
function clearInputs() {
|
||||
if (fileInputEl) fileInputEl.value = "";
|
||||
if (directoryInputEl) directoryInputEl.value = "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="uploadZone"
|
||||
class:hasFile={hasContent}
|
||||
class:allowBoth={useBoth}
|
||||
onclick={!textInput && !useBoth ? handleClick : undefined}
|
||||
role={!textInput && !useBoth ? "button" : undefined}
|
||||
tabindex={!textInput && !useBoth ? 0 : undefined}
|
||||
onkeydown={(e) => {
|
||||
if (!textInput && !useBoth && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
bind:this={fileInputEl}
|
||||
type="file"
|
||||
{accept}
|
||||
{multiple}
|
||||
webkitdirectory={directory && !allowBothFileAndDirectory ? "" : undefined}
|
||||
onchange={handleChange}
|
||||
{disabled}
|
||||
/>
|
||||
{#if useBoth}
|
||||
<input
|
||||
bind:this={directoryInputEl}
|
||||
type="file"
|
||||
multiple
|
||||
webkitdirectory=""
|
||||
onchange={handleChange}
|
||||
{disabled}
|
||||
/>
|
||||
{/if}
|
||||
<div class="label">{label}</div>
|
||||
{#if textInput}
|
||||
<input
|
||||
type="text"
|
||||
value={textValue}
|
||||
oninput={(e) => onTextChange?.((e.target as HTMLInputElement).value)}
|
||||
placeholder={textPlaceholder}
|
||||
{disabled}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{:else}
|
||||
{#if !hasContent}
|
||||
<span class="hint">
|
||||
{#if uploading}
|
||||
Uploading…
|
||||
{:else if useBoth}
|
||||
<span
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="selectFilesTrigger"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClick();
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Select files
|
||||
</span>
|
||||
·
|
||||
<button
|
||||
type="button"
|
||||
class="clearLink"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!disabled) directoryInputEl?.click();
|
||||
}}
|
||||
{disabled}
|
||||
>
|
||||
Select folder
|
||||
</button>
|
||||
{:else if directory}
|
||||
Click or drop folder
|
||||
{:else}
|
||||
Click or drop file(s)
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{#if fileName}
|
||||
<div class="fileName">
|
||||
{fileName}
|
||||
{#if onClear}
|
||||
·
|
||||
<button
|
||||
type="button"
|
||||
class="clearLink"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClear();
|
||||
clearInputs();
|
||||
}}
|
||||
disabled={disabled || uploading}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if hint}
|
||||
<div class="hint">{hint}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.uploadZone {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem 1.25rem;
|
||||
min-height: 150px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
flex-grow: 1;
|
||||
}
|
||||
.uploadZone.allowBoth {
|
||||
cursor: default;
|
||||
}
|
||||
.uploadZone:hover {
|
||||
border-color: var(--accent-h);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.uploadZone.hasFile {
|
||||
border-style: solid;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.uploadZone input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
.uploadZone input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
.selectFilesTrigger {
|
||||
cursor: pointer;
|
||||
color: var(--accent-h);
|
||||
}
|
||||
.selectFilesTrigger:hover {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.fileName {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.clearLink {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--accent-h);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.clearLink:hover:not(:disabled) {
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -1,100 +0,0 @@
|
||||
// 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<DefaultOptions>;
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
// 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<JobType, WorkloadOption[]> = {
|
||||
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",
|
||||
},
|
||||
]
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { writable } from "svelte/store";
|
||||
import type { Dataset } from "$lib/api";
|
||||
|
||||
export const activeDatasetId = writable<string | null>(null);
|
||||
export const activeDataset = writable<Dataset | null>(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);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { writable } from "svelte/store";
|
||||
import type { Job } from "$lib/types";
|
||||
|
||||
export const activeJobId = writable<string | null>(null);
|
||||
export const activeJob = writable<Job | null>(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);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export const createDatasetModalOpen = writable(false);
|
||||
@@ -1,60 +0,0 @@
|
||||
// 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<DefaultOptions>(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<K extends keyof DefaultOptions>(
|
||||
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),
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { writable } from "svelte/store";
|
||||
import type { Component } from "svelte";
|
||||
|
||||
export interface HeaderAction {
|
||||
component: Component;
|
||||
props?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Components to render in the header actions slot. */
|
||||
export const headerActions = writable<HeaderAction[]>([]);
|
||||
|
||||
export function setHeaderActions(actions: HeaderAction[]): void {
|
||||
headerActions.set(actions);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
// 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?.();
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
<script lang="ts">
|
||||
import "../app.css";
|
||||
import { onMount } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
import Header from "$lib/components/Header.svelte";
|
||||
import PrimarySidebar from "$lib/components/PrimarySidebar.svelte";
|
||||
import SecondarySidebar from "$lib/components/SecondarySidebar.svelte";
|
||||
import DatasetSidebar from "$lib/components/DatasetSidebar.svelte";
|
||||
import { initDefaultOptions } from "$lib/stores/defaultOptions";
|
||||
import { activeJob, setActiveJobId } from "$lib/stores/activeJob";
|
||||
import { activeDataset, setActiveDatasetId } from "$lib/stores/activeDataset";
|
||||
|
||||
onMount(() => {
|
||||
initDefaultOptions();
|
||||
});
|
||||
|
||||
let primaryWidth = $state(220);
|
||||
let secondaryWidth = $state(0);
|
||||
|
||||
const jobRoutes = ["/inference", "/finetuning", "/distillation"];
|
||||
const pathname = $derived($page.url.pathname);
|
||||
const jobSidebarOpen = $derived(
|
||||
jobRoutes.includes(pathname) && $activeJob != null,
|
||||
);
|
||||
const datasetSidebarOpen = $derived(
|
||||
pathname === "/datasets" && $activeDataset != null,
|
||||
);
|
||||
const secondaryOpen = $derived(jobSidebarOpen || datasetSidebarOpen);
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && !document.querySelector("[data-modal]")) {
|
||||
if ($activeJob) setActiveJobId(null);
|
||||
if ($activeDataset) setActiveDatasetId(null);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Header />
|
||||
<div class="layout">
|
||||
<PrimarySidebar onWidthChange={(w) => (primaryWidth = w)} />
|
||||
<div
|
||||
class="content"
|
||||
style="margin-left: {primaryWidth}px; margin-right: {secondaryOpen ? secondaryWidth : 0}px;"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
{#if jobSidebarOpen && $activeJob}
|
||||
<SecondarySidebar
|
||||
job={$activeJob}
|
||||
onClose={() => setActiveJobId(null)}
|
||||
onWidthChange={(w) => (secondaryWidth = w)}
|
||||
/>
|
||||
{/if}
|
||||
{#if datasetSidebarOpen && $activeDataset}
|
||||
<DatasetSidebar
|
||||
dataset={$activeDataset}
|
||||
onClose={() => setActiveDatasetId(null)}
|
||||
onWidthChange={(w) => (secondaryWidth = w)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.layout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: calc(100vh - var(--header-height));
|
||||
overflow: hidden;
|
||||
margin-top: var(--header-height);
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +0,0 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Layout load: no server data needed; default options are inited in +layout.svelte onMount
|
||||
|
||||
export const ssr = false;
|
||||
@@ -1,17 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
onMount(() => {
|
||||
goto("/inference", { replaceState: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
<p class="redirect-msg">Redirecting to Inference…</p>
|
||||
|
||||
<style>
|
||||
.redirect-msg {
|
||||
padding: 2rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -1,91 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { getDatasets } from "$lib/api";
|
||||
import type { Dataset } from "$lib/api";
|
||||
import CreateDatasetModal from "$lib/components/CreateDatasetModal.svelte";
|
||||
import DatasetCard from "$lib/components/DatasetCard.svelte";
|
||||
import AddDatasetButton from "$lib/components/AddDatasetButton.svelte";
|
||||
import { setHeaderActions } from "$lib/stores/headerActions";
|
||||
import {
|
||||
activeDataset,
|
||||
setActiveDataset,
|
||||
setActiveDatasetId,
|
||||
} from "$lib/stores/activeDataset";
|
||||
import { createDatasetModalOpen } from "$lib/stores/createDatasetModalOpen";
|
||||
|
||||
let datasets = $state<Dataset[]>([]);
|
||||
|
||||
onMount(() => {
|
||||
setHeaderActions([{ component: AddDatasetButton, props: {} }]);
|
||||
});
|
||||
onDestroy(() => setHeaderActions([]));
|
||||
|
||||
async function fetchDatasets() {
|
||||
try {
|
||||
datasets = await getDatasets();
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch datasets:", err);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchDatasets();
|
||||
});
|
||||
|
||||
function handleSelectDataset(ds: Dataset) {
|
||||
setActiveDataset(ds);
|
||||
setActiveDatasetId(ds.id);
|
||||
}
|
||||
|
||||
function handleCloseModal() {
|
||||
createDatasetModalOpen.set(false);
|
||||
}
|
||||
|
||||
function handleSuccess() {
|
||||
fetchDatasets();
|
||||
createDatasetModalOpen.set(false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="main">
|
||||
<section class="card">
|
||||
<div id="datasets-container">
|
||||
{#if datasets.length === 0}
|
||||
<p class="placeholder">No datasets yet.</p>
|
||||
{:else}
|
||||
{#each datasets as ds (ds.id)}
|
||||
<DatasetCard
|
||||
dataset={ds}
|
||||
onUpdated={fetchDatasets}
|
||||
onSelect={() => handleSelectDataset(ds)}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<CreateDatasetModal
|
||||
isOpen={$createDatasetModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.main {
|
||||
max-width: 850px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem 3rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
.card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.placeholder {
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
padding: 2rem 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script lang="ts">
|
||||
import JobQueue from "$lib/components/JobQueue.svelte";
|
||||
import CreateJobButton from "$lib/components/CreateJobButton.svelte";
|
||||
import { setHeaderActions } from "$lib/stores/headerActions";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
|
||||
onMount(() => {
|
||||
setHeaderActions([
|
||||
{ component: CreateJobButton, props: { jobType: "distillation" } },
|
||||
]);
|
||||
});
|
||||
onDestroy(() => setHeaderActions([]));
|
||||
</script>
|
||||
|
||||
<JobQueue jobType="distillation" />
|
||||
@@ -1,15 +0,0 @@
|
||||
<script lang="ts">
|
||||
import JobQueue from "$lib/components/JobQueue.svelte";
|
||||
import CreateJobButton from "$lib/components/CreateJobButton.svelte";
|
||||
import { setHeaderActions } from "$lib/stores/headerActions";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
|
||||
onMount(() => {
|
||||
setHeaderActions([
|
||||
{ component: CreateJobButton, props: { jobType: "finetuning" } },
|
||||
]);
|
||||
});
|
||||
onDestroy(() => setHeaderActions([]));
|
||||
</script>
|
||||
|
||||
<JobQueue jobType="finetuning" jobTypesForList={["finetuning", "lora"]} />
|
||||
@@ -1,190 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { getJobsList } from "$lib/api";
|
||||
import { getJobVideoUrl } from "$lib/api";
|
||||
import type { Job } from "$lib/types";
|
||||
|
||||
let jobs = $state<Job[]>([]);
|
||||
let isLoading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const galleryJobs = $derived(
|
||||
jobs.filter(
|
||||
(j) =>
|
||||
j.status === "completed" &&
|
||||
j.output_path &&
|
||||
(j.job_type === "inference" || !j.job_type),
|
||||
),
|
||||
);
|
||||
|
||||
async function fetchJobs() {
|
||||
try {
|
||||
error = null;
|
||||
const list = await getJobsList("inference");
|
||||
jobs = list.sort(
|
||||
(a, b) =>
|
||||
(b.finished_at ?? b.created_at ?? 0) -
|
||||
(a.finished_at ?? a.created_at ?? 0),
|
||||
);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Failed to load jobs";
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchJobs();
|
||||
});
|
||||
|
||||
function isImage(job: Job): boolean {
|
||||
return job.output_path?.toLowerCase().endsWith(".png") ?? false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="main">
|
||||
<section class="card">
|
||||
<h2 class="pageTitle">Gallery</h2>
|
||||
<p class="pageDesc">
|
||||
Generated videos from completed inference jobs. Captions show the prompt
|
||||
used for each generation.
|
||||
</p>
|
||||
{#if isLoading}
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<span>Loading gallery…</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<p class="error">{error}</p>
|
||||
{:else if galleryJobs.length === 0}
|
||||
<p class="placeholder">
|
||||
No generated videos yet. Complete some
|
||||
<a href="/inference">inference jobs</a> to see them here.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each galleryJobs as job (job.id)}
|
||||
<article class="tile">
|
||||
<div class="mediaWrap">
|
||||
{#if isImage(job)}
|
||||
<img
|
||||
src={getJobVideoUrl(job.id)}
|
||||
alt={job.prompt}
|
||||
class="media"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<video
|
||||
src={getJobVideoUrl(job.id)}
|
||||
class="media"
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
preload="metadata"
|
||||
></video>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="caption" title={job.prompt}>{job.prompt || "—"}</p>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.main {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem 3rem;
|
||||
width: 100%;
|
||||
}
|
||||
.card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.pageTitle {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.25rem 0;
|
||||
color: var(--text);
|
||||
}
|
||||
.pageDesc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-dim);
|
||||
margin: 0 0 1.5rem 0;
|
||||
}
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 2rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.error {
|
||||
color: var(--red);
|
||||
padding: 2rem 0;
|
||||
}
|
||||
.placeholder {
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
padding: 2rem 0;
|
||||
}
|
||||
.placeholder a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.placeholder a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.tile {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.mediaWrap {
|
||||
aspect-ratio: 16 / 9;
|
||||
background: var(--surface);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.media {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
.caption {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim);
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script lang="ts">
|
||||
import JobQueue from "$lib/components/JobQueue.svelte";
|
||||
import CreateJobButton from "$lib/components/CreateJobButton.svelte";
|
||||
import { setHeaderActions } from "$lib/stores/headerActions";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
|
||||
onMount(() => {
|
||||
setHeaderActions([
|
||||
{ component: CreateJobButton, props: { jobType: "inference" } },
|
||||
]);
|
||||
});
|
||||
onDestroy(() => setHeaderActions([]));
|
||||
</script>
|
||||
|
||||
<JobQueue jobType="inference" />
|
||||
@@ -1,15 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
goto("/finetuning", { replaceState: true });
|
||||
</script>
|
||||
|
||||
<p class="redirect-msg">Redirecting to Finetuning…</p>
|
||||
|
||||
<style>
|
||||
.redirect-msg {
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
padding: 2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,405 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { getModels } from "$lib/api";
|
||||
import type { Model } from "$lib/api";
|
||||
import type { WorkloadType } from "$lib/defaultOptions";
|
||||
import { defaultOptions, updateOption, resetToDefaults } from "$lib/stores/defaultOptions";
|
||||
import { setHeaderActions } from "$lib/stores/headerActions";
|
||||
import Toggle from "$lib/components/Toggle.svelte";
|
||||
import Slider from "$lib/components/Slider.svelte";
|
||||
|
||||
onMount(() => setHeaderActions([]));
|
||||
onDestroy(() => setHeaderActions([]));
|
||||
|
||||
let modelsT2v = $state<Model[]>([]);
|
||||
let modelsI2v = $state<Model[]>([]);
|
||||
let modelsT2i = $state<Model[]>([]);
|
||||
|
||||
onMount(() => {
|
||||
Promise.all([
|
||||
getModels("t2v"),
|
||||
getModels("i2v"),
|
||||
getModels("t2i"),
|
||||
])
|
||||
.then(([t2v, i2v, t2i]) => {
|
||||
modelsT2v = t2v;
|
||||
modelsI2v = i2v;
|
||||
modelsT2i = t2i;
|
||||
})
|
||||
.catch((e) => console.error("Failed to load models:", e));
|
||||
});
|
||||
</script>
|
||||
|
||||
<main class="main">
|
||||
<section class="card">
|
||||
<h2>Behavior</h2>
|
||||
<div class="row">
|
||||
<label for="settings-auto-start-job">Auto Start Job on Create</label>
|
||||
<Toggle
|
||||
id="settings-auto-start-job"
|
||||
checked={$defaultOptions.autoStartJob}
|
||||
onChange={(v) => updateOption("autoStartJob", v)}
|
||||
/>
|
||||
</div>
|
||||
<hr class="hr" />
|
||||
<h2>Paths</h2>
|
||||
<div class="formRow">
|
||||
<label for="settings-api-server-base-url">API Server Base URL</label>
|
||||
<input
|
||||
id="settings-api-server-base-url"
|
||||
type="text"
|
||||
value={$defaultOptions.apiServerBaseUrl ?? ""}
|
||||
oninput={(e) =>
|
||||
updateOption(
|
||||
"apiServerBaseUrl",
|
||||
(e.target as HTMLInputElement).value,
|
||||
)}
|
||||
placeholder="http://localhost:8189/api"
|
||||
style="font-family: monospace; font-size: 0.9rem"
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="settings-dataset-upload-path">Dataset Upload Path</label>
|
||||
<input
|
||||
id="settings-dataset-upload-path"
|
||||
type="text"
|
||||
value={$defaultOptions.datasetUploadPath ?? ""}
|
||||
oninput={(e) =>
|
||||
updateOption("datasetUploadPath", (e.target as HTMLInputElement).value)}
|
||||
placeholder="outputs/ui_data/uploads/datasets"
|
||||
style="font-family: monospace; font-size: 0.9rem"
|
||||
/>
|
||||
</div>
|
||||
<hr class="hr" />
|
||||
<div class="sectionHeader">
|
||||
<h2>Default Options</h2>
|
||||
<button type="button" class="btn btnSmall" onclick={resetToDefaults}>
|
||||
Reset to Defaults
|
||||
</button>
|
||||
</div>
|
||||
<p class="helperP">
|
||||
These values are used as defaults when creating new jobs.
|
||||
</p>
|
||||
<div class="settingsGrid">
|
||||
<div class="formRow">
|
||||
<label for="settings-default-model-t2v">Default Model (T2V)</label>
|
||||
<select
|
||||
id="settings-default-model-t2v"
|
||||
value={$defaultOptions.defaultModelIdT2v}
|
||||
onchange={(e) =>
|
||||
updateOption(
|
||||
"defaultModelIdT2v",
|
||||
(e.target as HTMLSelectElement).value,
|
||||
)}
|
||||
>
|
||||
<option value="">None (select when creating job)</option>
|
||||
{#each modelsT2v as model}
|
||||
<option value={model.id}>{model.label} ({model.id})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="settings-default-model-i2v">Default Model (I2V)</label>
|
||||
<select
|
||||
id="settings-default-model-i2v"
|
||||
value={$defaultOptions.defaultModelIdI2v}
|
||||
onchange={(e) =>
|
||||
updateOption(
|
||||
"defaultModelIdI2v",
|
||||
(e.target as HTMLSelectElement).value,
|
||||
)}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{#each modelsI2v as model}
|
||||
<option value={model.id}>{model.label} ({model.id})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="settings-default-model-t2i">Default Model (T2I)</label>
|
||||
<select
|
||||
id="settings-default-model-t2i"
|
||||
value={$defaultOptions.defaultModelIdT2i}
|
||||
onchange={(e) =>
|
||||
updateOption(
|
||||
"defaultModelIdT2i",
|
||||
(e.target as HTMLSelectElement).value,
|
||||
)}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{#each modelsT2i as model}
|
||||
<option value={model.id}>{model.label} ({model.id})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="settings-num-frames">Frames</label>
|
||||
<Slider
|
||||
id="settings-num-frames"
|
||||
min={1}
|
||||
max={500}
|
||||
step={1}
|
||||
value={$defaultOptions.numFrames}
|
||||
onChange={(v) => updateOption("numFrames", v)}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="settings-height">Height</label>
|
||||
<Slider
|
||||
id="settings-height"
|
||||
min={64}
|
||||
max={1080}
|
||||
step={16}
|
||||
value={$defaultOptions.height}
|
||||
onChange={(v) => updateOption("height", v)}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="settings-width">Width</label>
|
||||
<Slider
|
||||
id="settings-width"
|
||||
min={64}
|
||||
max={1920}
|
||||
step={16}
|
||||
value={$defaultOptions.width}
|
||||
onChange={(v) => updateOption("width", v)}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="settings-num-steps">Inference Steps</label>
|
||||
<Slider
|
||||
id="settings-num-steps"
|
||||
min={1}
|
||||
max={200}
|
||||
step={1}
|
||||
value={$defaultOptions.numInferenceSteps}
|
||||
onChange={(v) => updateOption("numInferenceSteps", v)}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="settings-vsa-sparsity" title="VSA sparsity (0–1)">VSA Sparsity</label>
|
||||
<Slider
|
||||
id="settings-vsa-sparsity"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={$defaultOptions.vsaSparsity}
|
||||
onChange={(v) => updateOption("vsaSparsity", v)}
|
||||
formatValue={(v) => v.toFixed(2)}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="settings-guidance">Guidance Scale</label>
|
||||
<Slider
|
||||
id="settings-guidance"
|
||||
min={0}
|
||||
max={20}
|
||||
step={0.1}
|
||||
value={$defaultOptions.guidanceScale}
|
||||
onChange={(v) => updateOption("guidanceScale", v)}
|
||||
formatValue={(v) => v.toFixed(1)}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="settings-guidance-rescale" title="0 = disabled">Guidance Rescale</label>
|
||||
<Slider
|
||||
id="settings-guidance-rescale"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={$defaultOptions.guidanceRescale ?? 0}
|
||||
onChange={(v) => updateOption("guidanceRescale", v)}
|
||||
formatValue={(v) => v.toFixed(2)}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="settings-tp-size" title="-1 = auto">TP Size</label>
|
||||
<Slider
|
||||
id="settings-tp-size"
|
||||
min={-1}
|
||||
max={8}
|
||||
step={1}
|
||||
value={$defaultOptions.tpSize}
|
||||
onChange={(v) => updateOption("tpSize", v)}
|
||||
formatValue={(v) => (v === -1 ? "Auto" : String(v))}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="settings-sp-size" title="-1 = auto">SP Size</label>
|
||||
<Slider
|
||||
id="settings-sp-size"
|
||||
min={-1}
|
||||
max={8}
|
||||
step={1}
|
||||
value={$defaultOptions.spSize}
|
||||
onChange={(v) => updateOption("spSize", v)}
|
||||
formatValue={(v) => (v === -1 ? "Auto" : String(v))}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="settings-fps">FPS</label>
|
||||
<Slider
|
||||
id="settings-fps"
|
||||
min={1}
|
||||
max={60}
|
||||
step={1}
|
||||
value={$defaultOptions.fps ?? 24}
|
||||
onChange={(v) => updateOption("fps", v)}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="settings-dit-cpu-offload">DiT CPU Offload</label>
|
||||
<Toggle
|
||||
id="settings-dit-cpu-offload"
|
||||
checked={$defaultOptions.ditCpuOffload}
|
||||
onChange={(v) => updateOption("ditCpuOffload", v)}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="settings-text-encoder-cpu-offload">Text Encoder CPU Offload</label>
|
||||
<Toggle
|
||||
id="settings-text-encoder-cpu-offload"
|
||||
checked={$defaultOptions.textEncoderCpuOffload}
|
||||
onChange={(v) => updateOption("textEncoderCpuOffload", v)}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="settings-use-fsdp-inference">Use FSDP Inference</label>
|
||||
<Toggle
|
||||
id="settings-use-fsdp-inference"
|
||||
checked={$defaultOptions.useFsdpInference}
|
||||
onChange={(v) => updateOption("useFsdpInference", v)}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="settings-vae-cpu-offload">VAE CPU Offload</label>
|
||||
<Toggle
|
||||
id="settings-vae-cpu-offload"
|
||||
checked={$defaultOptions.vaeCpuOffload}
|
||||
onChange={(v) => updateOption("vaeCpuOffload", v)}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="settings-image-encoder-cpu-offload">Image Encoder CPU Offload</label>
|
||||
<Toggle
|
||||
id="settings-image-encoder-cpu-offload"
|
||||
checked={$defaultOptions.imageEncoderCpuOffload}
|
||||
onChange={(v) => updateOption("imageEncoderCpuOffload", v)}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="settings-enable-torch-compile">Torch Compile</label>
|
||||
<Toggle
|
||||
id="settings-enable-torch-compile"
|
||||
checked={$defaultOptions.enableTorchCompile}
|
||||
onChange={(v) => updateOption("enableTorchCompile", v)}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="settings-num-gpus">GPUs</label>
|
||||
<Slider
|
||||
id="settings-num-gpus"
|
||||
min={1}
|
||||
max={8}
|
||||
step={1}
|
||||
value={$defaultOptions.numGpus}
|
||||
onChange={(v) => updateOption("numGpus", v)}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label for="settings-seed">Seed</label>
|
||||
<input
|
||||
id="settings-seed"
|
||||
type="number"
|
||||
value={$defaultOptions.seed}
|
||||
oninput={(e) =>
|
||||
updateOption(
|
||||
"seed",
|
||||
parseInt((e.target as HTMLInputElement).value, 10),
|
||||
)}
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.main {
|
||||
max-width: 850px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem 3rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
.card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.card h2 {
|
||||
font-size: 1.15rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.row label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.hr {
|
||||
margin: 1rem 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.formRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.formRow label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.sectionHeader h2 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.helperP {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.settingsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 0.5rem 0.75rem;
|
||||
}
|
||||
.btn {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.55rem 1.2rem;
|
||||
}
|
||||
.btnSmall {
|
||||
background: transparent;
|
||||
color: var(--accent-h);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.3rem 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 119 KiB |
@@ -1,18 +0,0 @@
|
||||
<svg width="252" height="105" viewBox="0 0 252 105" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M89.4843 55.5457H101.361L87.7028 101H74.638L89.4843 55.5457Z" fill="#356CFF"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M96.0167 1.00057H112.645L118.583 48.273H104.924L103.737 39.7882H79.9827L67.5117 55.5457H85.3273L43.1638 101H28.3174L22.3789 55.5457H33.6621L38.4129 91.3031L58.604 68.2729H44.3515L96.0167 1.00057ZM100.768 13.1217L87.7028 29.4852H103.143L100.768 13.1217Z" fill="#356CFF"/>
|
||||
<path d="M37.2252 1.00057L22.3789 48.273H36.0375L40.7884 30.6974L62.6727 30.6974L69.6727 21.0005L43.7576 21.0004L46.7269 11.9096L77.6727 11.9096L86 1.00057L37.2252 1.00057Z" fill="#356CFF"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M108.488 55.5457L94.2351 101C94.2351 101 105.518 101 120.959 101C136.399 101 144.078 93.0133 148.276 79.788C152.432 68.0157 153.027 55.5457 136.399 55.5457C119.771 55.5457 108.488 55.5457 108.488 55.5457ZM109.081 90.697L116.802 65.8487C116.802 65.8487 120.959 65.8487 132.242 65.8487C143.525 65.8487 137.586 78.5759 135.211 84.0304C133.307 88.4021 127.491 90.697 122.74 90.697C117.989 90.697 109.081 90.697 109.081 90.697Z" fill="#356CFF"/>
|
||||
<path d="M173.188 1.00056L168.625 11.9096C168.625 11.9096 149.386 11.9092 142.525 11.9095C135.664 11.9098 136.586 20.3944 141.337 20.3944H159.747C168.654 20.3944 166.961 33.6899 163.904 38.5761C160.467 44.0675 157.371 48.273 148.463 48.273L125.188 48.273L124 37.97L147.87 37.97C153.808 37.97 156.184 29.4852 151.433 29.4852H131.836C120.142 29.4852 125.897 1.00043 141.337 1.00043L173.188 1.00056Z" fill="#356CFF"/>
|
||||
<path d="M179.938 1.00056L175.688 11.9096L191.221 11.9096L179.938 48.273H192.409L203.692 11.9096L219.132 11.9095L223.289 1.00043L179.938 1.00056Z" fill="#356CFF"/>
|
||||
<path d="M161.341 55.5457H202.845L198.5 65.8487H169.654L167.279 73.7268H188.5L184.749 82.8177H164.31L161.934 90.697H190.251L186.624 101H146.494L161.341 55.5457Z" fill="#356CFF"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M230.821 54.9391C255.169 54.9391 251.776 67.0602 249.231 77.9692C246.686 88.8783 240.917 101 217.757 101C194.596 101 195.606 88.8783 199.347 77.9692C203.089 67.0602 206.473 54.9391 230.821 54.9391ZM237.948 77.9692C239.984 70.6965 240.917 65.242 228.446 65.242C215.975 65.242 211.818 71.9087 210.037 77.9692C208.255 84.0298 208.255 91.3025 219.538 91.3025C230.821 91.3025 235.911 85.2419 237.948 77.9692Z" fill="#356CFF"/>
|
||||
<path d="M173.188 1.00056L168.625 11.9096C168.625 11.9096 149.386 11.9092 142.525 11.9095C135.664 11.9098 136.586 20.3944 141.337 20.3944M173.188 1.00056C173.188 1.00056 156.777 1.00043 141.337 1.00043M173.188 1.00056L141.337 1.00043M141.337 20.3944C146.088 20.3944 150.839 20.3944 159.747 20.3944M141.337 20.3944H159.747M159.747 20.3944C168.654 20.3944 166.961 33.6899 163.904 38.5761C160.467 44.0675 157.371 48.273 148.463 48.273M148.463 48.273C139.556 48.273 125.188 48.273 125.188 48.273M148.463 48.273L125.188 48.273M125.188 48.273L124 37.97M124 37.97C124 37.97 141.931 37.97 147.87 37.97M124 37.97L147.87 37.97M147.87 37.97C153.808 37.97 156.184 29.4852 151.433 29.4852M151.433 29.4852C146.682 29.4852 138.962 29.4852 131.836 29.4852M151.433 29.4852H131.836M131.836 29.4852C120.142 29.4852 125.897 1.00043 141.337 1.00043M37.2252 1.00057L22.3789 48.273H36.0375L40.7884 30.6974L62.6727 30.6974L69.6727 21.0005L43.7576 21.0004L46.7269 11.9096L77.6727 11.9096L86 1.00057L37.2252 1.00057ZM96.0167 1.00057H112.645L118.583 48.273H104.924L103.737 39.7882H79.9827L67.5117 55.5457H85.3273L43.1638 101H28.3174L22.3789 55.5457H33.6621L38.4129 91.3031L58.604 68.2729H44.3515L96.0167 1.00057ZM87.7028 29.4852L100.768 13.1217L103.143 29.4852H87.7028ZM89.4843 55.5457H101.361L87.7028 101H74.638L89.4843 55.5457ZM108.488 55.5457L94.2351 101C94.2351 101 105.518 101 120.959 101C136.399 101 144.078 93.0133 148.276 79.788C152.432 68.0157 153.027 55.5457 136.399 55.5457C119.771 55.5457 108.488 55.5457 108.488 55.5457ZM116.802 65.8487L109.081 90.697C109.081 90.697 117.989 90.697 122.74 90.697C127.491 90.697 133.307 88.4021 135.211 84.0304C137.586 78.5759 143.525 65.8487 132.242 65.8487C120.959 65.8487 116.802 65.8487 116.802 65.8487ZM179.938 1.00056L175.688 11.9096L191.221 11.9096L179.938 48.273H192.409L203.692 11.9096L219.132 11.9095L223.289 1.00043L179.938 1.00056ZM161.341 55.5457H202.845L198.5 65.8487H169.654L167.279 73.7268H188.5L184.749 82.8177H164.31L161.934 90.697H190.251L186.624 101H146.494L161.341 55.5457ZM230.821 54.9391C255.169 54.9391 251.776 67.0602 249.231 77.9692C246.686 88.8783 240.917 101 217.757 101C194.596 101 195.606 88.8783 199.347 77.9692C203.089 67.0602 206.473 54.9391 230.821 54.9391ZM228.446 65.242C240.917 65.242 239.984 70.6965 237.948 77.9692C235.911 85.2419 230.821 91.3025 219.538 91.3025C208.255 91.3025 208.255 84.0298 210.037 77.9692C211.818 71.9087 215.975 65.242 228.446 65.242Z" stroke="#356CFF" stroke-width="1.18771"/>
|
||||
<path d="M15.2524 55.5451L21.191 100.999L24.7541 100.999L18.8156 55.5451L15.2524 55.5451Z" fill="#356CFF" stroke="#356CFF" stroke-width="1.18771"/>
|
||||
<path d="M8.12646 55.5451L14.065 100.999L15.2527 100.999L9.31417 55.5451L8.12646 55.5451Z" fill="#356CFF" stroke="#356CFF" stroke-width="1.18771"/>
|
||||
<path d="M1 55.5451L6.93853 100.999L7.53239 100.999L1.59385 55.5451L1 55.5451Z" fill="#356CFF" stroke="#356CFF" stroke-width="0.593853"/>
|
||||
<path d="M15.2524 48.2724L30.0988 1H33.6619L18.8156 48.2724H15.2524Z" fill="#356CFF" stroke="#356CFF" stroke-width="1.18771"/>
|
||||
<path d="M8.12646 48.2724L22.9728 1H24.1605L9.31417 48.2724H8.12646Z" fill="#356CFF" stroke="#356CFF" stroke-width="1.18771"/>
|
||||
<path d="M1 48.2724L15.8463 1H16.4402L1.59385 48.2724H1Z" fill="#356CFF" stroke="#356CFF" stroke-width="0.593853"/>
|
||||
<path d="M85.3271 55.5457H67.5116L87 12.7363L44.3513 68.2729H58.6038L43.1636 101L85.3271 55.5457Z" fill="#FDC717" stroke="#FDC717" stroke-width="1.18771" stroke-miterlimit="16"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.7 KiB |
@@ -1,16 +0,0 @@
|
||||
// @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;
|
||||
@@ -1,177 +0,0 @@
|
||||
# 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
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.svelte"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
import type { UserConfig } from "vite";
|
||||
|
||||
const config: UserConfig = {
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
port: 3000
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
Reference in New Issue
Block a user