[misc] FastVideo Studio UI Additions (H3 Ref2V support) (#1783)
This commit is contained in:
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
from pathlib import Path
|
||||
@@ -46,8 +47,13 @@ DEFAULT_SETTINGS: dict[str, Any] = {
|
||||
|
||||
|
||||
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
|
||||
"""Like dict.get for sqlite3.Row (Row has no .get on Python 3.10).
|
||||
|
||||
NOTE: `key in row` tests Row *values*, not column names, so the membership
|
||||
check has to go through .keys() -- otherwise every lookup falls back to the
|
||||
default and jobs restored from the database lose their stored fields.
|
||||
"""
|
||||
return row[key] if key in row.keys() else default # noqa: SIM401, SIM118
|
||||
|
||||
|
||||
def _get_db_path(data_dir: Path) -> Path:
|
||||
@@ -83,6 +89,9 @@ def _migrate_db(conn: sqlite3.Connection) -> None:
|
||||
_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", "name", "TEXT", "''")
|
||||
_add_column_if_missing(conn, "jobs", "last_image_path", "TEXT", "''")
|
||||
_add_column_if_missing(conn, "jobs", "references_json", "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")
|
||||
@@ -242,7 +251,8 @@ class Database:
|
||||
self._execute(
|
||||
"""
|
||||
INSERT INTO jobs (
|
||||
id, model_id, prompt, workload_type, image_path, job_type, status,
|
||||
id, model_id, name, prompt, workload_type, image_path,
|
||||
last_image_path, references_json, 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,
|
||||
@@ -254,14 +264,17 @@ class Database:
|
||||
dmd_use_vsa, dmd_vsa_sparsity, dmd_denoising_steps,
|
||||
real_score_guidance_scale,
|
||||
generator_update_interval, real_score_model_path, fake_score_model_path
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
job["id"],
|
||||
job["model_id"],
|
||||
job.get("name", ""),
|
||||
job["prompt"],
|
||||
job.get("workload_type", "t2v"),
|
||||
job.get("image_path", ""),
|
||||
job.get("last_image_path", ""),
|
||||
json.dumps(job.get("references") or []),
|
||||
job.get("job_type", "inference"),
|
||||
job["status"],
|
||||
job["created_at"],
|
||||
@@ -540,9 +553,12 @@ def _row_to_job(row: sqlite3.Row) -> dict[str, Any]:
|
||||
result = {
|
||||
"id": row["id"],
|
||||
"model_id": row["model_id"],
|
||||
"name": _sqlite_row_get(row, "name", "") or "",
|
||||
"prompt": row["prompt"],
|
||||
"workload_type": _sqlite_row_get(row, "workload_type", "t2v"),
|
||||
"image_path": _sqlite_row_get(row, "image_path", "") or "",
|
||||
"last_image_path": _sqlite_row_get(row, "last_image_path", "") or "",
|
||||
"references": _sqlite_row_get(row, "references_json", "") or "",
|
||||
"job_type": _sqlite_row_get(row, "job_type", "inference"),
|
||||
"status": row["status"],
|
||||
"created_at": row["created_at"],
|
||||
|
||||
@@ -10,7 +10,9 @@ from __future__ import annotations
|
||||
import atexit
|
||||
import collections
|
||||
import contextlib
|
||||
import copy
|
||||
import enum
|
||||
import json
|
||||
import logging
|
||||
import logging.handlers
|
||||
import multiprocessing as mp
|
||||
@@ -123,10 +125,13 @@ class LogBufferHandler(logging.Handler):
|
||||
class Job:
|
||||
id: str
|
||||
model_id: str
|
||||
prompt: str
|
||||
name: str = ""
|
||||
prompt: str = ""
|
||||
workload_type: str = "t2v"
|
||||
job_type: str = "inference"
|
||||
image_path: str = ""
|
||||
last_image_path: str = ""
|
||||
references: list[dict[str, Any]] = field(default_factory=list)
|
||||
status: JobStatus = JobStatus.PENDING
|
||||
created_at: float = field(default_factory=time.time)
|
||||
started_at: float | None = None
|
||||
@@ -145,6 +150,7 @@ class Job:
|
||||
negative_prompt: str = ""
|
||||
num_gpus: int = 1
|
||||
dit_cpu_offload: bool = False
|
||||
dit_layerwise_offload: bool = False
|
||||
text_encoder_cpu_offload: bool = False
|
||||
vae_cpu_offload: bool = False
|
||||
image_encoder_cpu_offload: bool = False
|
||||
@@ -180,10 +186,13 @@ class Job:
|
||||
return {
|
||||
"id": self.id,
|
||||
"model_id": self.model_id,
|
||||
"name": self.name,
|
||||
"prompt": self.prompt,
|
||||
"workload_type": self.workload_type,
|
||||
"job_type": self.job_type,
|
||||
"image_path": self.image_path,
|
||||
"last_image_path": self.last_image_path,
|
||||
"references": self.references,
|
||||
"status": self.status.value,
|
||||
"created_at": self.created_at,
|
||||
"started_at": self.started_at,
|
||||
@@ -202,6 +211,7 @@ class Job:
|
||||
"negative_prompt": self.negative_prompt,
|
||||
"num_gpus": self.num_gpus,
|
||||
"dit_cpu_offload": self.dit_cpu_offload,
|
||||
"dit_layerwise_offload": self.dit_layerwise_offload,
|
||||
"text_encoder_cpu_offload": self.text_encoder_cpu_offload,
|
||||
"vae_cpu_offload": self.vae_cpu_offload,
|
||||
"image_encoder_cpu_offload": self.image_encoder_cpu_offload,
|
||||
@@ -232,6 +242,75 @@ class Job:
|
||||
}
|
||||
|
||||
|
||||
MINIMAX_H3_REF2VA_PIPELINE = "MiniMaxH3Ref2VAModularPipeline"
|
||||
|
||||
|
||||
def _build_h3_references(raw: list[dict[str, Any]]) -> list[Any]:
|
||||
"""Turn the API's reference dicts into MiniMaxH3Reference objects.
|
||||
|
||||
Imported lazily so the API server starts without pulling in fastvideo.
|
||||
"""
|
||||
from fastvideo.pipelines.basic.minimax_h3 import MiniMaxH3Reference
|
||||
|
||||
built = []
|
||||
for i, ref in enumerate(raw):
|
||||
source = (ref or {}).get("source")
|
||||
if not source:
|
||||
raise ValueError(f"reference {i} has no source")
|
||||
if not os.path.isfile(source):
|
||||
raise ValueError(f"reference {i} source not found: {source}")
|
||||
kwargs: dict[str, Any] = {
|
||||
"source": source,
|
||||
"media_type": (ref.get("media_type") or "image"),
|
||||
}
|
||||
for opt in ("soundtrack", "fps", "sample_rate"):
|
||||
if ref.get(opt) not in (None, ""):
|
||||
kwargs[opt] = ref[opt]
|
||||
built.append(MiniMaxH3Reference(**kwargs))
|
||||
return built
|
||||
|
||||
|
||||
JOB_LOG_FILENAME = "out.log"
|
||||
|
||||
|
||||
def _job_log_path(output_dir: str, job_id: str) -> str:
|
||||
"""Each job's log lives beside its outputs: <output_dir>/<job_id>/out.log."""
|
||||
return os.path.join(output_dir, job_id, JOB_LOG_FILENAME)
|
||||
|
||||
|
||||
def _decode_references(value: Any) -> list[dict[str, Any]]:
|
||||
"""Reference lists round-trip through the DB as JSON text."""
|
||||
if not value:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return list(value)
|
||||
try:
|
||||
decoded = json.loads(value)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Could not decode stored references: %r", value)
|
||||
return []
|
||||
return list(decoded) if isinstance(decoded, list) else []
|
||||
|
||||
|
||||
def _generator_is_alive(generator: Any) -> bool:
|
||||
"""True if the generator's worker processes are all still running.
|
||||
|
||||
A cached VideoGenerator holds a MultiprocExecutor whose workers are separate
|
||||
processes; nothing notices when they exit. Probing `proc.is_alive()` is what
|
||||
the executor itself uses during shutdown. Anything unexpected in the object
|
||||
graph is treated as alive so a probe failure can never wedge the cache.
|
||||
"""
|
||||
executor = getattr(generator, "executor", None)
|
||||
workers = getattr(executor, "workers", None)
|
||||
if not workers:
|
||||
return True
|
||||
try:
|
||||
return all(w.proc.is_alive() for w in workers)
|
||||
except Exception:
|
||||
logger.debug("Worker liveness probe failed", exc_info=True)
|
||||
return True
|
||||
|
||||
|
||||
class JobRunner:
|
||||
"""Manages video generation jobs, their execution, and generator caching."""
|
||||
|
||||
@@ -276,7 +355,7 @@ class JobRunner:
|
||||
"""Populate job's log buffer from its log file if it exists."""
|
||||
path = job.log_file_path
|
||||
if not path:
|
||||
path = os.path.join(self.log_dir, f"{job.id}.log")
|
||||
path = _job_log_path(self.output_dir, job.id)
|
||||
if not os.path.isfile(path):
|
||||
return
|
||||
try:
|
||||
@@ -314,10 +393,13 @@ class JobRunner:
|
||||
job = Job(
|
||||
id=row["id"],
|
||||
model_id=row["model_id"],
|
||||
name=row.get("name", "") or "",
|
||||
prompt=row["prompt"],
|
||||
workload_type=row.get("workload_type", "t2v"),
|
||||
job_type=row.get("job_type", "inference"),
|
||||
image_path=row.get("image_path", "") or "",
|
||||
last_image_path=row.get("last_image_path", "") or "",
|
||||
references=_decode_references(row.get("references")),
|
||||
data_path=row.get("data_path", "") or "",
|
||||
max_train_steps=row.get("max_train_steps", 1000),
|
||||
train_batch_size=row.get("train_batch_size", 1),
|
||||
@@ -350,6 +432,7 @@ class JobRunner:
|
||||
negative_prompt=row.get("negative_prompt", "") or "",
|
||||
num_gpus=row.get("num_gpus", 1),
|
||||
dit_cpu_offload=row.get("dit_cpu_offload", False),
|
||||
dit_layerwise_offload=row.get("dit_layerwise_offload", False),
|
||||
text_encoder_cpu_offload=row.get("text_encoder_cpu_offload", False),
|
||||
vae_cpu_offload=row.get("vae_cpu_offload", False),
|
||||
image_encoder_cpu_offload=row.get("image_encoder_cpu_offload", False),
|
||||
@@ -394,9 +477,12 @@ class JobRunner:
|
||||
job_id: str,
|
||||
model_id: str,
|
||||
prompt: str,
|
||||
name: str = "",
|
||||
workload_type: str = "t2v",
|
||||
job_type: str = "inference",
|
||||
image_path: str = "",
|
||||
last_image_path: str = "",
|
||||
references: list[dict[str, Any]] | None = None,
|
||||
data_path: str = "",
|
||||
max_train_steps: int = 1000,
|
||||
train_batch_size: int = 1,
|
||||
@@ -422,6 +508,7 @@ class JobRunner:
|
||||
num_gpus: int = 1,
|
||||
negative_prompt: str = "",
|
||||
dit_cpu_offload: bool = False,
|
||||
dit_layerwise_offload: bool = False,
|
||||
text_encoder_cpu_offload: bool = False,
|
||||
vae_cpu_offload: bool = False,
|
||||
image_encoder_cpu_offload: bool = False,
|
||||
@@ -435,10 +522,13 @@ class JobRunner:
|
||||
job = Job(
|
||||
id=job_id,
|
||||
model_id=model_id,
|
||||
name=(name or "").strip(),
|
||||
prompt=prompt.strip(),
|
||||
workload_type=workload_type or "t2v",
|
||||
job_type=job_type or "inference",
|
||||
image_path=image_path or "",
|
||||
last_image_path=last_image_path or "",
|
||||
references=list(references or []),
|
||||
data_path=data_path or "",
|
||||
max_train_steps=max_train_steps,
|
||||
train_batch_size=train_batch_size,
|
||||
@@ -464,6 +554,7 @@ class JobRunner:
|
||||
negative_prompt=negative_prompt or "",
|
||||
num_gpus=num_gpus,
|
||||
dit_cpu_offload=dit_cpu_offload,
|
||||
dit_layerwise_offload=dit_layerwise_offload,
|
||||
text_encoder_cpu_offload=text_encoder_cpu_offload,
|
||||
vae_cpu_offload=vae_cpu_offload,
|
||||
image_encoder_cpu_offload=image_encoder_cpu_offload,
|
||||
@@ -521,6 +612,84 @@ class JobRunner:
|
||||
logger.info("Deleted job %s", job.id)
|
||||
return True
|
||||
|
||||
CONFIG_FIELDS: tuple[str, ...] = (
|
||||
"model_id",
|
||||
"name",
|
||||
"prompt",
|
||||
"workload_type",
|
||||
"job_type",
|
||||
"image_path",
|
||||
"last_image_path",
|
||||
"references",
|
||||
"negative_prompt",
|
||||
"num_inference_steps",
|
||||
"num_frames",
|
||||
"height",
|
||||
"width",
|
||||
"guidance_scale",
|
||||
"guidance_rescale",
|
||||
"fps",
|
||||
"seed",
|
||||
"num_gpus",
|
||||
"dit_cpu_offload",
|
||||
"dit_layerwise_offload",
|
||||
"text_encoder_cpu_offload",
|
||||
"vae_cpu_offload",
|
||||
"image_encoder_cpu_offload",
|
||||
"use_fsdp_inference",
|
||||
"enable_torch_compile",
|
||||
"vsa_sparsity",
|
||||
"tp_size",
|
||||
"sp_size",
|
||||
"data_path",
|
||||
"max_train_steps",
|
||||
"train_batch_size",
|
||||
"learning_rate",
|
||||
"num_latent_t",
|
||||
"validation_dataset_file",
|
||||
"lora_rank",
|
||||
"dmd_use_vsa",
|
||||
"dmd_vsa_sparsity",
|
||||
"dmd_denoising_steps",
|
||||
"real_score_guidance_scale",
|
||||
"generator_update_interval",
|
||||
"real_score_model_path",
|
||||
"fake_score_model_path",
|
||||
)
|
||||
|
||||
def duplicate_job(self, job_id: str, new_job_id: str) -> Job:
|
||||
"""Create a new pending job with an existing job's configuration.
|
||||
|
||||
Runtime state (status, timings, logs, outputs) is not carried over.
|
||||
"""
|
||||
with self._jobs_lock:
|
||||
source = self._jobs.get(job_id)
|
||||
if source is None:
|
||||
raise ValueError(f"Job {job_id} not found")
|
||||
config = {f: copy.deepcopy(getattr(source, f)) for f in self.CONFIG_FIELDS}
|
||||
return self.create_job(job_id=new_job_id, **config)
|
||||
|
||||
#: Editable exactly when startable: the same set start_job() accepts.
|
||||
EDITABLE_STATUSES = (JobStatus.PENDING, JobStatus.FAILED, JobStatus.STOPPED)
|
||||
|
||||
def update_job_config(self, job_id: str, updates: dict[str, Any]) -> Job:
|
||||
"""Edit the configuration of a job that has not produced a result."""
|
||||
with self._jobs_lock:
|
||||
job = self._jobs.get(job_id)
|
||||
if job is None:
|
||||
raise ValueError(f"Job {job_id} not found")
|
||||
if job.status not in self.EDITABLE_STATUSES:
|
||||
allowed = ", ".join(s.value for s in self.EDITABLE_STATUSES)
|
||||
raise ValueError(f"Job is {job.status.value}; only {allowed} jobs can be edited. "
|
||||
"Duplicate it instead.")
|
||||
unknown = set(updates) - set(self.CONFIG_FIELDS)
|
||||
if unknown:
|
||||
raise ValueError(f"Not editable: {', '.join(sorted(unknown))}")
|
||||
for field_name, value in updates.items():
|
||||
setattr(job, field_name, value)
|
||||
self._save_job(job)
|
||||
return job
|
||||
|
||||
def start_job(self, job_id: str) -> Job:
|
||||
"""Start (or restart) a pending / stopped / failed job.
|
||||
|
||||
@@ -623,6 +792,8 @@ class JobRunner:
|
||||
workload_type: str,
|
||||
num_gpus: int,
|
||||
dit_cpu_offload: bool = False,
|
||||
dit_layerwise_offload: bool = False,
|
||||
override_pipeline_cls_name: str | None = None,
|
||||
text_encoder_cpu_offload: bool = False,
|
||||
vae_cpu_offload: bool = False,
|
||||
image_encoder_cpu_offload: bool = False,
|
||||
@@ -638,6 +809,10 @@ class JobRunner:
|
||||
workload_type,
|
||||
num_gpus,
|
||||
dit_cpu_offload,
|
||||
dit_layerwise_offload,
|
||||
# Ref2VA loads different DiT weights (transformer_ref), so the
|
||||
# override must key the cache or a t2v/i2v generator gets reused.
|
||||
override_pipeline_cls_name,
|
||||
text_encoder_cpu_offload,
|
||||
vae_cpu_offload,
|
||||
image_encoder_cpu_offload,
|
||||
@@ -650,8 +825,21 @@ class JobRunner:
|
||||
|
||||
# Generators are cached by model_id and configuration parameters
|
||||
with self._generators_lock:
|
||||
if cache_key in self._generators:
|
||||
return self._generators[cache_key]
|
||||
cached = self._generators.get(cache_key)
|
||||
if cached is not None:
|
||||
if _generator_is_alive(cached):
|
||||
return cached
|
||||
# Workers can exit while a generator sits idle in the cache;
|
||||
# reusing it fails every later job with the same config.
|
||||
logger.warning(
|
||||
"Cached generator for %s has dead workers; reloading.",
|
||||
model_id,
|
||||
)
|
||||
self._generators.pop(cache_key, None)
|
||||
try:
|
||||
cached.shutdown()
|
||||
except Exception:
|
||||
logger.debug("Shutdown of the dead generator failed", exc_info=True)
|
||||
|
||||
# Import lazily so starting the server is fast even without a GPU.
|
||||
from fastvideo import VideoGenerator
|
||||
@@ -677,6 +865,11 @@ class JobRunner:
|
||||
gen = VideoGenerator.from_pretrained(
|
||||
model_id,
|
||||
workload_type=workload_type,
|
||||
num_gpus=num_gpus,
|
||||
dit_layerwise_offload=dit_layerwise_offload,
|
||||
**({
|
||||
"override_pipeline_cls_name": override_pipeline_cls_name
|
||||
} if override_pipeline_cls_name else {}),
|
||||
dit_cpu_offload=dit_cpu_offload,
|
||||
text_encoder_cpu_offload=text_encoder_cpu_offload,
|
||||
vae_cpu_offload=vae_cpu_offload,
|
||||
@@ -706,10 +899,9 @@ class JobRunner:
|
||||
def _run_training_job(self, job: Job):
|
||||
"""Run a finetuning, distillation, or LoRA job via subprocess."""
|
||||
buf = job._log_buf
|
||||
os.makedirs(self.log_dir, exist_ok=True)
|
||||
job.log_file_path = os.path.join(self.log_dir, f"{job.id}.log")
|
||||
job_output_dir = os.path.join(self.output_dir, job.id)
|
||||
os.makedirs(job_output_dir, exist_ok=True)
|
||||
job.log_file_path = _job_log_path(self.output_dir, job.id)
|
||||
|
||||
if not job.data_path or not os.path.isdir(job.data_path):
|
||||
job.status = JobStatus.FAILED
|
||||
@@ -827,8 +1019,8 @@ class JobRunner:
|
||||
|
||||
def _run_inference_job(self, job: Job):
|
||||
buf = job._log_buf
|
||||
os.makedirs(self.log_dir, exist_ok=True)
|
||||
job.log_file_path = os.path.join(self.log_dir, f"{job.id}.log")
|
||||
os.makedirs(os.path.join(self.output_dir, job.id), exist_ok=True)
|
||||
job.log_file_path = _job_log_path(self.output_dir, job.id)
|
||||
|
||||
# Add file handler to persist logs
|
||||
file_handler = logging.FileHandler(job.log_file_path, mode='w', encoding='utf-8')
|
||||
@@ -875,62 +1067,45 @@ class JobRunner:
|
||||
buf.phase = "loading model"
|
||||
logger.info("Loading model...")
|
||||
|
||||
# Run generator creation in a background thread so we
|
||||
# can poll _stop_event while the (potentially slow)
|
||||
# model download / load is in progress.
|
||||
_gen_result: list[Any] = []
|
||||
_gen_error: list[BaseException] = []
|
||||
# The generator MUST be created on this thread: building it spawns
|
||||
# the executor's worker processes, and they are torn down if the
|
||||
# creating thread exits. Running it in a helper thread (to poll
|
||||
# _stop_event during load) made every collective_rpc fail with
|
||||
# ConnectionResetError.
|
||||
if job._stop_event.is_set():
|
||||
job.status = JobStatus.STOPPED
|
||||
job.finished_at = time.time()
|
||||
self._save_job(job)
|
||||
logger.warning("Job %s stopped before model loading", job.id)
|
||||
buf.phase = "stopped"
|
||||
return
|
||||
|
||||
def _load_generator() -> None:
|
||||
try:
|
||||
gen = self._get_or_create_generator(
|
||||
job.model_id,
|
||||
job.workload_type,
|
||||
job.num_gpus,
|
||||
dit_cpu_offload=job.dit_cpu_offload,
|
||||
text_encoder_cpu_offload=(job.text_encoder_cpu_offload),
|
||||
vae_cpu_offload=job.vae_cpu_offload,
|
||||
image_encoder_cpu_offload=(job.image_encoder_cpu_offload),
|
||||
use_fsdp_inference=job.use_fsdp_inference,
|
||||
enable_torch_compile=(job.enable_torch_compile),
|
||||
vsa_sparsity=job.vsa_sparsity,
|
||||
tp_size=job.tp_size,
|
||||
sp_size=job.sp_size,
|
||||
log_queue=log_queue,
|
||||
)
|
||||
_gen_result.append(gen)
|
||||
except BaseException as exc:
|
||||
_gen_error.append(exc)
|
||||
|
||||
loader = threading.Thread(
|
||||
target=_load_generator,
|
||||
daemon=True,
|
||||
generator = self._get_or_create_generator(
|
||||
job.model_id,
|
||||
job.workload_type,
|
||||
job.num_gpus,
|
||||
dit_cpu_offload=job.dit_cpu_offload,
|
||||
dit_layerwise_offload=job.dit_layerwise_offload,
|
||||
override_pipeline_cls_name=(MINIMAX_H3_REF2VA_PIPELINE if job.references else None),
|
||||
text_encoder_cpu_offload=(job.text_encoder_cpu_offload),
|
||||
vae_cpu_offload=job.vae_cpu_offload,
|
||||
image_encoder_cpu_offload=(job.image_encoder_cpu_offload),
|
||||
use_fsdp_inference=job.use_fsdp_inference,
|
||||
enable_torch_compile=(job.enable_torch_compile),
|
||||
vsa_sparsity=job.vsa_sparsity,
|
||||
tp_size=job.tp_size,
|
||||
sp_size=job.sp_size,
|
||||
log_queue=log_queue,
|
||||
)
|
||||
loader.start()
|
||||
|
||||
while loader.is_alive():
|
||||
if job._stop_event.is_set():
|
||||
job.status = JobStatus.STOPPED
|
||||
job.finished_at = time.time()
|
||||
self._save_job(job)
|
||||
logger.warning(
|
||||
"Job %s stopped during model loading",
|
||||
job.id,
|
||||
)
|
||||
buf.phase = "stopped"
|
||||
return
|
||||
loader.join(timeout=0.5)
|
||||
|
||||
if _gen_error:
|
||||
raise _gen_error[0]
|
||||
|
||||
generator = _gen_result[0]
|
||||
buf.phase = "generating"
|
||||
logger.info("Starting generation for job %s (model=%s)", job.id, job.model_id)
|
||||
|
||||
# Without a name FastVideo derives the filename from the prompt.
|
||||
safe_name = re.sub(r'[\\/:*?"<>|]+', "", job.name).strip().strip(".")
|
||||
output_target = (os.path.join(job_output_dir, f"{safe_name[:80]}.mp4") if safe_name else job_output_dir)
|
||||
gen_kwargs: dict[str, Any] = {
|
||||
"prompt": job.prompt,
|
||||
"output_path": job_output_dir,
|
||||
"output_path": output_target,
|
||||
"save_video": True,
|
||||
"num_inference_steps": job.num_inference_steps,
|
||||
"num_frames": job.num_frames,
|
||||
@@ -945,6 +1120,12 @@ class JobRunner:
|
||||
}
|
||||
if job.image_path:
|
||||
gen_kwargs["image_path"] = job.image_path
|
||||
if job.references:
|
||||
gen_kwargs["references"] = _build_h3_references(job.references)
|
||||
if job.last_image_path:
|
||||
# _prepare_fl2va requires a PIL image, not a path.
|
||||
from PIL import Image as _PILImage
|
||||
gen_kwargs["last_image"] = _PILImage.open(job.last_image_path)
|
||||
generator.generate_video(**gen_kwargs)
|
||||
|
||||
buf.phase = "saving"
|
||||
@@ -977,7 +1158,7 @@ class JobRunner:
|
||||
|
||||
except Exception as exception:
|
||||
error_msg = str(exception)
|
||||
logger.error("Critical error in job thread: %s", error_msg)
|
||||
logger.exception("Critical error in job thread: %s", error_msg)
|
||||
job.status = JobStatus.FAILED
|
||||
job.error = f"Critical error ({type(exception).__name__}): {error_msg}"
|
||||
job.finished_at = time.time()
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Request model for creating a job."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CreateJobRequest(BaseModel):
|
||||
model_id: str
|
||||
name: str = ""
|
||||
prompt: str
|
||||
workload_type: str = "t2v"
|
||||
job_type: str = "inference"
|
||||
image_path: str = ""
|
||||
last_image_path: str = ""
|
||||
references: list[dict[str, Any]] | None = None
|
||||
data_path: str = ""
|
||||
max_train_steps: int = 1000
|
||||
train_batch_size: int = 1
|
||||
@@ -28,6 +33,7 @@ class CreateJobRequest(BaseModel):
|
||||
seed: int = 1024
|
||||
num_gpus: int = 1
|
||||
dit_cpu_offload: bool = False
|
||||
dit_layerwise_offload: bool = False
|
||||
text_encoder_cpu_offload: bool = False
|
||||
vae_cpu_offload: bool = False
|
||||
image_encoder_cpu_offload: bool = False
|
||||
|
||||
@@ -18,6 +18,7 @@ import argparse
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import time
|
||||
@@ -116,7 +117,30 @@ def list_models(workload_type: str | None = None) -> list[dict[str, Any]]:
|
||||
return _available_models
|
||||
|
||||
|
||||
def _safe_upload_name(filename: str | None, ext: str) -> str:
|
||||
"""A filesystem-safe version of the client's filename, keeping it readable.
|
||||
|
||||
Uploads live under a per-file uuid directory, so the basename does not have
|
||||
to be unique -- only safe. Keeping the original name means the path stays
|
||||
self-describing wherever it travels: the database, job logs, and payloads
|
||||
copied back out to the API.
|
||||
"""
|
||||
stem = os.path.basename(filename or "").rsplit(".", 1)[0]
|
||||
stem = re.sub(r"[^A-Za-z0-9._-]+", "_", stem).strip("._-")
|
||||
return f"{stem[:80] or 'upload'}{ext}"
|
||||
|
||||
|
||||
def _upload_destination(ext: str, filename: str | None) -> str:
|
||||
"""<upload_dir>/<uuid4>/<safe original name><ext>"""
|
||||
directory = os.path.join(upload_dir, uuid.uuid4().hex)
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
return os.path.join(directory, _safe_upload_name(filename, ext))
|
||||
|
||||
|
||||
ALLOWED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
|
||||
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".mov", ".mkv", ".webm", ".avi"}
|
||||
ALLOWED_AUDIO_EXTENSIONS = {".wav", ".mp3", ".flac", ".m4a", ".ogg"}
|
||||
ALLOWED_MEDIA_EXTENSIONS = (ALLOWED_IMAGE_EXTENSIONS | ALLOWED_VIDEO_EXTENSIONS | ALLOWED_AUDIO_EXTENSIONS)
|
||||
|
||||
|
||||
@app.post("/api/upload-image")
|
||||
@@ -136,8 +160,7 @@ async def upload_image(file: Annotated[UploadFile, File()], ) -> dict[str, str]:
|
||||
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)
|
||||
dest_path = _upload_destination(ext, file.filename)
|
||||
try:
|
||||
contents = await file.read()
|
||||
with open(dest_path, "wb") as f:
|
||||
@@ -150,6 +173,47 @@ async def upload_image(file: Annotated[UploadFile, File()], ) -> dict[str, str]:
|
||||
return {"path": os.path.abspath(dest_path)}
|
||||
|
||||
|
||||
@app.post("/api/upload-media")
|
||||
async def upload_media(file: Annotated[UploadFile, File()], ) -> dict[str, str]:
|
||||
"""Upload an image, video or audio file for Ref2VA references.
|
||||
|
||||
Returns the absolute path plus the media_type MiniMax-H3 expects, so the
|
||||
caller does not have to re-derive it from the extension.
|
||||
"""
|
||||
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_MEDIA_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(f"Invalid file type. Allowed: "
|
||||
f"{', '.join(sorted(ALLOWED_MEDIA_EXTENSIONS))}"),
|
||||
)
|
||||
if ext in ALLOWED_VIDEO_EXTENSIONS:
|
||||
media_type = "video"
|
||||
elif ext in ALLOWED_AUDIO_EXTENSIONS:
|
||||
media_type = "audio"
|
||||
else:
|
||||
media_type = "image"
|
||||
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
dest_path = _upload_destination(ext, file.filename)
|
||||
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), "media_type": media_type}
|
||||
|
||||
|
||||
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".webm", ".avi", ".mov", ".mkv"}
|
||||
|
||||
|
||||
@@ -282,10 +346,13 @@ def create_job(req: CreateJobRequest) -> dict[str, Any]:
|
||||
job = job_runner.create_job(
|
||||
job_id=str(uuid.uuid4()),
|
||||
model_id=req.model_id,
|
||||
name=req.name or "",
|
||||
prompt=req.prompt,
|
||||
workload_type=req.workload_type or "t2v",
|
||||
job_type=job_type,
|
||||
image_path=req.image_path or "",
|
||||
last_image_path=req.last_image_path or "",
|
||||
references=req.references or [],
|
||||
data_path=data_path,
|
||||
max_train_steps=req.max_train_steps,
|
||||
train_batch_size=req.train_batch_size,
|
||||
@@ -304,6 +371,7 @@ def create_job(req: CreateJobRequest) -> dict[str, Any]:
|
||||
seed=req.seed,
|
||||
num_gpus=req.num_gpus,
|
||||
dit_cpu_offload=req.dit_cpu_offload,
|
||||
dit_layerwise_offload=req.dit_layerwise_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,
|
||||
@@ -336,6 +404,28 @@ def create_job(req: CreateJobRequest) -> dict[str, Any]:
|
||||
return job.to_dict()
|
||||
|
||||
|
||||
@app.post("/api/jobs/{job_id}/duplicate", status_code=201)
|
||||
def duplicate_job(job_id: str) -> dict[str, Any]:
|
||||
"""Create a new pending job with the same configuration as an existing one."""
|
||||
try:
|
||||
job = job_runner.duplicate_job(job_id, str(uuid.uuid4()))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
return job.to_dict()
|
||||
|
||||
|
||||
@app.patch("/api/jobs/{job_id}")
|
||||
def update_job(job_id: str, updates: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Edit a pending job's configuration. Started jobs cannot be edited."""
|
||||
try:
|
||||
job = job_runner.update_job_config(job_id, updates)
|
||||
except ValueError as e:
|
||||
detail = str(e)
|
||||
status = 404 if "not found" in detail else 400
|
||||
raise HTTPException(status_code=status, detail=detail) from e
|
||||
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."""
|
||||
|
||||
@@ -22,15 +22,35 @@ import { useStore } from '@/hooks/useStore';
|
||||
import { defaultOptionsStore } from '@/stores/defaultOptions';
|
||||
import {
|
||||
createJob,
|
||||
updateJob,
|
||||
getDatasets,
|
||||
getModels,
|
||||
uploadImage,
|
||||
uploadMedia,
|
||||
type CreateJobRequest,
|
||||
type Model,
|
||||
} from '@/lib/api';
|
||||
import { getDefaultModelForWorkload } from '@/lib/defaultOptions';
|
||||
import { WORKLOAD_OPTIONS } from '@/lib/jobConfig';
|
||||
import type { JobType } from '@/lib/types';
|
||||
import {
|
||||
H3_MAX_REFERENCES,
|
||||
labelReferences,
|
||||
referencePromptSeed,
|
||||
validateReferences,
|
||||
type H3Reference,
|
||||
} from '@/lib/h3References';
|
||||
import {
|
||||
EMPTY_H3_PROMPT_FIELDS,
|
||||
H3_PROMPT_SECTIONS,
|
||||
H3_SECTION_HINTS,
|
||||
H3_SECTION_LABELS,
|
||||
isEmptyPromptFields,
|
||||
parseH3Prompt,
|
||||
serializeH3Prompt,
|
||||
type H3PromptFields,
|
||||
} from '@/lib/h3Prompt';
|
||||
import { jobToFormFields, type JobLike } from '@/lib/jobToFields';
|
||||
|
||||
export interface CreateJobModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -38,6 +58,10 @@ export interface CreateJobModalProps {
|
||||
onSuccess: () => void;
|
||||
jobType: JobType;
|
||||
workloadType: string;
|
||||
/** When set, the modal edits this pending job instead of creating a new one. */
|
||||
editingJob?: JobLike | null;
|
||||
/** Show the configuration without allowing changes (started/finished jobs). */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export default function CreateJobModal({
|
||||
@@ -46,6 +70,8 @@ export default function CreateJobModal({
|
||||
onSuccess,
|
||||
jobType,
|
||||
workloadType,
|
||||
editingJob,
|
||||
readOnly = false,
|
||||
}: CreateJobModalProps) {
|
||||
const { options } = useStore(defaultOptionsStore);
|
||||
|
||||
@@ -55,8 +81,22 @@ export default function CreateJobModal({
|
||||
|
||||
const [models, setModels] = React.useState<Model[]>([]);
|
||||
const [modelId, setModelId] = React.useState('');
|
||||
const [name, setName] = React.useState('');
|
||||
const [prompt, setPrompt] = React.useState('');
|
||||
const [imagePath, setImagePath] = React.useState('');
|
||||
const [lastImagePath, setLastImagePath] = React.useState('');
|
||||
const [references, setReferences] = React.useState<H3Reference[]>([]);
|
||||
const [isUploadingReference, setIsUploadingReference] = React.useState(false);
|
||||
const [referenceError, setReferenceError] = React.useState<string | null>(null);
|
||||
const [promptFields, setPromptFields] = React.useState<H3PromptFields>(
|
||||
EMPTY_H3_PROMPT_FIELDS,
|
||||
);
|
||||
const [useGuidedPrompt, setUseGuidedPrompt] = React.useState(true);
|
||||
const [lastImageFileName, setLastImageFileName] = React.useState('');
|
||||
const [isUploadingLastImage, setIsUploadingLastImage] = React.useState(false);
|
||||
const [lastImageUploadError, setLastImageUploadError] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [imageFileName, setImageFileName] = React.useState('');
|
||||
const [isUploadingImage, setIsUploadingImage] = React.useState(false);
|
||||
const [negativePrompt, setNegativePrompt] = React.useState('');
|
||||
@@ -70,12 +110,50 @@ export default function CreateJobModal({
|
||||
const [seed, setSeed] = React.useState(1024);
|
||||
const [numGpus, setNumGpus] = React.useState(1);
|
||||
const [ditCpuOffload, setDitCpuOffload] = React.useState(false);
|
||||
const [ditLayerwiseOffload, setDitLayerwiseOffload] = React.useState(false);
|
||||
const [textEncoderCpuOffload, setTextEncoderCpuOffload] =
|
||||
React.useState(false);
|
||||
const [vaeCpuOffload, setVaeCpuOffload] = React.useState(false);
|
||||
const [imageEncoderCpuOffload, setImageEncoderCpuOffload] =
|
||||
React.useState(false);
|
||||
const [useFsdpInference, setUseFsdpInference] = React.useState(false);
|
||||
|
||||
// H3 is the only registered model with an end frame or references.
|
||||
const supportsLastImage = modelId.toLowerCase().includes('minimax-h3');
|
||||
const usingReferences = supportsLastImage && references.length > 0;
|
||||
|
||||
// JobCard re-renders on every job-list poll, so `editingJob` is a fresh
|
||||
// object each time. Effects must depend on these, never on the object.
|
||||
const editingJobId = editingJob?.id ?? null;
|
||||
const editingJobModelId = editingJob?.model_id ?? null;
|
||||
|
||||
// Layerwise offload and FSDP compete for the DiT weights and FastVideoArgs
|
||||
// silently picks a winner (fastvideo_args.py:859); resolve it visibly here.
|
||||
// dit_cpu_offload is deliberately not interlocked -- it is a modifier, not a
|
||||
// competing strategy.
|
||||
const handleDitLayerwiseOffloadChange = React.useCallback((next: boolean) => {
|
||||
setDitLayerwiseOffload(next);
|
||||
if (next) {
|
||||
setUseFsdpInference(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleUseFsdpInferenceChange = React.useCallback((next: boolean) => {
|
||||
setUseFsdpInference(next);
|
||||
if (next) {
|
||||
setDitLayerwiseOffload(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleNumGpusChange = React.useCallback((next: number) => {
|
||||
setNumGpus(next);
|
||||
if (next > 1) {
|
||||
// Dropping back to one GPU leaves FSDP alone: single-GPU FSDP is a
|
||||
// valid way to reach its CPU offload (docs/inference/offloading.md).
|
||||
setUseFsdpInference(true);
|
||||
setDitLayerwiseOffload(false);
|
||||
}
|
||||
}, []);
|
||||
const [enableTorchCompile, setEnableTorchCompile] = React.useState(false);
|
||||
const [vsaSparsity, setVsaSparsity] = React.useState(0);
|
||||
const [tpSize, setTpSize] = React.useState(-1);
|
||||
@@ -126,6 +204,47 @@ export default function CreateJobModal({
|
||||
const justOpened = isOpen && !justOpenedRef.current;
|
||||
justOpenedRef.current = isOpen;
|
||||
if (!justOpened) return;
|
||||
if (editingJob) {
|
||||
// Must not fall through to the defaults below: a partially-seeded
|
||||
// form silently edits values the user never saw.
|
||||
const f = jobToFormFields(editingJob);
|
||||
setModelId(f.modelId);
|
||||
setName(f.name);
|
||||
setPrompt(f.prompt);
|
||||
setNegativePrompt(f.negativePrompt);
|
||||
setImagePath(f.imagePath);
|
||||
setImageFileName(f.imagePath.split('/').pop() ?? '');
|
||||
setLastImagePath(f.lastImagePath);
|
||||
setLastImageFileName(f.lastImagePath.split('/').pop() ?? '');
|
||||
setReferences(f.references);
|
||||
setPromptFields(f.promptFields ?? EMPTY_H3_PROMPT_FIELDS);
|
||||
setUseGuidedPrompt(f.promptFields !== null);
|
||||
setNumInferenceSteps(f.numInferenceSteps);
|
||||
setNumFrames(f.numFrames);
|
||||
setHeight(f.height);
|
||||
setWidth(f.width);
|
||||
setGuidanceScale(f.guidanceScale);
|
||||
setGuidanceRescale(f.guidanceRescale);
|
||||
setFps(f.fps);
|
||||
setSeed(f.seed);
|
||||
setNumGpus(f.numGpus);
|
||||
setDitCpuOffload(f.ditCpuOffload);
|
||||
setDitLayerwiseOffload(f.ditLayerwiseOffload);
|
||||
setTextEncoderCpuOffload(f.textEncoderCpuOffload);
|
||||
setVaeCpuOffload(f.vaeCpuOffload);
|
||||
setImageEncoderCpuOffload(f.imageEncoderCpuOffload);
|
||||
setUseFsdpInference(f.useFsdpInference);
|
||||
setEnableTorchCompile(f.enableTorchCompile);
|
||||
setVsaSparsity(f.vsaSparsity);
|
||||
setTpSize(f.tpSize);
|
||||
setSpSize(f.spSize);
|
||||
setReferenceError(null);
|
||||
setModelLoadError(null);
|
||||
setImageUploadError(null);
|
||||
setLastImageUploadError(null);
|
||||
setSubmitError(null);
|
||||
return;
|
||||
}
|
||||
const opts = options;
|
||||
setNumInferenceSteps(opts.numInferenceSteps);
|
||||
setNumFrames(workloadType === 't2i' ? 1 : opts.numFrames);
|
||||
@@ -137,6 +256,7 @@ export default function CreateJobModal({
|
||||
setSeed(opts.seed);
|
||||
setNumGpus(opts.numGpus);
|
||||
setDitCpuOffload(opts.ditCpuOffload);
|
||||
setDitLayerwiseOffload(opts.ditLayerwiseOffload ?? false);
|
||||
setTextEncoderCpuOffload(opts.textEncoderCpuOffload);
|
||||
setVaeCpuOffload(opts.vaeCpuOffload);
|
||||
setImageEncoderCpuOffload(opts.imageEncoderCpuOffload);
|
||||
@@ -151,8 +271,16 @@ export default function CreateJobModal({
|
||||
inferenceWorkload as 't2v' | 'i2v' | 't2i',
|
||||
),
|
||||
);
|
||||
setName('');
|
||||
setImagePath('');
|
||||
setImageFileName('');
|
||||
setLastImagePath('');
|
||||
setLastImageFileName('');
|
||||
setLastImageUploadError(null);
|
||||
setReferences([]);
|
||||
setReferenceError(null);
|
||||
setPromptFields(EMPTY_H3_PROMPT_FIELDS);
|
||||
setUseGuidedPrompt(true);
|
||||
setSelectedDatasetId('');
|
||||
setSelectedValidationDatasetId('');
|
||||
setModelLoadError(null);
|
||||
@@ -168,7 +296,7 @@ export default function CreateJobModal({
|
||||
setRealScoreModelPath('');
|
||||
setFakeScoreModelPath('');
|
||||
}
|
||||
}, [isOpen, workloadType, inferenceWorkload, options]);
|
||||
}, [isOpen, workloadType, inferenceWorkload, options, editingJobId]);
|
||||
|
||||
// Load the models available for this workload.
|
||||
React.useEffect(() => {
|
||||
@@ -188,7 +316,16 @@ export default function CreateJobModal({
|
||||
opts,
|
||||
inferenceWorkload as 't2v' | 'i2v' | 't2i',
|
||||
);
|
||||
const chosen = ids.includes(defaultId) ? defaultId : (list[0]?.id ?? '');
|
||||
// When editing, the job's own model wins over the workload default --
|
||||
// this resolves after the seeding effect, so choosing a default here
|
||||
// would silently swap the model out from under the user.
|
||||
const editedId = editingJobModelId;
|
||||
const chosen =
|
||||
editedId && ids.includes(editedId)
|
||||
? editedId
|
||||
: ids.includes(defaultId)
|
||||
? defaultId
|
||||
: (list[0]?.id ?? '');
|
||||
setModelId(chosen);
|
||||
if (workloadType === 'dmd_t2v') {
|
||||
setRealScoreModelPath(chosen);
|
||||
@@ -210,7 +347,7 @@ export default function CreateJobModal({
|
||||
return () => {
|
||||
stale = true;
|
||||
};
|
||||
}, [isOpen, inferenceWorkload, workloadType]);
|
||||
}, [isOpen, inferenceWorkload, workloadType, editingJobModelId]);
|
||||
|
||||
// Training jobs need a dataset; load the ready datasets when relevant.
|
||||
React.useEffect(() => {
|
||||
@@ -262,6 +399,106 @@ export default function CreateJobModal({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLastImageChange(
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) {
|
||||
setLastImagePath('');
|
||||
setLastImageFileName('');
|
||||
setLastImageUploadError(null);
|
||||
return;
|
||||
}
|
||||
setIsUploadingLastImage(true);
|
||||
setLastImageFileName(file.name);
|
||||
setLastImageUploadError(null);
|
||||
try {
|
||||
const { path } = await uploadImage(file);
|
||||
setLastImagePath(path);
|
||||
} catch (error) {
|
||||
console.error('Failed to upload end image:', error);
|
||||
setLastImagePath('');
|
||||
setLastImageFileName('');
|
||||
setLastImageUploadError(
|
||||
error instanceof Error
|
||||
? `${error.message}. Choose the image again to retry.`
|
||||
: 'The image could not be uploaded. Choose it again to retry.',
|
||||
);
|
||||
} finally {
|
||||
setIsUploadingLastImage(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddReference(
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = ''; // allow re-picking the same file
|
||||
if (!file) return;
|
||||
setIsUploadingReference(true);
|
||||
setReferenceError(null);
|
||||
try {
|
||||
const { path, media_type } = await uploadMedia(file);
|
||||
const next: H3Reference[] = [
|
||||
...references,
|
||||
{
|
||||
id: `${Date.now()}-${file.name}`,
|
||||
source: path,
|
||||
media_type,
|
||||
fileName: file.name,
|
||||
},
|
||||
];
|
||||
setReferences(next);
|
||||
setReferenceError(validateReferences(next));
|
||||
} catch (error) {
|
||||
console.error('Failed to upload reference:', error);
|
||||
setReferenceError(
|
||||
error instanceof Error ? error.message : 'The file could not be uploaded.',
|
||||
);
|
||||
} finally {
|
||||
setIsUploadingReference(false);
|
||||
}
|
||||
}
|
||||
|
||||
function removeReference(id: string) {
|
||||
const next = references.filter((r) => r.id !== id);
|
||||
setReferences(next);
|
||||
setReferenceError(validateReferences(next));
|
||||
}
|
||||
|
||||
function seedPromptFields() {
|
||||
setPromptFields({
|
||||
...EMPTY_H3_PROMPT_FIELDS,
|
||||
...referencePromptSeed(references),
|
||||
});
|
||||
setUseGuidedPrompt(true);
|
||||
}
|
||||
|
||||
function setPromptField(section: string, value: string) {
|
||||
setPromptFields((prev) => ({ ...prev, [section]: value }));
|
||||
}
|
||||
|
||||
// Switching between the guided fields and the raw editor keeps whatever was
|
||||
// typed: serialize on the way out, parse back on the way in.
|
||||
function toggleGuidedPrompt() {
|
||||
if (useGuidedPrompt) {
|
||||
if (!isEmptyPromptFields(promptFields)) {
|
||||
setPrompt(serializeH3Prompt(promptFields));
|
||||
}
|
||||
setUseGuidedPrompt(false);
|
||||
} else {
|
||||
const parsed = parseH3Prompt(prompt);
|
||||
if (parsed) setPromptFields(parsed);
|
||||
setUseGuidedPrompt(true);
|
||||
}
|
||||
}
|
||||
|
||||
function clearLastImage() {
|
||||
setLastImagePath('');
|
||||
setLastImageFileName('');
|
||||
setLastImageUploadError(null);
|
||||
}
|
||||
|
||||
function clearImage() {
|
||||
setImagePath('');
|
||||
setImageFileName('');
|
||||
@@ -271,7 +508,16 @@ export default function CreateJobModal({
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (isInference && workloadType === 'i2v' && !imagePath) return;
|
||||
if (isInference && workloadType === 'i2v' && !imagePath && !usingReferences)
|
||||
return;
|
||||
if (usingReferences && validateReferences(references)) return;
|
||||
if (
|
||||
usingReferences &&
|
||||
useGuidedPrompt &&
|
||||
isEmptyPromptFields(promptFields) &&
|
||||
!prompt.trim()
|
||||
)
|
||||
return;
|
||||
// Send the dataset id; the backend resolves it to the on-disk media dir.
|
||||
const effectiveDataPath = selectedDatasetId ?? '';
|
||||
if (!isInference && !selectedDatasetId) return;
|
||||
@@ -285,14 +531,35 @@ export default function CreateJobModal({
|
||||
try {
|
||||
const payload: CreateJobRequest = {
|
||||
model_id: modelId,
|
||||
prompt,
|
||||
name: name.trim(),
|
||||
prompt:
|
||||
usingReferences && useGuidedPrompt && !isEmptyPromptFields(promptFields)
|
||||
? serializeH3Prompt(promptFields)
|
||||
: prompt,
|
||||
workload_type: workloadType,
|
||||
job_type: effectiveJobType,
|
||||
...(isInference
|
||||
? {
|
||||
...(workloadType === 'i2v' && imagePath
|
||||
// Ref2VA and the FL2VA keyframes are mutually exclusive:
|
||||
// _prepare_ref2va rejects image_path/last_image_path outright
|
||||
// when references are present.
|
||||
...(workloadType === 'i2v' && !usingReferences && imagePath
|
||||
? { image_path: imagePath }
|
||||
: {}),
|
||||
...(workloadType === 'i2v' &&
|
||||
supportsLastImage &&
|
||||
!usingReferences &&
|
||||
lastImagePath
|
||||
? { last_image_path: lastImagePath }
|
||||
: {}),
|
||||
...(workloadType === 'i2v' && supportsLastImage && references.length
|
||||
? {
|
||||
references: references.map((r) => ({
|
||||
source: r.source,
|
||||
media_type: r.media_type,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
negative_prompt: negativePrompt,
|
||||
num_inference_steps: numInferenceSteps,
|
||||
num_frames: numFrames,
|
||||
@@ -304,6 +571,7 @@ export default function CreateJobModal({
|
||||
seed,
|
||||
num_gpus: numGpus,
|
||||
dit_cpu_offload: ditCpuOffload,
|
||||
dit_layerwise_offload: ditLayerwiseOffload,
|
||||
text_encoder_cpu_offload: textEncoderCpuOffload,
|
||||
vae_cpu_offload: vaeCpuOffload,
|
||||
image_encoder_cpu_offload: imageEncoderCpuOffload,
|
||||
@@ -334,7 +602,14 @@ export default function CreateJobModal({
|
||||
: {}),
|
||||
}),
|
||||
};
|
||||
await createJob(payload);
|
||||
if (editingJob) {
|
||||
await updateJob(
|
||||
editingJob.id,
|
||||
payload as unknown as Record<string, unknown>,
|
||||
);
|
||||
} else {
|
||||
await createJob(payload);
|
||||
}
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -356,9 +631,9 @@ export default function CreateJobModal({
|
||||
|
||||
const workloadLabel =
|
||||
WORKLOAD_OPTIONS[jobType]?.find((o) => o.type === workloadType)?.label ?? '';
|
||||
const title = `New ${jobType.charAt(0).toUpperCase() + jobType.slice(1)} Job${
|
||||
workloadLabel ? ` (${workloadLabel})` : ''
|
||||
}`;
|
||||
const title = `${readOnly ? 'View' : editingJob ? 'Edit' : 'New'} ${
|
||||
jobType.charAt(0).toUpperCase() + jobType.slice(1)
|
||||
} Job${workloadLabel ? ` (${workloadLabel})` : ''}`;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -385,6 +660,23 @@ export default function CreateJobModal({
|
||||
autoComplete="off"
|
||||
className="flex flex-col gap-3.5"
|
||||
>
|
||||
{/* disabled cascades to every control inside; display:contents
|
||||
keeps the parent's flex layout. */}
|
||||
<fieldset
|
||||
disabled={readOnly}
|
||||
style={{ display: 'contents' }}
|
||||
className="contents"
|
||||
>
|
||||
<FieldRow htmlFor="modal-name" label="Name (optional)">
|
||||
<Input
|
||||
id="modal-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Shown on the job card and used for the output filename"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</FieldRow>
|
||||
|
||||
<FieldRow htmlFor="modal-modelId" label="Model">
|
||||
<NativeSelect
|
||||
id="modal-modelId"
|
||||
@@ -429,12 +721,12 @@ export default function CreateJobModal({
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.webp,.bmp"
|
||||
onChange={handleImageChange}
|
||||
disabled={isSubmitting || isUploadingImage}
|
||||
disabled={isSubmitting || isUploadingImage || usingReferences}
|
||||
aria-describedby={
|
||||
imageUploadError ? 'modal-image-error' : undefined
|
||||
}
|
||||
aria-invalid={imageUploadError ? true : undefined}
|
||||
required
|
||||
required={!usingReferences}
|
||||
className="h-auto py-2 file:mr-3 file:cursor-pointer file:rounded-md file:border-0 file:bg-secondary file:px-2 file:py-1 file:text-sm file:text-secondary-foreground"
|
||||
/>
|
||||
{imageFileName && (
|
||||
@@ -462,24 +754,169 @@ export default function CreateJobModal({
|
||||
</FieldRow>
|
||||
)}
|
||||
|
||||
<FieldRow
|
||||
htmlFor="modal-prompt"
|
||||
label={isInference ? 'Prompt' : 'Description'}
|
||||
>
|
||||
<Textarea
|
||||
id="modal-prompt"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
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}
|
||||
/>
|
||||
</FieldRow>
|
||||
{isInference && workloadType === 'i2v' && supportsLastImage && (
|
||||
<FieldRow htmlFor="modal-last-image" label="End Frame (optional)">
|
||||
<Input
|
||||
id="modal-last-image"
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.webp,.bmp"
|
||||
onChange={handleLastImageChange}
|
||||
disabled={isSubmitting || isUploadingLastImage}
|
||||
aria-describedby={
|
||||
lastImageUploadError ? 'modal-last-image-error' : undefined
|
||||
}
|
||||
aria-invalid={lastImageUploadError ? true : undefined}
|
||||
className="h-auto py-2 file:mr-3 file:cursor-pointer file:rounded-md file:border-0 file:bg-secondary file:px-2 file:py-1 file:text-sm file:text-secondary-foreground"
|
||||
/>
|
||||
{lastImageFileName && (
|
||||
<span className="mt-0.5 text-xs text-muted-foreground">
|
||||
{isUploadingLastImage ? 'Uploading…' : lastImageFileName} ·{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearLastImage}
|
||||
disabled={isSubmitting || isUploadingLastImage}
|
||||
className="text-accent-blue underline-offset-2 hover:underline disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{lastImageUploadError && (
|
||||
<p
|
||||
id="modal-last-image-error"
|
||||
role="alert"
|
||||
className="text-sm text-destructive"
|
||||
>
|
||||
{lastImageUploadError}
|
||||
</p>
|
||||
)}
|
||||
</FieldRow>
|
||||
)}
|
||||
|
||||
{isInference && workloadType === 'i2v' && supportsLastImage && (
|
||||
<FieldRow htmlFor="modal-reference" label="References (Ref2VA)">
|
||||
<Input
|
||||
id="modal-reference"
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.webp,.bmp,.mp4,.mov,.mkv,.webm,.avi,.wav,.mp3,.flac,.m4a,.ogg"
|
||||
onChange={handleAddReference}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
isUploadingReference ||
|
||||
references.length >= H3_MAX_REFERENCES
|
||||
}
|
||||
className="h-auto py-2 file:mr-3 file:cursor-pointer file:rounded-md file:border-0 file:bg-secondary file:px-2 file:py-1 file:text-sm file:text-secondary-foreground"
|
||||
/>
|
||||
{isUploadingReference && (
|
||||
<span className="mt-0.5 text-xs text-muted-foreground">
|
||||
Uploading…
|
||||
</span>
|
||||
)}
|
||||
{references.length > 0 && (
|
||||
<ul className="mt-1 flex list-none flex-col gap-1 p-0">
|
||||
{references.map((reference, index) => (
|
||||
<li
|
||||
key={reference.id}
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<code className="font-mono text-accent-blue">
|
||||
{labelReferences(references)[index]}
|
||||
</code>
|
||||
<span className="truncate">{reference.fileName}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeReference(reference.id)}
|
||||
disabled={isSubmitting}
|
||||
className="ml-auto text-accent-blue underline-offset-2 hover:underline disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{references.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={seedPromptFields}
|
||||
disabled={isSubmitting}
|
||||
className="mt-1 self-start text-xs text-accent-blue underline-offset-2 hover:underline disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Fill prompt sections from references
|
||||
</button>
|
||||
)}
|
||||
{referenceError && (
|
||||
<p role="alert" className="mt-0.5 text-xs text-destructive">
|
||||
{referenceError}
|
||||
</p>
|
||||
)}
|
||||
<span className="mt-0.5 text-xs text-muted-foreground">
|
||||
Ref2VA replaces the keyframes: up to 9 images, 3 videos, 3 audio
|
||||
(12 total). Audio needs at least one image or video.
|
||||
</span>
|
||||
</FieldRow>
|
||||
)}
|
||||
|
||||
{usingReferences && useGuidedPrompt ? (
|
||||
/* Six-section format from the model's reference prompt guide. */
|
||||
<>
|
||||
{H3_PROMPT_SECTIONS.map((section) => (
|
||||
<FieldRow
|
||||
key={section}
|
||||
htmlFor={`modal-prompt-${section}`}
|
||||
label={H3_SECTION_LABELS[section]}
|
||||
>
|
||||
<Textarea
|
||||
id={`modal-prompt-${section}`}
|
||||
value={promptFields[section]}
|
||||
onChange={(e) => setPromptField(section, e.target.value)}
|
||||
rows={section === 'detailed_description' ? 5 : 2}
|
||||
placeholder={H3_SECTION_HINTS[section]}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</FieldRow>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleGuidedPrompt}
|
||||
disabled={isSubmitting}
|
||||
className="self-start text-xs text-accent-blue underline-offset-2 hover:underline disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Edit as raw prompt
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FieldRow
|
||||
htmlFor="modal-prompt"
|
||||
label={isInference ? 'Prompt' : 'Description'}
|
||||
>
|
||||
<Textarea
|
||||
id="modal-prompt"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
rows={isInference ? 3 : 2}
|
||||
placeholder={
|
||||
isInference
|
||||
? 'A curious raccoon peers through a vibrant field of yellow sunflowers…'
|
||||
: 'Brief description of this training job…'
|
||||
}
|
||||
required={!(usingReferences && useGuidedPrompt)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</FieldRow>
|
||||
{usingReferences && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleGuidedPrompt}
|
||||
disabled={isSubmitting}
|
||||
className="self-start text-xs text-accent-blue underline-offset-2 hover:underline disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Edit as prompt sections
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isInference && (
|
||||
<FieldRow htmlFor="modal-negative-prompt" label="Negative Prompt">
|
||||
@@ -849,6 +1286,13 @@ export default function CreateJobModal({
|
||||
onChange={setDitCpuOffload}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<ToggleRow
|
||||
id="modal-dit-layerwise-offload"
|
||||
label="DiT Layerwise Offload"
|
||||
checked={ditLayerwiseOffload}
|
||||
onChange={handleDitLayerwiseOffloadChange}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<ToggleRow
|
||||
id="modal-text-encoder-cpu-offload"
|
||||
label="Text Encoder CPU Offload"
|
||||
@@ -860,7 +1304,7 @@ export default function CreateJobModal({
|
||||
id="modal-use-fsdp-inference"
|
||||
label="Use FSDP Inference"
|
||||
checked={useFsdpInference}
|
||||
onChange={setUseFsdpInference}
|
||||
onChange={handleUseFsdpInferenceChange}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<ToggleRow
|
||||
@@ -891,7 +1335,7 @@ export default function CreateJobModal({
|
||||
max={8}
|
||||
step={1}
|
||||
value={numGpus}
|
||||
onChange={setNumGpus}
|
||||
onChange={handleNumGpusChange}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<NumberRow
|
||||
@@ -906,6 +1350,8 @@ export default function CreateJobModal({
|
||||
</details>
|
||||
)}
|
||||
|
||||
</fieldset>
|
||||
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
{submitError && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
@@ -914,14 +1360,22 @@ export default function CreateJobModal({
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
hidden={readOnly}
|
||||
disabled={
|
||||
readOnly ||
|
||||
isSubmitting ||
|
||||
isUploadingImage ||
|
||||
!!modelLoadError ||
|
||||
!!datasetLoadError
|
||||
}
|
||||
>
|
||||
{isSubmitting ? 'Creating…' : 'Create Job'}
|
||||
{isSubmitting
|
||||
? editingJob
|
||||
? 'Saving…'
|
||||
: 'Creating…'
|
||||
: editingJob
|
||||
? 'Save Changes'
|
||||
: 'Create Job'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import * as React from 'react';
|
||||
import { Timer } from 'lucide-react';
|
||||
|
||||
import CreateJobModal from '@/components/jobs/CreateJobModal';
|
||||
import { Badge, type BadgeProps } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useStore } from '@/hooks/useStore';
|
||||
import {
|
||||
deleteJob,
|
||||
duplicateJob,
|
||||
downloadJobVideo,
|
||||
startJob,
|
||||
stopJob,
|
||||
@@ -62,6 +64,8 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
|
||||
const isSelected = activeJobId === job.id;
|
||||
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [isViewing, setIsViewing] = React.useState(false);
|
||||
const [currentTime, setCurrentTime] = React.useState(() => Date.now());
|
||||
|
||||
const elapsedTime = computeElapsed(job, currentTime);
|
||||
@@ -103,6 +107,21 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDuplicate(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isLoading) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await duplicateJob(job.id);
|
||||
onJobUpdated?.();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to duplicate job');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -156,16 +175,23 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
|
||||
>
|
||||
<span className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-[0.95rem] font-semibold text-foreground">
|
||||
{job.model_id}
|
||||
{job.name?.trim() || job.model_id}
|
||||
</span>
|
||||
<Badge variant={BADGE_VARIANTS[job.status] ?? 'secondary'}>
|
||||
{job.status}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-muted-foreground">
|
||||
{job.prompt}
|
||||
{job.name?.trim() ? `${job.model_id} · ${job.prompt}` : job.prompt}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
|
||||
{/* Short job id; logs and output dirs are keyed on the full UUID. */}
|
||||
<span
|
||||
className="font-mono text-muted-foreground/80"
|
||||
title={job.id}
|
||||
>
|
||||
{job.id.slice(0, 8)}
|
||||
</span>
|
||||
{job.job_type === 'inference' ? (
|
||||
<>
|
||||
<span>{job.num_frames} frames</span>
|
||||
@@ -226,6 +252,51 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
|
||||
Download Video
|
||||
</Button>
|
||||
)}
|
||||
{!(
|
||||
job.status === 'pending' ||
|
||||
job.status === 'failed' ||
|
||||
job.status === 'stopped'
|
||||
) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsViewing(true);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
title="View this job's configuration"
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
)}
|
||||
{(job.status === 'pending' ||
|
||||
job.status === 'failed' ||
|
||||
job.status === 'stopped') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsEditing(true);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
title="Edit this job's configuration"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleDuplicate}
|
||||
disabled={isLoading}
|
||||
title="Create a new pending job with this configuration"
|
||||
>
|
||||
Duplicate
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
@@ -235,6 +306,30 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
{isViewing && (
|
||||
<CreateJobModal
|
||||
isOpen
|
||||
readOnly
|
||||
editingJob={job}
|
||||
jobType={(job.job_type ?? 'inference') as never}
|
||||
workloadType={job.workload_type ?? 't2v'}
|
||||
onClose={() => setIsViewing(false)}
|
||||
onSuccess={() => setIsViewing(false)}
|
||||
/>
|
||||
)}
|
||||
{isEditing && (
|
||||
<CreateJobModal
|
||||
isOpen
|
||||
editingJob={job}
|
||||
jobType={(job.job_type ?? 'inference') as never}
|
||||
workloadType={job.workload_type ?? 't2v'}
|
||||
onClose={() => setIsEditing(false)}
|
||||
onSuccess={() => {
|
||||
setIsEditing(false);
|
||||
onJobUpdated?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,9 +153,16 @@ export default function JobDetailsSidebar({
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<h2 className="m-0 text-base font-semibold text-foreground">
|
||||
Job Details
|
||||
</h2>
|
||||
<div className="min-w-0">
|
||||
<h2 className="m-0 text-base font-semibold text-foreground">
|
||||
Job Details
|
||||
</h2>
|
||||
{/* Full job id: keys ~/h3_studio_logs/<id>.log, the output directory
|
||||
and every API route, so make it selectable for copy/paste. */}
|
||||
<code className="mt-0.5 block select-all truncate font-mono text-xs text-muted-foreground">
|
||||
{job.id}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -56,6 +56,8 @@ export function getJobVideoUrl(jobId: string): string {
|
||||
|
||||
export interface CreateJobRequest {
|
||||
model_id: string;
|
||||
/** Optional label; the card and output filename fall back to the prompt. */
|
||||
name?: string;
|
||||
prompt: string;
|
||||
workload_type?: string;
|
||||
job_type?: JobType;
|
||||
@@ -152,6 +154,65 @@ export async function updateSettings(
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export type MediaType = "image" | "video" | "audio";
|
||||
|
||||
/**
|
||||
* Upload an image, video or audio file for a MiniMax-H3 Ref2VA reference.
|
||||
* The server derives media_type from the extension and returns it, so callers
|
||||
* do not have to duplicate that mapping.
|
||||
*/
|
||||
/** Create a new pending job with the same configuration as an existing one. */
|
||||
export async function duplicateJob(jobId: string): Promise<{ id: string }> {
|
||||
const baseApiUrl = getApiBaseUrl();
|
||||
const response = await fetch(`${baseApiUrl}/jobs/${jobId}/duplicate`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Duplicate failed" }));
|
||||
throw new Error(err.detail || "Duplicate failed");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/** Edit a pending job's configuration. Started jobs are rejected by the API. */
|
||||
export async function updateJob(
|
||||
jobId: string,
|
||||
updates: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
const baseApiUrl = getApiBaseUrl();
|
||||
const response = await fetch(`${baseApiUrl}/jobs/${jobId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({ detail: "Update failed" }));
|
||||
throw new Error(err.detail || "Update failed");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function uploadMedia(
|
||||
file: File,
|
||||
): Promise<{ path: string; media_type: MediaType }> {
|
||||
const baseApiUrl = getApiBaseUrl();
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const response = await fetch(`${baseApiUrl}/upload-media`, {
|
||||
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 uploadImage(file: File): Promise<{ path: string }> {
|
||||
const baseApiUrl = getApiBaseUrl();
|
||||
const formData = new FormData();
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface DefaultOptions {
|
||||
seed: number;
|
||||
numGpus: number;
|
||||
ditCpuOffload: boolean;
|
||||
ditLayerwiseOffload: boolean;
|
||||
textEncoderCpuOffload: boolean;
|
||||
vaeCpuOffload: boolean;
|
||||
imageEncoderCpuOffload: boolean;
|
||||
@@ -44,6 +45,7 @@ export const DEFAULT_OPTIONS: DefaultOptions = {
|
||||
seed: 1024,
|
||||
numGpus: 1,
|
||||
ditCpuOffload: false,
|
||||
ditLayerwiseOffload: false,
|
||||
textEncoderCpuOffload: false,
|
||||
vaeCpuOffload: false,
|
||||
imageEncoderCpuOffload: false,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
EMPTY_H3_PROMPT_FIELDS,
|
||||
H3_PROMPT_SECTIONS,
|
||||
isEmptyPromptFields,
|
||||
parseH3Prompt,
|
||||
serializeH3Prompt,
|
||||
type H3PromptFields,
|
||||
} from "@/lib/h3Prompt";
|
||||
|
||||
const filled: H3PromptFields = {
|
||||
subject_definitions: "<Subject 1> is the dog in <Picture 1>.",
|
||||
summary: "[reference generation] The target video shows <Subject 1>.",
|
||||
retention_analysis: "<Subject 1> (appears in [Shot 1]): fully_preserved - fur retained.",
|
||||
detailed_description: "[Shot 1] A medium shot establishes <Subject 1>.",
|
||||
overall_soundscape: "Room tone throughout.",
|
||||
non_diegetic_music: "N/A",
|
||||
};
|
||||
|
||||
describe("serializeH3Prompt", () => {
|
||||
it("emits sections in order, flush left, blank-line separated", () => {
|
||||
const out = serializeH3Prompt(filled);
|
||||
expect(out).toBe(
|
||||
[
|
||||
"subject_definitions:\n<Subject 1> is the dog in <Picture 1>.",
|
||||
"summary:\n[reference generation] The target video shows <Subject 1>.",
|
||||
"retention_analysis:\n<Subject 1> (appears in [Shot 1]): fully_preserved - fur retained.",
|
||||
"detailed_description:\n[Shot 1] A medium shot establishes <Subject 1>.",
|
||||
"overall_soundscape:\nRoom tone throughout.",
|
||||
"non_diegetic_music:\nN/A",
|
||||
].join("\n\n"),
|
||||
);
|
||||
// content is never indented
|
||||
expect(out).not.toMatch(/\n {2}\S/);
|
||||
});
|
||||
|
||||
it("fills blank sections with N/A rather than dropping them", () => {
|
||||
const out = serializeH3Prompt({ ...EMPTY_H3_PROMPT_FIELDS, summary: "x" });
|
||||
for (const s of H3_PROMPT_SECTIONS) expect(out).toContain(`${s}:`);
|
||||
expect(out).toContain("non_diegetic_music:\nN/A");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseH3Prompt", () => {
|
||||
it("round-trips a serialized prompt", () => {
|
||||
expect(parseH3Prompt(serializeH3Prompt(filled))).toEqual(filled);
|
||||
});
|
||||
|
||||
it("keeps multi-line section bodies", () => {
|
||||
const p = parseH3Prompt("summary:\nline one\nline two\n\ndetailed_description:\nd");
|
||||
expect(p?.summary).toBe("line one\nline two");
|
||||
expect(p?.detailed_description).toBe("d");
|
||||
});
|
||||
|
||||
it("returns null for a plain prompt", () => {
|
||||
expect(parseH3Prompt("a toy car drives into a plush dog")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isEmptyPromptFields", () => {
|
||||
it("detects blank vs filled", () => {
|
||||
expect(isEmptyPromptFields(EMPTY_H3_PROMPT_FIELDS)).toBe(true);
|
||||
expect(isEmptyPromptFields(filled)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* MiniMax-H3 full-reference prompt sections. Serialization follows the worked
|
||||
* example in the model's VIDEO_PROMPT_WRITING_GUIDE_ref_en.md: `section_name:`
|
||||
* on its own line, content flush left, one blank line between sections.
|
||||
*/
|
||||
|
||||
export const H3_PROMPT_SECTIONS = [
|
||||
'subject_definitions',
|
||||
'summary',
|
||||
'retention_analysis',
|
||||
'detailed_description',
|
||||
'overall_soundscape',
|
||||
'non_diegetic_music',
|
||||
] as const;
|
||||
|
||||
export type H3PromptSection = (typeof H3_PROMPT_SECTIONS)[number];
|
||||
|
||||
export type H3PromptFields = Record<H3PromptSection, string>;
|
||||
|
||||
export const EMPTY_H3_PROMPT_FIELDS: H3PromptFields = {
|
||||
subject_definitions: '',
|
||||
summary: '',
|
||||
retention_analysis: '',
|
||||
detailed_description: '',
|
||||
overall_soundscape: '',
|
||||
non_diegetic_music: '',
|
||||
};
|
||||
|
||||
export const H3_SECTION_LABELS: Record<H3PromptSection, string> = {
|
||||
subject_definitions: 'Subject definitions',
|
||||
summary: 'Summary',
|
||||
retention_analysis: 'Retention analysis',
|
||||
detailed_description: 'Detailed description',
|
||||
overall_soundscape: 'Overall soundscape',
|
||||
non_diegetic_music: 'Non-diegetic music',
|
||||
};
|
||||
|
||||
/** Per-section guidance, condensed from the guide's rules for each section. */
|
||||
export const H3_SECTION_HINTS: Record<H3PromptSection, string> = {
|
||||
subject_definitions:
|
||||
'One line per <Subject N>. A subject may draw on several references, e.g. "<Subject 2> is the dog in <Picture 2>, <Picture 3>, and <Picture 4>."',
|
||||
summary:
|
||||
'One paragraph, starting with a [task type] prefix: reference generation, keyframe completion, video editing, video continuation, audio reuse, audio reference. Combine with " + ".',
|
||||
retention_analysis:
|
||||
'Per subject: "<Subject 1> (appears in [Shot 1], [Shot 2]): fully_preserved - what is retained."',
|
||||
detailed_description:
|
||||
'Playback order, using [Shot N] markers, timestamps, (S1) speaker tags and <d>[English] dialogue</d>.',
|
||||
overall_soundscape: 'Ambience and physical sounds. N/A if none.',
|
||||
non_diegetic_music:
|
||||
'Background music audible only to the audience. N/A if none.',
|
||||
};
|
||||
|
||||
/** True when every section is blank. */
|
||||
export function isEmptyPromptFields(fields: H3PromptFields): boolean {
|
||||
return H3_PROMPT_SECTIONS.every((s) => !fields[s].trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Join the sections into the prompt string the model is given. Blank sections
|
||||
* become "N/A" rather than being dropped, matching the guide's example.
|
||||
*/
|
||||
export function serializeH3Prompt(fields: H3PromptFields): string {
|
||||
return H3_PROMPT_SECTIONS.map((section) => {
|
||||
const body = fields[section].trim() || 'N/A';
|
||||
return `${section}:\n${body}`;
|
||||
}).join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a serialized prompt back into sections, so switching between the
|
||||
* guided fields and the raw editor does not lose work. Returns null when the
|
||||
* text is not in section format (a plain prompt, say).
|
||||
*/
|
||||
export function parseH3Prompt(text: string): H3PromptFields | null {
|
||||
const fields = { ...EMPTY_H3_PROMPT_FIELDS };
|
||||
const headings = new Set<string>(H3_PROMPT_SECTIONS);
|
||||
let current: H3PromptSection | null = null;
|
||||
let found = false;
|
||||
|
||||
for (const line of text.split('\n')) {
|
||||
const heading = line.trim().replace(/:$/, '');
|
||||
if (line.trim().endsWith(':') && headings.has(heading)) {
|
||||
current = heading as H3PromptSection;
|
||||
found = true;
|
||||
continue;
|
||||
}
|
||||
if (current) fields[current] += (fields[current] ? '\n' : '') + line;
|
||||
}
|
||||
if (!found) return null;
|
||||
for (const section of H3_PROMPT_SECTIONS) {
|
||||
fields[section] = fields[section].trim();
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
labelReferences,
|
||||
referencePromptSeed,
|
||||
validateReferences,
|
||||
type H3Reference,
|
||||
} from "@/lib/h3References";
|
||||
|
||||
const ref = (media_type: H3Reference["media_type"], n: number): H3Reference => ({
|
||||
id: `${media_type}-${n}`,
|
||||
source: `/tmp/${media_type}${n}`,
|
||||
media_type,
|
||||
fileName: `${media_type}${n}`,
|
||||
});
|
||||
|
||||
describe("labelReferences", () => {
|
||||
it("numbers each media type independently, in list order", () => {
|
||||
expect(
|
||||
labelReferences([ref("image", 1), ref("video", 1), ref("image", 2)]),
|
||||
).toEqual(["<Picture 1>", "<Video 1>", "<Picture 2>"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateReferences", () => {
|
||||
it("accepts an empty list and a normal mix", () => {
|
||||
expect(validateReferences([])).toBeNull();
|
||||
expect(validateReferences([ref("image", 1), ref("audio", 1)])).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects audio-only lists", () => {
|
||||
expect(validateReferences([ref("audio", 1)])).toMatch(/paired/);
|
||||
});
|
||||
|
||||
it("enforces the per-type caps", () => {
|
||||
const videos = [1, 2, 3, 4].map((n) => ref("video", n));
|
||||
expect(validateReferences(videos)).toMatch(/At most 3 video/);
|
||||
});
|
||||
|
||||
it("enforces the overall cap", () => {
|
||||
const many = Array.from({ length: 13 }, (_, i) => ref("image", i));
|
||||
expect(validateReferences(many)).toMatch(/At most 12/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("referencePromptSeed", () => {
|
||||
it("cites the real labels and never indents content", () => {
|
||||
const seed = referencePromptSeed([ref("image", 1), ref("video", 1)]);
|
||||
expect(seed.subject_definitions).toContain("<Picture 1>");
|
||||
expect(seed.subject_definitions).toContain("<Video 1>");
|
||||
for (const value of Object.values(seed)) {
|
||||
expect(value).not.toMatch(/^ {2}\S/m);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the guide's retention_analysis form", () => {
|
||||
const seed = referencePromptSeed([ref("image", 1)]);
|
||||
expect(seed.retention_analysis).toMatch(/fully_preserved - /);
|
||||
});
|
||||
|
||||
it("starts the summary with a bracketed task type", () => {
|
||||
const seed = referencePromptSeed([ref("image", 1)]);
|
||||
expect(seed.summary).toMatch(/^\[[a-z +]+\]/);
|
||||
});
|
||||
|
||||
it("describes audio references separately", () => {
|
||||
const seed = referencePromptSeed([ref("image", 1), ref("audio", 1)]);
|
||||
expect(seed.subject_definitions).toContain("<Audio 1>");
|
||||
expect(seed.retention_analysis).toContain("<Audio 1>: reference -");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* MiniMax-H3 Ref2VA reference helpers. Labels mirror the per-type counters in
|
||||
* `build_ref2va_presentation`, so what the user sees is what the model is shown.
|
||||
*/
|
||||
import type { MediaType } from "@/lib/api";
|
||||
|
||||
export interface H3Reference {
|
||||
/** stable key for React lists */
|
||||
id: string;
|
||||
source: string;
|
||||
media_type: MediaType;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
/** Per-media-type caps enforced by validate_references (reference.py). */
|
||||
export const H3_REFERENCE_LIMITS: Record<MediaType, number> = {
|
||||
image: 9,
|
||||
video: 3,
|
||||
audio: 3,
|
||||
};
|
||||
export const H3_MAX_REFERENCES = 12;
|
||||
|
||||
const LABEL_FOR: Record<MediaType, string> = {
|
||||
image: "Picture",
|
||||
video: "Video",
|
||||
audio: "Audio",
|
||||
};
|
||||
|
||||
/** Label each reference the way the pipeline will, e.g. "<Picture 2>". */
|
||||
export function labelReferences(refs: H3Reference[]): string[] {
|
||||
const counts: Record<MediaType, number> = { image: 0, video: 0, audio: 0 };
|
||||
return refs.map((ref) => {
|
||||
counts[ref.media_type] += 1;
|
||||
return `<${LABEL_FOR[ref.media_type]} ${counts[ref.media_type]}>`;
|
||||
});
|
||||
}
|
||||
|
||||
/** Human-readable reason the list is invalid, or null when it is acceptable. */
|
||||
export function validateReferences(refs: H3Reference[]): string | null {
|
||||
if (refs.length === 0) return null;
|
||||
if (refs.length > H3_MAX_REFERENCES) {
|
||||
return `At most ${H3_MAX_REFERENCES} references (have ${refs.length}).`;
|
||||
}
|
||||
const counts: Record<MediaType, number> = { image: 0, video: 0, audio: 0 };
|
||||
for (const ref of refs) counts[ref.media_type] += 1;
|
||||
for (const type of Object.keys(counts) as MediaType[]) {
|
||||
if (counts[type] > H3_REFERENCE_LIMITS[type]) {
|
||||
return `At most ${H3_REFERENCE_LIMITS[type]} ${type} references (have ${counts[type]}).`;
|
||||
}
|
||||
}
|
||||
if (counts.audio === refs.length) {
|
||||
return "Audio references must be paired with at least one image or video.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Seed the guided prompt fields from the current reference list. */
|
||||
export function referencePromptSeed(
|
||||
refs: H3Reference[],
|
||||
): Record<string, string> {
|
||||
const labels = labelReferences(refs);
|
||||
const visual = labels.filter((l) => !l.startsWith("<Audio"));
|
||||
const audio = labels.filter((l) => l.startsWith("<Audio"));
|
||||
|
||||
const subjects = visual.map(
|
||||
(label, i) =>
|
||||
`<Subject ${i + 1}> is the subject from ${label}; describe appearance and distinguishing features.`,
|
||||
);
|
||||
for (const label of audio) {
|
||||
subjects.push(`${label} is the audio reference; describe what it provides.`);
|
||||
}
|
||||
|
||||
const retention = visual.map(
|
||||
(_, i) =>
|
||||
`<Subject ${i + 1}> (appears in [Shot 1]): fully_preserved - what is retained.`,
|
||||
);
|
||||
for (const label of audio) {
|
||||
retention.push(`${label}: reference - how it guides the audio.`);
|
||||
}
|
||||
|
||||
return {
|
||||
subject_definitions: subjects.join("\n"),
|
||||
summary: "[reference generation] Describe the target video and each reference's role.",
|
||||
retention_analysis: retention.join("\n"),
|
||||
detailed_description:
|
||||
"[Shot 1] Describe composition, subjects, environment, lighting, action and camera movement, saying where each reference takes effect.",
|
||||
overall_soundscape: "",
|
||||
non_diegetic_music: "",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { jobToFormFields, referenceFileName, type JobLike } from "@/lib/jobToFields";
|
||||
|
||||
const job: JobLike = {
|
||||
id: "abc",
|
||||
model_id: "MiniMaxAI/MiniMax-H3",
|
||||
prompt: "subject_definitions:\n<Subject 1> is a dog.\n\nsummary:\n[reference generation] x",
|
||||
workload_type: "i2v",
|
||||
references: [
|
||||
{ source: "/uploads/9f/wukong_source.mp4", media_type: "video" },
|
||||
{ source: "/uploads/2a/MonkeyKing_0.jpg", media_type: "image" },
|
||||
],
|
||||
num_frames: 141,
|
||||
height: 768,
|
||||
width: 1344,
|
||||
guidance_scale: 1.0,
|
||||
guidance_rescale: 0.1,
|
||||
seed: 0,
|
||||
num_gpus: 4,
|
||||
use_fsdp_inference: true,
|
||||
};
|
||||
|
||||
describe("jobToFormFields", () => {
|
||||
it("carries the settings that differ from form defaults", () => {
|
||||
const f = jobToFormFields(job);
|
||||
expect(f.numFrames).toBe(141);
|
||||
expect(f.height).toBe(768);
|
||||
expect(f.width).toBe(1344);
|
||||
expect(f.guidanceScale).toBe(1.0);
|
||||
expect(f.guidanceRescale).toBe(0.1);
|
||||
expect(f.seed).toBe(0); // 0 must survive, not fall back to 1024
|
||||
expect(f.numGpus).toBe(4);
|
||||
expect(f.useFsdpInference).toBe(true);
|
||||
});
|
||||
|
||||
it("does not let falsy-but-valid values fall through to defaults", () => {
|
||||
const f = jobToFormFields({ ...job, seed: 0, vsa_sparsity: 0, guidance_rescale: 0 });
|
||||
expect(f.seed).toBe(0);
|
||||
expect(f.vsaSparsity).toBe(0);
|
||||
expect(f.guidanceRescale).toBe(0);
|
||||
});
|
||||
|
||||
it("rebuilds the reference list with readable names", () => {
|
||||
const f = jobToFormFields(job);
|
||||
expect(f.references).toHaveLength(2);
|
||||
expect(f.references[0].fileName).toBe("wukong_source.mp4");
|
||||
expect(f.references[1].media_type).toBe("image");
|
||||
expect(new Set(f.references.map((r) => r.id)).size).toBe(2);
|
||||
});
|
||||
|
||||
it("splits a six-section prompt back into fields", () => {
|
||||
const f = jobToFormFields(job);
|
||||
expect(f.promptFields?.subject_definitions).toBe("<Subject 1> is a dog.");
|
||||
expect(f.promptFields?.summary).toBe("[reference generation] x");
|
||||
});
|
||||
|
||||
it("returns null promptFields for a plain prompt", () => {
|
||||
const f = jobToFormFields({ ...job, prompt: "a toy car" });
|
||||
expect(f.promptFields).toBeNull();
|
||||
expect(f.prompt).toBe("a toy car");
|
||||
});
|
||||
|
||||
it("handles a job with no references", () => {
|
||||
const f = jobToFormFields({ ...job, references: null });
|
||||
expect(f.references).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("referenceFileName", () => {
|
||||
it("takes the basename", () => {
|
||||
expect(referenceFileName("/a/b/c.mp4")).toBe("c.mp4");
|
||||
expect(referenceFileName("c.mp4")).toBe("c.mp4");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Map a persisted job back onto the create-job form's fields. Every value the
|
||||
* form seeds from defaults must be covered here, or edit mode silently shows
|
||||
* defaults for whatever is missing.
|
||||
*/
|
||||
import type { H3Reference } from "@/lib/h3References";
|
||||
import { parseH3Prompt, type H3PromptFields } from "@/lib/h3Prompt";
|
||||
|
||||
export interface JobLike {
|
||||
id: string;
|
||||
model_id: string;
|
||||
name?: string;
|
||||
prompt: string;
|
||||
workload_type?: string;
|
||||
job_type?: string;
|
||||
image_path?: string;
|
||||
last_image_path?: string;
|
||||
references?: { source: string; media_type: string }[] | null;
|
||||
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;
|
||||
dit_layerwise_offload?: boolean;
|
||||
text_encoder_cpu_offload?: boolean;
|
||||
vae_cpu_offload?: boolean;
|
||||
image_encoder_cpu_offload?: boolean;
|
||||
use_fsdp_inference?: boolean;
|
||||
enable_torch_compile?: boolean;
|
||||
vsa_sparsity?: number;
|
||||
tp_size?: number;
|
||||
sp_size?: number;
|
||||
}
|
||||
|
||||
export interface JobFormFields {
|
||||
modelId: string;
|
||||
name: string;
|
||||
workloadType: string;
|
||||
jobType: string;
|
||||
prompt: string;
|
||||
negativePrompt: string;
|
||||
imagePath: string;
|
||||
lastImagePath: string;
|
||||
references: H3Reference[];
|
||||
promptFields: H3PromptFields | null;
|
||||
numInferenceSteps: number;
|
||||
numFrames: number;
|
||||
height: number;
|
||||
width: number;
|
||||
guidanceScale: number;
|
||||
guidanceRescale: number;
|
||||
fps: number;
|
||||
seed: number;
|
||||
numGpus: number;
|
||||
ditCpuOffload: boolean;
|
||||
ditLayerwiseOffload: boolean;
|
||||
textEncoderCpuOffload: boolean;
|
||||
vaeCpuOffload: boolean;
|
||||
imageEncoderCpuOffload: boolean;
|
||||
useFsdpInference: boolean;
|
||||
enableTorchCompile: boolean;
|
||||
vsaSparsity: number;
|
||||
tpSize: number;
|
||||
spSize: number;
|
||||
}
|
||||
|
||||
/** Uploads keep their original basename, so this is the display name. */
|
||||
export function referenceFileName(source: string): string {
|
||||
return source.split("/").filter(Boolean).pop() ?? source;
|
||||
}
|
||||
|
||||
export function jobToFormFields(job: JobLike): JobFormFields {
|
||||
const refs: H3Reference[] = (job.references ?? []).map((r, i) => ({
|
||||
id: `${job.id}-${i}`,
|
||||
source: r.source,
|
||||
media_type: r.media_type as H3Reference["media_type"],
|
||||
fileName: referenceFileName(r.source),
|
||||
}));
|
||||
|
||||
return {
|
||||
modelId: job.model_id,
|
||||
name: job.name ?? "",
|
||||
workloadType: job.workload_type ?? "t2v",
|
||||
jobType: job.job_type ?? "inference",
|
||||
prompt: job.prompt ?? "",
|
||||
negativePrompt: job.negative_prompt ?? "",
|
||||
imagePath: job.image_path ?? "",
|
||||
lastImagePath: job.last_image_path ?? "",
|
||||
references: refs,
|
||||
// null when the prompt is not in six-section form; the caller then keeps
|
||||
// the raw editor rather than silently dropping content into fields.
|
||||
promptFields: parseH3Prompt(job.prompt ?? ""),
|
||||
numInferenceSteps: job.num_inference_steps ?? 50,
|
||||
numFrames: job.num_frames ?? 81,
|
||||
height: job.height ?? 480,
|
||||
width: job.width ?? 832,
|
||||
guidanceScale: job.guidance_scale ?? 5.0,
|
||||
guidanceRescale: job.guidance_rescale ?? 0.0,
|
||||
fps: job.fps ?? 24,
|
||||
seed: job.seed ?? 1024,
|
||||
numGpus: job.num_gpus ?? 1,
|
||||
ditCpuOffload: job.dit_cpu_offload ?? false,
|
||||
ditLayerwiseOffload: job.dit_layerwise_offload ?? false,
|
||||
textEncoderCpuOffload: job.text_encoder_cpu_offload ?? false,
|
||||
vaeCpuOffload: job.vae_cpu_offload ?? false,
|
||||
imageEncoderCpuOffload: job.image_encoder_cpu_offload ?? false,
|
||||
useFsdpInference: job.use_fsdp_inference ?? false,
|
||||
enableTorchCompile: job.enable_torch_compile ?? false,
|
||||
vsaSparsity: job.vsa_sparsity ?? 0,
|
||||
tpSize: job.tp_size ?? -1,
|
||||
spSize: job.sp_size ?? -1,
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export type JobType = "inference" | "finetuning" | "distillation";
|
||||
export interface Job {
|
||||
id: string;
|
||||
model_id: string;
|
||||
name?: string;
|
||||
prompt: string;
|
||||
job_type?: JobType;
|
||||
workload_type?: string;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Duplicating a job's config, and editing one that has not started."""
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from fastvideo_studio.database import Database
|
||||
from fastvideo_studio.job_runner import JobRunner, JobStatus
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner(tmp_path):
|
||||
return JobRunner(
|
||||
output_dir=str(tmp_path / "out"),
|
||||
log_dir=str(tmp_path / "logs"),
|
||||
database=Database(tmp_path / "t.db"),
|
||||
)
|
||||
|
||||
|
||||
def _make(runner, **over):
|
||||
kwargs = dict(
|
||||
job_id=str(uuid.uuid4()),
|
||||
model_id="MiniMaxAI/MiniMax-H3",
|
||||
prompt="p",
|
||||
workload_type="i2v",
|
||||
num_frames=141,
|
||||
guidance_scale=1.0,
|
||||
num_gpus=4,
|
||||
references=[{"source": "/x/clip.mp4", "media_type": "video"}],
|
||||
)
|
||||
kwargs.update(over)
|
||||
return runner.create_job(**kwargs)
|
||||
|
||||
|
||||
def test_duplicate_copies_config_but_not_runtime_state(runner):
|
||||
src = _make(runner)
|
||||
src.status = JobStatus.COMPLETED
|
||||
src.output_path = "/x/out.mp4"
|
||||
|
||||
dup = runner.duplicate_job(src.id, str(uuid.uuid4()))
|
||||
|
||||
assert dup.id != src.id
|
||||
assert dup.status is JobStatus.PENDING
|
||||
assert dup.output_path is None
|
||||
for field in ("model_id", "prompt", "workload_type", "num_frames",
|
||||
"guidance_scale", "num_gpus", "references"):
|
||||
assert getattr(dup, field) == getattr(src, field)
|
||||
|
||||
|
||||
def test_duplicate_deep_copies_references(runner):
|
||||
src = _make(runner)
|
||||
dup = runner.duplicate_job(src.id, str(uuid.uuid4()))
|
||||
dup.references[0]["source"] = "/changed"
|
||||
assert src.references[0]["source"] == "/x/clip.mp4"
|
||||
|
||||
|
||||
def test_duplicate_unknown_job(runner):
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
runner.duplicate_job("nope", str(uuid.uuid4()))
|
||||
|
||||
|
||||
def test_edit_pending_job(runner):
|
||||
job = _make(runner)
|
||||
updated = runner.update_job_config(job.id, {"num_frames": 192, "seed": 7})
|
||||
assert updated.num_frames == 192
|
||||
assert updated.seed == 7
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [JobStatus.FAILED, JobStatus.STOPPED])
|
||||
def test_edit_allows_restartable_jobs(runner, status):
|
||||
"""Editable exactly when startable: neither has produced an output."""
|
||||
job = _make(runner)
|
||||
job.status = status
|
||||
assert runner.update_job_config(job.id, {"seed": 7}).seed == 7
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [JobStatus.COMPLETED, JobStatus.RUNNING])
|
||||
def test_edit_rejects_jobs_with_or_producing_a_result(runner, status):
|
||||
job = _make(runner)
|
||||
job.status = status
|
||||
with pytest.raises(ValueError, match="can be edited"):
|
||||
runner.update_job_config(job.id, {"seed": 7})
|
||||
|
||||
|
||||
def test_edit_rejects_unknown_field(runner):
|
||||
job = _make(runner)
|
||||
with pytest.raises(ValueError, match="Not editable"):
|
||||
runner.update_job_config(job.id, {"status": "completed"})
|
||||
|
||||
|
||||
def test_name_is_carried_by_duplicate(runner):
|
||||
src = _make(runner, name="wukong swap v2")
|
||||
dup = runner.duplicate_job(src.id, str(uuid.uuid4()))
|
||||
assert dup.name == "wukong swap v2"
|
||||
|
||||
|
||||
def test_name_is_editable(runner):
|
||||
job = _make(runner, name="a")
|
||||
assert runner.update_job_config(job.id, {"name": "b"}).name == "b"
|
||||
@@ -0,0 +1,36 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Uploaded files keep a readable basename under a unique directory."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from fastvideo_studio.server import _safe_upload_name
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("filename", "ext", "expected"),
|
||||
[
|
||||
("wukong_source.mp4", ".mp4", "wukong_source.mp4"),
|
||||
("MonkeyKing_0.jpg", ".jpg", "MonkeyKing_0.jpg"),
|
||||
("my clip (final).mp4", ".mp4", "my_clip_final.mp4"),
|
||||
("../../etc/passwd.png", ".png", "passwd.png"),
|
||||
("/abs/path/frame.png", ".png", "frame.png"),
|
||||
("émoji✨.png", ".png", "moji.png"),
|
||||
("", ".png", "upload.png"),
|
||||
(None, ".png", "upload.png"),
|
||||
("...", ".png", "upload.png"),
|
||||
],
|
||||
)
|
||||
def test_safe_upload_name(filename, ext, expected):
|
||||
assert _safe_upload_name(filename, ext) == expected
|
||||
|
||||
|
||||
def test_long_names_are_capped():
|
||||
out = _safe_upload_name("x" * 300 + ".png", ".png")
|
||||
assert out == "x" * 80 + ".png"
|
||||
|
||||
|
||||
def test_no_path_separators_survive():
|
||||
for bad in ("a/b.png", "a\\b.png", "../x.png"):
|
||||
assert "/" not in _safe_upload_name(bad, ".png")
|
||||
assert "\\" not in _safe_upload_name(bad, ".png")
|
||||
Reference in New Issue
Block a user