Compare commits

...
Author SHA1 Message Date
Jinzhe Pan c2a12fd087 Revert "[feat] Job Runner UI (#1172)"
This reverts commit f72618dafb.
2026-03-29 16:42:04 +08:00
74 changed files with 33 additions and 12333 deletions
-2
View File
@@ -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
View File
@@ -1 +0,0 @@
3.12
+1 -1
View File
@@ -23,7 +23,7 @@ classifiers = [
]
dependencies = [
"torch>=2.5.0",
"triton>=2.0.0; sys_platform == 'linux'",
"triton>=2.0.0",
]
[project.urls]
+9 -57
View File
@@ -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
View File
@@ -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 -18
View File
@@ -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 -108
View File
@@ -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
View File
@@ -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
View File
@@ -1 +0,0 @@
PUBLIC_API_BASE_URL=http://localhost:8189/api
-42
View File
@@ -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
-4
View File
@@ -1,4 +0,0 @@
{
"useTabs": true,
"tabWidth": 4
}
-60
View File
@@ -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
View File
@@ -1 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
-7
View File
@@ -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
View File
@@ -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 "",
}
-2
View File
@@ -1,2 +0,0 @@
// Minimal ESLint config for SvelteKit (add typescript-eslint / eslint-plugin-svelte as needed)
export default [];
-1049
View File
File diff suppressed because it is too large Load Diff
-14
View File
@@ -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",
]
-11
View File
@@ -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
-49
View File
@@ -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 = ""
-31
View File
@@ -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
-9
View File
@@ -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
-3066
View File
File diff suppressed because it is too large Load Diff
-28
View File
@@ -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"
}
}
-122
View File
@@ -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

-18
View File
@@ -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
View File
@@ -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()
-66
View File
@@ -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;
}
-13
View File
@@ -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>
-76
View File
@@ -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);
}
},
};
}
-404
View File
@@ -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 &lt;video&gt; or &lt;img&gt; 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)}`;
}
-185
View File
@@ -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>
-989
View File
@@ -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>
-133
View File
@@ -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>
-320
View File
@@ -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>
-72
View File
@@ -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>
-318
View File
@@ -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>
-111
View File
@@ -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>
-275
View File
@@ -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>
-97
View File
@@ -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>
-70
View File
@@ -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>
-100
View File
@@ -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>
-250
View File
@@ -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>
-100
View File
@@ -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
}
}
-51
View File
@@ -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",
},
]
};
-16
View File
@@ -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);
}
-16
View File
@@ -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);
-60
View File
@@ -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),
);
}
-16
View File
@@ -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);
}
-16
View File
@@ -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?.();
}
-28
View File
@@ -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;
}
-84
View File
@@ -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>
-4
View File
@@ -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;
-17
View File
@@ -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>
-91
View File
@@ -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>
-15
View File
@@ -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" />
-15
View File
@@ -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"]} />
-190
View File
@@ -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>
-15
View File
@@ -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" />
-15
View File
@@ -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>
-405
View File
@@ -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

-18
View File
@@ -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

-16
View File
@@ -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;
-177
View File
@@ -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
-10
View File
@@ -1,10 +0,0 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"strict": true
},
"include": ["src/**/*.ts", "src/**/*.svelte"],
"exclude": ["node_modules"]
}
-11
View File
@@ -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;