Compare commits

..
187 changed files with 14319 additions and 8148 deletions
+34
View File
@@ -77,6 +77,17 @@ steps:
limit: 2
agents:
queue: "default"
- label: ":microscope: Preprocessing Tests"
if: build.env("TEST_SCOPE") == "direct" && build.env("TEST_TYPE") == "preprocessing"
command: "timeout 90m .buildkite/scripts/pr_test.sh"
retry:
automatic:
- exit_status: 128
limit: 3
- exit_status: -1
limit: 2
agents:
queue: "default"
- label: ":microscope: DreamVerse App Tests"
if: build.env("TEST_SCOPE") == "direct" && build.env("TEST_TYPE") == "dreamverse_app"
command: "timeout 90m .buildkite/scripts/pr_test.sh"
@@ -322,6 +333,29 @@ steps:
- TEST_TYPE=unit_test
agents:
queue: "default"
- path:
- "fastvideo/pipelines/preprocess/**"
- "fastvideo/workflow/preprocess/**"
- "fastvideo/dataset/dataloader/parquet_io.py"
- "fastvideo/dataset/dataloader/record_schema.py"
- "fastvideo/dataset/dataloader/schema.py"
- "fastvideo/configs/configs.py"
- "fastvideo/fastvideo_args.py"
- "fastvideo/tests/workflow/test_t2v_preprocessing_e2e.py"
- "fastvideo/tests/nightly/reference_video_1_sample_v0.mp4"
- "fastvideo/tests/modal/pr_test.py"
- ".buildkite/pipeline.yml"
- ".buildkite/scripts/pr_test.sh"
- ".github/workflows/ci-slash-commands.yml"
- "pyproject.toml"
- "docker/Dockerfile"
config:
command: "timeout 20m .buildkite/scripts/pr_test.sh"
label: ":microscope: Preprocessing Tests"
env:
- TEST_TYPE=preprocessing
agents:
queue: "default"
- path:
- "apps/dreamverse/**"
- "pyproject.toml"
+4
View File
@@ -233,6 +233,10 @@ case "$TEST_TYPE" in
log "Running unit tests..."
MODAL_COMMAND="$MODAL_ENV python3 -m modal run $MODAL_TEST_FILE::run_unit_test"
;;
"preprocessing")
log "Running preprocessing integration test..."
MODAL_COMMAND="$MODAL_ENV HF_API_KEY=$HF_API_KEY python3 -m modal run $MODAL_TEST_FILE::run_preprocessing_tests"
;;
"dreamverse_app")
log "Running DreamVerse app tests..."
MODAL_COMMAND="$MODAL_ENV python3 -m modal run $MODAL_TEST_FILE::run_dreamverse_app_tests"
+3 -2
View File
@@ -129,7 +129,7 @@ jobs:
set -euo pipefail
TEST_NAME=$(echo "$COMMENT" | grep -oP '(?<=/test\s)\S+' | head -1 || true)
VALID="encoder vae transformer kernel unit dreamverse ssim training lora-inference lora-training lora-extraction distillation self-forcing vsa vmoba performance api train-framework eval full fastcheck pre-commit"
VALID="encoder vae transformer kernel unit preprocessing dreamverse ssim training lora-inference lora-training lora-extraction distillation self-forcing vsa vmoba performance api train-framework eval full fastcheck pre-commit"
if [ -z "$TEST_NAME" ] || ! echo "$VALID" | grep -qw "$TEST_NAME"; then
echo "Unknown test: '$TEST_NAME'. Valid: $VALID"
exit 1
@@ -137,7 +137,8 @@ jobs:
declare -A MAP=(
[encoder]=encoder [vae]=vae [transformer]=transformer
[kernel]=kernel_tests [unit]=unit_test [dreamverse]=dreamverse_app
[kernel]=kernel_tests [unit]=unit_test [preprocessing]=preprocessing
[dreamverse]=dreamverse_app
[ssim]=ssim [training]=training
[lora-inference]=inference_lora [lora-training]=training_lora
[lora-extraction]=lora_extraction
+14 -8
View File
@@ -11,7 +11,9 @@ on:
- 'requirements-mkdocs.txt'
- 'scripts/check_docs_links.py'
- '.github/workflows/infra-docs.yml'
pull_request:
# Run the trusted base-branch workflow so fork PRs can be skipped without
# waiting for maintainer approval.
pull_request_target:
branches: [ main ]
paths:
- 'docs/**'
@@ -24,21 +26,19 @@ on:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
build:
# MkDocs executes repository code; only trusted same-repository PRs run it.
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Setup Python
uses: actions/setup-python@v5
@@ -52,6 +52,7 @@ jobs:
run: uv pip install --system -r requirements-mkdocs.txt
- name: Setup Pages
if: github.event_name == 'push'
uses: actions/configure-pages@v4
- name: Build documentation
@@ -61,17 +62,22 @@ jobs:
run: python scripts/check_docs_links.py
- name: Upload artifact
if: github.event_name == 'push'
uses: actions/upload-pages-artifact@v3
with:
path: ./site
deploy:
permissions:
pages: write
id-token: write
concurrency: pages
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main'
if: github.event_name == 'push'
steps:
- name: Deploy to GitHub Pages
id: deployment
+5 -5
View File
@@ -67,7 +67,7 @@ MODEL_REGISTRY = {
},
}
DEFAULT_MODEL_ID = "fast-ltx23"
DEFAULT_MODEL_ID = "fast-ltx2"
ACTIVE_MODEL_ID = (os.getenv("DREAMVERSE_MODEL_ID", "").strip() or DEFAULT_MODEL_ID)
if ACTIVE_MODEL_ID not in MODEL_REGISTRY:
@@ -76,11 +76,14 @@ if ACTIVE_MODEL_ID not in MODEL_REGISTRY:
# Active model configuration
MODEL_CONFIG = MODEL_REGISTRY[ACTIVE_MODEL_ID]
# Generation limits
SESSION_TIMEOUT_SECONDS = 300
# Frame settings
NUM_FRAMES = 121
FRAME_HEIGHT = 1088
FRAME_WIDTH = 1920
NUM_INFERENCE_STEPS = 6
NUM_INFERENCE_STEPS = 5
JPEG_QUALITY = 100
BATCH_SIZE = 3
@@ -165,9 +168,6 @@ def _optional_env(*names: str) -> str | None:
return None
# Generation limits
SESSION_TIMEOUT_SECONDS = _env_int("DREAMVERSE_SESSION_TIMEOUT_SECONDS", 300)
DEVTOOLS_ENABLED = _env_bool("FASTVIDEO_ENABLE_DEVTOOLS", False)
PROMPT_SAFETY_ENABLED = _env_bool("FASTVIDEO_ENABLE_PROMPT_SAFETY", False)
DREAMVERSE_MAX_AUTOTUNE = _env_bool("DREAMVERSE_MAX_AUTOTUNE", True)
+1 -5
View File
@@ -1023,11 +1023,7 @@ def get_available_gpus() -> list[int]:
"""Get list of available GPU IDs from environment or auto-detect."""
cuda_visible = os.environ.get("CUDA_VISIBLE_DEVICES", "")
if cuda_visible:
try:
visible_gpu_ids = [int(x.strip()) for x in cuda_visible.split(",") if x.strip()]
except ValueError as exc:
raise RuntimeError("CUDA_VISIBLE_DEVICES must be a comma-separated list of integer GPU "
f"indices (got {cuda_visible!r}); GPU UUIDs are not supported.") from exc
visible_gpu_ids = [int(x.strip()) for x in cuda_visible.split(",") if x.strip()]
return _limit_gpu_ids(visible_gpu_ids)
# Auto-detect available GPUs
+1 -3
View File
@@ -206,9 +206,7 @@ def cli() -> None:
args = parser.parse_args()
_install_heartbeat_log_filter()
# A 15MB init image (session_init_image.MAX_SESSION_INIT_IMAGE_BYTES) is ~20MB
# as a base64 ws message, above uvicorn's default 16MiB frame cap.
uvicorn.run(app, host=args.host, port=args.port, ws_max_size=32 * 1024 * 1024)
uvicorn.run(app, host=args.host, port=args.port)
if __name__ == "__main__":
+21 -47
View File
@@ -30,11 +30,11 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from dreamverse._deps import require_dreamverse_runtime_deps
from dreamverse.config import FRONTEND_STATIC_DIR_CANDIDATES, GENERATION_SEGMENT_CAP, SESSION_TIMEOUT_SECONDS
from dreamverse.config import FRONTEND_STATIC_DIR_CANDIDATES, GENERATION_SEGMENT_CAP
from dreamverse.session_init_image import cleanup_session_init_image, persist_session_init_image
from dreamverse.utils import _resolve_generation_segment_cap
LATENCY_MS = 200
SESSION_TIMEOUT_SECONDS = 300
MOCK_FRAME_WIDTH = 640
MOCK_FRAME_HEIGHT = 352
MOCK_FPS = 24
@@ -334,7 +334,6 @@ async def websocket_endpoint(websocket: WebSocket):
auto_extension_enabled = bool(init_data.get("auto_extension_enabled", False))
loop_generation_enabled = bool(init_data.get("loop_generation_enabled", False))
single_clip_mode = bool(init_data.get("single_clip_mode", False))
manual_continuation_mode = bool(init_data.get("manual_continuation_mode", False))
generation_paused = False
if init_type == "session_init_v2":
@@ -345,8 +344,7 @@ async def websocket_endpoint(websocket: WebSocket):
incoming_prompts = []
curated_prompts = [prompt.strip() for prompt in incoming_prompts if isinstance(prompt, str) and prompt.strip()]
generation_paused = bool(not manual_continuation_mode and initial_rollout_prompt and not single_clip_mode
and len(curated_prompts) == 0)
generation_paused = bool(initial_rollout_prompt and not single_clip_mode and len(curated_prompts) == 0)
try:
session_init_image = persist_session_init_image(init_data.get("initial_image"))
@@ -375,10 +373,7 @@ async def websocket_endpoint(websocket: WebSocket):
prompt_sources_blocked = False
pending_seed_reset = False
pending_seed_reset_reason = ""
pending_simple_submission: PromptSubmission | None = (PromptSubmission(
prompt_id=str(init_data.get("initial_rollout_prompt_id") or uuid.uuid4()),
raw_prompt=initial_rollout_prompt,
) if manual_continuation_mode and initial_rollout_prompt else None)
pending_simple_submission: PromptSubmission | None = None
single_clip_waiting_for_request = False
rollout_waiting_for_rewrite = False
initial_rollout_waiting_for_rewrite = generation_paused
@@ -398,26 +393,14 @@ async def websocket_endpoint(websocket: WebSocket):
async def send_stream_start(seed_reason: str) -> None:
await ws_send_json({
"type":
"ltx2_stream_start",
"total_segments":
len(curated_prompts),
"preset_id":
preset_id,
"stream_mode":
"av_fmp4",
"live_mode":
True,
"loop_generation_enabled":
loop_generation_enabled,
"loop_iteration":
loop_iteration,
"generation_segment_cap":
_resolve_generation_segment_cap(
single_clip_mode=single_clip_mode,
cap=GENERATION_SEGMENT_CAP,
manual_continuation_mode=manual_continuation_mode,
),
"type": "ltx2_stream_start",
"total_segments": len(curated_prompts),
"preset_id": preset_id,
"stream_mode": "av_fmp4",
"live_mode": True,
"loop_generation_enabled": loop_generation_enabled,
"loop_iteration": loop_iteration,
"generation_segment_cap": 0,
})
if seed_reason == "init":
await ws_send_json({
@@ -510,7 +493,6 @@ async def websocket_endpoint(websocket: WebSocket):
nonlocal auto_extension_enabled
nonlocal loop_generation_enabled
nonlocal single_clip_mode
nonlocal manual_continuation_mode
nonlocal generation_paused
nonlocal seed_prompt_memory
nonlocal curated_prompts
@@ -555,7 +537,6 @@ async def websocket_endpoint(websocket: WebSocket):
auto_extension_enabled = bool(payload.get("auto_extension_enabled", False))
loop_generation_enabled = bool(payload.get("loop_generation_enabled", False))
single_clip_mode = bool(payload.get("single_clip_mode", False))
manual_continuation_mode = bool(payload.get("manual_continuation_mode", False))
seed_prompt_memory = list(next_curated_prompts)
curated_prompts = list(seed_prompt_memory)
@@ -563,14 +544,10 @@ async def websocket_endpoint(websocket: WebSocket):
segment_idx = 0
pending_seed_reset = False
pending_seed_reset_reason = ""
pending_simple_submission = (PromptSubmission(
prompt_id=str(payload.get("initial_rollout_prompt_id") or uuid.uuid4()),
raw_prompt=initial_rollout_prompt,
) if manual_continuation_mode and initial_rollout_prompt else None)
pending_simple_submission = None
single_clip_waiting_for_request = False
rollout_waiting_for_rewrite = False
generation_paused = bool(not manual_continuation_mode and initial_rollout_prompt and not single_clip_mode
and len(curated_prompts) == 0)
generation_paused = bool(initial_rollout_prompt and not single_clip_mode and len(curated_prompts) == 0)
initial_rollout_waiting_for_rewrite = generation_paused
rewrite_restart_pending = False
loop_iteration = 0
@@ -991,11 +968,10 @@ async def websocket_endpoint(websocket: WebSocket):
project_stream_started = True
await send_stream_start(pending_seed_reset_reason)
pending_seed_reset_reason = ""
if pending_simple_submission is not None:
submission = pending_simple_submission
pending_simple_submission = None
await promote_submission_to_ready(submission)
if pending_simple_submission is not None:
submission = pending_simple_submission
pending_simple_submission = None
await promote_submission_to_ready(submission)
if generation_paused:
await asyncio.sleep(0.05)
@@ -1005,8 +981,8 @@ async def websocket_endpoint(websocket: WebSocket):
await asyncio.sleep(0.05)
continue
if (not single_clip_mode and not manual_continuation_mode and not rollout_waiting_for_rewrite
and GENERATION_SEGMENT_CAP > 0 and segment_idx >= GENERATION_SEGMENT_CAP):
if (not single_clip_mode and not rollout_waiting_for_rewrite and GENERATION_SEGMENT_CAP > 0
and segment_idx >= GENERATION_SEGMENT_CAP):
rollout_waiting_for_rewrite = True
loop_generation_enabled = False
project_stream_started = False
@@ -1241,9 +1217,7 @@ def cli() -> None:
print(f"Starting mock server with {LATENCY_MS}ms latency on port {args.port}")
_install_heartbeat_log_filter()
# A 15MB init image (session_init_image.MAX_SESSION_INIT_IMAGE_BYTES) is ~20MB
# as a base64 ws message, above uvicorn's default 16MiB frame cap.
uvicorn.run(app, host="0.0.0.0", port=args.port, ws_max_size=32 * 1024 * 1024)
uvicorn.run(app, host="0.0.0.0", port=args.port)
if __name__ == "__main__":
+18 -60
View File
@@ -313,33 +313,6 @@ def _extract_content_or_empty(response_json: dict[str, Any]) -> str:
return ""
def _find_balanced_object_end(text: str, start: int) -> int:
"""Return the index just past the brace-balanced span opening at
``text[start] == '{'``, honoring JSON string literals and escapes, or -1
if the braces never balance (i.e. the object was truncated)."""
depth = 0
in_string = False
escaped = False
for i in range(start, len(text)):
ch = text[i]
if in_string:
if escaped:
escaped = False
elif ch == "\\":
escaped = True
elif ch == '"':
in_string = False
elif ch == '"':
in_string = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return i + 1
return -1
def _parse_json_response(content: str) -> dict[str, Any]:
text = content.strip()
if not text:
@@ -357,7 +330,6 @@ def _parse_json_response(content: str) -> dict[str, Any]:
r"```(?:json)?\s*([\s\S]*?)```",
flags=re.IGNORECASE,
)
last_fenced: dict[str, Any] | None = None
for match in fence_pattern.finditer(text):
block = match.group(1).strip()
if not block:
@@ -365,37 +337,21 @@ def _parse_json_response(content: str) -> dict[str, Any]:
try:
parsed = json.loads(block)
if isinstance(parsed, dict):
last_fenced = parsed
return parsed
except json.JSONDecodeError:
continue
if last_fenced is not None:
return last_fenced
# Scan for all decodable JSON objects and return the last — chain-of-thought
# models emit draft JSON mid-reasoning; the final answer is always last.
# Fall back to scanning for the first decodable JSON object in free-form text.
decoder = json.JSONDecoder()
last_parsed: dict[str, Any] | None = None
pos = 0
while (idx := text.find("{", pos)) != -1:
for idx, char in enumerate(text):
if char != "{":
continue
try:
parsed, consumed = decoder.raw_decode(text[idx:])
parsed, _ = decoder.raw_decode(text[idx:])
except json.JSONDecodeError:
# Skip the whole failed object rather than rescanning inside it:
# fragments nested in a malformed or truncated (finish_reason=
# length) object must not override an earlier complete object.
span_end = _find_balanced_object_end(text, idx)
if span_end == -1:
break
pos = span_end
continue
# Skip past the consumed span so nested braces inside a decoded
# object are not re-parsed as standalone objects.
pos = idx + consumed
if isinstance(parsed, dict):
last_parsed = parsed
if last_parsed is not None:
return last_parsed
return parsed
raise ValueError("No JSON object found in assistant response.")
@@ -425,7 +381,7 @@ def _format_locked_segments(locked_segments: list[str]) -> str:
class PromptEnhancer:
def __init__(self) -> None:
def __init__(self):
self.provider = PROMPT_PROVIDER
self.provider_label = _resolve_provider_label(PROMPT_PROVIDER)
self.api_key = PROMPT_API_KEY
@@ -1461,12 +1417,15 @@ class PromptEnhancer:
locked_text = _format_locked_segments(locked_segments_clean)
request_system_prompt = self.enhance_system_prompt
user_payload = {
"request": ("<locked_segments>\n"
f"{locked_text}\n"
"</locked_segments>\n\n"
f"<conditioning_prompt>{cleaned}</conditioning_prompt>\n\n"
f"Write exactly one new segment ({next_segment_key}) "
"continuing from the locked segments."),
"request": (
"<locked_segments>\n"
f"{locked_text}\n"
"</locked_segments>\n\n"
f"<conditioning_prompt>{cleaned}</conditioning_prompt>\n\n"
f"Write exactly one new segment ({next_segment_key}) "
"continuing from the locked segments. "
'Respond with valid JSON only as {"next_prompt": "..."}.' # noqa: E501
),
}
t0 = time.perf_counter()
@@ -1498,7 +1457,6 @@ class PromptEnhancer:
body=request_body,
timeout_seconds=timeout_seconds,
)
_enhance_print("INFO", f"raw_response: {response_content}")
if is_single_clip_mode:
prompt = self._extract_single_clip_prompt(response_content)
else:
@@ -1581,7 +1539,7 @@ class PromptEnhancer:
f"Write exactly one new segment ({next_segment_key}) "
"that continues linearly from the locked segments. "
"Infer the next narrative beat from this history. "
'Respond with valid JSON only: {"next_prompt": "<your segment description here>"}.' # noqa: E501
'Respond with valid JSON only as {"next_prompt": "..."}.' # noqa: E501
),
}
@@ -20,7 +20,6 @@ from __future__ import annotations
# mypy: ignore-errors
import asyncio
import os
import time
import uuid
from typing import TYPE_CHECKING
@@ -53,14 +52,6 @@ if TYPE_CHECKING:
from dreamverse.prompt_enhancer import PromptEnhancer
from dreamverse.prompt_safety import PromptSafetyFilter
# Optional append-only log of every generated segment prompt; unset disables it.
SEGMENT_PROMPT_LOG_PATH = os.environ.get("DREAMVERSE_SEGMENT_PROMPT_LOG", "")
def _append_segment_prompt_log(path: str, text: str) -> None:
with open(path, "a") as f:
f.write(text)
class SessionController:
"""Runs one WebSocket session from accept() through disconnect."""
@@ -207,7 +198,6 @@ class SessionController:
auto_extension_enabled = bool(init_data.get("auto_extension_enabled", False))
loop_generation_enabled = bool(init_data.get("loop_generation_enabled", False))
single_clip_mode = bool(init_data.get("single_clip_mode", False))
manual_continuation_mode = bool(init_data.get("manual_continuation_mode", False))
rewrite_model = self.prompt_enhancer.resolve_rewrite_model(init_data.get("rewrite_model"))
rewrite_system_prompt_override = str(init_data.get("rewrite_window_system_prompt") or "").strip()
rewrite_user_system_prompt_override = str(init_data.get("rewrite_user_system_prompt") or "").strip()
@@ -292,9 +282,6 @@ class SessionController:
# Session queues and mutable state.
raw_prompt_queue: asyncio.Queue[PromptSubmission] = asyncio.Queue()
ready_prompt_queue: asyncio.Queue[ReadyPrompt] = asyncio.Queue()
# Submissions dequeued by prompt_worker_loop but not yet resolved; while
# non-zero the prompt sources are busy, not drained.
prompt_enhancement_inflight = 0
curated_idx = 0
segment_idx = 0
@@ -304,20 +291,13 @@ class SessionController:
generation_cap_blocked = False
auto_extension_blocked_segment_idx: int | None = None
prompt_sources_drained_logged = False
generation_paused = bool(not manual_continuation_mode and initial_rollout_prompt and not single_clip_mode
and len(curated_prompts) == 0)
generation_paused = bool(initial_rollout_prompt and not single_clip_mode and len(curated_prompts) == 0)
pending_seed_reset = False
pending_seed_reset_reason = ""
pending_reset_conditioning = False
loop_iteration = 0 if generation_paused else 1
force_curated_restart_segment = False
# The frontend records the opening scene under this id; reuse it so
# prompt lifecycle events for the opening prompt reach that record.
pending_simple_prompt_submission: PromptSubmission | None = (PromptSubmission(
prompt_id=str(init_data.get("initial_rollout_prompt_id") or uuid.uuid4()),
raw_prompt=initial_rollout_prompt,
created_at_s=time.time(),
) if manual_continuation_mode and initial_rollout_prompt else None)
pending_simple_prompt_submission: PromptSubmission | None = None
single_clip_waiting_for_request = False
rollout_waiting_for_rewrite = False
initial_rollout_waiting_for_rewrite = generation_paused
@@ -326,7 +306,6 @@ class SessionController:
project_active = True
project_stream_started = False
pending_project_end = False
segment_prompt_log_warned = False
def replace_session_init_image(initial_image_payload: object) -> None:
nonlocal session_init_image
@@ -445,7 +424,6 @@ class SessionController:
nonlocal auto_extension_enabled
nonlocal loop_generation_enabled
nonlocal single_clip_mode
nonlocal manual_continuation_mode
nonlocal generation_paused
nonlocal curated_prompts
nonlocal seed_prompt_memory
@@ -480,7 +458,6 @@ class SessionController:
next_auto_extension_enabled = bool(payload.get("auto_extension_enabled", False))
next_loop_generation_enabled = bool(payload.get("loop_generation_enabled", False))
next_single_clip_mode = bool(payload.get("single_clip_mode", False))
next_manual_continuation_mode = bool(payload.get("manual_continuation_mode", False))
next_preset_id = str(payload.get("preset_id") or "").strip()
if next_preset_id:
@@ -533,7 +510,6 @@ class SessionController:
auto_extension_enabled = next_auto_extension_enabled
loop_generation_enabled = next_loop_generation_enabled
single_clip_mode = next_single_clip_mode
manual_continuation_mode = next_manual_continuation_mode
rewrite_model = next_rewrite_model
rewrite_system_prompt_override = (next_rewrite_system_prompt_override)
rewrite_user_system_prompt_override = (next_rewrite_user_system_prompt_override)
@@ -554,15 +530,10 @@ class SessionController:
generation_cap_blocked = False
auto_extension_blocked_segment_idx = None
prompt_sources_drained_logged = False
pending_simple_prompt_submission = (PromptSubmission(
prompt_id=str(payload.get("initial_rollout_prompt_id") or uuid.uuid4()),
raw_prompt=initial_rollout_prompt,
created_at_s=time.time(),
) if manual_continuation_mode and initial_rollout_prompt else None)
pending_simple_prompt_submission = None
single_clip_waiting_for_request = False
rollout_waiting_for_rewrite = False
generation_paused = bool(not manual_continuation_mode and initial_rollout_prompt
and not single_clip_mode and len(curated_prompts) == 0)
generation_paused = bool(initial_rollout_prompt and not single_clip_mode and len(curated_prompts) == 0)
initial_rollout_waiting_for_rewrite = generation_paused
rewrite_restart_pending = False
loop_iteration = 0
@@ -971,7 +942,6 @@ class SessionController:
_resolve_generation_segment_cap(
single_clip_mode=single_clip_mode,
cap=GENERATION_SEGMENT_CAP,
manual_continuation_mode=manual_continuation_mode,
),
})
continue
@@ -1035,9 +1005,11 @@ class SessionController:
continue
async def prompt_worker_loop():
nonlocal prompt_enhancement_inflight
async def process_submission(submission: PromptSubmission) -> None:
while not stop_event.is_set():
try:
submission = await asyncio.wait_for(raw_prompt_queue.get(), timeout=0.1)
except asyncio.TimeoutError:
continue
_main_print("INFO", f"Received user prompt for enhancement: {submission.raw_prompt}")
prompt_id = submission.prompt_id
raw_prompt = submission.raw_prompt
@@ -1060,9 +1032,8 @@ class SessionController:
await ws_send_json({
"type": "error",
"message": blocked_raw_prompt_error,
"prompt_id": prompt_id,
})
return
continue
await log_event(
"enhance_request",
{
@@ -1122,9 +1093,8 @@ class SessionController:
await ws_send_json({
"type": "error",
"message": blocked_final_prompt_error,
"prompt_id": prompt_id,
})
return
continue
if result.fallback_used or not final_prompt:
source = "user_enhancement_failed"
_main_print(
@@ -1143,7 +1113,7 @@ class SessionController:
})
# Enhancement is strict JSON-only; do not enqueue raw
# prompt when enhancement fails.
return
continue
else:
source = "user_enhanced"
await ws_send_json({
@@ -1160,7 +1130,6 @@ class SessionController:
source=source,
fallback_used=result.fallback_used,
loop_iteration=loop_iteration,
raw_prompt=raw_prompt,
))
else:
await ready_prompt_queue.put(
@@ -1170,7 +1139,6 @@ class SessionController:
source="user_raw",
fallback_used=False,
loop_iteration=loop_iteration,
raw_prompt=raw_prompt,
))
await ws_send_json({
"type": "prompt_ready",
@@ -1180,21 +1148,6 @@ class SessionController:
"latency_ms": 0.0,
})
while not stop_event.is_set():
try:
submission = raw_prompt_queue.get_nowait()
except asyncio.QueueEmpty:
await asyncio.sleep(0.1)
continue
# Dequeue and increment without an await in between so the
# generation loop never sees an empty queue with zero in flight
# while this submission is still being enhanced.
prompt_enhancement_inflight += 1
try:
await process_submission(submission)
finally:
prompt_enhancement_inflight -= 1
def queue_snapshot() -> dict[str, object]:
return {
"user_ready": ready_prompt_queue.qsize(),
@@ -1347,7 +1300,6 @@ class SessionController:
_resolve_generation_segment_cap(
single_clip_mode=single_clip_mode,
cap=GENERATION_SEGMENT_CAP,
manual_continuation_mode=manual_continuation_mode,
),
})
await ws_send_json({
@@ -1407,7 +1359,6 @@ class SessionController:
_resolve_generation_segment_cap(
single_clip_mode=single_clip_mode,
cap=GENERATION_SEGMENT_CAP,
manual_continuation_mode=manual_continuation_mode,
),
})
if nonlocal_reason == "loop_restart":
@@ -1437,9 +1388,8 @@ class SessionController:
await raw_prompt_queue.put(pending_simple_prompt_submission)
pending_simple_prompt_submission = None
if (not single_clip_mode and not manual_continuation_mode and not generation_cap_blocked
and not rollout_waiting_for_rewrite and GENERATION_SEGMENT_CAP > 0
and generated_segment_count >= GENERATION_SEGMENT_CAP):
if (not single_clip_mode and not generation_cap_blocked and not rollout_waiting_for_rewrite
and GENERATION_SEGMENT_CAP > 0 and generated_segment_count >= GENERATION_SEGMENT_CAP):
loop_generation_enabled = False
rollout_waiting_for_rewrite = True
_main_print(
@@ -1592,10 +1542,7 @@ class SessionController:
if single_clip_mode:
await asyncio.sleep(PROMPT_AUTO_SLEEP_MS / 1000.0)
continue
# A raw submission still queued or being enhanced will produce a
# ready prompt shortly; that is not a drained/blocked state.
enhancement_pending = (raw_prompt_queue.qsize() > 0 or prompt_enhancement_inflight > 0)
if not prompt_sources_drained_logged and not enhancement_pending:
if not prompt_sources_drained_logged:
snapshot = queue_snapshot()
_main_print(
"WARN",
@@ -1634,24 +1581,6 @@ class SessionController:
total_segments_hint = max(segment_idx, len(curated_prompts))
prompt = selected.prompt
locked_segment_prompts.append(prompt)
if SEGMENT_PROMPT_LOG_PATH:
_ts = time.strftime("%Y-%m-%d %H:%M:%S")
_lines = [
f"\n=== Segment {segment_idx} [{_ts}] source={selected.source} client={client_id[:8]} ===",
]
if selected.raw_prompt and selected.raw_prompt != prompt:
_lines.append(f"User: {selected.raw_prompt}")
_lines.append(f"Rewritten: {prompt}")
try:
await asyncio.to_thread(_append_segment_prompt_log, SEGMENT_PROMPT_LOG_PATH,
"\n".join(_lines) + "\n")
except Exception as exc:
if not segment_prompt_log_warned:
segment_prompt_log_warned = True
_main_print(
"WARN",
f"Failed to write segment prompt log {SEGMENT_PROMPT_LOG_PATH}: {exc}",
)
if (auto_extension_blocked_segment_idx is not None
and auto_extension_blocked_segment_idx <= segment_idx):
auto_extension_blocked_segment_idx = None
@@ -19,4 +19,3 @@ class ReadyPrompt:
fallback_used: bool = False
seed_prompt_index: int | None = None
loop_iteration: int | None = None
raw_prompt: str | None = None
@@ -150,22 +150,12 @@ def test_config_enables_prompt_safety_when_requested(monkeypatch):
def test_config_uses_five_minute_session_timeout(monkeypatch):
_set_required_prompt_keys(monkeypatch)
monkeypatch.delenv("DREAMVERSE_SESSION_TIMEOUT_SECONDS", raising=False)
module = _load_config_module()
assert module.SESSION_TIMEOUT_SECONDS == 300
def test_config_session_timeout_env_override(monkeypatch):
_set_required_prompt_keys(monkeypatch)
monkeypatch.setenv("DREAMVERSE_SESSION_TIMEOUT_SECONDS", "1800")
module = _load_config_module()
assert module.SESSION_TIMEOUT_SECONDS == 1800
def test_config_rejects_invalid_prompt_provider(monkeypatch):
monkeypatch.setenv("FASTVIDEO_PROMPT_PROVIDER", "unsupported")
_set_required_prompt_keys(monkeypatch)
@@ -75,13 +75,12 @@ def _run_cli(module, monkeypatch, argv: list[str]) -> list[dict[str, object]]:
calls: list[dict[str, object]] = []
uvicorn_stub = types.ModuleType("uvicorn")
def run(app, host: str, port: int, **kwargs) -> None:
def run(app, host: str, port: int) -> None:
calls.append(
{
"app": app,
"host": host,
"port": port,
**kwargs,
}
)
@@ -105,7 +104,6 @@ def test_server_cli_defaults_to_local_web_port(monkeypatch):
"app": server_main.app,
"host": "0.0.0.0",
"port": 8009,
"ws_max_size": 32 * 1024 * 1024,
}
]
@@ -123,7 +121,6 @@ def test_server_cli_allows_explicit_host_and_port(monkeypatch):
"app": server_main.app,
"host": "127.0.0.1",
"port": 8123,
"ws_max_size": 32 * 1024 * 1024,
}
]
@@ -150,7 +147,6 @@ def test_mock_server_cli_defaults_to_local_web_port(monkeypatch):
"app": mock_server.app,
"host": "0.0.0.0",
"port": 8009,
"ws_max_size": 32 * 1024 * 1024,
}
]
@@ -170,7 +166,6 @@ def test_mock_server_cli_updates_latency(monkeypatch):
"app": mock_server.app,
"host": "0.0.0.0",
"port": 8111,
"ws_max_size": 32 * 1024 * 1024,
}
]
assert mock_server.LATENCY_MS == 321
@@ -290,152 +290,6 @@ def test_mock_server_supports_initial_custom_rollout_prompt():
mock_server.LATENCY_MS = old_latency_ms
def test_mock_server_manual_mode_streams_initial_prompt_without_rewrite_or_cap():
old_segment_bytes = mock_server.MOCK_SEGMENT_BYTES
old_latency_ms = mock_server.LATENCY_MS
old_generation_segment_cap = mock_server.GENERATION_SEGMENT_CAP
try:
mock_server.MOCK_SEGMENT_BYTES = b"mock-fmp4-bytes"
mock_server.LATENCY_MS = 1
mock_server.GENERATION_SEGMENT_CAP = 1
ws = _FakeWebSocket(
[
(
0.0,
{
"type": "session_init_v2",
"preset_id": "custom_editable",
"preset_label": "Custom rollout",
"curated_prompts": [],
"initial_rollout_prompt": "A drone skims a neon canyon",
"initial_rollout_prompt_id": "steer-prompt-1",
"manual_continuation_mode": True,
"enhancement_enabled": True,
"auto_extension_enabled": False,
"loop_generation_enabled": False,
},
),
(
0.08,
{
"type": "append_prompt",
"prompt": "The drone dives toward the river",
"prompt_id": "steer-prompt-2",
},
),
(0.30, {"type": "leave"}),
]
)
asyncio.run(mock_server.websocket_endpoint(ws))
message_types = [payload["type"] for payload in ws.sent_json]
assert "rewrite_seed_prompts_started" not in message_types
assert "rewrite_seed_prompts_complete" not in message_types
assert "ltx2_stream_start" in message_types
# cap=1 must not stop a manual-mode session after the first segment
assert "ltx2_stream_complete" not in message_types
prompt_ready_events = [
payload for payload in ws.sent_json if payload["type"] == "prompt_ready"
]
assert [payload["prompt_id"] for payload in prompt_ready_events] == [
"steer-prompt-1",
"steer-prompt-2",
]
segment_start_events = [
payload
for payload in ws.sent_json
if payload["type"] == "ltx2_segment_start"
]
assert [payload["prompt"] for payload in segment_start_events] == [
"A drone skims a neon canyon",
"The drone dives toward the river",
]
segment_source_events = [
payload
for payload in ws.sent_json
if payload["type"] == "segment_prompt_source"
]
assert [payload["prompt_id"] for payload in segment_source_events] == [
"steer-prompt-1",
"steer-prompt-2",
]
finally:
mock_server.MOCK_SEGMENT_BYTES = old_segment_bytes
mock_server.LATENCY_MS = old_latency_ms
mock_server.GENERATION_SEGMENT_CAP = old_generation_segment_cap
def test_mock_server_project_init_manual_mode_streams_initial_prompt():
old_segment_bytes = mock_server.MOCK_SEGMENT_BYTES
old_latency_ms = mock_server.LATENCY_MS
try:
mock_server.MOCK_SEGMENT_BYTES = b"mock-fmp4-bytes"
mock_server.LATENCY_MS = 1
ws = _FakeWebSocket(
[
(
0.0,
{
"type": "session_init_v2",
"preset_id": "test_preset",
"curated_prompts": ["segment one"],
"enhancement_enabled": True,
"auto_extension_enabled": False,
"loop_generation_enabled": False,
},
),
(0.05, {"type": "end_project_keep_session"}),
(
0.15,
{
"type": "project_init_v1",
"preset_id": "custom_editable",
"preset_label": "Custom rollout",
"curated_prompts": [],
"initial_rollout_prompt": "A drone skims a neon canyon",
"initial_rollout_prompt_id": "steer-prompt-1",
"manual_continuation_mode": True,
"enhancement_enabled": True,
"auto_extension_enabled": False,
"loop_generation_enabled": False,
},
),
(0.45, {"type": "leave"}),
]
)
asyncio.run(mock_server.websocket_endpoint(ws))
message_types = [payload["type"] for payload in ws.sent_json]
assert "project_idle" in message_types
project_idle_index = message_types.index("project_idle")
# manual-mode restart must not run the rewrite rollout
assert "rewrite_seed_prompts_started" not in message_types[project_idle_index:]
assert "ltx2_stream_start" in message_types[project_idle_index:]
prompt_ready_events = [
payload for payload in ws.sent_json if payload["type"] == "prompt_ready"
]
assert [payload["prompt_id"] for payload in prompt_ready_events] == [
"steer-prompt-1",
]
segment_start_events = [
payload
for payload in ws.sent_json
if payload["type"] == "ltx2_segment_start"
]
assert segment_start_events[-1]["prompt"] == "A drone skims a neon canyon"
finally:
mock_server.MOCK_SEGMENT_BYTES = old_segment_bytes
mock_server.LATENCY_MS = old_latency_ms
def test_mock_server_can_start_new_project_without_reconnecting():
old_segment_bytes = mock_server.MOCK_SEGMENT_BYTES
old_latency_ms = mock_server.LATENCY_MS
@@ -6,7 +6,6 @@ import os
import re
import time
import pytest
os.environ.setdefault("CEREBRAS_API_KEY", "dummy")
os.environ.setdefault("GROQ_API_KEY", "dummy")
@@ -206,59 +205,6 @@ def test_parse_json_response_extracts_first_embedded_object():
assert parsed == {"segment_prompts": ["A", "B"]}
def test_parse_json_response_returns_outer_object_not_nested_value():
parsed = _parse_json_response(
"Final answer: {\"next_prompt\": \"a scene\", \"style\": {\"mood\": \"noir\"}} done."
)
assert parsed == {"next_prompt": "a scene", "style": {"mood": "noir"}}
def test_parse_json_response_returns_last_of_multiple_objects():
parsed = _parse_json_response(
"Draft: {\"next_prompt\": \"draft\"}\nRefined: {\"next_prompt\": \"final\"}"
)
assert parsed == {"next_prompt": "final"}
def test_parse_json_response_ignores_fragments_of_truncated_trailing_object():
# finish_reason=length cut the refined object short; the complete draft must
# win over a nested fragment of the truncated object.
parsed = _parse_json_response(
'{"next_prompt": "draft"} refined: {"next_prompt": "final", "style": {"mood": "noir"}'
)
assert parsed == {"next_prompt": "draft"}
def test_parse_json_response_ignores_fragments_of_mid_string_truncated_object():
# Unterminated-string truncation reports the error at the opening quote,
# not end-of-text; nested fragments still must not win over the draft.
parsed = _parse_json_response(
'{"next_prompt": "draft"} refined: {"style": {"mood": "noir"}, "next_prompt": "cut off'
)
assert parsed == {"next_prompt": "draft"}
def test_parse_json_response_ignores_fragments_of_malformed_object_with_trailing_prose():
parsed = _parse_json_response(
'{"next_prompt": "draft"} {"final": {"mood": "noir"}, "x": 1 and then some prose'
)
assert parsed == {"next_prompt": "draft"}
def test_parse_json_response_raises_when_only_object_is_truncated():
with pytest.raises(ValueError):
_parse_json_response('{"style": {"mood": "noir"}, "next_prompt": "cut off')
def test_parse_json_response_returns_outer_rollout_dict():
parsed = _parse_json_response(
"{\"rollout\": {\"segment_prompts\": [{\"prompt\": \"a\"}, {\"prompt\": \"b\"}]}}"
)
assert parsed == {
"rollout": {"segment_prompts": [{"prompt": "a"}, {"prompt": "b"}]}
}
def test_load_prompt_required_falls_back_to_default_path(tmp_path):
fallback_path = tmp_path / "next_segment_system_prompt.md"
fallback_path.write_text("fallback prompt\n", encoding="utf-8")
+2 -3
View File
@@ -15,6 +15,5 @@ def _utc_now_iso() -> str:
PROMPT_EXTENSION_FAILURE_USER_MESSAGE = ("Prompt extension failed for this request.")
def _resolve_generation_segment_cap(*, single_clip_mode: bool, cap: int, manual_continuation_mode: bool = False) -> int:
# Steering (manual continuation) lets the user keep going indefinitely, like single-clip mode.
return 0 if (single_clip_mode or manual_continuation_mode) else cap
def _resolve_generation_segment_cap(*, single_clip_mode: bool, cap: int) -> int:
return 0 if single_clip_mode else cap
@@ -489,7 +489,7 @@ class VideoGenerationWorker:
num_inference_steps=NUM_INFERENCE_STEPS,
guidance_scale=1.0,
seed=10,
ltx2_image_crf=(33.0 if image_path and segment_idx == 1 else 0.0),
ltx2_image_crf=0.0,
image_path=image_path if segment_idx == 1 else None,
return_continuation_state=False,
)
-3
View File
@@ -29,9 +29,6 @@ test = [
dreamverse-server = "dreamverse.server_entry:cli"
dreamverse-mock-server = "dreamverse.mock_server:cli"
[tool.setuptools.packages.find]
include = ["dreamverse*"]
[tool.uv]
package = false
@@ -1,67 +0,0 @@
#!/usr/bin/env bash
# launch-dreamverse.sh — launch dreamverse-server on a compute node.
#
# Usage (from repo root):
# bash apps/dreamverse/scripts/launch-dreamverse.sh # GPUs 0-3, SP_SIZE=4
# CUDA_VISIBLE_DEVICES=0 bash apps/dreamverse/scripts/launch-dreamverse.sh # single GPU, SP_SIZE=1
set -euo pipefail
CONDA_PREFIX="$HOME/miniconda3/envs/dreamverse"
CUDA_RT_DIR="$CONDA_PREFIX/lib/python3.11/site-packages/nvidia/cuda_runtime/lib"
GXX="$CONDA_PREFIX/bin/aarch64-conda-linux-gnu-g++"
export CUDA_HOME="$CONDA_PREFIX"
export FASTVIDEO_ENABLE_STARTUP_WARMUP=true
export FASTVIDEO_ENABLE_PROMPT_SAFETY=false
export DREAMVERSE_MAX_AUTOTUNE=true
export LTX2_USE_DISTILLED_SIGMAS=0
export LTX2_VIDEO_CONDITIONING_NUM_FRAMES=1
export AUDIO_CONDITIONING_NUM_FRAMES=41
export DREAMVERSE_SESSION_TIMEOUT_SECONDS="${DREAMVERSE_SESSION_TIMEOUT_SECONDS:-1800}"
# GB200 max-autotune warmup compiles can run for hours; keep the watchdog generous here
export FASTVIDEO_STARTUP_WARMUP_TIMEOUT_SECONDS="${FASTVIDEO_STARTUP_WARMUP_TIMEOUT_SECONDS:-24000}"
export CEREBRAS_API_KEY="${CEREBRAS_API_KEY:-}" # set this in your env or ~/.env
export FASTVIDEO_PROMPT_CEREBRAS_MODEL="gpt-oss-120b"
export TORCHINDUCTOR_CACHE_DIR="$HOME/.cache/torchinductor"
export TRITON_CACHE_DIR="$HOME/.triton/cache"
export TORCH_CUDA_ARCH_LIST="10.0a"
# Compiler env (needed for flashinfer JIT compilation at server startup)
export CXX="$CONDA_PREFIX/compiler_compat/g++"
export CC="$CONDA_PREFIX/compiler_compat/gcc"
export CUDAHOSTCXX="$GXX"
export NVCC_PREPEND_FLAGS="-ccbin $GXX -allow-unsupported-compiler"
# Link against libcudart.so.12 at JIT compile time; stubs for libcuda.so
# cuda-compat has libcudart.so -> libcudart.so.12 (linker needs unversioned name)
export LIBRARY_PATH="$CONDA_PREFIX/lib/cuda-compat:$CONDA_PREFIX/lib/stubs"
# Only libcudart.so.12 at runtime — prevents cuDNN from seeing .so.13
export LD_LIBRARY_PATH="$CUDA_RT_DIR"
CUDA_VISIBLE_DEVICES="${CUDA_VISIBLE_DEVICES:-0,1,2,3}"
export FASTVIDEO_GPU_COUNT="${FASTVIDEO_GPU_COUNT:-all}"
# Default SP size to the usable GPU count so single-GPU invocations work.
# A numeric FASTVIDEO_GPU_COUNT caps the pool below the visible count, and an
# SP size above the pool size fails GPUPool startup with "Not enough GPUs".
IFS=',' read -ra _VISIBLE_GPUS <<< "$CUDA_VISIBLE_DEVICES"
# Count only non-empty tokens, matching gpu_pool.get_available_gpus (e.g. ",0,1" is 2 GPUs).
_USABLE_GPU_COUNT=0
for _gpu in "${_VISIBLE_GPUS[@]}"; do
[[ -n "${_gpu//[[:space:]]/}" ]] && _USABLE_GPU_COUNT=$((_USABLE_GPU_COUNT + 1))
done
if [[ "$FASTVIDEO_GPU_COUNT" =~ ^[0-9]+$ ]] && (( FASTVIDEO_GPU_COUNT < _USABLE_GPU_COUNT )); then
_USABLE_GPU_COUNT="$FASTVIDEO_GPU_COUNT"
fi
export DREAMVERSE_SP_SIZE="${DREAMVERSE_SP_SIZE:-$_USABLE_GPU_COUNT}"
PORT="${DREAMVERSE_PORT:-8009}"
FFMPEG_ENV="$(dirname "$0")/ffmpeg-env.sh"
# shellcheck source=ffmpeg-env.sh
[[ -f "$FFMPEG_ENV" ]] && source "$FFMPEG_ENV"
echo "==> Launching dreamverse-server on GPU $CUDA_VISIBLE_DEVICES port $PORT"
CUDA_VISIBLE_DEVICES="$CUDA_VISIBLE_DEVICES" \
"$CONDA_PREFIX/bin/dreamverse-server" --host 0.0.0.0 --port "$PORT"
@@ -1,30 +0,0 @@
#!/usr/bin/env bash
# launch-frontend.sh — start the Dreamverse Next.js dev server and ngrok tunnel.
#
# Usage (from repo root):
# bash apps/dreamverse/scripts/launch-frontend.sh
#
# Override backend or ngrok URL via env:
# BACKEND_HOST=1.2.3.4 bash apps/dreamverse/scripts/launch-frontend.sh
set -euo pipefail
CONDA_PREFIX="$HOME/miniconda3/envs/dreamverse"
BACKEND_HOST="${BACKEND_HOST:-10.244.18.228}"
BACKEND_PORT="${BACKEND_PORT:-8009}"
NGROK_URL="${NGROK_URL:-ltx23.ngrok.app}"
WEB_DIR="$(git rev-parse --show-toplevel)/apps/dreamverse/web"
cleanup() {
echo "==> Shutting down..."
kill "$FRONTEND_PID" 2>/dev/null || true
}
trap cleanup EXIT
echo "==> Starting frontend (backend: $BACKEND_HOST:$BACKEND_PORT)"
BACKEND_HOST="$BACKEND_HOST" BACKEND_PORT="$BACKEND_PORT" \
npm run --prefix "$WEB_DIR" dev &
FRONTEND_PID=$!
echo "==> Starting ngrok tunnel -> $NGROK_URL"
"$CONDA_PREFIX/bin/ngrok" http --url="$NGROK_URL" 5299
@@ -1,105 +0,0 @@
#!/usr/bin/env bash
# setup-dreamverse-env.sh — create and configure the dreamverse conda env
# from scratch on this aarch64 NFS Slurm cluster.
#
# Run from the login node (from the repo root):
# bash apps/dreamverse/scripts/setup-dreamverse-env.sh
#
# After this script completes, use launch-dreamverse.sh on a compute node.
set -euo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel)"
ENV_NAME="dreamverse"
LOCAL_DIR="/mnt/local/hal-kevin" # cache/pkgs — keep on local disk
CONDA_PREFIX="$HOME/miniconda3/envs/$ENV_NAME" # env — on shared NFS so it survives node changes
echo "==> Removing existing env if present"
conda env remove -p "$CONDA_PREFIX" -y 2>/dev/null || true
rm -rf "$CONDA_PREFIX" 2>/dev/null || true
echo "==> Creating conda env at $CONDA_PREFIX"
CONDA_PKGS_DIRS="$LOCAL_DIR/conda/pkgs" conda create -p "$CONDA_PREFIX" python=3.11 -y
GXX="$CONDA_PREFIX/bin/aarch64-conda-linux-gnu-g++"
GCC="$CONDA_PREFIX/bin/aarch64-conda-linux-gnu-gcc"
echo "==> Installing compiler"
CONDA_PKGS_DIRS="$LOCAL_DIR/conda/pkgs" conda install -p "$CONDA_PREFIX" gxx_linux-aarch64 -y
echo "==> Installing CUDA toolkit (nvcc + headers)"
CONDA_PKGS_DIRS="$LOCAL_DIR/conda/pkgs" conda install -p "$CONDA_PREFIX" -c nvidia cuda-toolkit -y
echo "==> Hiding conflicting libcudart.so.13 immediately"
mkdir -p "$CONDA_PREFIX/lib/hidden"
mv "$CONDA_PREFIX"/lib/libcudart.so* "$CONDA_PREFIX/lib/hidden/" 2>/dev/null || true
echo "==> Fixing compiler_compat symlinks"
mkdir -p "$CONDA_PREFIX/compiler_compat"
ln -sf "$GXX" "$CONDA_PREFIX/compiler_compat/g++"
ln -sf "$GCC" "$CONDA_PREFIX/compiler_compat/gcc"
echo "==> Symlinking CUDA headers to standard location"
for f in "$CONDA_PREFIX/targets/sbsa-linux/include/"*; do
ln -sf "$f" "$CONDA_PREFIX/include/$(basename "$f")" 2>/dev/null || true
done
echo "==> Installing ffmpeg (native build with x264 + NVENC)"
CUDA_PREFIX="$CONDA_PREFIX" bash "$REPO_ROOT/apps/dreamverse/scripts/install_native_ffmpeg.sh"
echo "==> Installing pip and uv"
CONDA_PKGS_DIRS="$LOCAL_DIR/conda/pkgs" conda install -p "$CONDA_PREFIX" pip -y
"$CONDA_PREFIX/bin/pip" install uv
echo "==> Setting compiler env vars"
export UV_CACHE_DIR="$LOCAL_DIR/cache"
export UV_LINK_MODE=copy
export CXX="$CONDA_PREFIX/compiler_compat/g++"
export CC="$CONDA_PREFIX/compiler_compat/gcc"
export CUDAHOSTCXX="$GXX"
export NVCC_PREPEND_FLAGS="-ccbin $GXX -allow-unsupported-compiler"
export CUDA_HOME="$CONDA_PREFIX"
# Only build for GB200 (sm_100a); CUDA 13 dropped support for older archs
export TORCH_CUDA_ARCH_LIST="10.0a"
echo "==> Installing torch with CUDA 12.8"
UV_CACHE_DIR="$LOCAL_DIR/cache" "$CONDA_PREFIX/bin/uv" pip install torch==2.11.0 torchvision \
--index-url https://download.pytorch.org/whl/cu128
echo "==> Hiding any newly introduced libcudart.so.13"
mv "$CONDA_PREFIX"/lib/libcudart.so* "$CONDA_PREFIX/lib/hidden/" 2>/dev/null || true
# Set paths now that torch (and its nvidia packages) are installed
CUDA_RT_DIR="$CONDA_PREFIX/lib/python3.11/site-packages/nvidia/cuda_runtime/lib"
CUDA_RT_SO="$(ls "$CUDA_RT_DIR"/libcudart.so.* 2>/dev/null | head -1)"
# The pip nvidia package only has libcudart.so.12 (versioned), not libcudart.so.
# The linker needs the unversioned name to satisfy -lcudart. Create a compat dir.
mkdir -p "$CONDA_PREFIX/lib/cuda-compat"
ln -sf "$CUDA_RT_SO" "$CONDA_PREFIX/lib/cuda-compat/libcudart.so"
export LIBRARY_PATH="$CONDA_PREFIX/lib/cuda-compat:$CONDA_PREFIX/lib/stubs"
export CMAKE_ARGS="-DCUDA_CUDART_LIBRARY=$CUDA_RT_SO -DCUDA_INCLUDE_DIRS=$CONDA_PREFIX/targets/sbsa-linux/include"
echo "==> Installing build tools"
"$CONDA_PREFIX/bin/pip" install scikit-build-core cmake ninja
echo "==> Initializing git submodules"
cd "$REPO_ROOT"
git submodule update --init fastvideo-kernel/include/cutlass fastvideo-kernel/include/tk
echo "==> Building fastvideo-kernel from local source"
UV_CACHE_DIR="$LOCAL_DIR/cache" "$CONDA_PREFIX/bin/uv" pip install \
-e "./fastvideo-kernel" --no-build-isolation
echo "==> Installing fastvideo + dreamverse extras"
UV_CACHE_DIR="$LOCAL_DIR/cache" "$CONDA_PREFIX/bin/uv" pip install \
-e ".[dreamverse]" --no-build-isolation
echo "==> Installing flashinfer-python (pinned, must be last)"
UV_CACHE_DIR="$LOCAL_DIR/cache" "$CONDA_PREFIX/bin/uv" pip install \
https://github.com/flashinfer-ai/flashinfer/releases/download/v0.6.11.post3/flashinfer_python-0.6.11.post3-py3-none-any.whl
echo ""
echo "Done. On a compute node run (GPUs 0-3 by default; set CUDA_VISIBLE_DEVICES to restrict):"
echo " bash apps/dreamverse/scripts/launch-dreamverse.sh"
@@ -110,7 +110,7 @@ default_request:
fps: 24 # internal: gpu_pool.py:85 TARGET_FPS
streaming:
# internal: config.py SESSION_TIMEOUT_SECONDS (env DREAMVERSE_SESSION_TIMEOUT_SECONDS, default 300)
# internal: config.py:33 SESSION_TIMEOUT_SECONDS = 300
session_timeout_seconds: 300
# internal: config.py:282-284 GENERATION_SEGMENT_CAP default 6
generation_segment_cap: 6
+13 -125
View File
@@ -9,7 +9,7 @@ import SessionTimeoutModal from "@/components/SessionTimeoutModal";
import Sidebar from "@/components/Sidebar";
import Header from "@/components/Header";
import VideoPlayer from "@/components/VideoPlayer";
import Workspace, { SceneHistoryList } from "@/components/Workspace";
import Workspace from "@/components/Workspace";
import { saveProject, saveProjectMetadata, listProjects, loadProjectClips, deleteProject, pruneOldProjects, type StoredProject, type StoredClip } from "@/lib/projectStorage";
import { isInfrastructureError } from "@/lib/ws/reducer";
import { useStore } from "@/hooks/useStore";
@@ -31,7 +31,7 @@ import { applyNormalizedSocketEvent } from "@/lib/ws/reducer";
import { createPromptWindowStore } from "@/stores/promptWindow";
import { createRewriteStore } from "@/stores/rewrite";
import { createSessionStore } from "@/stores/session";
import { createStreamStore, USER_PROMPT_SOURCES } from "@/stores/stream";
import { createStreamStore } from "@/stores/stream";
import { createUiStore } from "@/stores/ui";
import { Button } from "@/components/ui/button";
@@ -80,7 +80,7 @@ function yieldToEventLoop(): Promise<void> {
const HERO_WAVE_LIGHT = ["#2A4A98", "#4878E5", "#6FA0F2", "#B0BCC8", "#E8D99E", "#D8C844", "#C2A620"];
const HERO_WAVE_DARK = ["#143468", "#1E58B8", "#3892F0", "#80B8E8", "#B8D0EA", "#E2D498", "#DABB50"];
const HERO_TEXT = "Direct scenes in seconds with";
const HERO_TEXT = "Direct scenes in seconds";
function HeroTagline() {
const ref = useRef<HTMLHeadingElement>(null);
@@ -166,8 +166,6 @@ function HeroTagline() {
</span>
</Fragment>
))}
<span data-char className="transition-[color,filter] duration-150">{" "}</span>
<img src="/logo.svg" alt="FastVideo" className="inline-block h-[1.1em] w-auto align-middle" />
</h1>
);
}
@@ -239,7 +237,6 @@ export default function Page() {
enhancementEnabled,
promptExtensionError,
autoExtensionEnabled,
manualContinuationMode,
autoExtensionTimeoutHint,
loopGenerationEnabled,
generationPaused,
@@ -252,8 +249,6 @@ export default function Page() {
livePromptRewriteMode,
sessionExpired,
projectResetPending,
waitingForSegmentPrompt,
generatingNextScene,
} = sessionState;
const {
@@ -328,11 +323,6 @@ export default function Page() {
const [ttffValueMs, setTtffValueMs] = useState<number | null>(null);
const ttffIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const pendingInitialPromptRef = useRef("");
// Prompt id the opening scene is recorded under; sent as initial_rollout_prompt_id
// so the backend's pre-seeded opening PromptSubmission emits status updates
// (prompt_enhancing/prompt_ready/prompt_fallback_used) against the same id.
const pendingInitialPromptIdRef = useRef("");
const [initialImageDataUrl, setInitialImageDataUrl] = useState("");
const lastArchivedReplayKeyRef = useRef("");
const [sidebarOpen, setSidebarOpen] = useState(false);
const [currentThumbnail, setCurrentThumbnail] = useState<string | null>(null);
@@ -432,11 +422,6 @@ export default function Page() {
if (String(e?.source || "") === "user_rewrite" && typeof e?.text === "string" && e.text.trim()) {
return e.text.trim();
}
// Steering opening: the backend overwrites text/source with the enhanced
// prompt once ready, so fall back to the stable rawText record.
if (e?.steeringUserPrompt && typeof e?.rawText === "string" && e.rawText.trim()) {
return e.rawText.trim();
}
}
return "Untitled project";
}, [selectedPreset, promptEvents]);
@@ -447,39 +432,11 @@ export default function Page() {
const canDownloadVideo = useMemo(() => {
const currentActiveClip = activeClip as Record<string, any> | null;
if (currentActiveClip?.blob instanceof Blob) return true;
if ((completedClips as Record<string, any>[]).some((clip) => clip?.blob instanceof Blob)) return true;
// Steering only: once playback has started the live AV pipeline holds playable segments, so
// the user can download the in-progress video at any time (handleDownloadVideo remuxes live
// segments). Auto mode keeps its original blob-gated behavior.
return Boolean(manualContinuationMode) && Boolean(avPlaybackStarted);
}, [activeClip, completedClips, avPlaybackStarted, manualContinuationMode]);
// Steering mode scene list (oldest first). Primary source is the user's own words, captured
// stably at submit time as `rawText` (the backend later overwrites text/source with the
// enhanced prompt, so we never read those). A segment with no user prompt — e.g. a preset's
// opening scene — falls back to promptHistory (the actual prompt that drove that segment).
const steeringScenes = useMemo(() => {
if (!manualContinuationMode) return [] as Record<string, any>[];
const userScenes = (promptEvents as Record<string, any>[])
.filter((e) => e?.steeringUserPrompt && !e?.steeringFailed && typeof e?.rawText === "string" && e.rawText.trim())
.slice()
.reverse() // oldest -> newest
.map((e) => ({ id: e.promptId, prompt: e.rawText as string }));
const scenes: Record<string, any>[] = [];
// Preset opening segments: curated seeds with no user prompt of their own.
const curatedHists = (promptHistory as Record<string, any>[])
.slice()
.reverse() // oldest first
.filter((h) => !USER_PROMPT_SOURCES.has(String(h?.source || "")) && typeof h?.prompt === "string" && (h.prompt as string).trim());
scenes.push(...curatedHists.map((h) => ({ id: h.id || "scene_open", prompt: h.prompt })));
scenes.push(...userScenes);
return scenes;
}, [manualContinuationMode, promptEvents, promptHistory]);
return (completedClips as Record<string, any>[]).some((clip) => clip?.blob instanceof Blob);
}, [activeClip, completedClips]);
const hasEdits = useMemo(
() => Boolean(sessionStarted) && (
(promptEvents as Record<string, any>[]).some((e) => typeof e?.text === "string" && e.text.trim() && String(e?.source || "").trim() === "user_rewrite")
),
() => Boolean(sessionStarted) && (promptEvents as Record<string, any>[]).some((e) => typeof e?.text === "string" && e.text.trim() && String(e?.source || "").trim() === "user_rewrite"),
[sessionStarted, promptEvents],
);
@@ -1464,8 +1421,7 @@ export default function Page() {
if (!prompt) return;
lastSubmitTimeRef.current = now;
const rCAR = !uiStore.get().devtoolsMode && !uiStore.get().demoMode;
const inManualMode = sessionStore.get().manualContinuationMode;
if (!inManualMode && (rCAR || (sessionStore.get().livePromptRewriteMode && !uiStore.get().demoMode))) {
if (rCAR || (sessionStore.get().livePromptRewriteMode && !uiStore.get().demoMode)) {
if (rewriteStore.get().rewritingSeedPrompts) return;
const rewriteSourcePromptWindowPrompts = getActivePromptWindowPrompts();
const nextPendingClip = {
@@ -1506,11 +1462,6 @@ export default function Page() {
status: "submitted",
source: "user_raw",
text: prompt,
// Stable record of the user's own words for the steering scene list. The backend
// later overwrites `text`/`source` with the enhanced prompt via prompt/ready, but
// these two fields are never touched by trackPromptEvent.
steeringUserPrompt: true,
rawText: prompt,
});
ws.send(
JSON.stringify({
@@ -1524,14 +1475,7 @@ export default function Page() {
activeClipId: shouldUseArchivedPlaybackFallback() ? streamStore.get().activeClipId : "",
activePlaybackStartTime: shouldUseArchivedPlaybackFallback() ? streamStore.get().activePlaybackStartTime : 0,
});
sessionStore.patch({
livePromptDraft: "",
waitingForSegmentPrompt: false,
sessionNotice: "",
// Light the "Generating next scene" overlay immediately on a real submit;
// stream/media_init (or a fallback/error) clears it.
...(inManualMode ? { generatingNextScene: true } : {}),
});
sessionStore.patch({ livePromptDraft: "" });
}
function setLivePromptRewriteMode(enabled: boolean) {
@@ -1606,14 +1550,6 @@ export default function Page() {
);
}
// Steering (manual continuation) vs the automatic 6-segment rollout — a pre-session
// preference honored when the session starts.
function handleManualContinuationToggle(event: any) {
sessionStore.patch({
manualContinuationMode: Boolean(event.currentTarget.checked),
});
}
function handleLoopGenerationToggle(event: any) {
sessionStore.patch({
loopGenerationEnabled: Boolean(event.currentTarget.checked),
@@ -1789,9 +1725,6 @@ export default function Page() {
sessionNotice: preserveSessionNotice ? sessionStore.get().sessionNotice : "",
sessionExpired: preserveSessionNotice ? sessionStore.get().sessionExpired : false,
projectResetPending: false,
manualContinuationMode: true,
waitingForSegmentPrompt: false,
generatingNextScene: false,
});
rewriteStore.resetSessionState();
streamStore.resetSessionState();
@@ -1804,7 +1737,6 @@ export default function Page() {
function resetToProjectLobbyState() {
setVideoMuted(true);
pendingInitialPromptRef.current = "";
pendingInitialPromptIdRef.current = "";
sessionStore.patch({
sessionStarted: false,
livePromptDraft: "",
@@ -1818,9 +1750,6 @@ export default function Page() {
sessionNotice: "",
sessionExpired: false,
projectResetPending: false,
manualContinuationMode: true,
waitingForSegmentPrompt: false,
generatingNextScene: false,
});
rewriteStore.resetSessionState();
streamStore.resetSessionState();
@@ -1831,14 +1760,7 @@ export default function Page() {
}
function buildProjectInitPayload(type: "session_init_v2" | "project_init_v1") {
const manualMode = Boolean(sessionStore.get().manualContinuationMode);
let segmentPrompts = getSessionInitPrompts();
// Steering mode: seed the first 2 segments from the preset so there's no
// gap between segment 1 and 2; the user drives every subsequent segment.
// Force auto/loop off so the backend waits after the seeded prompts run out.
if (manualMode) {
segmentPrompts = segmentPrompts.slice(0, 2);
}
const segmentPrompts = getSessionInitPrompts();
setSeedPrompts(segmentPrompts);
return {
type,
@@ -1846,17 +1768,11 @@ export default function Page() {
preset_label: getInitialPresetLabel(),
curated_prompts: segmentPrompts,
initial_rollout_prompt: normalizeInitialPrompt(pendingInitialPromptRef.current),
// Ties the backend's pre-seeded opening PromptSubmission to the prompt event
// recorded in beginProjectLocally so its status updates land on that record.
initial_rollout_prompt_id: pendingInitialPromptIdRef.current,
initial_image: initialImageDataUrl
? { data_url: initialImageDataUrl, mime_type: initialImageDataUrl.split(";")[0].split(":")[1] || "image/png", name: "upload.png" }
: null,
initial_image: null,
single_clip_mode: false,
enhancement_enabled: sessionStore.get().enhancementEnabled,
auto_extension_enabled: manualMode ? false : sessionStore.get().autoExtensionEnabled,
loop_generation_enabled: manualMode ? false : sessionStore.get().loopGenerationEnabled,
manual_continuation_mode: manualMode,
auto_extension_enabled: sessionStore.get().autoExtensionEnabled,
loop_generation_enabled: sessionStore.get().loopGenerationEnabled,
};
}
@@ -2035,11 +1951,7 @@ export default function Page() {
setCurrentThumbnail(null);
const initialPrompt = normalizeInitialPrompt(sessionStore.get().livePromptDraft as string);
pendingInitialPromptRef.current = initialPrompt;
setInitialImageDataUrl("");
const rCAR = !uiStore.get().devtoolsMode && !uiStore.get().demoMode;
// The "Steering mode" toggle is authoritative: checked = manual per-segment steering,
// unchecked = automatic 6-segment rollout (default).
const nextManualContinuationMode = Boolean(sessionStore.get().manualContinuationMode);
sessionStore.patch({
sessionNotice: "",
sessionExpired: false,
@@ -2054,9 +1966,6 @@ export default function Page() {
autoExtensionTimeoutHint: "",
generationPaused: false,
projectResetPending: false,
manualContinuationMode: nextManualContinuationMode,
waitingForSegmentPrompt: false,
generatingNextScene: false,
});
resetPlaybackState();
streamStore.patch({
@@ -2070,15 +1979,12 @@ export default function Page() {
selectedHistoryId: "",
});
rewriteStore.resetSessionState();
pendingInitialPromptIdRef.current = initialPrompt ? makePromptId() : "";
if (initialPrompt) {
addPromptEvent({
promptId: pendingInitialPromptIdRef.current,
promptId: makePromptId(),
status: "rewrite_requested",
source: "user_rewrite",
text: initialPrompt,
// In steering mode the typed opening is the user's Scene 1 — record it stably.
...(nextManualContinuationMode ? { steeringUserPrompt: true, rawText: initialPrompt } : {}),
});
}
setSeedPrompts(getSessionInitPrompts());
@@ -2594,7 +2500,6 @@ export default function Page() {
selectedPresetId={selectedPresetId as string}
enhancementEnabled={enhancementEnabled as boolean}
autoExtensionEnabled={autoExtensionEnabled as boolean}
manualContinuationEnabled={manualContinuationMode as boolean}
loopGenerationEnabled={loopGenerationEnabled as boolean}
canJoinSession={canJoinSession as boolean}
canSubmitContinuation={canSubmitContinuation}
@@ -2607,7 +2512,6 @@ export default function Page() {
onEnhancementToggle={handleEnhancementToggle}
onCuratedPromptLimitChange={handleCuratedPromptLimitChange}
onAutoExtensionToggle={handleAutoExtensionToggle}
onManualContinuationToggle={handleManualContinuationToggle}
onLoopToggle={handleLoopGenerationToggle}
onJoin={joinSession}
onLeave={leaveSession}
@@ -2737,7 +2641,6 @@ export default function Page() {
/>
<Header timeLeft={headerTimeLeft} formatTime={formatTime} onToggleSidebar={() => setSidebarOpen((prev) => !prev)} />
<div className={cn("flex flex-1 min-h-0 flex-col", sessionStarted && "pb-16 sm:pb-28")}>
<div className="relative flex flex-1 min-h-0 flex-col justify-center px-4 pb-2 sm:px-6 sm:pb-12">
{isViewingMode && (
<>
@@ -2821,8 +2724,6 @@ export default function Page() {
showLivePlayback={showLivePlayback}
defaultMuted={videoMuted}
canDownload={canDownloadVideo}
waitingForSegmentPrompt={waitingForSegmentPrompt as boolean}
generatingNextScene={generatingNextScene as boolean}
onPlaying={markFirstFrameRendered}
onDownload={handleDownloadVideo}
/>
@@ -2833,7 +2734,6 @@ export default function Page() {
<section className={cn("mx-auto w-full max-w-2xl", hasEdits && "flex-1 min-h-0 overflow-y-auto")}>
<Workspace
promptEvents={promptEvents as any[]}
manualMode={manualContinuationMode as boolean}
currentThumbnail={currentThumbnail}
originalLabel={pendingInitialPromptRef.current || (selectedPreset as Record<string, any>)?.label || ""}
sessionStarted={sessionStarted as boolean}
@@ -2880,17 +2780,11 @@ export default function Page() {
isGenerating={loadingAnimation as boolean}
storyPresets={storyPresets as any[]}
continuationDraft={livePromptDraft as string}
manualContinuationEnabled={manualContinuationMode as boolean}
onModeChange={(manual) => sessionStore.patch({ manualContinuationMode: manual })}
initialImageDataUrl={initialImageDataUrl}
onImageUpload={(dataUrl) => setInitialImageDataUrl(dataUrl)}
onImageClear={() => setInitialImageDataUrl("")}
canJoinSession={canStartSession}
canSubmitContinuation={canSubmitContinuation}
sessionExpired={sessionExpired as boolean}
sessionNotice={sessionNotice as string}
projectResetPending={projectResetPending as boolean}
waitingForSegmentPrompt={waitingForSegmentPrompt as boolean}
onPresetGenerate={handlePresetGenerate}
onContinuationInput={handleLivePromptInput}
onContinuationKeydown={handleLivePromptKeydown}
@@ -2904,12 +2798,6 @@ export default function Page() {
</motion.div>
</div>
</div>
{manualContinuationMode && (
<div className="px-4 sm:px-6">
<SceneHistoryList sceneHistory={steeringScenes as any[]} />
</div>
)}
</div>
</main>
);
}
+6 -96
View File
@@ -2,16 +2,13 @@
import React, { useRef, useState, useCallback, useEffect } from "react";
import Image from "next/image";
import { Film, ArrowUp, X, Loader2, ArrowLeft, ImagePlus } from "lucide-react";
import { Film, ArrowUp, X, Loader2, ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import LeaveSessionModal, { shouldShowLeaveWarning } from "@/components/LeaveSessionModal";
import SpeechToTextButton from "@/components/SpeechToTextButton";
import { cn } from "@/lib/utils";
const PROMPT_MAX_LENGTH = 500;
// Must match backend session_init_image.py: MAX_SESSION_INIT_IMAGE_BYTES / SUPPORTED_SESSION_INIT_IMAGE_MIME_TYPES.
const IMAGE_MAX_BYTES = 15 * 1024 * 1024;
const IMAGE_ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp"];
interface Props {
sessionStarted?: boolean;
@@ -25,12 +22,6 @@ interface Props {
sessionNotice?: string;
projectResetPending?: boolean;
viewingReadOnly?: boolean;
waitingForSegmentPrompt?: boolean;
manualContinuationEnabled?: boolean;
onModeChange?: (manual: boolean) => void;
initialImageDataUrl?: string;
onImageUpload?: (dataUrl: string, mimeType: string, name: string) => void;
onImageClear?: () => void;
onPresetGenerate?: (presetId: string) => void;
onContinuationInput?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
onContinuationKeydown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
@@ -55,12 +46,6 @@ export default function ChatBar({
sessionNotice = "",
projectResetPending = false,
viewingReadOnly = false,
waitingForSegmentPrompt = false,
manualContinuationEnabled = false,
onModeChange = () => {},
initialImageDataUrl = "",
onImageUpload = () => {},
onImageClear = () => {},
onPresetGenerate = () => {},
onContinuationInput = () => {},
onContinuationKeydown = () => {},
@@ -74,51 +59,15 @@ export default function ChatBar({
}: Props) {
const [sttBusy, setSttBusy] = useState(false);
const [leaveModalOpen, setLeaveModalOpen] = useState(false);
const [imageError, setImageError] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const processImageFile = useCallback((file: File) => {
if (!IMAGE_ALLOWED_TYPES.includes(file.type)) {
setImageError("Unsupported image type. Use a PNG, JPEG, or WebP image.");
return;
}
if (file.size > IMAGE_MAX_BYTES) {
setImageError("Image is too large. The maximum size is 15MB.");
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target?.result as string;
if (dataUrl) {
setImageError("");
onImageUpload(dataUrl, file.type, file.name);
}
};
reader.onerror = () => {
setImageError("Could not read the image file. Please try again.");
};
reader.readAsDataURL(file);
}, [onImageUpload]);
const handleImagePaste = useCallback((e: React.ClipboardEvent) => {
if (sessionStarted) return;
const items = Array.from(e.clipboardData?.items ?? []);
const imageItem = items.find((item) => item.type.startsWith("image/"));
if (!imageItem) return;
const file = imageItem.getAsFile();
if (file) processImageFile(file);
}, [sessionStarted, processImageFile]);
const showSpinner = isGenerating || rewritingSeedPrompts;
const isBusy = isGenerating || rewritingSeedPrompts || projectResetPending;
const messagePlaceholder = projectResetPending
? "Starting new project\u2026"
: isBusy
? "Generating video\u2026"
: waitingForSegmentPrompt
? "Describe the next scene\u2026"
: !sessionStarted
? "What video are you imagining?"
: "What do you want to edit?";
: !sessionStarted
? "What video are you imagining?"
: "What do you want to edit?";
const actionLabel = !sessionStarted ? "Generate" : "Rewrite rollout";
const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -318,9 +267,9 @@ export default function ChatBar({
<Button onClick={onStartNewProject} size="sm" className="rounded-full px-5">
New Project
</Button>
<a href="https://haoailab.com/blogs/dreamverse/" target="_blank" rel="noopener noreferrer">
<a href="https://docs.google.com/forms/d/e/1FAIpQLSe5zpO1iD8Ds-Ih-fOLm64qd7YZVvuvAyHuJaAfw1hkRHTe_A/viewform?usp=publish-editor" target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm" className="rounded-full px-5">
Blog
Join Waitlist
</Button>
</a>
</div>
@@ -397,31 +346,6 @@ export default function ChatBar({
</div>
)}
{!sessionStarted && imageError && (
<div className="rounded-xl border border-rose-500/20 bg-rose-500/10 px-4 py-2.5 text-center text-xs text-rose-700 dark:text-rose-300">
{imageError}
</div>
)}
{!sessionStarted && initialImageDataUrl && (
<div className="flex items-center gap-2 rounded-2xl border border-input bg-card/65 px-3 py-2">
<img src={initialImageDataUrl} alt="Initial frame" className="h-12 w-12 rounded-lg object-cover" />
<span className="flex-1 truncate text-xs text-muted-foreground">Starting image set</span>
<button type="button" onClick={() => { setImageError(""); onImageClear(); }} className="text-muted-foreground hover:text-foreground transition-colors">
<X className="size-4" />
</button>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept={IMAGE_ALLOWED_TYPES.join(",")}
className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) processImageFile(f); e.target.value = ""; }}
/>
<div
className={cn(
"flex min-w-0 items-center gap-1.5 rounded-4xl border py-2.5 pl-5 pr-2.5 shadow-md backdrop-blur-sm transition-all duration-200",
@@ -435,7 +359,6 @@ export default function ChatBar({
value={continuationDraft}
onChange={onContinuationInput}
onKeyDown={handleKeyDown}
onPaste={handleImagePaste}
placeholder={sttBusy ? "Listening\u2026" : messagePlaceholder}
maxLength={PROMPT_MAX_LENGTH}
disabled={isBusy || sttBusy}
@@ -446,19 +369,6 @@ export default function ChatBar({
)}
/>
{onSpeechTranscript && <SpeechToTextButton disabled={isBusy} onTranscript={onSpeechTranscript} onInterimChange={onSpeechInterimChange} onBusyChange={setSttBusy} />}
{!sessionStarted && (
<Button
type="button"
variant="ghost"
size="icon-sm"
title="Add starting image"
onClick={() => fileInputRef.current?.click()}
disabled={isBusy}
className="shrink-0 rounded-full text-muted-foreground hover:text-foreground"
>
<ImagePlus className="size-4" />
</Button>
)}
{!sessionStarted ? (
<Button
aria-label={actionLabel}
@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ThemeToggle } from "@/components/ui/theme-toggle";
const FASTVIDEO_REPO_URL = "https://haoailab.com/blogs/dreamverse/";
const FASTVIDEO_BLOG_URL = "https://haoailab.com/blogs/dreamverse/";
interface Props {
@@ -28,13 +29,13 @@ export default function Header({ timeLeft = null, formatTime = (seconds) => `${s
<SidePanelOpenFilled size={20} />
</Button>
)}
<a href="/" title="FastVideo home">
<a href={FASTVIDEO_REPO_URL} target="_blank" rel="noopener noreferrer" title="FastVideo on GitHub">
<Image src="/logo.svg" alt="FastVideo" width={32} height={32} className="h-8 w-auto sm:h-9 transition-opacity hover:opacity-70" />
</a>
<div className="hidden sm:flex items-center gap-3">
<a href={FASTVIDEO_BLOG_URL} target="_blank" rel="noopener noreferrer">
<a href="https://docs.google.com/forms/d/e/1FAIpQLSe5zpO1iD8Ds-Ih-fOLm64qd7YZVvuvAyHuJaAfw1hkRHTe_A/viewform?usp=publish-editor" target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm" className="gap-1.5 rounded-full px-3 text-xs">
Blog
Join Waitlist
<ExternalLink className="size-3 opacity-60" />
</Button>
</a>
@@ -52,9 +53,9 @@ export default function Header({ timeLeft = null, formatTime = (seconds) => `${s
</div>
<div className="flex sm:hidden items-center gap-2 px-4 pb-3">
<a href={FASTVIDEO_BLOG_URL} target="_blank" rel="noopener noreferrer">
<a href="https://docs.google.com/forms/d/e/1FAIpQLSe5zpO1iD8Ds-Ih-fOLm64qd7YZVvuvAyHuJaAfw1hkRHTe_A/viewform?usp=publish-editor" target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm" className="gap-1.5 rounded-full px-3 text-xs">
Blog
Join Waitlist
<ExternalLink className="size-3 opacity-60" />
</Button>
</a>
@@ -3,7 +3,7 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import { cn } from "@/lib/utils";
import { PlayFilledAlt } from "@carbon/icons-react";
import { Check, ChevronDown, Download, Loader2, Share } from "lucide-react";
import { Download, Loader2, Share } from "lucide-react";
import { Button } from "@/components/ui/button";
interface VideoPlayerProps {
videoRef?: React.RefCallback<HTMLVideoElement>;
@@ -21,8 +21,6 @@ interface VideoPlayerProps {
showLivePlayback?: boolean;
defaultMuted?: boolean;
rewritePending?: boolean;
waitingForSegmentPrompt?: boolean;
generatingNextScene?: boolean;
onPlaying?: () => void;
onDownload?: () => void;
}
@@ -59,8 +57,6 @@ export default function VideoPlayer({
showLivePlayback = true,
defaultMuted = true,
rewritePending = false,
waitingForSegmentPrompt = false,
generatingNextScene = false,
onPlaying = () => {},
onDownload,
}: VideoPlayerProps) {
@@ -92,137 +88,6 @@ export default function VideoPlayer({
setCanShare(typeof navigator.canShare === "function" && window.matchMedia("(pointer: coarse)").matches);
}, []);
// Steering mode: the backend may signal "waiting for next prompt" while the current
// segment is still PLAYING (it generates ahead). Only surface the "Segment complete"
// overlay once the playhead actually reaches the end of the buffered segment.
const [playbackReachedEnd, setPlaybackReachedEnd] = useState(false);
// Steering mode: when the user submits the next scene, the segment is generated
// (a few seconds of latency) before frames stream. Show a "Generating next scene…"
// indicator across that gap so the frozen frame isn't silent. Driven by the explicit
// generatingNextScene state (set on scene submit / prompt selection), never inferred
// from waitingForSegmentPrompt edges.
const [generatingNext, setGeneratingNext] = useState(false);
// End of the buffered timeline captured the moment generation starts; the freshly
// generated segment extends the buffer past this, which is how we know it landed.
const genBoundaryRef = useRef(0);
// Steering mode: the backend may signal "waiting for next prompt" while the current
// segment is still PLAYING (it generates ahead). Track whether the playhead has reached
// the end of the buffered segment so end-overlays only show there. Keep tracking through
// the generating phase too, so scrubbing back and replaying to the end re-shows them.
useEffect(() => {
if (!waitingForSegmentPrompt && !generatingNext) {
setPlaybackReachedEnd(false);
return;
}
const el = liveVideoEl.current;
if (!el) return;
const check = () => {
try {
const buffered = el.buffered;
if (buffered.length === 0) return;
const end = buffered.end(buffered.length - 1);
// Track proximity both ways: scrubbing back off the end hides the overlay,
// playing forward to the end re-shows it.
setPlaybackReachedEnd(el.ended || end - el.currentTime <= 0.2);
} catch {
/* buffered access can throw mid-append */
}
};
check();
el.addEventListener("timeupdate", check);
el.addEventListener("ended", check);
el.addEventListener("waiting", check);
el.addEventListener("stalled", check);
el.addEventListener("pause", check);
el.addEventListener("seeking", check);
el.addEventListener("seeked", check);
el.addEventListener("playing", check);
el.addEventListener("progress", check);
return () => {
el.removeEventListener("timeupdate", check);
el.removeEventListener("ended", check);
el.removeEventListener("waiting", check);
el.removeEventListener("stalled", check);
el.removeEventListener("pause", check);
el.removeEventListener("seeking", check);
el.removeEventListener("seeked", check);
el.removeEventListener("playing", check);
el.removeEventListener("progress", check);
};
}, [waitingForSegmentPrompt, generatingNext]);
useEffect(() => {
if (waitingForSegmentPrompt || !sessionStarted) {
// Back to waiting (or session over): nothing is generating.
setGeneratingNext(false);
return;
}
if (!generatingNextScene) return;
// Snapshot the current end of the buffered timeline; the generated segment will
// extend the buffer past this boundary.
const el = liveVideoEl.current;
let boundary = el?.currentTime ?? 0;
try {
const b = el?.buffered;
if (b && b.length) boundary = Math.max(boundary, b.end(b.length - 1));
} catch {
/* buffered access can throw mid-append */
}
genBoundaryRef.current = boundary;
setGeneratingNext(true);
}, [generatingNextScene, waitingForSegmentPrompt, sessionStarted]);
useEffect(() => {
if (!generatingNext) return;
if (!sessionStarted) {
setGeneratingNext(false);
return;
}
const el = liveVideoEl.current;
if (!el) return;
// Clear the instant the freshly generated segment lands: the buffer grows past the
// boundary captured at generation start (or the playhead advances into the new
// frames). Deliberately NOT a bare "playing" handler — scrubbing back and replaying
// the EXISTING segment must keep "Generating" up until the new frames actually arrive.
const check = () => {
try {
const b = el.buffered;
const end = b.length ? b.end(b.length - 1) : 0;
if (end > genBoundaryRef.current + 0.25 || el.currentTime > genBoundaryRef.current + 0.1) {
setGeneratingNext(false);
}
} catch {
/* buffered access can throw mid-append */
}
};
check();
el.addEventListener("progress", check);
el.addEventListener("timeupdate", check);
el.addEventListener("durationchange", check);
return () => {
el.removeEventListener("progress", check);
el.removeEventListener("timeupdate", check);
el.removeEventListener("durationchange", check);
};
}, [generatingNext, sessionStarted]);
// Drive a ~10.5s progress bar during generation so the wait has a visible ETA.
const GEN_DURATION_MS = 10500;
const [genProgress, setGenProgress] = useState(0);
useEffect(() => {
if (!generatingNext) {
setGenProgress(0);
return;
}
const start = performance.now();
setGenProgress(0);
const id = setInterval(() => {
setGenProgress(Math.min((performance.now() - start) / GEN_DURATION_MS, 1));
}, 50);
return () => clearInterval(id);
}, [generatingNext]);
return (
<div className="mx-auto w-full max-w-3xl mb-2 sm:mb-6">
<div className="rounded-2xl border border-border bg-card/50 p-2 shadow-lg backdrop-blur-md">
@@ -239,7 +104,7 @@ export default function VideoPlayer({
<PlayFilledAlt className="size-10 text-white/25" />
<p className="text-sm text-white/50">Your video will appear here</p>
</div>
) : !avPlaybackStarted && !mediaAppendError && !inQueue && !waitingForSegmentPrompt && loadingAnimation ? (
) : !avPlaybackStarted && !mediaAppendError && !inQueue && loadingAnimation ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-slate-900/60 p-4 backdrop-blur-[2px]">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute inset-0 -translate-x-full animate-[shimmer_3s_ease-in-out_infinite] bg-gradient-to-r from-transparent via-white/[0.04] to-transparent" />
@@ -249,35 +114,6 @@ export default function VideoPlayer({
</div>
) : null}
{/* Steering mode: this segment finished — wait gracefully for the user's next scene
instead of spinning. The last frame stays visible behind a soft bottom gradient. */}
{sessionStarted && waitingForSegmentPrompt && playbackReachedEnd && !mediaAppendError && !inQueue && (
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-end gap-2 bg-gradient-to-t from-slate-950/85 via-slate-950/15 to-transparent p-5 pb-6 text-center">
<div className="flex size-9 items-center justify-center rounded-full border border-white/25 bg-white/10 shadow-lg backdrop-blur-md">
<Check className="size-4 text-white/90" />
</div>
<div className="space-y-0.5">
<p className="text-sm font-medium text-white/95">Segment complete</p>
<p className="text-xs text-white/65">Describe the next scene below to keep going</p>
</div>
<ChevronDown className="size-4 animate-bounce text-white/45" />
</div>
)}
{/* Steering mode: generating the next segment — show a ~10.5s progress bar so the wait has an ETA.
Gated on playbackReachedEnd like "Segment complete": scrubbing back hides it, playing to the end re-shows it. */}
{sessionStarted && generatingNext && playbackReachedEnd && !mediaAppendError && !inQueue && (
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-end gap-3 bg-gradient-to-t from-slate-950/85 via-slate-950/15 to-transparent p-5 pb-7 text-center">
<p className="text-sm font-medium text-white/95">Generating next scene&hellip;</p>
<div className="h-1.5 w-48 overflow-hidden rounded-full bg-white/15 shadow-sm">
<div
className="h-full rounded-full bg-white/85 transition-[width] duration-100 ease-linear"
style={{ width: `${Math.round(genProgress * 100)}%` }}
/>
</div>
</div>
)}
{rewritePending && avPlaybackStarted && (
<div className="absolute inset-x-0 bottom-0 z-10 flex items-center justify-center gap-2 bg-gradient-to-t from-black/60 to-transparent px-4 pb-12 pt-8 pointer-events-none">
<Loader2 className="size-4 animate-spin text-white/90" />
@@ -3,90 +3,11 @@ import React, { useRef, useMemo, useEffect, useCallback, useState } from "react"
import { motion, useAnimationControls } from "framer-motion";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { Check, Clapperboard, Lightbulb, Pencil } from "lucide-react";
import { Check, Lightbulb, Pencil } from "lucide-react";
export const WORKSPACE_ORIGINAL_SELECTION_KEY = "original";
export const WORKSPACE_CURRENT_SELECTION_KEY = "current";
export function SceneHistoryList({ sceneHistory = [] }: { sceneHistory?: Record<string, any>[] }) {
const bottomSentinelRef = useRef<HTMLDivElement>(null);
const topSentinelRef = useRef<HTMLDivElement>(null);
const [showTopFade, setShowTopFade] = useState(false);
const scenes = useMemo(
() => (sceneHistory || []).filter((s) => normalizeText(s?.prompt)),
[sceneHistory],
);
const scrollToBottom = useCallback(() => {
setTimeout(() => {
bottomSentinelRef.current?.scrollIntoView({ block: "end", behavior: "smooth" });
}, 60);
}, []);
useEffect(() => {
if (scenes.length > 0) scrollToBottom();
}, [scenes.length, scrollToBottom]);
useEffect(() => {
const sentinel = bottomSentinelRef.current;
if (!sentinel || typeof ResizeObserver === "undefined") return;
let container: HTMLElement | null = sentinel.parentElement;
while (container) {
const oy = getComputedStyle(container).overflowY;
if (oy === "auto" || oy === "scroll") break;
container = container.parentElement;
}
if (!container) return;
const ro = new ResizeObserver(() => {
const nearBottom = container!.scrollHeight - container!.scrollTop - container!.clientHeight < 96;
if (nearBottom) scrollToBottom();
});
ro.observe(container);
return () => ro.disconnect();
}, [scenes.length, scrollToBottom]);
useEffect(() => {
const el = topSentinelRef.current;
if (!el) return;
const observer = new IntersectionObserver(([entry]) => setShowTopFade(!entry.isIntersecting), { threshold: 0.1 });
observer.observe(el);
return () => observer.disconnect();
}, [scenes.length]);
if (scenes.length === 0) return null;
return (
<section className="relative z-10 flex flex-col mx-auto w-full max-w-2xl max-h-32 overflow-y-auto">
<div
className={cn(
"pointer-events-none sticky top-0 z-20 -mb-12 h-12 bg-linear-to-b from-background to-transparent transition-opacity duration-200",
showTopFade ? "opacity-100" : "opacity-0",
)}
aria-hidden="true"
/>
<div ref={topSentinelRef} className="h-0 w-0" aria-hidden="true" />
<div className="flex flex-col gap-2 pt-12 pb-4">
{scenes.map((scene, index) => (
<div
key={scene.id || index}
className="flex items-start gap-3 rounded-xl p-3 transition-colors duration-200 hover:bg-slate-200/50 hover:dark:bg-slate-800/30"
>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<Badge variant="secondary" className="horizontal gap-2 items-center w-fit">
<Clapperboard className="size-3 opacity-70" />
{`Scene ${index + 1}`}
</Badge>
<p className="line-clamp-2 text-sm leading-5 text-muted-foreground">{scene.prompt}</p>
</div>
</div>
))}
</div>
<div ref={bottomSentinelRef} className="h-0 w-0" aria-hidden="true" />
</section>
);
}
interface WorkspaceProps {
promptEvents?: Record<string, any>[];
currentThumbnail?: string | null;
@@ -98,7 +19,6 @@ interface WorkspaceProps {
selectedClipId?: string;
selectedEntryKey?: string;
originalClipId?: string;
manualMode?: boolean;
}
function normalizeText(value: any): string {
@@ -298,7 +218,7 @@ function ChromaGradient({ sessionStarted = false }: { sessionStarted?: boolean }
);
}
export default function Workspace({ promptEvents = [], currentThumbnail = null, originalLabel = "", sessionStarted = false, onSelectOriginal, onSelectEvent, onSelectCurrent, selectedClipId, selectedEntryKey: selectedEntryKeyProp, originalClipId = "", manualMode = false }: WorkspaceProps) {
export default function Workspace({ promptEvents = [], currentThumbnail = null, originalLabel = "", sessionStarted = false, onSelectOriginal, onSelectEvent, onSelectCurrent, selectedClipId, selectedEntryKey: selectedEntryKeyProp, originalClipId = "" }: WorkspaceProps) {
const bottomSentinelRef = useRef<HTMLDivElement>(null);
const topSentinelRef = useRef<HTMLDivElement>(null);
const [showTopFade, setShowTopFade] = useState(false);
@@ -339,14 +259,6 @@ export default function Workspace({ promptEvents = [], currentThumbnail = null,
return () => observer.disconnect();
}, [conversationEvents.length]);
if (manualMode) {
return (
<div className="mt-auto flex flex-col">
<ChromaGradient sessionStarted={sessionStarted} />
</div>
);
}
return (
<div className="mt-auto flex flex-col">
<ChromaGradient sessionStarted={sessionStarted} />
@@ -34,7 +34,6 @@ interface DevtoolsComposerProps {
demoMode?: boolean;
enhancementEnabled?: boolean;
autoExtensionEnabled?: boolean;
manualContinuationEnabled?: boolean;
loopGenerationEnabled?: boolean;
curatedPromptLimit?: number;
maxCuratedPromptCount?: number;
@@ -50,7 +49,6 @@ interface DevtoolsComposerProps {
onEnhancementToggle?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onCuratedPromptLimitChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onAutoExtensionToggle?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onManualContinuationToggle?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onLoopToggle?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onLivePromptModeToggle?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onSpeechTranscript?: (text: string) => void;
@@ -73,7 +71,6 @@ export default function DevtoolsComposer({
demoMode = false,
enhancementEnabled = true,
autoExtensionEnabled = false,
manualContinuationEnabled = false,
loopGenerationEnabled = false,
curatedPromptLimit = 0,
maxCuratedPromptCount = 0,
@@ -89,7 +86,6 @@ export default function DevtoolsComposer({
onEnhancementToggle = () => {},
onCuratedPromptLimitChange = () => {},
onAutoExtensionToggle = () => {},
onManualContinuationToggle = () => {},
onLoopToggle = () => {},
onLivePromptModeToggle = () => {},
onSpeechTranscript,
@@ -332,28 +328,6 @@ export default function DevtoolsComposer({
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox
id="devtools-steering-mode"
checked={manualContinuationEnabled}
onCheckedChange={(checked) =>
onManualContinuationToggle({
target: { checked: Boolean(checked) },
currentTarget: { checked: Boolean(checked) },
} as React.ChangeEvent<HTMLInputElement>)
}
/>
<div className="space-y-1">
<Label htmlFor="devtools-steering-mode">
Steering mode
</Label>
<p className="text-sm text-muted-foreground">
Drive each segment manually — type the next scene to
continue (vs the automatic 6-segment rollout).
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox
id="devtools-loop-generation"
@@ -21,7 +21,6 @@ interface DevtoolsShellProps {
selectedPresetId?: string;
enhancementEnabled?: boolean;
autoExtensionEnabled?: boolean;
manualContinuationEnabled?: boolean;
loopGenerationEnabled?: boolean;
canJoinSession?: boolean;
canSubmitContinuation?: boolean;
@@ -35,7 +34,6 @@ interface DevtoolsShellProps {
onEnhancementToggle?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onCuratedPromptLimitChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onAutoExtensionToggle?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onManualContinuationToggle?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onLoopToggle?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onJoin?: () => void;
onLeave?: () => void;
@@ -130,7 +128,6 @@ export default function DevtoolsShell({
selectedPresetId = '',
enhancementEnabled = true,
autoExtensionEnabled = false,
manualContinuationEnabled = false,
loopGenerationEnabled = false,
canJoinSession = false,
canSubmitContinuation = false,
@@ -144,7 +141,6 @@ export default function DevtoolsShell({
onEnhancementToggle = () => {},
onCuratedPromptLimitChange = () => {},
onAutoExtensionToggle = () => {},
onManualContinuationToggle = () => {},
onLoopToggle = () => {},
onJoin = () => {},
onLeave = () => {},
@@ -288,7 +284,6 @@ export default function DevtoolsShell({
demoMode={demoMode}
enhancementEnabled={enhancementEnabled}
autoExtensionEnabled={autoExtensionEnabled}
manualContinuationEnabled={manualContinuationEnabled}
loopGenerationEnabled={loopGenerationEnabled}
curatedPromptLimit={curatedPromptLimit}
maxCuratedPromptCount={maxCuratedPromptCount}
@@ -304,7 +299,6 @@ export default function DevtoolsShell({
onEnhancementToggle={onEnhancementToggle}
onCuratedPromptLimitChange={onCuratedPromptLimitChange}
onAutoExtensionToggle={onAutoExtensionToggle}
onManualContinuationToggle={onManualContinuationToggle}
onLoopToggle={onLoopToggle}
onLivePromptModeToggle={onLivePromptModeToggle}
onSpeechTranscript={onSpeechTranscript}
@@ -57,32 +57,4 @@ describe('prependPromptEvent', () => {
expect(next[0].promptId).toBe('new');
expect(next.some((item: any) => item.promptId === 'p-23')).toBe(false);
});
it('never drops steering scene events when capping', () => {
// 30 scenes interleaved with 30 other events — well past the cap.
let events: Record<string, any>[] = [];
for (let i = 0; i < 30; i += 1) {
events = prependPromptEvent(events, {
promptId: `scene-${i}`,
status: 'submitted',
steeringUserPrompt: true,
rawText: `scene ${i}`,
});
events = prependPromptEvent(events, {
promptId: `other-${i}`,
status: 'submitted',
});
}
const scenes = events.filter((e) => e.steeringUserPrompt);
expect(scenes).toHaveLength(30);
// Oldest-first scene order (and therefore numbering) is stable and complete.
expect(scenes[scenes.length - 1].promptId).toBe('scene-0');
expect(scenes[0].promptId).toBe('scene-29');
// Non-scene events are still capped, oldest dropped first.
const others = events.filter((e) => !e.steeringUserPrompt);
expect(others.length).toBeLessThanOrEqual(24);
expect(others.some((e) => e.promptId === 'other-0')).toBe(false);
expect(others[0].promptId).toBe('other-29');
});
});
+1 -15
View File
@@ -16,19 +16,5 @@ export function prependPromptEvent(
events: Record<string, any>[],
event: Record<string, any>,
): Record<string, any>[] {
const next = [event, ...events];
if (next.length <= MAX_PROMPT_EVENTS) {
return next;
}
// Steering scene events (steeringUserPrompt) are exempt from the cap: the
// scene list is derived from them and must stay complete and stably numbered
// for long sessions. Only the oldest non-scene events are dropped.
let nonSceneKept = 0;
return next.filter((e) => {
if (e?.steeringUserPrompt) {
return true;
}
nonSceneKept += 1;
return nonSceneKept <= MAX_PROMPT_EVENTS;
});
return [event, ...events].slice(0, MAX_PROMPT_EVENTS);
}
+1 -165
View File
@@ -1,11 +1,6 @@
import { describe, expect, it } from 'vitest';
import { applyNormalizedSocketEvent, resolveSessionErrorMessage } from './reducer';
import { createSessionStore } from '../../stores/session';
import { createRewriteStore } from '../../stores/rewrite';
import { createStreamStore } from '../../stores/stream';
import { createUiStore } from '../../stores/ui';
import { createPromptWindowStore } from '../../stores/promptWindow';
import { resolveSessionErrorMessage } from './reducer';
describe('resolveSessionErrorMessage', () => {
it('returns a dedicated message for IP session limit errors', () => {
@@ -24,162 +19,3 @@ describe('resolveSessionErrorMessage', () => {
})).toBe('Backend replica unavailable. Rejoin session.');
});
});
function buildContext(overrides: Record<string, unknown> = {}) {
const sessionStore = createSessionStore();
const rewriteStore = createRewriteStore();
const streamStore = createStreamStore();
const uiStore = createUiStore();
const promptWindowStore = createPromptWindowStore();
const avPipeline = {
reset: () => {},
setStreamCompleted: () => {},
noteSegmentInit: () => {},
noteSegmentComplete: () => {},
maybeStartPlayback: () => {},
ensurePipeline: async () => {},
};
return {
sessionStore,
promptWindowStore,
rewriteStore,
streamStore,
uiStore,
avPipeline,
tick: async () => {},
defaultAvMime: 'video/mp4',
fixedRewriteModel: 'model',
parseLatencyMs: () => null,
formatPromptWindowEventText: () => '',
makePromptId: () => 'generated-id',
buildClipLabel: () => 'clip',
startSessionCountdown: () => {},
clearCountdownInterval: () => {},
resetTtffTimer: () => {},
startTtffTimer: () => {},
preserveArchivedPlaybackSelection: false,
finalizeStreamCompletion: async () => {},
...overrides,
};
}
describe('steering generatingNextScene flow', () => {
it('sets generatingNextScene on prompt/sources_resumed in manual mode', async () => {
const context = buildContext();
await applyNormalizedSocketEvent(
{ type: 'prompt/sources_resumed', payload: { segment_idx: 2 } },
context,
);
expect(context.sessionStore.get().generatingNextScene).toBe(true);
expect(context.sessionStore.get().waitingForSegmentPrompt).toBe(false);
});
it('does NOT set generatingNextScene on session/auto_extension_updated', async () => {
const context = buildContext();
await applyNormalizedSocketEvent(
{ type: 'session/auto_extension_updated', payload: { enabled: true } },
context,
);
expect(context.sessionStore.get().generatingNextScene).toBe(false);
expect(context.sessionStore.get().waitingForSegmentPrompt).toBe(false);
});
it('clears generatingNextScene when segment media arrives', async () => {
const context = buildContext();
context.sessionStore.patch({ generatingNextScene: true });
await applyNormalizedSocketEvent(
{
type: 'stream/media_init',
payload: { segment_idx: 2, stream_id: 's', mime: 'video/mp4' },
},
context,
);
expect(context.sessionStore.get().generatingNextScene).toBe(false);
});
it('clears generatingNextScene and returns to waiting on prompt/sources_blocked', async () => {
const context = buildContext();
context.sessionStore.patch({ generatingNextScene: true });
await applyNormalizedSocketEvent(
{ type: 'prompt/sources_blocked', payload: { segment_idx: 3 } },
context,
);
expect(context.sessionStore.get().generatingNextScene).toBe(false);
expect(context.sessionStore.get().waitingForSegmentPrompt).toBe(true);
});
it('clears generatingNextScene when the opening prompt falls back', async () => {
const context = buildContext();
context.sessionStore.patch({ generatingNextScene: true });
await applyNormalizedSocketEvent(
{
type: 'prompt/fallback_used',
payload: { prompt_id: 'p1', prompt: '', source: 'user_enhancement_failed' },
},
context,
);
expect(context.sessionStore.get().generatingNextScene).toBe(false);
expect(context.sessionStore.get().waitingForSegmentPrompt).toBe(true);
});
});
describe('opening prompt id tracking', () => {
it('routes prompt lifecycle updates to the frontend-recorded opening event', async () => {
// The frontend records the opening scene under its own prompt id and sends it
// as initial_rollout_prompt_id; the backend echoes it in status updates.
const context = buildContext();
context.rewriteStore.addPromptEvent({
promptId: 'opening-id',
status: 'rewrite_requested',
source: 'user_rewrite',
text: 'a castle at dawn',
steeringUserPrompt: true,
rawText: 'a castle at dawn',
});
await applyNormalizedSocketEvent(
{ type: 'prompt/enhancing', payload: { prompt_id: 'opening-id' } },
context,
);
let opening = (context.rewriteStore.get().promptEvents as Record<string, any>[])
.find((e) => e.promptId === 'opening-id');
expect(opening?.status).toBe('enhancing');
await applyNormalizedSocketEvent(
{
type: 'prompt/fallback_used',
payload: { prompt_id: 'opening-id', prompt: '', source: 'user_enhancement_failed' },
},
context,
);
opening = (context.rewriteStore.get().promptEvents as Record<string, any>[])
.find((e) => e.promptId === 'opening-id');
expect(opening?.status).toBe('ready_fallback');
// A failed opening is dropped from the steering scene list instead of
// lingering as a ghost "Scene 1".
expect(opening?.steeringFailed).toBe(true);
});
it('marks a prompt-scoped session/error (e.g. safety block) as steeringFailed', async () => {
const context = buildContext();
context.rewriteStore.addPromptEvent({
promptId: 'blocked-id',
status: 'queued',
source: 'user_raw',
text: 'a blocked prompt',
steeringUserPrompt: true,
rawText: 'a blocked prompt',
});
await applyNormalizedSocketEvent(
{
type: 'session/error',
payload: { message: 'Prompt blocked by safety filter.', prompt_id: 'blocked-id' },
},
context,
);
const blocked = (context.rewriteStore.get().promptEvents as Record<string, any>[])
.find((e) => e.promptId === 'blocked-id');
expect(blocked?.steeringFailed).toBe(true);
});
});
+9 -50
View File
@@ -99,19 +99,7 @@ export async function applyNormalizedSocketEvent(event: any, context: any): Prom
status: "ready_fallback",
source: payload.source || "user_raw",
text: payload.prompt,
// Steering: this prompt produced no segment — drop it from the scene list.
steeringFailed: true,
});
// Steering recovery: enhancement failed so the backend enqueued nothing AND won't
// re-emit prompt_sources_blocked (its drained flag is still set). Put the user back to
// "describe the next scene" ourselves so the generating overlay clears and they can retry.
if (!uiStore.get().simpleMode && sessionStore.get().manualContinuationMode) {
sessionStore.patch({
waitingForSegmentPrompt: true,
generatingNextScene: false,
sessionNotice: "Couldn't continue from that prompt — try rephrasing the next scene.",
});
}
console.warn("[PromptEnhanceFallback] Prompt extension failed for this request.");
return;
@@ -255,32 +243,19 @@ export async function applyNormalizedSocketEvent(event: any, context: any): Prom
}
case "prompt/sources_blocked":
if (sessionStore.get().manualContinuationMode) {
sessionStore.patch({ waitingForSegmentPrompt: true, generatingNextScene: false, autoExtensionTimeoutHint: "" });
} else {
sessionStore.patch({
autoExtensionTimeoutHint: uiStore.get().simpleMode ? "" : "blocked on user input, increase prompt count for smoother experience",
});
}
sessionStore.patch({
autoExtensionTimeoutHint: uiStore.get().simpleMode ? "" : "blocked on user input, increase prompt count for smoother experience",
});
return;
case "prompt/sources_resumed":
sessionStore.patch({
autoExtensionTimeoutHint: "",
waitingForSegmentPrompt: false,
// A real prompt was just selected for the next segment; media arriving
// (stream/media_init) clears this again.
...(sessionStore.get().manualContinuationMode ? { generatingNextScene: true } : {}),
});
return;
case "session/auto_extension_updated":
// Deliberately does NOT touch generatingNextScene: toggling auto extension
// starts no generation.
sessionStore.patch({ autoExtensionTimeoutHint: "", waitingForSegmentPrompt: false });
console.log("[AutoExtensionUpdated]", {
enabled: sessionStore.get().autoExtensionEnabled,
});
sessionStore.patch({ autoExtensionTimeoutHint: "" });
if (event.type === "session/auto_extension_updated") {
console.log("[AutoExtensionUpdated]", {
enabled: sessionStore.get().autoExtensionEnabled,
});
}
return;
case "segment/step_complete":
@@ -302,7 +277,6 @@ export async function applyNormalizedSocketEvent(event: any, context: any): Prom
projectResetPending: false,
sessionExpired: true,
sessionNotice: "",
generatingNextScene: false,
});
console.log("Session timed out");
clearCountdownInterval();
@@ -380,8 +354,6 @@ export async function applyNormalizedSocketEvent(event: any, context: any): Prom
return;
case "stream/media_init":
// Segment media is arriving — the "Generating next scene" phase is over.
sessionStore.patch({ generatingNextScene: false });
streamStore.patch({
mediaAppendError: null,
loadingAnimation: streamStore.get().avPlaybackStarted ? streamStore.get().loadingAnimation : true,
@@ -499,30 +471,17 @@ export async function applyNormalizedSocketEvent(event: any, context: any): Prom
sessionStore.patch({
generationCapReached: false,
sessionNotice: "",
generatingNextScene: false,
});
await finalizeStreamCompletion();
return;
case "session/error": {
const errorMessage = resolveSessionErrorMessage(payload);
if (payload?.prompt_id) {
// Prompt-scoped error (e.g. safety-blocked): the prompt produced no
// segment, so drop it from the steering scene list.
rewriteStore.trackPromptEvent(payload.prompt_id, {
steeringFailed: true,
});
}
sessionStore.patch({
generationCapReached: false,
preservePlaybackOnClose: false,
promptExtensionError: "",
sessionNotice: errorMessage,
// Steering: a blocked/failed prompt produced no segment and the backend won't re-emit
// prompt_sources_blocked, so recover the "describe the next scene" state ourselves.
...(!uiStore.get().simpleMode && sessionStore.get().manualContinuationMode
? { waitingForSegmentPrompt: true, generatingNextScene: false }
: {}),
});
rewriteStore.patch({
rewritingSeedPrompts: false,
@@ -24,9 +24,6 @@ export interface SessionState {
livePromptRewriteMode: boolean;
sessionExpired: boolean;
projectResetPending: boolean;
manualContinuationMode: boolean;
waitingForSegmentPrompt: boolean;
generatingNextScene: boolean;
}
const DEFAULT_SESSION_STATE: SessionState = {
@@ -52,12 +49,6 @@ const DEFAULT_SESSION_STATE: SessionState = {
livePromptRewriteMode: false,
sessionExpired: false,
projectResetPending: false,
// Steering-only product: every session drives scenes manually. There is no
// auto-rollout mode and no UI selector, so this stays true throughout.
manualContinuationMode: true,
waitingForSegmentPrompt: false,
// True from scene submit / prompt selection until the segment's media arrives.
generatingNextScene: false,
};
export type SessionStore = ManagedStore<SessionState> & {
+1 -18
View File
@@ -1,18 +1,5 @@
import { createManagedStore, type ManagedStore } from "./createManagedStore";
// Prompt-history sources driven by the user's own submissions. Curated (non-user)
// entries — e.g. a preset's opening scene — feed the steering scene list and are
// exempt from the history cap so Scene 1 survives long sessions.
export const USER_PROMPT_SOURCES = new Set([
"user_raw",
"user",
"user_enhanced",
"user_rewrite",
"user_enhancement_failed",
]);
const PROMPT_HISTORY_CAP = 120;
export interface StreamState {
playingSeedPromptIndex: number | null;
generatingSeedPromptIndex: number | null;
@@ -172,14 +159,10 @@ export function createStreamStore(initialState: Partial<StreamState> = {}): Stre
loopIteration: typeof loopIteration === "number" ? loopIteration : null,
};
const nextHistory = [entry, ...state.promptHistory];
return {
...state,
promptHistoryCounter: nextCounter,
promptHistory: nextHistory.length > PROMPT_HISTORY_CAP
? nextHistory.filter((item, index) =>
index < PROMPT_HISTORY_CAP || !USER_PROMPT_SOURCES.has(String(item.source || "")))
: nextHistory,
promptHistory: [entry, ...state.promptHistory].slice(0, 120),
selectedHistoryId: state.selectedHistoryId || (entry.id as string),
};
});
+1 -1
View File
@@ -1 +1 @@
PUBLIC_API_BASE_URL=http://localhost:8189/api
NEXT_PUBLIC_API_BASE_URL=http://localhost:8189/api
+6 -1
View File
@@ -13,6 +13,12 @@
# testing
/coverage
# playwright
/test-results/
/playwright-report/
/blob-report/
/.playwright/
# next.js
/.next/
/out/
@@ -39,4 +45,3 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
.svelte-kit
-4
View File
@@ -1,4 +0,0 @@
{
"useTabs": true,
"tabWidth": 4
}
+49 -5
View File
@@ -11,19 +11,63 @@ The UI currently supports:
- Datasets
- Gallery View
## Project Structure
```
apps/fastvideo_studio/
├── server.py / job_runner.py / database.py # FastAPI backend + job lifecycle
├── mock_server.py # In-memory API mock for e2e tests
├── models/ # Pydantic request models (shared with the mock)
├── training_config.py # Studio workloads → fastvideo/train YAML configs
├── tests/ # Backend unit tests (pytest)
├── e2e/ # Playwright specs (run against the mock)
└── src/
├── app/ # Next.js App Router pages (thin routes)
├── components/
│ ├── shell/ # App chrome: header, sidebars, layout
│ ├── jobs/ # Job queue, cards, create-job modal, log sidebar
│ ├── datasets/ # Dataset cards, upload, captions
│ └── ui/ # shadcn-style primitives (shared theme)
├── stores/ # Framework-agnostic state + React bridge (hooks/)
├── lib/ # API client, types, option persistence
└── test/ # Vitest setup + factories
```
The visual theme (slate light/dark palettes, IBM Plex type, `#356cff` accent)
is shared with `apps/dreamverse`; the toggle in the header persists the choice
per browser.
## Testing
```bash
npm run typecheck # tsc
npm test # vitest unit tests
npm run e2e # Playwright against the in-memory mock backend
python -m pytest tests/ # backend unit tests (from apps/fastvideo_studio)
```
## Quick Start
The easiest way to run the application is:
For local development, install dependencies and start the Next.js dev server:
```bash
cd apps/fastvideo_studio
npm install
npm run dev
```
You can then access the app at [http://localhost:3000](http://localhost:3000).
Start the Python API server (default port 8189) in a separate terminal — see below.
For a production build of the web app together with the API server:
```bash
cd apps/fastvideo_studio
npm install
npm run build
npm run start
npm run start:all
```
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:
@@ -31,7 +75,7 @@ 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`.
To run each component separately, you can use the commands `npm run start:web` (after `npm run build`) and `npm run start:api`.
You can also run the API server with this command from the `apps/` directory:
+4 -28
View File
@@ -251,11 +251,10 @@ class Database:
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,
real_score_guidance_scale,
generator_update_interval, real_score_model_path, fake_score_model_path
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
job["id"],
@@ -297,12 +296,9 @@ class Database:
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", ""),
@@ -401,24 +397,6 @@ class Database:
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, ))
@@ -443,7 +421,7 @@ class Database:
cur = self._execute("SELECT * FROM settings WHERE id = 1")
row = cur.fetchone()
if not row:
return _default_settings_dict()
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 "")
@@ -557,8 +535,6 @@ def _row_to_job(row: sqlite3.Row) -> dict[str, Any]:
}
float_defaults = {
"dmd_vsa_sparsity": 0.8,
"min_timestep_ratio": 0.02,
"max_timestep_ratio": 0.98,
"real_score_guidance_scale": 3.5,
}
result = {
@@ -625,7 +601,7 @@ def _row_to_job(row: sqlite3.Row) -> dict[str, Any]:
return result
def _default_settings_dict() -> dict[str, Any]:
def default_settings_dict() -> dict[str, Any]:
"""Return default settings as API-style dict."""
return {
"defaultModelId": DEFAULT_SETTINGS["default_model_id"],
@@ -0,0 +1,43 @@
import { expect, test } from '@playwright/test';
import { skipWithoutMock } from './helpers';
/**
* Create-job flow: open the Create Job modal on /inference, fill the prompt
* (the model auto-selects once the mock's /api/models loads), submit, and
* confirm the new job lands in the queue.
*/
test.describe('create inference job', () => {
skipWithoutMock();
test('creates a T2V job and shows it in the queue', async ({ page }) => {
await page.goto('/inference');
// The "Create Job" button reveals a workload menu on hover; wait for the
// T2V item to become visible before clicking so the CSS hover transition
// can't race the click.
await page.getByRole('button', { name: /create job/i }).hover();
const t2vItem = page.getByRole('menuitem', { name: /T2V/i });
await expect(t2vItem).toBeVisible();
await t2vItem.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
// Wait for the mock's model catalogue to populate the dropdown (more than
// just the disabled placeholder), then pick one explicitly — the app's
// auto-selection is racy.
const modelSelect = dialog.getByLabel('Model', { exact: true });
await expect(modelSelect.locator('option')).not.toHaveCount(1);
await modelSelect.selectOption({ index: 1 });
const prompt = `e2e raccoon in sunflowers ${Date.now()}`;
await dialog.getByLabel('Prompt', { exact: true }).fill(prompt);
await dialog.getByRole('button', { name: 'Create Job' }).click();
// Modal closes and the queue refreshes with the newly created job.
await expect(dialog).toBeHidden();
await expect(page.getByText(prompt)).toBeVisible();
});
});
@@ -0,0 +1,37 @@
import { expect, test } from '@playwright/test';
import { API_BASE, skipWithoutMock } from './helpers';
/**
* Datasets page: the seeded datasets render as cards, and the Create Dataset
* modal opens from the header action.
*/
test.describe('datasets', () => {
skipWithoutMock();
test('lists the seeded datasets', async ({ page, request }) => {
// Read the seeded names from the mock so the assertion tracks the fixture.
const res = await request.get(`${API_BASE}/datasets`);
const datasets = (await res.json()) as Array<{ name: string }>;
expect(datasets.length).toBeGreaterThan(0);
await page.goto('/datasets');
for (const ds of datasets) {
await expect(page.getByText(ds.name, { exact: true })).toBeVisible();
}
});
test('opens the Create Dataset modal', async ({ page }) => {
await page.goto('/datasets');
await page.getByRole('button', { name: 'Add Dataset' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(
dialog.getByRole('heading', { name: /Add Dataset/i }),
).toBeVisible();
await expect(dialog.getByLabel('Name', { exact: true })).toBeVisible();
});
});
+41
View File
@@ -0,0 +1,41 @@
import { expect, test } from '@playwright/test';
import { API_BASE, skipWithoutMock } from './helpers';
/**
* Gallery page: the seeded completed inference job surfaces as a media tile
* (an <article> wrapping a <video>) captioned with its prompt.
*/
test.describe('gallery', () => {
skipWithoutMock();
test('shows a media tile for the seeded completed job', async ({
page,
request,
}) => {
const res = await request.get(`${API_BASE}/jobs?job_type=inference`);
const jobs = (await res.json()) as Array<{
status: string;
output_path: string | null;
prompt: string;
}>;
const completed = jobs.find(
(j) => j.status === 'completed' && j.output_path,
);
expect(completed, 'mock should seed a completed inference job').toBeTruthy();
await page.goto('/gallery');
await expect(
page.getByRole('heading', { level: 1, name: 'Gallery' }),
).toBeVisible();
// The completed job renders as an <article> containing a <video> tile.
const tile = page
.locator('article')
.filter({ has: page.locator('video') });
await expect(tile.first()).toBeVisible();
await expect(page.getByText(completed!.prompt)).toBeVisible();
});
});
+30
View File
@@ -0,0 +1,30 @@
import { expect, test } from '@playwright/test';
import { skipWithoutMock } from './helpers';
/**
* GPU status page: the mock's fake GPUs render as cards with meters, and the
* page is reachable from the primary sidebar.
*/
test.describe('gpus', () => {
skipWithoutMock();
test('lists the mock GPUs with utilization meters', async ({ page }) => {
await page.goto('/gpus');
await expect(
page.getByRole('heading', { level: 1, name: 'GPUs' }),
).toBeVisible();
await expect(page.getByText('GPU 0')).toBeVisible();
await expect(page.getByText('GPU 1')).toBeVisible();
expect(await page.getByRole('meter').count()).toBe(4);
});
test('is reachable from the sidebar', async ({ page }) => {
await page.goto('/inference');
await page.getByRole('link', { name: 'GPUs' }).click();
await expect(page).toHaveURL(/\/gpus$/);
await expect(page.getByText('GPU 0')).toBeVisible();
});
});
+44
View File
@@ -0,0 +1,44 @@
import { test, type APIRequestContext } from '@playwright/test';
/**
* Port and base URL of the mock API — the single source of truth shared by
* playwright.config.ts (which boots the mock and points `npm run dev` at it
* via NEXT_PUBLIC_API_BASE_URL) and the specs (which probe/read mock state
* directly through the Playwright `request` fixture).
*
* The default port is deliberately NOT the real server's default (8189): the
* e2e specs create and (if autostart is on) launch jobs, so running them
* against a real backend would mutate the developer's database and fire real
* GPU runs. API_BASE is derived solely from MOCK_API_PORT (it does not inherit
* NEXT_PUBLIC_API_BASE_URL) so the specs always talk to the mock.
*/
export const MOCK_API_PORT = process.env.MOCK_API_PORT || '8190';
export const API_BASE = `http://127.0.0.1:${MOCK_API_PORT}/api`;
export const MOCK_SKIP_MESSAGE =
`Mock backend not reachable at ${API_BASE}/__mock__ ` +
`(start fastvideo_studio.mock_server on port ${MOCK_API_PORT}).`;
/**
* True only when the mock server answers on the configured port. Probes the
* mock-only /api/__mock__ sentinel (which the real server does not serve) so
* the suite refuses to run its mutating specs against a real backend.
*/
export async function mockIsUp(request: APIRequestContext): Promise<boolean> {
try {
const res = await request.get(`${API_BASE}/__mock__`);
if (!res.ok()) return false;
const body = await res.json();
return body?.mock === true;
} catch {
return false;
}
}
/** Self-skip every test in the enclosing describe when the mock is down. */
export function skipWithoutMock(): void {
test.beforeEach(async ({ request }) => {
test.skip(!(await mockIsUp(request)), MOCK_SKIP_MESSAGE);
});
}
+47
View File
@@ -0,0 +1,47 @@
import { expect, test } from '@playwright/test';
import { skipWithoutMock } from './helpers';
/**
* App-shell smoke: the Next.js frontend hydrates, renders the FastVideo logo
* and the primary-sidebar navigation, and routing between the top-level
* sections works. Each spec self-skips when the mock backend isn't reachable.
*/
test.describe('app shell', () => {
skipWithoutMock();
test('loads with the logo and primary-sidebar nav', async ({ page }) => {
await page.goto('/');
// The root route redirects to /inference.
await expect(page).toHaveURL(/\/inference$/);
await expect(page.getByRole('img', { name: /fastvideo/i })).toBeVisible();
for (const label of ['Inference', 'Datasets', 'Gallery', 'Settings']) {
await expect(page.getByRole('link', { name: label })).toBeVisible();
}
});
test('navigates between the primary sections', async ({ page }) => {
await page.goto('/inference');
await expect(
page.getByRole('heading', { level: 1, name: 'Jobs' }),
).toBeVisible();
const sections: Array<{ link: string; url: RegExp; title: string }> = [
{ link: 'Datasets', url: /\/datasets$/, title: 'Datasets' },
{ link: 'Gallery', url: /\/gallery$/, title: 'Gallery' },
{ link: 'Settings', url: /\/settings$/, title: 'Settings' },
{ link: 'Inference', url: /\/inference$/, title: 'Jobs' },
];
for (const section of sections) {
await page.getByRole('link', { name: section.link }).click();
await expect(page).toHaveURL(section.url);
// The header <h1> is the only level-1 heading and reflects the route.
await expect(
page.getByRole('heading', { level: 1, name: section.title }),
).toBeVisible();
}
});
});
-2
View File
@@ -1,2 +0,0 @@
// Minimal ESLint config for SvelteKit (add typescript-eslint / eslint-plugin-svelte as needed)
export default [];
+74
View File
@@ -0,0 +1,74 @@
# SPDX-License-Identifier: Apache-2.0
"""GPU telemetry for the studio status page, via NVML (nvidia-ml-py)."""
from __future__ import annotations
import contextlib
import logging
from typing import Any
logger = logging.getLogger("fastvideo.studio.gpu")
_nvml_initialized = False
def _ensure_nvml() -> Any:
"""Import and initialize NVML once; raises on machines without it."""
global _nvml_initialized # noqa: PLW0603
import pynvml
if not _nvml_initialized:
pynvml.nvmlInit()
_nvml_initialized = True
return pynvml
def _device_snapshot(pynvml: Any, index: int) -> dict[str, Any]:
handle = pynvml.nvmlDeviceGetHandleByIndex(index)
name = pynvml.nvmlDeviceGetName(handle)
if isinstance(name, bytes):
name = name.decode()
mem = pynvml.nvmlDeviceGetMemoryInfo(handle)
util = pynvml.nvmlDeviceGetUtilizationRates(handle)
# Optional sensors: not every GPU/driver exposes them.
temperature: int | None = None
power_watts: float | None = None
power_limit_watts: float | None = None
with contextlib.suppress(pynvml.NVMLError):
temperature = int(pynvml.nvmlDeviceGetTemperature(handle, pynvml.NVML_TEMPERATURE_GPU))
with contextlib.suppress(pynvml.NVMLError):
power_watts = pynvml.nvmlDeviceGetPowerUsage(handle) / 1000.0
power_limit_watts = (pynvml.nvmlDeviceGetEnforcedPowerLimit(handle) / 1000.0)
return {
"index": index,
"name": name,
"utilization": int(util.gpu),
"memory_used_mib": int(mem.used / (1024 * 1024)),
"memory_total_mib": int(mem.total / (1024 * 1024)),
"temperature_c": temperature,
"power_watts": power_watts,
"power_limit_watts": power_limit_watts,
}
def get_gpu_snapshot() -> dict[str, Any]:
"""Return {"available", "gpus", "error"} — never raises.
``available: False`` covers both "no NVIDIA driver/library on this host"
and transient NVML failures; the frontend shows ``error`` as-is.
"""
try:
pynvml = _ensure_nvml()
count = pynvml.nvmlDeviceGetCount()
gpus = [_device_snapshot(pynvml, i) for i in range(count)]
return {"available": True, "gpus": gpus, "error": None}
except ImportError:
return {
"available": False,
"gpus": [],
"error": "nvidia-ml-py is not installed on the API server host.",
}
except Exception as exc: # NVMLError, driver issues, …
logger.warning("GPU snapshot failed: %s", exc)
return {"available": False, "gpus": [], "error": str(exc)}
+33 -58
View File
@@ -23,12 +23,13 @@ import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
from fastvideo.utils import get_mp_context
from fastvideo_studio.database import Database
from fastvideo_studio.training_config import (
build_training_args,
build_training_config,
get_training_env,
get_training_module_info,
)
logger = logging.getLogger("fastvideo.studio.job_runner")
@@ -160,13 +161,10 @@ class Job:
num_latent_t: int = 20
validation_dataset_file: str = ""
lora_rank: int = 32
ltx2_first_frame_conditioning_p: float | None = None
# DMD options
dmd_use_vsa: bool = False
dmd_vsa_sparsity: float = 0.8
dmd_denoising_steps: str = "1000,757,522"
min_timestep_ratio: float = 0.02
max_timestep_ratio: float = 0.98
real_score_guidance_scale: float = 3.5
generator_update_interval: int = 5
real_score_model_path: str = ""
@@ -221,12 +219,9 @@ class Job:
"num_width": self.width,
"validation_dataset_file": self.validation_dataset_file,
"lora_rank": self.lora_rank,
"ltx2_first_frame_conditioning_p": self.ltx2_first_frame_conditioning_p,
"dmd_use_vsa": self.dmd_use_vsa,
"dmd_vsa_sparsity": self.dmd_vsa_sparsity,
"dmd_denoising_steps": self.dmd_denoising_steps,
"min_timestep_ratio": self.min_timestep_ratio,
"max_timestep_ratio": self.max_timestep_ratio,
"real_score_guidance_scale": self.real_score_guidance_scale,
"generator_update_interval": self.generator_update_interval,
"real_score_model_path": self.real_score_model_path or "",
@@ -330,12 +325,9 @@ class JobRunner:
num_latent_t=row.get("num_latent_t", 20),
validation_dataset_file=row.get("validation_dataset_file", "") or "",
lora_rank=row.get("lora_rank", 32),
ltx2_first_frame_conditioning_p=row.get("ltx2_first_frame_conditioning_p"),
dmd_use_vsa=row.get("dmd_use_vsa", False),
dmd_vsa_sparsity=float(row.get("dmd_vsa_sparsity", 0.8)),
dmd_denoising_steps=row.get("dmd_denoising_steps", "1000,757,522") or "1000,757,522",
min_timestep_ratio=float(row.get("min_timestep_ratio", 0.02)),
max_timestep_ratio=float(row.get("max_timestep_ratio", 0.98)),
real_score_guidance_scale=float(row.get("real_score_guidance_scale", 3.5)),
generator_update_interval=int(row.get("generator_update_interval", 5)),
real_score_model_path=row.get("real_score_model_path", "") or "",
@@ -412,12 +404,9 @@ class JobRunner:
num_latent_t: int = 20,
validation_dataset_file: str = "",
lora_rank: int = 32,
ltx2_first_frame_conditioning_p: float | None = None,
dmd_use_vsa: bool = False,
dmd_vsa_sparsity: float = 0.8,
dmd_denoising_steps: str = "1000,757,522",
min_timestep_ratio: float = 0.02,
max_timestep_ratio: float = 0.98,
real_score_guidance_scale: float = 3.5,
generator_update_interval: int = 5,
real_score_model_path: str = "",
@@ -457,12 +446,9 @@ class JobRunner:
num_latent_t=num_latent_t,
validation_dataset_file=validation_dataset_file or "",
lora_rank=lora_rank,
ltx2_first_frame_conditioning_p=ltx2_first_frame_conditioning_p,
dmd_use_vsa=dmd_use_vsa,
dmd_vsa_sparsity=dmd_vsa_sparsity,
dmd_denoising_steps=dmd_denoising_steps,
min_timestep_ratio=min_timestep_ratio,
max_timestep_ratio=max_timestep_ratio,
real_score_guidance_scale=real_score_guidance_scale,
generator_update_interval=generator_update_interval,
real_score_model_path=real_score_model_path or "",
@@ -733,46 +719,23 @@ class JobRunner:
self._save_job(job)
return
module_info = get_training_module_info(job.workload_type, job.model_id)
if not module_info:
env = os.environ.copy()
env.update(get_training_env())
try:
# Job.to_dict() carries every key the config builder reads
# (extra keys are ignored by its .get() lookups).
train_config = build_training_config(job.to_dict(), job_output_dir)
except ValueError as exc:
job.status = JobStatus.FAILED
job.error = f"Unknown workload type: {job.workload_type}"
job.error = str(exc)
job.finished_at = time.time()
self._save_job(job)
return
module_path, _pipeline_workload, use_vsa, _is_lora = module_info
dmd_use_vsa = (job.workload_type.startswith("dmd_") and getattr(job, "dmd_use_vsa", False))
env = os.environ.copy()
env.update(get_training_env(use_vsa or dmd_use_vsa))
job_dict = {
"model_id": job.model_id,
"data_path": job.data_path,
"workload_type": job.workload_type,
"num_gpus": job.num_gpus,
"max_train_steps": job.max_train_steps,
"train_batch_size": job.train_batch_size,
"learning_rate": job.learning_rate,
"num_latent_t": job.num_latent_t,
"num_height": job.height,
"num_width": job.width,
"num_frames": job.num_frames,
"validation_dataset_file": job.validation_dataset_file,
"lora_rank": job.lora_rank,
"ltx2_first_frame_conditioning_p": job.ltx2_first_frame_conditioning_p,
}
if job.workload_type.startswith("dmd_") or job.workload_type.startswith("self_forcing_"):
job_dict["dmd_use_vsa"] = getattr(job, "dmd_use_vsa", False)
job_dict["dmd_vsa_sparsity"] = getattr(job, "dmd_vsa_sparsity", 0.8)
job_dict["dmd_denoising_steps"] = getattr(job, "dmd_denoising_steps", "1000,757,522")
job_dict["min_timestep_ratio"] = getattr(job, "min_timestep_ratio", 0.02)
job_dict["max_timestep_ratio"] = getattr(job, "max_timestep_ratio", 0.98)
job_dict["real_score_guidance_scale"] = getattr(job, "real_score_guidance_scale", 3.5)
job_dict["generator_update_interval"] = getattr(job, "generator_update_interval", 5)
job_dict["real_score_model_path"] = (getattr(job, "real_score_model_path", "") or job.model_id)
job_dict["fake_score_model_path"] = (getattr(job, "fake_score_model_path", "") or job.model_id)
train_args = build_training_args(job_dict, job_output_dir)
config_path = os.path.join(job_output_dir, "train_config.yaml")
with open(config_path, "w", encoding="utf-8") as f:
yaml.safe_dump(train_config, f, sort_keys=False)
repo_root = Path(__file__).resolve().parent.parent
torchrun_cmd = [
@@ -783,9 +746,12 @@ class JobRunner:
str(job.num_gpus),
"--nnodes",
"1",
str(repo_root / module_path),
] + train_args
buf.write(f"Starting training: {' '.join(torchrun_cmd[:12])}...")
"-m",
"fastvideo.train.entrypoint.train",
"--config",
config_path,
]
buf.write(f"Starting training: {' '.join(torchrun_cmd)}")
buf.phase = "starting"
try:
@@ -825,12 +791,21 @@ class JobRunner:
job._process.wait()
exit_code = job._process.returncode or 0
if exit_code == 0:
if job._stop_event.is_set():
# Terminated by stop_job() without the reader loop observing the
# flag (e.g. the process died between log lines).
job.status = JobStatus.STOPPED
buf.phase = "stopped"
elif exit_code == 0:
job.status = JobStatus.COMPLETED
buf.progress = 100.0
buf.phase = "done"
# Training outputs checkpoints, not video
ckpt_dirs = sorted(Path(job_output_dir).glob("checkpoint-*"))
# Training outputs checkpoints, not video. Sort by step number,
# not lexically ("checkpoint-1000" < "checkpoint-500" as strings).
ckpt_dirs = sorted(
Path(job_output_dir).glob("checkpoint-*"),
key=lambda p: int(m.group(1)) if (m := re.fullmatch(r"checkpoint-(\d+)", p.name)) else -1,
)
if ckpt_dirs:
job.output_path = str(ckpt_dirs[-1])
else:
+660
View File
@@ -0,0 +1,660 @@
# SPDX-License-Identifier: Apache-2.0
"""
In-memory mock of the FastVideo Studio API (``server.py``).
This mock implements the same ``/api`` routes and response shapes as the real
FastAPI server but keeps everything in memory and never touches the FastVideo
library, a GPU, or a database. It is meant to back the Playwright e2e suite so
the Next.js frontend can be exercised end-to-end without a real backend.
Job lifecycle is simulated by recording a start timestamp and *computing* the
status on read: a started job reports ``running`` for a few seconds and then
flips to ``completed`` with an ``output_path``, so polling the job list / logs
shows progression. Generated media is a tiny 1s ``testsrc`` MP4 built lazily
with ffmpeg and cached.
Usage (from the ``apps/`` directory)::
PYTHONPATH=.. python -m fastvideo_studio.mock_server --port 8190
"""
from __future__ import annotations
import contextlib
import os
import random
import shutil
import subprocess
import tempfile
import threading
import time
import uuid
from typing import Annotated, Any
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, PlainTextResponse
from fastvideo_studio.database import default_settings_dict
from fastvideo_studio.models import (CreateDatasetRequest, CreateJobRequest, SettingsUpdate, UpdateCaptionRequest,
model_label)
# --- Config -----------------------------------------------------------------
# How long a started job stays "running" before it flips to "completed".
COMPLETE_AFTER_SECONDS = 3.0
FFMPEG_BIN = shutil.which(os.getenv("FASTVIDEO_FFMPEG_BIN", "ffmpeg"))
# A small catalogue of fake models keyed by workload type. Mirrors the real
# server's {id, label} shape (label derived from the HF-style path).
_MODELS_BY_WORKLOAD: dict[str, list[str]] = {
"t2v": [
"Wan-AI/Wan2.1-T2V-1.3B-Diffusers",
"FastVideo/FastHunyuan-diffusers",
],
"i2v": [
"Wan-AI/Wan2.1-I2V-14B-480P-Diffusers",
],
"t2i": [
"black-forest-labs/FLUX.1-schnell",
],
}
def _models_for(workload_type: str | None) -> list[dict[str, str]]:
if workload_type:
paths = _MODELS_BY_WORKLOAD.get(workload_type, [])
else:
seen: dict[str, None] = {}
for paths_for_workload in _MODELS_BY_WORKLOAD.values():
for path in paths_for_workload:
seen.setdefault(path, None)
paths = list(seen)
return [{"id": path, "label": model_label(path)} for path in paths]
# The real settings catalogue lives in database.py; the mock only pre-fills
# the default model ids so the Create Job modal auto-selects one.
_DEFAULT_SETTINGS: dict[str, Any] = {
**default_settings_dict(),
"defaultModelId": "Wan-AI/Wan2.1-T2V-1.3B-Diffusers",
"defaultModelIdT2v": "Wan-AI/Wan2.1-T2V-1.3B-Diffusers",
"defaultModelIdI2v": "Wan-AI/Wan2.1-I2V-14B-480P-Diffusers",
"defaultModelIdT2i": "black-forest-labs/FLUX.1-schnell",
}
# --- In-memory state --------------------------------------------------------
_state_lock = threading.Lock()
_settings: dict[str, Any] = dict(_DEFAULT_SETTINGS)
_jobs: dict[str, dict[str, Any]] = {}
_datasets: dict[str, dict[str, Any]] = {}
# dataset_id -> {"file_names": [...], "captions": {file_name: caption}}
_dataset_files: dict[str, dict[str, Any]] = {}
# Lazily-built, cached media clips (shared by every job/dataset media response),
# keyed by extension: "mp4" (1s testsrc video) or "png" (single testsrc frame).
_mock_media_cache: dict[str, str] = {}
_mock_media_lock = threading.Lock()
def _build_mock_media(kind: str) -> str:
"""Build (once) a tiny testsrc clip of the given ``kind`` with ffmpeg; cache the path."""
with _mock_media_lock:
cached = _mock_media_cache.get(kind)
if cached and os.path.isfile(cached):
return cached
if not FFMPEG_BIN:
raise HTTPException(status_code=500, detail="ffmpeg is required to build mock media")
fd, path = tempfile.mkstemp(prefix="fvstudio_mock_", suffix=f".{kind}")
os.close(fd)
if kind == "png":
command = [
FFMPEG_BIN,
"-hide_banner",
"-loglevel",
"error",
"-y",
"-f",
"lavfi",
"-i",
"testsrc=size=320x240:rate=1",
"-frames:v",
"1",
"-f",
"image2",
path,
]
else:
command = [
FFMPEG_BIN,
"-hide_banner",
"-loglevel",
"error",
"-y",
"-f",
"lavfi",
"-i",
"testsrc=size=320x240:rate=24",
"-t",
"1",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
"-f",
"mp4",
path,
]
try:
subprocess.run(command, check=True, capture_output=True)
except (subprocess.CalledProcessError, OSError) as exc:
with contextlib.suppress(OSError):
os.remove(path)
raise HTTPException(status_code=500, detail=f"ffmpeg failed to build mock {kind}: {exc}") from exc
if not os.path.isfile(path) or os.path.getsize(path) == 0:
raise HTTPException(status_code=500, detail=f"ffmpeg produced no mock {kind} bytes")
_mock_media_cache[kind] = path
return path
# --- Job helpers ------------------------------------------------------------
def _new_job_dict(req: CreateJobRequest) -> dict[str, Any]:
"""Build a job dict (mirrors job_runner.Job.to_dict()) in the pending state."""
job_id = str(uuid.uuid4())
return {
"id": job_id,
"model_id": req.model_id,
"prompt": req.prompt,
"workload_type": req.workload_type or "t2v",
"job_type": req.job_type or "inference",
"image_path": req.image_path or "",
"status": "pending",
"created_at": time.time(),
"started_at": None,
"finished_at": None,
"error": None,
"output_path": None,
"log_file_path": None,
"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,
"negative_prompt": req.negative_prompt or "",
"num_gpus": req.num_gpus,
"data_path": req.data_path or "",
"progress": 0.0,
"progress_msg": "",
"phase": "pending",
}
def _advance_job(job: dict[str, Any]) -> None:
"""Flip a running job to completed once enough wall-clock time has passed.
Status is *computed on read* from the recorded start timestamp, so polling
the job list / logs naturally shows pending -> running -> completed.
"""
if job["status"] != "running" or not job.get("started_at"):
return
elapsed = time.time() - job["started_at"]
if elapsed >= COMPLETE_AFTER_SECONDS:
job["status"] = "completed"
job["finished_at"] = job["started_at"] + COMPLETE_AFTER_SECONDS
job["progress"] = 100.0
job["progress_msg"] = "50/50 steps"
job["phase"] = "done"
ext = "png" if job.get("workload_type") == "t2i" else "mp4"
job["output_path"] = f"/mock/outputs/{job['id']}/output.{ext}"
def _public_job(job: dict[str, Any]) -> dict[str, Any]:
"""Advance + return a copy safe to serialize."""
_advance_job(job)
return dict(job)
_LOG_TAIL = [
"Loading model...",
"Model loaded.",
"Starting generation...",
"Denoising step 10/50",
"Denoising step 20/50",
"Denoising step 30/50",
"Denoising step 40/50",
"Denoising step 50/50",
"Generation complete. Saving output...",
"Saved output file.",
"Job completed successfully.",
]
def _log_sequence(job: dict[str, Any]) -> list[str]:
return [
f"Job {job['id']} started",
f"Model: {job['model_id']}",
f"Prompt: {job['prompt']}",
*_LOG_TAIL,
]
def _compute_logs(job: dict[str, Any]) -> dict[str, Any]:
"""Return JobLogs-shaped data, growing the visible lines as time passes."""
seq = _log_sequence(job)
status = job["status"]
if status == "pending":
return {"lines": [], "progress": 0.0, "progress_msg": "", "phase": "pending"}
if status == "completed":
return {"lines": seq, "progress": 100.0, "progress_msg": "50/50 steps", "phase": "done"}
if status in ("failed", "stopped"):
# Keep the total line count monotonic across running -> terminal (the
# `after` cursor relies on it): show every non-final line plus a terminal
# notice, which is always >= any prefix a running job revealed.
lines = seq[:-1] + [f"Job {status}."]
return {"lines": lines, "progress": job.get("progress", 0.0), "progress_msg": "", "phase": status}
# running: reveal a prefix proportional to elapsed time, but never the final
# "completed" line — that appears only once the job is actually completed.
elapsed = time.time() - (job.get("started_at") or time.time())
frac = max(0.0, min(elapsed / COMPLETE_AFTER_SECONDS, 0.99))
reveal = min(max(4, round(frac * len(seq))), len(seq) - 1)
lines = seq[:reveal]
if frac < 0.2:
phase = "loading model"
elif frac < 0.85:
phase = "denoising"
else:
phase = "saving"
return {
"lines": lines,
"progress": round(frac * 100.0, 1),
"progress_msg": f"{int(frac * 50)}/50 steps",
"phase": phase,
}
# --- Dataset helpers --------------------------------------------------------
def _dataset_stats(dataset_id: str) -> tuple[int, int]:
files = _dataset_files.get(dataset_id, {}).get("file_names", [])
count = len(files)
# Fake but stable per-file size so the UI shows a non-zero footprint.
return count, count * 1_048_576
def _public_dataset(dataset: dict[str, Any]) -> dict[str, Any]:
count, size = _dataset_stats(dataset["id"])
return {**dataset, "file_count": count, "size_bytes": size}
def _seed() -> None:
"""Seed a couple of datasets and one completed inference job."""
now = time.time()
seeds = [
("Sunset Clips", ["sunset_01.mp4", "sunset_02.mp4"], {
"sunset_01.mp4": "A sunset over the ocean",
"sunset_02.mp4": "A sunset over the mountains"
}),
("City Timelapse", ["city_01.mp4"], {
"city_01.mp4": "A busy city intersection at night"
}),
]
for idx, (name, file_names, captions) in enumerate(seeds):
dataset_id = str(uuid.uuid4())
_datasets[dataset_id] = {"id": dataset_id, "name": name, "created_at": now - 600 + idx}
_dataset_files[dataset_id] = {"file_names": list(file_names), "captions": dict(captions)}
job_id = str(uuid.uuid4())
_jobs[job_id] = {
"id": job_id,
"model_id": "Wan-AI/Wan2.1-T2V-1.3B-Diffusers",
"prompt": "A curious raccoon peers through a field of yellow sunflowers",
"workload_type": "t2v",
"job_type": "inference",
"image_path": "",
"status": "completed",
"created_at": now - 300,
"started_at": now - 280,
"finished_at": now - 250,
"error": None,
"output_path": f"/mock/outputs/{job_id}/output.mp4",
"log_file_path": f"/mock/logs/{job_id}.log",
"num_inference_steps": 50,
"num_frames": 81,
"height": 480,
"width": 832,
"guidance_scale": 5.0,
"guidance_rescale": 0.0,
"fps": 24,
"seed": 1024,
"negative_prompt": "",
"num_gpus": 1,
"data_path": "",
"progress": 100.0,
"progress_msg": "50/50 steps",
"phase": "done",
}
# --- App --------------------------------------------------------------------
app = FastAPI(title="FastVideo Studio Mock API", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
_seed()
@app.get("/api/__mock__")
def mock_sentinel() -> dict[str, bool]:
"""Mock-only marker so the e2e suite can prove it isn't hitting a real
backend before running its mutating specs (the real server has no such
route)."""
return {"mock": True}
# --- Settings ---------------------------------------------------------------
@app.get("/api/settings")
def get_settings() -> dict[str, Any]:
with _state_lock:
return dict(_settings)
@app.put("/api/settings")
def update_settings(settings: SettingsUpdate) -> dict[str, Any]:
updates = settings.model_dump(exclude_unset=True)
with _state_lock:
_settings.update({k: v for k, v in updates.items() if v is not None})
return dict(_settings)
# --- Models -----------------------------------------------------------------
@app.get("/api/models")
def list_models(workload_type: str | None = None) -> list[dict[str, Any]]:
return _models_for(workload_type)
# --- GPUs -------------------------------------------------------------------
@app.get("/api/gpus")
def list_gpus() -> dict[str, Any]:
"""Two fake GPUs with slight per-request jitter so the page looks live."""
gpus = []
for index, (base_util, used_mib) in enumerate([(62, 61_440), (7, 4_096)]):
gpus.append({
"index": index,
"name": "NVIDIA Mock GPU 80GB",
"utilization": max(0, min(100, base_util + random.randint(-5, 5))),
"memory_used_mib": used_mib + random.randint(-256, 256),
"memory_total_mib": 81_920,
"temperature_c": 55 + random.randint(-3, 3),
"power_watts": 310.0 + random.randint(-20, 20),
"power_limit_watts": 700.0,
})
return {"available": True, "gpus": gpus, "error": None}
# --- Uploads ----------------------------------------------------------------
@app.post("/api/upload-image")
async def upload_image(file: Annotated[UploadFile, File()]) -> dict[str, str]:
name = file.filename or "image.png"
return {"path": f"/mock/uploads/{uuid.uuid4().hex}_{os.path.basename(name)}"}
@app.post("/api/upload-raw-dataset")
async def upload_raw_dataset(files: Annotated[list[UploadFile], File()]) -> dict[str, Any]:
video_exts = {".mp4", ".webm", ".avi", ".mov", ".mkv"}
file_names: list[str] = []
for uf in files:
name = os.path.basename(uf.filename or f"{uuid.uuid4().hex}.mp4")
if os.path.splitext(name)[1].lower() in video_exts:
file_names.append(name)
if not file_names:
raise HTTPException(status_code=400, detail="No video files found.")
upload_id = uuid.uuid4().hex
path = f"/mock/uploads/{upload_id}"
return {"path": path, "upload_id": upload_id, "file_names": file_names}
# --- Jobs -------------------------------------------------------------------
@app.get("/api/jobs")
def list_jobs(job_type: str | None = None) -> list[dict[str, Any]]:
# _public_job mutates the shared job dict via _advance_job, so it must run
# under the lock (matching every other job route) to avoid racing start/stop.
with _state_lock:
jobs = list(_jobs.values())
if job_type:
jobs = [j for j in jobs if j.get("job_type") == job_type]
jobs.sort(key=lambda j: j["created_at"], reverse=True)
return [_public_job(j) for j in jobs]
@app.get("/api/jobs/{job_id}")
def get_job(job_id: str) -> dict[str, Any]:
with _state_lock:
job = _jobs.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job not found")
return _public_job(job)
@app.post("/api/jobs", status_code=201)
def create_job(req: CreateJobRequest) -> dict[str, Any]:
job = _new_job_dict(req)
with _state_lock:
_jobs[job["id"]] = job
if _settings.get("autoStartJob"):
_start(job)
return _public_job(job)
def _start(job: dict[str, Any]) -> None:
job["status"] = "running"
job["started_at"] = time.time()
job["finished_at"] = None
job["error"] = None
job["output_path"] = None
# The real server assigns the log path once the job starts running; mirror
# that so the Job Details "Download Log" button (gated on log_file_path) works.
job["log_file_path"] = f"/mock/logs/{job['id']}.log"
job["progress"] = 0.0
job["progress_msg"] = ""
job["phase"] = "starting"
@app.post("/api/jobs/{job_id}/start")
def start_job(job_id: str) -> dict[str, Any]:
with _state_lock:
job = _jobs.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
_advance_job(job)
if job["status"] == "running":
raise HTTPException(status_code=409, detail="Job is already running")
if job["status"] == "completed":
raise HTTPException(status_code=409, detail="Job already completed. Delete and re-create to run again.")
_start(job)
return _public_job(job)
@app.post("/api/jobs/{job_id}/stop")
def stop_job(job_id: str) -> dict[str, Any]:
with _state_lock:
job = _jobs.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
_advance_job(job)
if job["status"] != "running":
raise HTTPException(status_code=409, detail=f"Job is not running (status={job['status']})")
job["status"] = "stopped"
job["finished_at"] = time.time()
job["phase"] = "stopped"
return dict(job)
@app.delete("/api/jobs/{job_id}")
def delete_job(job_id: str) -> dict[str, str]:
with _state_lock:
if _jobs.pop(job_id, None) is None:
raise HTTPException(status_code=404, detail="Job not found")
return {"detail": f"Job {job_id} deleted"}
@app.get("/api/jobs/{job_id}/logs")
def get_job_logs(job_id: str, after: int = 0) -> dict[str, Any]:
with _state_lock:
job = _jobs.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
_advance_job(job)
result = _compute_logs(job)
all_lines = result["lines"]
return {
"lines": all_lines[after:],
"total": len(all_lines),
"progress": result["progress"],
"progress_msg": result["progress_msg"],
"phase": result["phase"],
}
@app.get("/api/jobs/{job_id}/video")
def get_video(job_id: str) -> FileResponse:
with _state_lock:
job = _jobs.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job not found")
_advance_job(job)
output_path = job.get("output_path")
if job["status"] != "completed" or not output_path:
raise HTTPException(status_code=404, detail="No output available for this job")
# Mirror the real server: image workloads (t2i) output a .png served as an
# image; everything else is a video.
if output_path.endswith(".png"):
return FileResponse(_build_mock_media("png"), media_type="image/png", filename=f"job_{job_id}.png")
return FileResponse(_build_mock_media("mp4"), media_type="video/mp4", filename=f"job_{job_id}.mp4")
@app.get("/api/jobs/{job_id}/download_log")
def download_log(job_id: str) -> PlainTextResponse:
with _state_lock:
job = _jobs.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job not found")
_advance_job(job)
lines = _compute_logs(job)["lines"]
return PlainTextResponse("\n".join(lines) + "\n", media_type="text/plain")
# --- Datasets ---------------------------------------------------------------
@app.get("/api/datasets")
def list_datasets() -> list[dict[str, Any]]:
with _state_lock:
datasets = sorted(_datasets.values(), key=lambda d: d["created_at"], reverse=True)
return [_public_dataset(d) for d in datasets]
@app.get("/api/datasets/{dataset_id}")
def get_dataset(dataset_id: str) -> dict[str, Any]:
with _state_lock:
dataset = _datasets.get(dataset_id)
if dataset is None:
raise HTTPException(status_code=404, detail="Dataset not found")
return _public_dataset(dataset)
@app.post("/api/datasets", status_code=201)
def create_dataset(req: CreateDatasetRequest) -> dict[str, Any]:
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.")
dataset_id = str(uuid.uuid4())
dataset = {"id": dataset_id, "name": req.name, "created_at": time.time()}
captions = {fn: (req.captions.get(fn, "") if req.captions else "") for fn in req.file_names}
with _state_lock:
_datasets[dataset_id] = dataset
_dataset_files[dataset_id] = {"file_names": list(req.file_names), "captions": captions}
return _public_dataset(dataset)
@app.get("/api/datasets/{dataset_id}/files")
def get_dataset_files(dataset_id: str) -> dict[str, Any]:
with _state_lock:
if dataset_id not in _datasets:
raise HTTPException(status_code=404, detail="Dataset not found")
files = _dataset_files.get(dataset_id, {"file_names": [], "captions": {}})
return {"file_names": list(files["file_names"]), "captions": dict(files["captions"])}
@app.put("/api/datasets/{dataset_id}/captions")
def update_dataset_caption(dataset_id: str, req: UpdateCaptionRequest) -> dict[str, str]:
with _state_lock:
if dataset_id not in _datasets:
raise HTTPException(status_code=404, detail="Dataset not found")
files = _dataset_files.setdefault(dataset_id, {"file_names": [], "captions": {}})
files["captions"][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:
with _state_lock:
if dataset_id not in _datasets:
raise HTTPException(status_code=404, detail="Dataset not found")
if file_name not in _dataset_files.get(dataset_id, {}).get("file_names", []):
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(_build_mock_media("mp4"), media_type="video/mp4")
@app.delete("/api/datasets/{dataset_id}")
def delete_dataset(dataset_id: str) -> dict[str, str]:
with _state_lock:
if _datasets.pop(dataset_id, None) is None:
raise HTTPException(status_code=404, detail="Dataset not found")
_dataset_files.pop(dataset_id, None)
return {"detail": f"Dataset {dataset_id} deleted"}
def main() -> None:
import argparse
import uvicorn
parser = argparse.ArgumentParser(description="FastVideo Studio mock API server")
parser.add_argument("--host", default="127.0.0.1", help="Bind address (default: 127.0.0.1)")
# Default off the real server's 8189 so the mock never shadows a real API.
parser.add_argument("--port", type=int, default=8190, help="Port number (default: 8190)")
args = parser.parse_args()
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
if __name__ == "__main__":
main()
+9 -1
View File
@@ -1,14 +1,22 @@
# SPDX-License-Identifier: Apache-2.0
"""Pydantic request/response models for the API."""
"""Pydantic request/response models for the API, plus tiny shared helpers
usable by both the real server and the dependency-light mock server."""
from fastvideo_studio.models.create_job_request import CreateJobRequest
from fastvideo_studio.models.settings_update import SettingsUpdate
from fastvideo_studio.models.create_dataset_request import CreateDatasetRequest
from fastvideo_studio.models.update_caption_request import UpdateCaptionRequest
def model_label(model_path: str) -> str:
"""Derive a readable label from an HF-style model path."""
return model_path.split("/")[-1].replace("-", " ").replace("_", " ")
__all__ = [
"CreateJobRequest",
"SettingsUpdate",
"CreateDatasetRequest",
"UpdateCaptionRequest",
"model_label",
]
@@ -17,7 +17,6 @@ class CreateJobRequest(BaseModel):
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
@@ -41,8 +40,6 @@ class CreateJobRequest(BaseModel):
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 = ""
+13
View File
@@ -0,0 +1,13 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type { NextConfig } from 'next';
const configDir = path.dirname(fileURLToPath(import.meta.url));
const nextConfig: NextConfig = {
// Point tracing at the monorepo root (apps/fastvideo_studio -> apps -> repo
// root) so Next stops warning about multiple lockfiles in the workspace.
outputFileTracingRoot: path.join(configDir, '..', '..'),
};
export default nextConfig;
+4618 -1038
View File
File diff suppressed because it is too large Load Diff
+43 -22
View File
@@ -4,33 +4,54 @@
"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\"",
"dev": "next dev --port 3000",
"build": "next build",
"start": "next start --port 3000",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"e2e": "playwright test",
"start:api": "cd .. && python -m fastvideo_studio.server",
"start:web": "node build",
"lint": "eslint ."
"start:web": "next start --port 3000",
"start:all": "concurrently --kill-others-on-fail \"npm:start:api\" \"npm:start:web\""
},
"dependencies": {
"@sveltejs/kit": "2.57.1",
"svelte": "5.55.7"
"@radix-ui/react-dialog": "^1.1.0",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.2.0",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"next": "15.5.18",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/node": "^20",
"@playwright/test": "^1.59.1",
"@tailwindcss/postcss": "^4.2.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.5.2",
"@vitest/coverage-v8": "^3.2.4",
"concurrently": "^9",
"eslint": "^9",
"typescript": "^5",
"vite": "6.4.2"
},
"overrides": {
"cookie": "0.7.0",
"devalue": "5.8.1",
"flatted": "3.4.2",
"minimatch": "3.1.4",
"picomatch": "4.0.4",
"postcss": "8.5.10"
"jsdom": "^26.1.0",
"postcss": "8.5.10",
"tailwindcss": "^4.2.1",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
}
}
@@ -0,0 +1,54 @@
import { defineConfig, devices } from '@playwright/test';
import { API_BASE, MOCK_API_PORT } from './e2e/helpers';
/**
* Playwright config for FastVideo Studio end-to-end tests.
*
* The Next.js frontend runs on port 3000 and talks to the in-memory mock API
* (fastvideo_studio.mock_server) on port 8189. The webServer block boots both
* the mock backend and `npm run dev` (pointed at the mock via
* NEXT_PUBLIC_API_BASE_URL) if nothing is already listening, so the suite works
* both locally and in CI. Set PLAYWRIGHT_SKIP_WEBSERVER to reuse externally
* managed servers.
*/
export default defineConfig({
testDir: './e2e',
fullyParallel: false,
workers: 1,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? 'github' : 'list',
timeout: 120_000,
expect: { timeout: 30_000 },
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:3000',
headless: true,
viewport: { width: 1280, height: 720 },
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: process.env.PLAYWRIGHT_SKIP_WEBSERVER
? undefined
: [
{
command: `PYTHONPATH=.. python -m fastvideo_studio.mock_server --port ${MOCK_API_PORT}`,
// Probe the mock-only sentinel, not /api/models (which a real server
// also serves), so a real backend on this port is never mistaken for
// the mock. Always boot a fresh mock in CI.
url: `http://127.0.0.1:${MOCK_API_PORT}/api/__mock__`,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
{
command: 'npm run dev',
url: 'http://127.0.0.1:3000',
reuseExistingServer: true,
timeout: 120_000,
env: {
NEXT_PUBLIC_API_BASE_URL: API_BASE,
},
},
],
});
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};
-142
View File
@@ -1,142 +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
from pathlib import Path
from collections.abc import Callable
logger = logging.getLogger("fastvideo.studio.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)
+54 -20
View File
@@ -32,8 +32,10 @@ from fastapi.responses import FileResponse
from fastvideo.registry import (get_registered_model_paths, get_registered_models_with_workloads)
from fastvideo_studio.database import Database, _get_db_path
from fastvideo_studio.gpu import get_gpu_snapshot
from fastvideo_studio.job_runner import JobRunner, JobStatus
from fastvideo_studio.models import (CreateDatasetRequest, CreateJobRequest, SettingsUpdate, UpdateCaptionRequest)
from fastvideo_studio.models import (CreateDatasetRequest, CreateJobRequest, SettingsUpdate, UpdateCaptionRequest,
model_label)
logging.basicConfig(
level=logging.INFO,
@@ -43,15 +45,9 @@ logger = logging.getLogger("fastvideo.studio.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)
"label": model_label(path)
} for path in get_registered_model_paths()]
job_runner: JobRunner
@@ -100,6 +96,12 @@ def update_settings(settings: SettingsUpdate) -> dict[str, Any]:
return database.get_settings()
@app.get("/api/gpus")
def list_gpus() -> dict[str, Any]:
"""Return an NVML snapshot of every GPU on the API server host."""
return get_gpu_snapshot()
@app.get("/api/models")
def list_models(workload_type: str | None = None) -> list[dict[str, Any]]:
"""Return the catalogue of available video-generation models.
@@ -148,7 +150,7 @@ async def upload_image(file: Annotated[UploadFile, File()], ) -> dict[str, str]:
return {"path": os.path.abspath(dest_path)}
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".webm", ".avi", ".mov"}
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".webm", ".avi", ".mov", ".mkv"}
def _filter_video_files(files: list[UploadFile]) -> list[UploadFile]:
@@ -156,6 +158,23 @@ def _filter_video_files(files: list[UploadFile]) -> list[UploadFile]:
return [f for f in files if Path(f.filename or "").suffix.lower() in ALLOWED_VIDEO_EXTENSIONS]
def _path_is_within(child: str, parent: str) -> bool:
"""True if ``child`` resolves to a location inside ``parent``."""
try:
parent_real = os.path.realpath(parent)
return os.path.commonpath([os.path.realpath(child), parent_real]) == parent_real
except (ValueError, OSError):
return False
def _staging_base_path() -> str:
"""Root under which raw uploads are staged (settings override or default)."""
settings = database.get_settings() if database is not None else {}
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 "")
return datasets_upload_dir if not base_path else os.path.abspath(base_path)
@app.post("/api/upload-raw-dataset")
async def upload_raw_dataset(files: Annotated[list[UploadFile], File()], ) -> dict[str, Any]:
"""
@@ -167,10 +186,7 @@ async def upload_raw_dataset(files: Annotated[list[UploadFile], File()], ) -> di
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))
base_path = _staging_base_path()
if not base_path:
raise HTTPException(
status_code=503,
@@ -250,6 +266,19 @@ def create_job(req: CreateJobRequest) -> dict[str, Any]:
f"Valid options: {sorted(valid_ids)}"),
)
# Training jobs reference a dataset by id; resolve it to the on-disk media
# directory the trainer reads (the UI has no free-text path field). Falls
# through unchanged for inference and for anything already a real path.
def _resolve_dataset_path(value: str) -> str:
media_dir = _dataset_media_dir(value) if value else ""
return media_dir if media_dir and os.path.isdir(media_dir) else value
data_path = req.data_path or ""
validation_dataset_file = req.validation_dataset_file or ""
if job_type != "inference":
data_path = _resolve_dataset_path(data_path)
validation_dataset_file = _resolve_dataset_path(validation_dataset_file)
job = job_runner.create_job(
job_id=str(uuid.uuid4()),
model_id=req.model_id,
@@ -257,14 +286,13 @@ def create_job(req: CreateJobRequest) -> dict[str, Any]:
workload_type=req.workload_type or "t2v",
job_type=job_type,
image_path=req.image_path or "",
data_path=req.data_path or "",
data_path=data_path,
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 "",
validation_dataset_file=validation_dataset_file,
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,
@@ -287,8 +315,6 @@ def create_job(req: CreateJobRequest) -> dict[str, Any]:
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 "",
@@ -418,6 +444,13 @@ def create_dataset(req: CreateDatasetRequest) -> dict[str, Any]:
status_code=400,
detail="No media files found. Ensure at least one image or video.",
)
# upload_path must be a staging dir produced by /api/upload-raw-dataset,
# not an arbitrary client path — the code below copies then rmtrees it.
if not _path_is_within(req.upload_path, _staging_base_path()):
raise HTTPException(
status_code=400,
detail="upload_path must be a staged upload directory.",
)
dataset_id = str(uuid.uuid4())
created_at = time.time()
dest_dir = os.path.join(datasets_upload_dir, dataset_id)
@@ -491,8 +524,9 @@ def serve_dataset_media(dataset_id: str, file_name: str) -> FileResponse:
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):
media_dir = _dataset_media_dir(dataset_id)
media_path = os.path.realpath(os.path.join(media_dir, file_name))
if not (_path_is_within(media_path, media_dir) and os.path.isfile(media_path)):
raise HTTPException(status_code=404, detail="File not found")
import mimetypes
mime, _ = mimetypes.guess_type(media_path)
-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>
@@ -0,0 +1,85 @@
'use client';
import * as React from 'react';
import AddDatasetButton from '@/components/datasets/AddDatasetButton';
import CreateDatasetModal from '@/components/datasets/CreateDatasetModal';
import DatasetCard from '@/components/datasets/DatasetCard';
import { HeaderActions } from '@/components/shell/HeaderActionsContext';
import { Card } from '@/components/ui/card';
import { useStore } from '@/hooks/useStore';
import { getDatasets } from '@/lib/api';
import type { Dataset } from '@/lib/api';
import {
setActiveDataset,
setActiveDatasetId,
} from '@/stores/activeDataset';
import {
createDatasetModalStore,
setCreateDatasetModalOpen,
} from '@/stores/createDatasetModalOpen';
export default function DatasetsPage() {
const [datasets, setDatasets] = React.useState<Dataset[]>([]);
const [error, setError] = React.useState<string | null>(null);
const { open } = useStore(createDatasetModalStore);
const fetchDatasets = React.useCallback(async () => {
try {
setDatasets(await getDatasets());
setError(null);
} catch (err) {
console.error('Failed to fetch datasets:', err);
// Distinguish an API outage from a genuinely empty list, so the user
// isn't told they have no datasets when the server is unreachable.
setError(err instanceof Error ? err.message : 'Failed to load datasets');
}
}, []);
React.useEffect(() => {
fetchDatasets();
}, [fetchDatasets]);
function handleSelectDataset(ds: Dataset) {
setActiveDataset(ds);
setActiveDatasetId(ds.id);
}
return (
<>
<HeaderActions>
<AddDatasetButton />
</HeaderActions>
<main className="mx-auto flex w-full max-w-[850px] flex-col gap-6 px-4 pb-12">
<Card className="p-6">
<div>
{error ? (
<p className="py-8 text-center text-destructive">{error}</p>
) : datasets.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">
No datasets yet.
</p>
) : (
datasets.map((ds) => (
<DatasetCard
key={ds.id}
dataset={ds}
onUpdated={fetchDatasets}
onSelect={() => handleSelectDataset(ds)}
/>
))
)}
</div>
</Card>
</main>
<CreateDatasetModal
isOpen={open}
onClose={() => setCreateDatasetModalOpen(false)}
onSuccess={() => {
fetchDatasets();
setCreateDatasetModalOpen(false);
}}
/>
</>
);
}
@@ -0,0 +1,16 @@
'use client';
import CreateJobButton from '@/components/jobs/CreateJobButton';
import { HeaderActions } from '@/components/shell/HeaderActionsContext';
import JobQueue from '@/components/jobs/JobQueue';
export default function DistillationPage() {
return (
<>
<HeaderActions>
<CreateJobButton jobType="distillation" />
</HeaderActions>
<JobQueue jobType="distillation" />
</>
);
}
@@ -0,0 +1,21 @@
'use client';
import CreateJobButton from '@/components/jobs/CreateJobButton';
import { HeaderActions } from '@/components/shell/HeaderActionsContext';
import JobQueue from '@/components/jobs/JobQueue';
import type { JobType } from '@/lib/types';
// 'lora' is a backend job_type (LoRA finetunes) that isn't part of the
// JobType union; the finetuning queue lists both alongside full finetunes.
const FINETUNING_LIST = ['finetuning', 'lora'] as JobType[];
export default function FinetuningPage() {
return (
<>
<HeaderActions>
<CreateJobButton jobType="finetuning" />
</HeaderActions>
<JobQueue jobType="finetuning" jobTypesForList={FINETUNING_LIST} />
</>
);
}
@@ -0,0 +1,60 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import GalleryPage from './page';
import { HeaderActionsProvider } from '@/components/shell/HeaderActionsContext';
import { getJobsList } from '@/lib/api';
import type { Job } from '@/lib/types';
import { makeJob as makeBaseJob } from '@/test/factories';
vi.mock('@/lib/api', () => ({
getJobsList: vi.fn(),
getJobVideoUrl: (id: string) => `http://test.local/api/jobs/${id}/video`,
}));
const makeJob = (overrides: Partial<Job> = {}): Job =>
makeBaseJob({
model_id: 'wan',
prompt: 'a cat surfing a wave',
status: 'completed',
created_at: 1,
finished_at: 2,
output_path: '/out/clip.mp4',
...overrides,
});
function renderGallery() {
return render(
<HeaderActionsProvider>
<GalleryPage />
</HeaderActionsProvider>,
);
}
describe('GalleryPage', () => {
it('renders a grid item for a completed inference job', async () => {
vi.mocked(getJobsList).mockResolvedValue([
makeJob({ prompt: 'a cat surfing a wave' }),
]);
renderGallery();
expect(await screen.findByText('a cat surfing a wave')).toBeInTheDocument();
expect(getJobsList).toHaveBeenCalledWith('inference');
});
it('shows the empty state when no completed videos exist', async () => {
vi.mocked(getJobsList).mockResolvedValue([
makeJob({ status: 'running', output_path: null }),
]);
renderGallery();
expect(
await screen.findByText('No completed videos yet'),
).toBeInTheDocument();
expect(
screen.queryByText('a cat surfing a wave'),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,112 @@
'use client';
import { Loader2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Card } from '@/components/ui/card';
import { getJobVideoUrl, getJobsList } from '@/lib/api';
import type { Job } from '@/lib/types';
function isImage(job: Job): boolean {
return job.output_path?.toLowerCase().endsWith('.png') ?? false;
}
export default function GalleryPage() {
const [jobs, setJobs] = useState<Job[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const list = await getJobsList('inference');
if (cancelled) return;
const sorted = [...list].sort(
(a, b) =>
(b.finished_at ?? b.created_at ?? 0) -
(a.finished_at ?? a.created_at ?? 0),
);
setJobs(sorted);
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : 'Failed to load jobs');
}
} finally {
if (!cancelled) setIsLoading(false);
}
}
void load();
return () => {
cancelled = true;
};
}, []);
const galleryJobs = jobs.filter(
(j) =>
j.status === 'completed' &&
j.output_path &&
(j.job_type === 'inference' || !j.job_type),
);
return (
<div className="mx-auto w-full max-w-[1200px] px-4 pb-12 pt-4">
<Card className="p-6">
<h2 className="mb-1 text-2xl font-semibold text-foreground">Gallery</h2>
<p className="mb-6 text-sm text-muted-foreground">
Generated videos from completed inference jobs. Captions show the
prompt used for each generation.
</p>
{isLoading ? (
<div className="flex items-center gap-3 p-8 text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
<span>Loading gallery…</span>
</div>
) : error ? (
<p className="py-8 text-destructive">{error}</p>
) : galleryJobs.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">
No completed videos yet
</p>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-5">
{galleryJobs.map((job) => (
<article
key={job.id}
className="flex flex-col overflow-hidden rounded-lg border border-border bg-background"
>
<div className="relative aspect-video overflow-hidden bg-muted">
{isImage(job) ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={getJobVideoUrl(job.id)}
alt={job.prompt}
className="block h-full w-full object-contain"
loading="lazy"
/>
) : (
<video
src={getJobVideoUrl(job.id)}
className="block h-full w-full object-contain"
muted
loop
playsInline
preload="metadata"
/>
)}
</div>
<p
className="line-clamp-3 border-t border-border px-4 py-3 text-sm text-muted-foreground"
title={job.prompt}
>
{job.prompt || '—'}
</p>
</article>
))}
</div>
)}
</Card>
</div>
);
}
+221
View File
@@ -0,0 +1,221 @@
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
/*
* FastVideo Studio theme — shared with the Dreamverse design system:
* slate light/dark palettes, #356cff accent blue, IBM Plex type. The studio
* defaults to dark (see the theme init script in layout.tsx); the toggle in
* the header persists the choice to localStorage.
*/
:root {
color-scheme: light;
--header-height: 74px;
--accent-blue: #356cff;
--background: #f5f4f4;
--foreground: #0f172a;
--card: #ffffff;
--card-foreground: #0f172a;
--popover: #ffffff;
--popover-foreground: #0f172a;
--primary: #0f172a;
--primary-foreground: #f8fafc;
--secondary: #f1f5f9;
--secondary-foreground: #1e293b;
--muted: #f1f5f9;
--muted-foreground: #64748b;
--accent: #e2e8f0;
--accent-foreground: #0f172a;
--destructive: #ef4444;
--destructive-foreground: #ffffff;
--border: #e2e8f0;
--input: #cbd5e1;
--ring: #94a3b8;
--radius: 0.5rem;
}
.dark {
color-scheme: dark;
--accent-blue: #356cff;
--background: #0f172a;
--foreground: #e2e8f0;
--card: #0f172a;
--card-foreground: #f1f5f9;
--popover: #0f172a;
--popover-foreground: #f1f5f9;
--primary: #f1f5f9;
--primary-foreground: #0f172a;
--secondary: #1e293b;
--secondary-foreground: #f1f5f9;
--muted: #1e293b;
--muted-foreground: #94a3b8;
--accent: #1e293b;
--accent-foreground: #f1f5f9;
--destructive: #991b1b;
--destructive-foreground: #fecaca;
--border: #334155;
--input: #334155;
--ring: #cbd5e1;
}
@theme inline {
--font-sans: var(--font-plex-sans), ui-sans-serif, system-ui, sans-serif;
--font-mono: var(--font-plex-mono), ui-monospace, monospace;
--color-accent-blue: var(--accent-blue);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
/* ——— Resets ——— */
html,
body {
min-height: 100%;
}
html {
background: var(--background);
}
body {
margin: 0;
line-height: 1.6;
color: var(--foreground);
font-family: var(--font-plex-sans), ui-sans-serif, system-ui, sans-serif;
background: var(--background);
}
/* Dreamverse's dark-mode backdrop: subtle radial glows over a deep fade. */
body::after {
content: "";
position: fixed;
inset: 0;
z-index: -1;
opacity: 0;
pointer-events: none;
transition: opacity 300ms ease;
background:
radial-gradient(circle at top, rgba(56, 189, 248, 0.14), transparent 36%),
radial-gradient(circle at right top, rgba(129, 140, 248, 0.12), transparent 28%),
linear-gradient(180deg, #020617 0%, #000000 100%);
background-attachment: fixed;
}
.dark body::after {
opacity: 1;
}
a {
color: inherit;
}
summary {
list-style: none;
}
summary::-webkit-details-marker {
display: none;
}
::selection {
background: rgba(56, 189, 248, 0.25);
}
.dark ::selection {
background: rgba(56, 189, 248, 0.35);
color: #ffffff;
}
/* Smooth the light/dark switch (class applied briefly by the theme toggle). */
html.theme-transition,
html.theme-transition *,
html.theme-transition *::before,
html.theme-transition *::after {
transition:
background-color 300ms ease,
color 300ms ease,
border-color 300ms ease,
box-shadow 300ms ease,
fill 300ms ease,
stroke 300ms ease !important;
}
/* ——— Base layer ——— */
@layer base {
button,
input,
textarea,
select {
font: inherit;
}
img,
svg,
video,
canvas {
display: block;
max-width: 100%;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
@@ -0,0 +1,11 @@
'use client';
import GpuGrid from '@/components/system/GpuGrid';
export default function GpusPage() {
return (
<main className="mx-auto flex w-full max-w-[1100px] flex-col gap-6 px-4 pb-12 pt-6">
<GpuGrid />
</main>
);
}
@@ -0,0 +1,16 @@
'use client';
import CreateJobButton from '@/components/jobs/CreateJobButton';
import { HeaderActions } from '@/components/shell/HeaderActionsContext';
import JobQueue from '@/components/jobs/JobQueue';
export default function InferencePage() {
return (
<>
<HeaderActions>
<CreateJobButton jobType="inference" />
</HeaderActions>
<JobQueue jobType="inference" />
</>
);
}
+52
View File
@@ -0,0 +1,52 @@
import type { Metadata } from 'next';
import { IBM_Plex_Mono, IBM_Plex_Sans } from 'next/font/google';
import { AppShell } from '@/components/shell/AppShell';
import './globals.css';
const plexSans = IBM_Plex_Sans({
subsets: ['latin'],
weight: ['400', '500', '600', '700'],
variable: '--font-plex-sans',
display: 'swap',
});
const plexMono = IBM_Plex_Mono({
subsets: ['latin'],
weight: ['400', '500', '600'],
variable: '--font-plex-mono',
display: 'swap',
});
export const metadata: Metadata = {
title: 'FastVideo Studio',
icons: { icon: '/fastvideo.ico' },
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html
lang="en"
suppressHydrationWarning
className={`${plexSans.variable} ${plexMono.variable}`}
>
<head>
{/* The studio defaults to dark; apply the stored choice before paint. */}
<script
id="theme-init-script"
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: `(function(){var dark=true;try{dark=localStorage.getItem('theme')!=='light'}catch(e){}if(dark)document.documentElement.classList.add('dark')})()`,
}}
/>
</head>
<body className="antialiased">
<AppShell>{children}</AppShell>
</body>
</html>
);
}
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/finetuning');
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/inference');
}
@@ -0,0 +1,60 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { HeaderActionsProvider } from '@/components/shell/HeaderActionsContext';
import { resetToDefaults, updateOption } from '@/stores/defaultOptions';
import SettingsPage from './page';
vi.mock('@/lib/api', () => ({
getModels: vi.fn().mockResolvedValue([]),
getSettings: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn().mockResolvedValue({}),
}));
// Keep the real store (so `useStore` resolves) but spy on the mutators.
vi.mock('@/stores/defaultOptions', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@/stores/defaultOptions')>();
return {
...actual,
updateOption: vi.fn(),
resetToDefaults: vi.fn(),
};
});
function renderPage() {
return render(
<HeaderActionsProvider>
<SettingsPage />
</HeaderActionsProvider>,
);
}
describe('Settings page', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('calls updateOption when a toggle changes', () => {
renderPage();
fireEvent.click(
screen.getByRole('switch', { name: 'Auto Start Job on Create' }),
);
expect(updateOption).toHaveBeenCalledWith('autoStartJob', true);
});
it('calls updateOption when a slider changes', () => {
renderPage();
// First slider in DOM order is "Frames".
const slider = screen.getAllByRole('slider')[0];
fireEvent.keyDown(slider, { key: 'ArrowRight' });
expect(updateOption).toHaveBeenCalledWith('numFrames', expect.any(Number));
});
it('calls resetToDefaults when Reset to Defaults is clicked', () => {
renderPage();
fireEvent.click(screen.getByRole('button', { name: 'Reset to Defaults' }));
expect(resetToDefaults).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,358 @@
'use client';
import * as React from 'react';
import {
FieldRow,
NumberRow,
SliderRow,
ToggleRow,
} from '@/components/form-rows';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { NativeSelect } from '@/components/ui/native-select';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { useStore } from '@/hooks/useStore';
import { getModels, type Model } from '@/lib/api';
import {
defaultOptionsStore,
resetToDefaults,
updateOption,
} from '@/stores/defaultOptions';
function TextSettingRow({
id,
label,
value,
placeholder,
onCommit,
}: {
id: string;
label: string;
value: string;
placeholder?: string;
onCommit: (v: string) => void;
}) {
// Buffer keystrokes locally and persist on blur/Enter so each character
// doesn't fire a settings PUT (or a localStorage write).
const [draft, setDraft] = React.useState<string | null>(null);
return (
<FieldRow htmlFor={id} label={label}>
<Input
id={id}
type="text"
className="font-mono text-sm"
value={draft ?? value}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => {
if (draft !== null && draft !== value) onCommit(draft);
setDraft(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
}}
placeholder={placeholder}
/>
</FieldRow>
);
}
function SelectRow({
id,
label,
value,
onChange,
children,
}: {
id: string;
label: string;
value: string;
onChange: (v: string) => void;
children: React.ReactNode;
}) {
return (
<FieldRow htmlFor={id} label={label}>
<NativeSelect
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
>
{children}
</NativeSelect>
</FieldRow>
);
}
export default function SettingsPage() {
const { options } = useStore(defaultOptionsStore);
const [models, setModels] = React.useState<
Record<'t2v' | 'i2v' | 't2i', Model[]>
>({ t2v: [], i2v: [], t2i: [] });
React.useEffect(() => {
Promise.all([getModels('t2v'), getModels('i2v'), getModels('t2i')])
.then(([t2v, i2v, t2i]) => setModels({ t2v, i2v, t2i }))
.catch((e) => console.error('Failed to load models:', e));
}, []);
return (
<div className="mx-auto flex w-full max-w-[850px] flex-col gap-6 px-4 pb-12 pt-6">
<Card>
<CardContent className="space-y-4 p-6">
<h2 className="text-lg font-semibold">Behavior</h2>
<div className="flex items-center justify-between gap-4">
<Label
htmlFor="settings-auto-start-job"
className="pl-0.5 text-xs font-normal tracking-wide text-muted-foreground"
>
Auto Start Job on Create
</Label>
<Switch
id="settings-auto-start-job"
checked={options.autoStartJob}
onCheckedChange={(v) => updateOption('autoStartJob', v)}
/>
</div>
<Separator />
<h2 className="text-lg font-semibold">Paths</h2>
<div className="space-y-4">
<TextSettingRow
id="settings-api-server-base-url"
label="API Server Base URL"
value={options.apiServerBaseUrl ?? ''}
onCommit={(v) => updateOption('apiServerBaseUrl', v)}
placeholder="http://localhost:8189/api"
/>
<TextSettingRow
id="settings-dataset-upload-path"
label="Dataset Upload Path"
value={options.datasetUploadPath ?? ''}
onCommit={(v) => updateOption('datasetUploadPath', v)}
placeholder="outputs/ui_data/uploads/datasets"
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Default Options</h2>
<Button
type="button"
variant="outline"
size="sm"
onClick={resetToDefaults}
>
Reset to Defaults
</Button>
</div>
<p className="text-sm text-muted-foreground">
These values are used as defaults when creating new jobs.
</p>
<div className="grid gap-x-3 gap-y-2 [grid-template-columns:repeat(auto-fill,minmax(160px,1fr))]">
<SelectRow
id="settings-default-model-t2v"
label="Default Model (T2V)"
value={options.defaultModelIdT2v}
onChange={(v) => updateOption('defaultModelIdT2v', v)}
>
<option value="">None (select when creating job)</option>
{models.t2v.map((model) => (
<option key={model.id} value={model.id}>
{model.label} ({model.id})
</option>
))}
</SelectRow>
<SelectRow
id="settings-default-model-i2v"
label="Default Model (I2V)"
value={options.defaultModelIdI2v}
onChange={(v) => updateOption('defaultModelIdI2v', v)}
>
<option value="">None</option>
{models.i2v.map((model) => (
<option key={model.id} value={model.id}>
{model.label} ({model.id})
</option>
))}
</SelectRow>
<SelectRow
id="settings-default-model-t2i"
label="Default Model (T2I)"
value={options.defaultModelIdT2i}
onChange={(v) => updateOption('defaultModelIdT2i', v)}
>
<option value="">None</option>
{models.t2i.map((model) => (
<option key={model.id} value={model.id}>
{model.label} ({model.id})
</option>
))}
</SelectRow>
<SliderRow
id="settings-num-frames"
label="Frames"
min={1}
max={500}
step={1}
value={options.numFrames}
onChange={(v) => updateOption('numFrames', v)}
/>
<SliderRow
id="settings-height"
label="Height"
min={64}
max={1080}
step={16}
value={options.height}
onChange={(v) => updateOption('height', v)}
/>
<SliderRow
id="settings-width"
label="Width"
min={64}
max={1920}
step={16}
value={options.width}
onChange={(v) => updateOption('width', v)}
/>
<SliderRow
id="settings-num-steps"
label="Inference Steps"
min={1}
max={200}
step={1}
value={options.numInferenceSteps}
onChange={(v) => updateOption('numInferenceSteps', v)}
/>
<SliderRow
id="settings-vsa-sparsity"
label="VSA Sparsity"
title="VSA sparsity (0–1)"
min={0}
max={1}
step={0.05}
value={options.vsaSparsity}
onChange={(v) => updateOption('vsaSparsity', v)}
format={(v) => v.toFixed(2)}
/>
<SliderRow
id="settings-guidance"
label="Guidance Scale"
min={0}
max={20}
step={0.1}
value={options.guidanceScale}
onChange={(v) => updateOption('guidanceScale', v)}
format={(v) => v.toFixed(1)}
/>
<SliderRow
id="settings-guidance-rescale"
label="Guidance Rescale"
title="0 = disabled"
min={0}
max={1}
step={0.05}
value={options.guidanceRescale ?? 0}
onChange={(v) => updateOption('guidanceRescale', v)}
format={(v) => v.toFixed(2)}
/>
<SliderRow
id="settings-tp-size"
label="TP Size"
title="-1 = auto"
min={-1}
max={8}
step={1}
value={options.tpSize}
onChange={(v) => updateOption('tpSize', v)}
format={(v) => (v === -1 ? 'Auto' : String(v))}
/>
<SliderRow
id="settings-sp-size"
label="SP Size"
title="-1 = auto"
min={-1}
max={8}
step={1}
value={options.spSize}
onChange={(v) => updateOption('spSize', v)}
format={(v) => (v === -1 ? 'Auto' : String(v))}
/>
<SliderRow
id="settings-fps"
label="FPS"
min={1}
max={60}
step={1}
value={options.fps ?? 24}
onChange={(v) => updateOption('fps', v)}
/>
<ToggleRow
id="settings-dit-cpu-offload"
label="DiT CPU Offload"
checked={options.ditCpuOffload}
onChange={(v) => updateOption('ditCpuOffload', v)}
/>
<ToggleRow
id="settings-text-encoder-cpu-offload"
label="Text Encoder CPU Offload"
checked={options.textEncoderCpuOffload}
onChange={(v) => updateOption('textEncoderCpuOffload', v)}
/>
<ToggleRow
id="settings-use-fsdp-inference"
label="Use FSDP Inference"
checked={options.useFsdpInference}
onChange={(v) => updateOption('useFsdpInference', v)}
/>
<ToggleRow
id="settings-vae-cpu-offload"
label="VAE CPU Offload"
checked={options.vaeCpuOffload}
onChange={(v) => updateOption('vaeCpuOffload', v)}
/>
<ToggleRow
id="settings-image-encoder-cpu-offload"
label="Image Encoder CPU Offload"
checked={options.imageEncoderCpuOffload}
onChange={(v) => updateOption('imageEncoderCpuOffload', v)}
/>
<ToggleRow
id="settings-enable-torch-compile"
label="Torch Compile"
checked={options.enableTorchCompile}
onChange={(v) => updateOption('enableTorchCompile', v)}
/>
<SliderRow
id="settings-num-gpus"
label="GPUs"
min={1}
max={8}
step={1}
value={options.numGpus}
onChange={(v) => updateOption('numGpus', v)}
/>
<NumberRow
id="settings-seed"
label="Seed"
min={0}
value={options.seed}
onChange={(v) => updateOption('seed', v)}
/>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,12 @@
'use client';
import { Button } from '@/components/ui/button';
import { setCreateDatasetModalOpen } from '@/stores/createDatasetModalOpen';
export default function AddDatasetButton() {
return (
<Button type="button" onClick={() => setCreateDatasetModalOpen(true)}>
Add Dataset
</Button>
);
}
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import CreateDatasetModal from '@/components/datasets/CreateDatasetModal';
import { createDataset } from '@/lib/api';
vi.mock('@/lib/api', () => ({
createDataset: vi.fn(),
uploadRawDataset: vi.fn(),
}));
const mockedCreateDataset = vi.mocked(createDataset);
describe('CreateDatasetModal', () => {
it('renders nothing when closed', () => {
render(
<CreateDatasetModal isOpen={false} onClose={() => {}} onSuccess={() => {}} />,
);
expect(screen.queryByText('Add Dataset — Raw')).not.toBeInTheDocument();
});
it('renders the form and the default JSON caption upload when open', () => {
render(
<CreateDatasetModal isOpen onClose={() => {}} onSuccess={() => {}} />,
);
expect(screen.getByText('Add Dataset — Raw')).toBeInTheDocument();
expect(screen.getByText('Upload video files')).toBeInTheDocument();
expect(screen.getByText('Upload videos2caption.json')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Create Dataset' }),
).toBeInTheDocument();
});
it('does not create a dataset when the name is empty', () => {
const onSuccess = vi.fn();
render(
<CreateDatasetModal isOpen onClose={() => {}} onSuccess={onSuccess} />,
);
fireEvent.click(screen.getByRole('button', { name: 'Create Dataset' }));
expect(mockedCreateDataset).not.toHaveBeenCalled();
expect(onSuccess).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,465 @@
'use client';
import * as React from 'react';
import UploadZone from '@/components/datasets/UploadZone';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { createDataset, uploadRawDataset } from '@/lib/api';
import {
parseCaptionCsv,
parseVideos2Caption,
parseVideosCaptionsTxt,
} from '@/lib/captionParsing';
const ALLOWED_VIDEO_EXT = '.mp4,.webm,.avi,.mov,.mkv';
type CaptionFormat = 'json' | 'txt' | 'csv';
export interface CreateDatasetModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
export default function CreateDatasetModal({
isOpen,
onClose,
onSuccess,
}: CreateDatasetModalProps) {
const [name, setName] = React.useState('');
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [rawPath, setRawPath] = React.useState('');
const [fileNames, setFileNames] = React.useState<string[]>([]);
const [isUploading, setIsUploading] = React.useState(false);
const [validationError, setValidationError] = React.useState<string | null>(
null,
);
const [captionFormat, setCaptionFormat] = React.useState<CaptionFormat>('json');
const [captionMap, setCaptionMap] = React.useState<Record<
string,
string
> | null>(null);
const [captionFileName, setCaptionFileName] = React.useState<string | null>(
null,
);
const [videosTxtLines, setVideosTxtLines] = React.useState<string[] | null>(
null,
);
const [videosTxtFileName, setVideosTxtFileName] = React.useState<
string | null
>(null);
const [captionsTxtLines, setCaptionsTxtLines] = React.useState<
string[] | null
>(null);
const [captionsTxtFileName, setCaptionsTxtFileName] = React.useState<
string | null
>(null);
// Bumped on every new media selection and on reset/clear; an in-flight
// upload whose generation no longer matches is discarded, so a superseded or
// abandoned upload can't repopulate/overwrite state.
const uploadGeneration = React.useRef(0);
const txtCaptionMap = React.useMemo(() => {
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;
}, [captionsTxtLines, fileNames, videosTxtLines]);
const effectiveCaptionMap =
captionFormat === 'txt' ? txtCaptionMap : captionMap;
// Keep the validation message in sync with the TXT inputs.
React.useEffect(() => {
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,
);
setValidationError(error);
} else {
setValidationError(null);
}
}
}, [captionFormat, captionsTxtLines, fileNames, videosTxtLines]);
function resetState() {
uploadGeneration.current += 1;
setName('');
setRawPath('');
setFileNames([]);
setValidationError(null);
setCaptionFormat('json');
setCaptionMap(null);
setCaptionFileName(null);
setVideosTxtLines(null);
setVideosTxtFileName(null);
setCaptionsTxtLines(null);
setCaptionsTxtFileName(null);
}
function handleClose() {
if (isSubmitting) return;
resetState();
onClose();
}
async function handleMediaChange(files: File[]) {
const gen = (uploadGeneration.current += 1);
setValidationError(null);
if (files.length === 0) {
setRawPath('');
setFileNames([]);
return;
}
setIsUploading(true);
try {
const res = await uploadRawDataset(files);
if (gen !== uploadGeneration.current) return; // superseded or abandoned
setRawPath(res.path);
setFileNames(res.file_names);
if (res.file_names.length === 0) {
setValidationError(`No video files found. Allowed: ${ALLOWED_VIDEO_EXT}`);
}
} catch (err) {
if (gen !== uploadGeneration.current) return;
setRawPath('');
setFileNames([]);
setValidationError(err instanceof Error ? err.message : 'Upload failed');
} finally {
if (gen === uploadGeneration.current) setIsUploading(false);
}
}
async function handleCaptionJsonChange(files: File[]) {
setValidationError(null);
setCaptionMap(null);
setCaptionFileName(null);
if (files.length === 0) return;
const file = files[0];
try {
const text = await file.text();
const { captions, error } = parseVideos2Caption(text, fileNames);
if (error) {
setValidationError(error);
return;
}
setCaptionMap(captions);
setCaptionFileName(file.name);
} catch {
setValidationError('Could not read the file.');
}
}
async function handleCaptionCsvChange(files: File[]) {
setValidationError(null);
setCaptionMap(null);
setCaptionFileName(null);
if (files.length === 0) return;
const file = files[0];
try {
const text = await file.text();
const { captions, error } = parseCaptionCsv(text, fileNames);
if (error) {
setValidationError(error);
return;
}
setCaptionMap(captions);
setCaptionFileName(file.name);
} catch {
setValidationError('Could not read the file.');
}
}
async function handleVideosTxtChange(files: File[]) {
setValidationError(null);
setVideosTxtLines(null);
setVideosTxtFileName(null);
if (files.length === 0) return;
try {
const text = await files[0].text();
setVideosTxtLines(text.split(/\r?\n/).map((s) => s.trim()));
setVideosTxtFileName(files[0].name);
} catch {
setValidationError('Could not read videos.txt.');
}
}
async function handleCaptionsTxtChange(files: File[]) {
setValidationError(null);
setCaptionsTxtLines(null);
setCaptionsTxtFileName(null);
if (files.length === 0) return;
try {
const text = await files[0].text();
setCaptionsTxtLines(text.split(/\r?\n/).map((s) => s.trim()));
setCaptionsTxtFileName(files[0].name);
} catch {
setValidationError('Could not read captions.txt.');
}
}
function handleCaptionFormatChange(format: CaptionFormat) {
setCaptionFormat(format);
setValidationError(null);
setCaptionMap(null);
setCaptionFileName(null);
setVideosTxtLines(null);
setVideosTxtFileName(null);
setCaptionsTxtLines(null);
setCaptionsTxtFileName(null);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setValidationError(null);
if (!name.trim()) return;
if (!rawPath || fileNames.length === 0) {
setValidationError('No data was found. Upload at least one video.');
return;
}
if (captionFormat === 'json' || captionFormat === 'csv') {
if (captionFileName && !captionMap) {
setValidationError(
'Caption file has errors. Fix or remove it before creating the dataset.',
);
return;
}
} else if (captionFormat === 'txt') {
if (videosTxtFileName || captionsTxtFileName) {
if (!captionsTxtFileName) {
setValidationError('Upload captions.txt to use TXT captions.');
return;
}
// Re-validate against current state rather than the stale render-time
// `validationError`, so a videos.txt mismatch both blocks submission
// and keeps its message visible.
const hasVideosTxt =
!!videosTxtLines &&
videosTxtLines.length > 0 &&
videosTxtLines.some((s) => s.trim());
if (hasVideosTxt && videosTxtLines) {
const { error } = parseVideosCaptionsTxt(
videosTxtLines,
captionsTxtLines ?? [],
fileNames,
);
if (error) {
setValidationError(error);
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 = window.confirm(
`The caption file does not include captions for ${missing.length} video(s): ${list}. They will get empty captions. Continue?`,
);
if (!ok) return;
}
}
setIsSubmitting(true);
try {
await createDataset({
name: name.trim(),
upload_path: rawPath,
file_names: fileNames,
...(finalCaptionMap ? { captions: finalCaptionMap } : {}),
});
onSuccess();
resetState();
onClose();
} catch (err) {
setValidationError(
err instanceof Error ? err.message : 'Failed to create dataset',
);
} finally {
setIsSubmitting(false);
}
}
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) handleClose();
}}
>
<DialogContent
aria-describedby={undefined}
className="max-h-[90vh] w-[90vw] max-w-[850px] overflow-y-auto"
>
<DialogHeader>
<DialogTitle>Add Dataset — Raw</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} autoComplete="off">
<div className="mb-3.5 flex flex-col gap-1.5">
<Label htmlFor="add-dataset-name">Name</Label>
<Input
id="add-dataset-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My dataset"
required
disabled={isSubmitting}
/>
</div>
<div className="mb-3.5 flex flex-col gap-1.5">
<Label>Videos</Label>
<UploadZone
label="Upload video files"
hint="Select files or a folder (.mp4, .webm, .avi, .mov, .mkv)"
accept={ALLOWED_VIDEO_EXT}
multiple
directory
allowBothFileAndDirectory
value={rawPath}
fileName={
fileNames.length > 0 ? `${fileNames.length} file(s)` : undefined
}
onFiles={handleMediaChange}
onClear={() => {
uploadGeneration.current += 1;
setRawPath('');
setFileNames([]);
setCaptionMap(null);
setCaptionFileName(null);
setValidationError(null);
}}
disabled={isSubmitting}
uploading={isUploading}
/>
</div>
<div className="mb-3.5 flex flex-col gap-1.5">
<Tabs
value={captionFormat}
onValueChange={(value) =>
handleCaptionFormatChange(value as CaptionFormat)
}
>
<div className="mb-1.5 flex flex-wrap items-center gap-4">
<Label>Captions (optional)</Label>
<TabsList>
<TabsTrigger value="json" disabled={isSubmitting}>
JSON
</TabsTrigger>
<TabsTrigger value="txt" disabled={isSubmitting}>
TXT
</TabsTrigger>
<TabsTrigger value="csv" disabled={isSubmitting}>
CSV
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="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}
onFiles={handleCaptionJsonChange}
onClear={() => {
setCaptionMap(null);
setCaptionFileName(null);
setValidationError(null);
}}
disabled={isSubmitting}
/>
</TabsContent>
<TabsContent value="txt">
<div className="flex flex-wrap gap-[15px] [&>*]:min-w-[200px] [&>*]:flex-1">
<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}
onFiles={handleVideosTxtChange}
onClear={() => {
setVideosTxtLines(null);
setVideosTxtFileName(null);
setValidationError(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}
onFiles={handleCaptionsTxtChange}
onClear={() => {
setCaptionsTxtLines(null);
setCaptionsTxtFileName(null);
setValidationError(null);
}}
disabled={isSubmitting}
/>
</div>
</TabsContent>
<TabsContent value="csv">
<UploadZone
label="Upload captions CSV"
hint="Header: video_name, caption"
accept=".csv,text/csv"
value={captionFileName ? '1' : ''}
fileName={captionFileName ?? undefined}
onFiles={handleCaptionCsvChange}
onClear={() => {
setCaptionMap(null);
setCaptionFileName(null);
setValidationError(null);
}}
disabled={isSubmitting}
/>
</TabsContent>
</Tabs>
</div>
{validationError && (
<p className="mb-2 text-sm text-destructive">{validationError}</p>
)}
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating…' : 'Create Dataset'}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import DatasetCard from '@/components/datasets/DatasetCard';
import { deleteDataset } from '@/lib/api';
import type { Dataset } from '@/lib/api';
import { setActiveDatasetId } from '@/stores/activeDataset';
vi.mock('@/lib/api', () => ({
deleteDataset: vi.fn(),
}));
const mockedDeleteDataset = vi.mocked(deleteDataset);
const dataset: Dataset = {
id: 'ds-1',
name: 'My Dataset',
created_at: 0,
file_count: 3,
size_bytes: 2048,
};
beforeEach(() => {
setActiveDatasetId(null);
});
describe('DatasetCard', () => {
it('renders the name, file count and human-readable size', () => {
render(<DatasetCard dataset={dataset} onUpdated={() => {}} />);
expect(screen.getByText('My Dataset')).toBeInTheDocument();
expect(screen.getByText('3 files · 2.0 KB')).toBeInTheDocument();
});
it('uses the singular "file" label and byte units for a small dataset', () => {
render(
<DatasetCard
dataset={{ ...dataset, file_count: 1, size_bytes: 512 }}
onUpdated={() => {}}
/>,
);
expect(screen.getByText('1 file · 512 B')).toBeInTheDocument();
});
it('calls onSelect when the card body is clicked', () => {
const onSelect = vi.fn();
render(
<DatasetCard dataset={dataset} onUpdated={() => {}} onSelect={onSelect} />,
);
fireEvent.click(screen.getByText('My Dataset'));
expect(onSelect).toHaveBeenCalledTimes(1);
});
it('deletes after confirmation and notifies the parent without selecting', async () => {
const onUpdated = vi.fn();
const onSelect = vi.fn();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
mockedDeleteDataset.mockResolvedValue(undefined);
render(
<DatasetCard
dataset={dataset}
onUpdated={onUpdated}
onSelect={onSelect}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
expect(confirmSpy).toHaveBeenCalledWith('Delete dataset "My Dataset"?');
expect(mockedDeleteDataset).toHaveBeenCalledWith('ds-1');
await waitFor(() => expect(onUpdated).toHaveBeenCalledTimes(1));
expect(onSelect).not.toHaveBeenCalled();
});
it('selects on keyboard activation of the card body but not of the Delete button', () => {
const onSelect = vi.fn();
render(
<DatasetCard dataset={dataset} onUpdated={() => {}} onSelect={onSelect} />,
);
// Activating the Delete button must not bubble into a card selection.
fireEvent.keyDown(screen.getByRole('button', { name: 'Delete' }), {
key: 'Enter',
});
expect(onSelect).not.toHaveBeenCalled();
// Activating the card body itself does select.
fireEvent.keyDown(screen.getByText('My Dataset'), { key: 'Enter' });
expect(onSelect).toHaveBeenCalledTimes(1);
});
it('does not delete when the user cancels the confirm dialog', () => {
vi.spyOn(window, 'confirm').mockReturnValue(false);
render(<DatasetCard dataset={dataset} onUpdated={() => {}} />);
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
expect(mockedDeleteDataset).not.toHaveBeenCalled();
});
it('applies selected styling when it is the active dataset', () => {
setActiveDatasetId('ds-1');
const { container } = render(
<DatasetCard dataset={dataset} onUpdated={() => {}} />,
);
expect(container.firstChild).toHaveClass('border-accent-blue', 'bg-accent-blue/5');
});
});
@@ -0,0 +1,97 @@
'use client';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { deleteDataset } from '@/lib/api';
import type { Dataset } from '@/lib/api';
import { cn } from '@/lib/utils';
import { activeDatasetStore, setActiveDatasetId } from '@/stores/activeDataset';
import { useStore } from '@/hooks/useStore';
function formatSize(sizeBytes: number): string {
if (sizeBytes < 1024) return `${sizeBytes} B`;
if (sizeBytes < 1024 * 1024) return `${(sizeBytes / 1024).toFixed(1)} KB`;
if (sizeBytes < 1024 * 1024 * 1024) {
return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`;
}
return `${(sizeBytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
export default function DatasetCard({
dataset,
onUpdated,
onSelect = () => {},
}: {
dataset: Dataset;
onUpdated: () => void;
onSelect?: () => void;
}) {
const { activeDatasetId } = useStore(activeDatasetStore);
const [isLoading, setIsLoading] = React.useState(false);
const isSelected = activeDatasetId === dataset.id;
const fileCount = dataset.file_count ?? 0;
const sizeLabel = formatSize(dataset.size_bytes ?? 0);
async function handleDelete(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (isLoading) return;
if (!window.confirm(`Delete dataset "${dataset.name}"?`)) return;
setIsLoading(true);
try {
await deleteDataset(dataset.id);
if (activeDatasetStore.get().activeDatasetId === dataset.id) {
setActiveDatasetId(null);
}
onUpdated();
} catch (err) {
window.alert(
err instanceof Error ? err.message : 'Failed to delete dataset',
);
} finally {
setIsLoading(false);
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if ((e.target as HTMLElement).closest('button')) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect();
}
}
return (
<div
className={cn(
'mb-3 flex cursor-pointer flex-col gap-[0.6rem] rounded-lg border border-border bg-background px-[1.15rem] py-4',
isSelected && 'border-accent-blue bg-accent-blue/5',
)}
onClick={(e) => {
if ((e.target as HTMLElement).closest('button')) return;
onSelect();
}}
onKeyDown={handleKeyDown}
role="button"
tabIndex={0}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-[0.95rem] font-semibold">{dataset.name}</span>
<Button
type="button"
variant="destructive"
size="sm"
onClick={handleDelete}
disabled={isLoading}
>
Delete
</Button>
</div>
<div className="text-sm text-muted-foreground">
{fileCount} {fileCount === 1 ? 'file' : 'files'} · {sizeLabel}
</div>
</div>
);
}
@@ -0,0 +1,156 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import DatasetSidebar from '@/components/datasets/DatasetSidebar';
import * as api from '@/lib/api';
import type { Dataset } from '@/lib/api';
vi.mock('@/lib/api');
const mockedApi = vi.mocked(api);
const dataset: Dataset = {
id: 'ds-1',
name: 'My Dataset',
created_at: 0,
};
beforeEach(() => {
mockedApi.getDatasetFiles.mockResolvedValue({
file_names: ['a.mp4', 'b.mp4'],
captions: { 'a.mp4': 'cap a', 'b.mp4': '' },
});
mockedApi.getDatasetMediaUrl.mockImplementation(
(id, fileName) => `http://test/${id}/${fileName}`,
);
mockedApi.updateDatasetCaption.mockResolvedValue(undefined);
});
describe('DatasetSidebar', () => {
it('lists dataset files after loading', async () => {
render(<DatasetSidebar dataset={dataset} onClose={() => {}} />);
// The first file's caption is rendered once loading resolves.
expect(await screen.findByDisplayValue('cap a')).toBeInTheDocument();
expect(mockedApi.getDatasetFiles).toHaveBeenCalledWith('ds-1');
// One caption editor per returned file.
const captionFields = screen.getAllByPlaceholderText('Caption');
expect(captionFields).toHaveLength(2);
// Media URLs are requested per visible file.
expect(mockedApi.getDatasetMediaUrl).toHaveBeenCalledWith('ds-1', 'a.mp4');
expect(mockedApi.getDatasetMediaUrl).toHaveBeenCalledWith('ds-1', 'b.mp4');
});
it('debounces caption save by 500ms', async () => {
render(<DatasetSidebar dataset={dataset} onClose={() => {}} />);
const textarea = await screen.findByDisplayValue('cap a');
vi.useFakeTimers();
try {
fireEvent.change(textarea, { target: { value: 'updated caption' } });
// Nothing saved immediately or just before the debounce window closes.
expect(mockedApi.updateDatasetCaption).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(499);
});
expect(mockedApi.updateDatasetCaption).not.toHaveBeenCalled();
// Saved exactly once after the full 500ms.
act(() => {
vi.advanceTimersByTime(1);
});
expect(mockedApi.updateDatasetCaption).toHaveBeenCalledTimes(1);
expect(mockedApi.updateDatasetCaption).toHaveBeenCalledWith(
'ds-1',
'a.mp4',
'updated caption',
);
} finally {
vi.useRealTimers();
}
});
it('coalesces rapid edits into a single debounced save', async () => {
render(<DatasetSidebar dataset={dataset} onClose={() => {}} />);
const textarea = await screen.findByDisplayValue('cap a');
vi.useFakeTimers();
try {
fireEvent.change(textarea, { target: { value: 'one' } });
act(() => {
vi.advanceTimersByTime(300);
});
fireEvent.change(textarea, { target: { value: 'two' } });
act(() => {
vi.advanceTimersByTime(500);
});
expect(mockedApi.updateDatasetCaption).toHaveBeenCalledTimes(1);
expect(mockedApi.updateDatasetCaption).toHaveBeenCalledWith(
'ds-1',
'a.mp4',
'two',
);
} finally {
vi.useRealTimers();
}
});
it('debounces per file: editing another caption does not cancel a pending save', async () => {
render(<DatasetSidebar dataset={dataset} onClose={() => {}} />);
await screen.findByDisplayValue('cap a');
const [textareaA, textareaB] = screen.getAllByPlaceholderText('Caption');
vi.useFakeTimers();
try {
fireEvent.change(textareaA, { target: { value: 'new cap a' } });
act(() => {
vi.advanceTimersByTime(300);
});
// Editing b.mp4 inside a.mp4's debounce window must not drop a's save.
fireEvent.change(textareaB, { target: { value: 'new cap b' } });
act(() => {
vi.advanceTimersByTime(500);
});
expect(mockedApi.updateDatasetCaption).toHaveBeenCalledTimes(2);
expect(mockedApi.updateDatasetCaption).toHaveBeenCalledWith(
'ds-1',
'a.mp4',
'new cap a',
);
expect(mockedApi.updateDatasetCaption).toHaveBeenCalledWith(
'ds-1',
'b.mp4',
'new cap b',
);
} finally {
vi.useRealTimers();
}
});
it('flushes a pending save on unmount instead of dropping it', async () => {
const { unmount } = render(
<DatasetSidebar dataset={dataset} onClose={() => {}} />,
);
const textarea = await screen.findByDisplayValue('cap a');
vi.useFakeTimers();
try {
fireEvent.change(textarea, { target: { value: 'edited just before close' } });
unmount();
expect(mockedApi.updateDatasetCaption).toHaveBeenCalledTimes(1);
expect(mockedApi.updateDatasetCaption).toHaveBeenCalledWith(
'ds-1',
'a.mp4',
'edited just before close',
);
} finally {
vi.useRealTimers();
}
});
});
@@ -0,0 +1,253 @@
'use client';
import * as React from 'react';
import { X } from 'lucide-react';
import DownloadCaptions from '@/components/datasets/DownloadCaptions';
import { Textarea } from '@/components/ui/textarea';
import { useResizable } from '@/hooks/useResizable';
import {
getDatasetFiles,
getDatasetMediaUrl,
updateDatasetCaption,
type Dataset,
} from '@/lib/api';
import { cn } from '@/lib/utils';
const SIDEBAR_MIN_WIDTH = 320;
const SIDEBAR_MAX_WIDTH = 900;
const INITIAL_PAGE_SIZE = 24;
const PAGE_SIZE = 24;
const SCROLL_THRESHOLD = 200;
// Memoized so a caption keystroke re-renders only the edited card, not every
// visible <video> in the grid (visibleCount grows unbounded with scrolling).
const DatasetFileCard = React.memo(function DatasetFileCard({
fileName,
mediaUrl,
caption,
thumbLoaded,
onCaptionChange,
onThumbLoaded,
}: {
fileName: string;
mediaUrl: string;
caption: string;
thumbLoaded: boolean;
onCaptionChange: (fileName: string, value: string) => void;
onThumbLoaded: (fileName: string) => void;
}) {
return (
<div className="relative flex flex-col overflow-hidden rounded-lg border border-border bg-background">
{!thumbLoaded && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-background/70">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground/40 border-t-accent-blue" />
</div>
)}
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
src={mediaUrl}
className="aspect-video w-full bg-border object-cover"
muted
autoPlay
loop
playsInline
onLoadedData={() => onThumbLoaded(fileName)}
onError={() => onThumbLoaded(fileName)}
/>
<Textarea
value={caption}
onChange={(e) => onCaptionChange(fileName, e.target.value)}
placeholder="Caption"
rows={2}
className="min-h-[2.5rem] resize-y rounded-none border-0 bg-transparent p-1.5 text-xs shadow-none focus-visible:border-transparent focus-visible:ring-0"
/>
</div>
);
});
export default function DatasetSidebar({
dataset,
onClose,
onWidthChange,
}: {
dataset: Dataset;
onClose: () => void;
onWidthChange?: (w: number) => void;
}) {
const [width, setWidth] = React.useState(400);
const [isDragging, setIsDragging] = React.useState(false);
const [fileNames, setFileNames] = React.useState<string[]>([]);
const [captions, setCaptions] = React.useState<Record<string, string>>({});
const [visibleCount, setVisibleCount] = React.useState(INITIAL_PAGE_SIZE);
const [isLoading, setIsLoading] = React.useState(true);
const [thumbLoaded, setThumbLoaded] = React.useState<
Record<string, boolean>
>({});
// Pending debounced caption saves, keyed per file so editing one caption
// can't cancel another file's pending save.
const pendingSaves = React.useRef(
new Map<string, { timer: ReturnType<typeof setTimeout>; save: () => void }>(),
);
const scrollRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
onWidthChange?.(width);
}, [width, onWidthChange]);
React.useEffect(() => {
let cancelled = false;
setIsLoading(true);
getDatasetFiles(dataset.id)
.then((data) => {
if (cancelled) return;
setFileNames(data.file_names);
setCaptions(data.captions);
setVisibleCount(INITIAL_PAGE_SIZE);
setThumbLoaded({});
})
.catch((err) => console.error('Failed to load dataset files:', err))
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, [dataset.id]);
const flushPendingSaves = React.useCallback(() => {
for (const { timer, save } of pendingSaves.current.values()) {
clearTimeout(timer);
save();
}
pendingSaves.current.clear();
}, []);
// Flush (not drop) pending saves when the dataset changes or on unmount, so
// an edit made within the debounce window of closing isn't lost.
React.useEffect(() => flushPendingSaves, [dataset.id, flushPendingSaves]);
const { onMouseDown } = useResizable({
edge: 'right',
minWidth: SIDEBAR_MIN_WIDTH,
maxWidth: SIDEBAR_MAX_WIDTH,
getWidth: () => width,
onWidth: setWidth,
onDragChange: setIsDragging,
});
const datasetId = dataset.id;
const handleCaptionChange = React.useCallback(
(fileName: string, value: string) => {
setCaptions((prev) => ({ ...prev, [fileName]: value }));
const pending = pendingSaves.current.get(fileName);
if (pending) clearTimeout(pending.timer);
const save = () => {
updateDatasetCaption(datasetId, fileName, value).catch((err) =>
console.error('Failed to save caption:', err),
);
};
const timer = setTimeout(() => {
pendingSaves.current.delete(fileName);
save();
}, 500);
pendingSaves.current.set(fileName, { timer, save });
},
[datasetId],
);
function handleScroll() {
const el = scrollRef.current;
if (!el || isLoading || visibleCount >= fileNames.length) return;
const { scrollTop, scrollHeight, clientHeight } = el;
const distanceFromBottom = scrollHeight - (scrollTop + clientHeight);
if (distanceFromBottom < SCROLL_THRESHOLD) {
setVisibleCount((c) => Math.min(c + PAGE_SIZE, fileNames.length));
}
}
// When the visible grid doesn't overflow (a wide/tall sidebar can fit the
// first page without a scrollbar), no scroll event ever fires — so top up
// visibleCount until it overflows or every file is shown. Re-runs on resize
// (width) and after each page grows, otherwise files 25..N are unreachable.
React.useEffect(() => {
if (isLoading || visibleCount >= fileNames.length) return;
const el = scrollRef.current;
if (el && el.scrollHeight <= el.clientHeight) {
setVisibleCount((c) => Math.min(c + PAGE_SIZE, fileNames.length));
}
}, [visibleCount, fileNames.length, isLoading, width]);
const markThumbLoaded = React.useCallback((fileName: string) => {
setThumbLoaded((prev) =>
prev[fileName] ? prev : { ...prev, [fileName]: true },
);
}, []);
const visibleFiles = fileNames.slice(0, visibleCount);
return (
<aside
className="fixed bottom-0 right-0 top-[var(--header-height)] z-50 flex max-h-[calc(100vh-var(--header-height))] min-w-[320px] shrink-0 flex-col border-l border-border bg-card"
style={{ width, maxWidth: SIDEBAR_MAX_WIDTH }}
>
<div className="flex shrink-0 items-center justify-between border-b border-border px-5 py-4">
<h2 className="m-0 min-w-0 truncate text-base font-semibold text-foreground">
{dataset.name}
</h2>
<div className="flex shrink-0 items-center gap-2">
<DownloadCaptions fileNames={fileNames} captions={captions} />
<button
type="button"
onClick={onClose}
title="Close"
aria-label="Close"
className="flex items-center justify-center rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<X className="h-[18px] w-[18px]" />
</button>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto p-4"
>
{isLoading ? (
<p className="p-8 text-center text-muted-foreground">Loading…</p>
) : fileNames.length === 0 ? (
<p className="p-8 text-center text-muted-foreground">
No media files
</p>
) : (
<div className="grid gap-4 [grid-template-columns:repeat(auto-fill,minmax(140px,1fr))]">
{visibleFiles.map((fileName) => (
<DatasetFileCard
key={fileName}
fileName={fileName}
mediaUrl={getDatasetMediaUrl(dataset.id, fileName)}
caption={captions[fileName] ?? ''}
thumbLoaded={!!thumbLoaded[fileName]}
onCaptionChange={handleCaptionChange}
onThumbLoaded={markThumbLoaded}
/>
))}
</div>
)}
</div>
</div>
<div
role="presentation"
onMouseDown={onMouseDown}
className={cn(
'absolute bottom-0 left-0 top-0 z-[1] w-1.5 cursor-col-resize hover:bg-accent-blue/25',
isDragging && 'bg-accent-blue/25',
)}
/>
</aside>
);
}
@@ -0,0 +1,113 @@
'use client';
import { ChevronDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { downloadBlob } from '@/lib/utils';
const MENU_ITEM =
'block w-full cursor-pointer px-4 py-2 text-left text-sm font-medium text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50';
export default function DownloadCaptions({
fileNames,
captions,
}: {
fileNames: string[];
captions: Record<string, string>;
}) {
const disabled = fileNames.length === 0;
// Sorted lazily in the click handlers: this component re-renders with the
// sidebar on every caption keystroke, and the sort is only needed on click.
const sortedFileNames = () => [...fileNames].sort();
function handleDownloadJson() {
const data = sortedFileNames().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 sortedNames = sortedFileNames();
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 = sortedFileNames().map(
(fn) => `${escape(fn)},${escape(captions[fn] ?? '')}`,
);
const csv = ['video_name,caption', ...rows].join('\n');
downloadBlob(new Blob([csv], { type: 'text/csv' }), 'captions.csv');
}
return (
<div className="group relative inline-block">
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
aria-haspopup="menu"
className="gap-1.5"
>
Download Captions
<ChevronDown className="h-3.5 w-3.5 opacity-85" />
</Button>
{!disabled && (
<div
role="menu"
className="invisible absolute right-0 top-full z-[200] min-w-full -translate-y-1 pt-1 opacity-0 transition-all group-focus-within:visible group-focus-within:translate-y-0 group-focus-within:opacity-100 group-hover:visible group-hover:translate-y-0 group-hover:opacity-100"
>
<div className="overflow-hidden rounded-lg border border-border bg-popover py-1 shadow-lg">
<button
type="button"
role="menuitem"
className={MENU_ITEM}
onClick={handleDownloadJson}
>
JSON
</button>
<button
type="button"
role="menuitem"
className={MENU_ITEM}
onClick={handleDownloadTxt}
>
TXT
</button>
<button
type="button"
role="menuitem"
className={MENU_ITEM}
onClick={handleDownloadCsv}
>
CSV
</button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,205 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface UploadZoneProps {
label: string;
hint?: string;
accept?: string;
multiple?: boolean;
directory?: boolean;
allowBothFileAndDirectory?: boolean;
value?: string;
fileName?: string;
onFiles?: (files: File[]) => void;
onClear?: () => void;
disabled?: boolean;
uploading?: boolean;
}
const linkButtonClass =
'cursor-pointer bg-transparent p-0 text-accent-blue hover:underline disabled:cursor-not-allowed disabled:no-underline disabled:opacity-50';
export default function UploadZone({
label,
hint,
accept,
multiple = false,
directory = false,
allowBothFileAndDirectory = false,
value = '',
fileName,
onFiles,
onClear,
disabled = false,
uploading = false,
}: UploadZoneProps) {
const fileInputRef = React.useRef<HTMLInputElement>(null);
const directoryInputRef = React.useRef<HTMLInputElement>(null);
const useBoth = directory && allowBothFileAndDirectory;
const clickable = !useBoth;
const hasContent = !!(value || fileName);
// `webkitdirectory` is a DOM property without a typed JSX prop, so set it
// imperatively. The primary input only selects folders when it is the sole
// input; the dedicated directory input always does.
React.useEffect(() => {
if (fileInputRef.current) {
fileInputRef.current.webkitdirectory = directory && !allowBothFileAndDirectory;
}
if (directoryInputRef.current) {
directoryInputRef.current.webkitdirectory = true;
}
}, [directory, allowBothFileAndDirectory]);
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const files = e.target.files;
if (files && files.length > 0) {
onFiles?.(Array.from(files));
}
e.target.value = '';
}
function handleClick() {
if (!disabled) {
fileInputRef.current?.click();
}
}
function handleKeyActivate(e: React.KeyboardEvent) {
if ((e.target as HTMLElement).closest('button')) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
}
function handleDrop(e: React.DragEvent) {
if (disabled) return;
e.preventDefault();
const files = e.dataTransfer.files;
if (files && files.length > 0) {
onFiles?.(Array.from(files));
}
}
function handleDragOver(e: React.DragEvent) {
if (disabled) return;
e.preventDefault();
}
function clearInputs() {
if (fileInputRef.current) fileInputRef.current.value = '';
if (directoryInputRef.current) directoryInputRef.current.value = '';
}
return (
<div
className={cn(
'flex min-h-[150px] grow flex-col items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/40 px-5 py-6 text-center transition-colors hover:border-accent-blue hover:bg-muted/60',
clickable ? 'cursor-pointer' : 'cursor-default',
hasContent && 'border-solid border-accent-blue',
)}
onClick={clickable ? handleClick : undefined}
onKeyDown={clickable ? handleKeyActivate : undefined}
onDragOver={handleDragOver}
onDrop={handleDrop}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept={accept}
multiple={multiple}
onChange={handleChange}
disabled={disabled}
/>
{useBoth && (
<input
ref={directoryInputRef}
type="file"
className="hidden"
multiple
onChange={handleChange}
disabled={disabled}
/>
)}
<div className="mb-2 text-sm text-muted-foreground">{label}</div>
{!hasContent && (
<span className="mt-1.5 text-xs text-muted-foreground">
{uploading ? (
'Uploading…'
) : useBoth ? (
<>
<span
role="button"
tabIndex={0}
className="cursor-pointer text-accent-blue hover:underline"
onClick={(e) => {
e.stopPropagation();
handleClick();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
}}
>
Select files
</span>
{' · '}
<button
type="button"
className={linkButtonClass}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!disabled) directoryInputRef.current?.click();
}}
disabled={disabled}
>
Select folder
</button>
</>
) : directory ? (
'Click or drop folder'
) : (
'Click or drop file(s)'
)}
</span>
)}
{fileName && (
<div className="mt-2 text-sm text-foreground">
{fileName}
{onClear && (
<>
{' · '}
<button
type="button"
className={linkButtonClass}
onClick={(e) => {
e.stopPropagation();
onClear();
clearInputs();
}}
disabled={disabled || uploading}
>
Clear
</button>
</>
)}
</div>
)}
{hint && <div className="mt-1.5 text-xs text-muted-foreground">{hint}</div>}
</div>
);
}
@@ -0,0 +1,174 @@
'use client';
import * as React from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch';
import { cn } from '@/lib/utils';
/**
* Labeled form-field rows shared by the Settings page and the Create Job
* modal. They mirror the Svelte `Toggle`/`Slider` UX on top of the shadcn
* `Switch`/`Slider`/`Input` primitives.
*/
export function FieldRow({
htmlFor,
label,
title,
className,
children,
}: {
htmlFor: string;
label: string;
title?: string;
className?: string;
children: React.ReactNode;
}) {
return (
<div className={cn('flex flex-col gap-1.5', className)}>
<Label
htmlFor={htmlFor}
title={title}
className="pl-0.5 text-xs font-normal tracking-wide text-muted-foreground"
>
{label}
</Label>
{children}
</div>
);
}
export function SliderRow({
id,
label,
title,
min,
max,
step,
value,
onChange,
disabled,
format = (v) => String(v),
}: {
id: string;
label: string;
title?: string;
min: number;
max: number;
step: number;
value: number;
/** Called once per gesture (pointer release / key press), not per drag tick. */
onChange: (v: number) => void;
disabled?: boolean;
format?: (v: number) => string;
}) {
// Track the in-progress drag locally so `onChange` only fires on commit;
// any external `value` change (commit landing, reset button) takes over.
const [dragValue, setDragValue] = React.useState<number | null>(null);
React.useEffect(() => {
setDragValue(null);
}, [value]);
const shown = dragValue ?? value;
return (
<FieldRow htmlFor={id} label={label} title={title}>
<div className="flex items-center gap-2">
<Slider
id={id}
min={min}
max={max}
step={step}
value={[shown]}
onValueChange={(v) => setDragValue(v[0])}
onValueCommit={(v) => onChange(v[0])}
disabled={disabled}
aria-label={label}
className="min-w-0 flex-1"
/>
<span
aria-hidden="true"
className="min-w-10 shrink-0 text-right text-sm tabular-nums text-muted-foreground"
>
{format(shown)}
</span>
</div>
</FieldRow>
);
}
export function ToggleRow({
id,
label,
title,
checked,
onChange,
disabled,
}: {
id: string;
label: string;
title?: string;
checked: boolean;
onChange: (v: boolean) => void;
disabled?: boolean;
}) {
return (
<FieldRow htmlFor={id} label={label} title={title}>
<Switch
id={id}
checked={checked}
onCheckedChange={onChange}
disabled={disabled}
/>
</FieldRow>
);
}
export function NumberRow({
id,
label,
title,
min,
max,
step,
value,
onChange,
disabled,
}: {
id: string;
label: string;
title?: string;
min?: number;
max?: number;
step?: number | string;
value: number;
onChange: (v: number) => void;
disabled?: boolean;
}) {
// Buffer the raw text so the field can be emptied while retyping; only
// valid numbers are committed, and blur restores the last committed value.
const [draft, setDraft] = React.useState<string | null>(null);
React.useEffect(() => {
setDraft(null);
}, [value]);
return (
<FieldRow htmlFor={id} label={label} title={title}>
<Input
id={id}
type="number"
min={min}
max={max}
step={step}
value={draft ?? value}
onChange={(e) => {
setDraft(e.target.value);
const v = e.target.valueAsNumber;
if (!Number.isNaN(v)) onChange(v);
}}
onBlur={() => setDraft(null)}
disabled={disabled}
/>
</FieldRow>
);
}
@@ -0,0 +1,70 @@
'use client';
import * as React from 'react';
import { ChevronDown } from 'lucide-react';
import CreateJobModal from '@/components/jobs/CreateJobModal';
import { Button } from '@/components/ui/button';
import { WORKLOAD_OPTIONS } from '@/lib/jobConfig';
import type { JobType } from '@/lib/types';
import { triggerRefresh } from '@/stores/jobsRefresh';
interface CreateJobButtonProps {
jobType: JobType;
}
export default function CreateJobButton({ jobType }: CreateJobButtonProps) {
const options = WORKLOAD_OPTIONS[jobType] ?? [];
const [modalOpen, setModalOpen] = React.useState(false);
const [workloadType, setWorkloadType] = React.useState(
options[0]?.type ?? 't2v',
);
function openModal(type: string) {
setWorkloadType(type);
setModalOpen(true);
}
function handleSuccess() {
triggerRefresh();
setModalOpen(false);
}
return (
<>
<div className="group relative inline-block">
<Button type="button" className="gap-1.5">
Create Job
<ChevronDown className="size-3.5 opacity-85" aria-hidden />
</Button>
<div
role="menu"
className="invisible absolute right-0 top-full z-[200] mt-1 min-w-full -translate-y-1 rounded-lg border border-border bg-popover py-1 opacity-0 shadow-lg transition-all duration-150 group-hover:visible group-hover:translate-y-0 group-hover:opacity-100"
>
{options.map((opt) => (
<button
key={opt.type}
type="button"
role="menuitem"
onClick={() => openModal(opt.type)}
className="block w-full whitespace-nowrap px-4 py-2 text-left text-sm font-medium text-popover-foreground transition-colors hover:bg-secondary"
>
{opt.label}
<span className="mt-0.5 block text-xs font-normal text-muted-foreground">
{opt.desc}
</span>
</button>
))}
</div>
</div>
<CreateJobModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSuccess={handleSuccess}
jobType={jobType}
workloadType={workloadType}
/>
</>
);
}
@@ -0,0 +1,158 @@
import * as React from 'react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import CreateJobModal from './CreateJobModal';
import { createJob, getDatasets, getModels, uploadImage } from '@/lib/api';
import { defaultOptionsStore } from '@/stores/defaultOptions';
import { DEFAULT_OPTIONS } from '@/lib/defaultOptions';
vi.mock('@/lib/api', () => ({
createJob: vi.fn(),
getModels: vi.fn(),
getDatasets: vi.fn(),
uploadImage: vi.fn(),
getSettings: vi.fn(),
updateSettings: vi.fn(),
}));
const MODELS = [
{ id: 'wan/t2v-1.3b', label: 'Wan T2V', type: 't2v' },
{ id: 'wan/t2v-14b', label: 'Wan T2V Large', type: 't2v' },
];
beforeEach(() => {
// Reset the shared options store to a known baseline for test isolation.
defaultOptionsStore.set({ options: DEFAULT_OPTIONS });
vi.mocked(getModels).mockResolvedValue(MODELS);
vi.mocked(getDatasets).mockResolvedValue([]);
vi.mocked(uploadImage).mockResolvedValue({ path: '/uploads/x.png' });
vi.mocked(createJob).mockResolvedValue({ id: 'job-1' } as never);
});
function renderModal(
overrides: Partial<React.ComponentProps<typeof CreateJobModal>> = {},
) {
const onClose = vi.fn();
const onSuccess = vi.fn();
render(
<CreateJobModal
isOpen
onClose={onClose}
onSuccess={onSuccess}
jobType="inference"
workloadType="t2v"
{...overrides}
/>,
);
return { onClose, onSuccess };
}
describe('CreateJobModal', () => {
it('renders the form fields for an inference job', async () => {
renderModal();
expect(
await screen.findByText('New Inference Job (T2V)'),
).toBeInTheDocument();
expect(screen.getByLabelText('Model')).toBeInTheDocument();
expect(screen.getByLabelText('Prompt')).toBeInTheDocument();
expect(screen.getByLabelText('Negative Prompt')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Create Job' }),
).toBeInTheDocument();
// The model dropdown is populated once getModels resolves.
expect(
await screen.findByRole('option', {
name: 'Wan T2V (wan/t2v-1.3b)',
}),
).toBeInTheDocument();
});
it('seeds fields from the options store and submits an inference payload', async () => {
// Non-default store values prove the open-time seeding effect ran (the
// useState defaults are 50 / 480).
defaultOptionsStore.set({
options: { ...DEFAULT_OPTIONS, numInferenceSteps: 25, height: 720 },
});
const user = userEvent.setup();
const { onClose, onSuccess } = renderModal();
// Wait for models to load so the default model is selected.
await screen.findByRole('option', { name: 'Wan T2V (wan/t2v-1.3b)' });
await user.type(
screen.getByLabelText('Prompt'),
'a raccoon in sunflowers',
);
await user.click(screen.getByRole('button', { name: 'Create Job' }));
await waitFor(() => expect(createJob).toHaveBeenCalledTimes(1));
const payload = vi.mocked(createJob).mock.calls[0][0];
expect(payload).toMatchObject({
model_id: 'wan/t2v-1.3b',
prompt: 'a raccoon in sunflowers',
workload_type: 't2v',
job_type: 'inference',
num_inference_steps: 25,
height: 720,
num_frames: 81,
width: 832,
guidance_scale: 5,
seed: 1024,
});
await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('submits a dmd_t2v distillation payload including the DMD fields', async () => {
vi.mocked(getDatasets).mockResolvedValue([
{ id: 'ds1', name: 'My Dataset', created_at: 0 },
]);
const user = userEvent.setup();
const { onSuccess } = renderModal({
jobType: 'distillation',
workloadType: 'dmd_t2v',
});
// Models + datasets load asynchronously on open. The model/dataset option
// labels also appear in the Real/Fake Score Model and Validation Dataset
// selects, so scope each wait to the relevant select.
await within(screen.getByLabelText('Model')).findByRole('option', {
name: 'Wan T2V (wan/t2v-1.3b)',
});
const datasetSelect = screen.getByLabelText('Dataset *');
await within(datasetSelect).findByRole('option', { name: 'My Dataset' });
await user.type(screen.getByLabelText('Description'), 'distill run');
await user.selectOptions(datasetSelect, 'ds1');
await user.click(screen.getByRole('button', { name: 'Create Job' }));
await waitFor(() => expect(createJob).toHaveBeenCalledTimes(1));
const payload = vi.mocked(createJob).mock.calls[0][0];
expect(payload).toMatchObject({
workload_type: 'dmd_t2v',
job_type: 'distillation',
// The dataset id is sent; the backend resolves it to the on-disk dir.
data_path: 'ds1',
lora_rank: 32,
// DMD-specific fields added to CreateJobRequest for this modal.
dmd_use_vsa: false,
dmd_vsa_sparsity: 0.8,
dmd_denoising_steps: '1000,757,522',
real_score_guidance_scale: 3.5,
generator_update_interval: 5,
real_score_model_path: 'wan/t2v-1.3b',
fake_score_model_path: 'wan/t2v-1.3b',
});
// Inference-only keys must be absent for a training job.
expect(payload).not.toHaveProperty('num_inference_steps');
await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1));
});
});
@@ -0,0 +1,823 @@
'use client';
import * as React from 'react';
import {
FieldRow,
NumberRow,
SliderRow,
ToggleRow,
} from '@/components/form-rows';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
import { Textarea } from '@/components/ui/textarea';
import { useStore } from '@/hooks/useStore';
import { defaultOptionsStore } from '@/stores/defaultOptions';
import {
createJob,
getDatasets,
getModels,
uploadImage,
type CreateJobRequest,
type Model,
} from '@/lib/api';
import { getDefaultModelForWorkload } from '@/lib/defaultOptions';
import { WORKLOAD_OPTIONS } from '@/lib/jobConfig';
import type { JobType } from '@/lib/types';
export interface CreateJobModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
jobType: JobType;
workloadType: string;
}
export default function CreateJobModal({
isOpen,
onClose,
onSuccess,
jobType,
workloadType,
}: CreateJobModalProps) {
const { options } = useStore(defaultOptionsStore);
const isInference = jobType === 'inference';
// Training jobs always pick from the t2v model catalogue.
const inferenceWorkload = isInference ? workloadType : 't2v';
const [models, setModels] = React.useState<Model[]>([]);
const [modelId, setModelId] = React.useState('');
const [prompt, setPrompt] = React.useState('');
const [imagePath, setImagePath] = React.useState('');
const [imageFileName, setImageFileName] = React.useState('');
const [isUploadingImage, setIsUploadingImage] = React.useState(false);
const [negativePrompt, setNegativePrompt] = React.useState('');
const [numInferenceSteps, setNumInferenceSteps] = React.useState(50);
const [numFrames, setNumFrames] = React.useState(81);
const [height, setHeight] = React.useState(480);
const [width, setWidth] = React.useState(832);
const [guidanceScale, setGuidanceScale] = React.useState(5);
const [guidanceRescale, setGuidanceRescale] = React.useState(0);
const [fps, setFps] = React.useState(24);
const [seed, setSeed] = React.useState(1024);
const [numGpus, setNumGpus] = React.useState(1);
const [ditCpuOffload, setDitCpuOffload] = 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);
const [enableTorchCompile, setEnableTorchCompile] = React.useState(false);
const [vsaSparsity, setVsaSparsity] = React.useState(0);
const [tpSize, setTpSize] = React.useState(-1);
const [spSize, setSpSize] = React.useState(-1);
const [selectedDatasetId, setSelectedDatasetId] = React.useState('');
const [readyDatasets, setReadyDatasets] = React.useState<
Awaited<ReturnType<typeof getDatasets>>
>([]);
const [maxTrainSteps, setMaxTrainSteps] = React.useState(1000);
const [trainBatchSize, setTrainBatchSize] = React.useState(1);
const [learningRate, setLearningRate] = React.useState(5e-5);
const [numLatentT, setNumLatentT] = React.useState(20);
const [selectedValidationDatasetId, setSelectedValidationDatasetId] =
React.useState('');
const [loraRank, setLoraRank] = React.useState(32);
const [dmdUseVsa, setDmdUseVsa] = React.useState(false);
const [dmdVsaSparsity, setDmdVsaSparsity] = React.useState(0.8);
const [dmdDenoisingSteps, setDmdDenoisingSteps] =
React.useState('1000,757,522');
const [realScoreGuidanceScale, setRealScoreGuidanceScale] =
React.useState(3.5);
const [generatorUpdateInterval, setGeneratorUpdateInterval] =
React.useState(5);
const [realScoreModelPath, setRealScoreModelPath] = React.useState('');
const [fakeScoreModelPath, setFakeScoreModelPath] = React.useState('');
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [isLoadingModels, setIsLoadingModels] = React.useState(false);
const imageInputRef = React.useRef<HTMLInputElement>(null);
// Seed field values from the persisted default options each time the modal
// OPENS. A naive port of the Svelte `$effect` would re-seed on every
// `$defaultOptions` change; we deliberately seed only on the open transition
// so a late `initDefaultOptions()` settings refresh can't clobber the user's
// in-progress edits or desync the (already validated) model selection.
const justOpenedRef = React.useRef(false);
React.useEffect(() => {
const justOpened = isOpen && !justOpenedRef.current;
justOpenedRef.current = isOpen;
if (!justOpened) return;
const opts = options;
setNumInferenceSteps(opts.numInferenceSteps);
setNumFrames(workloadType === 't2i' ? 1 : opts.numFrames);
setHeight(opts.height);
setWidth(opts.width);
setGuidanceScale(opts.guidanceScale);
setGuidanceRescale(opts.guidanceRescale);
setFps(opts.fps);
setSeed(opts.seed);
setNumGpus(opts.numGpus);
setDitCpuOffload(opts.ditCpuOffload);
setTextEncoderCpuOffload(opts.textEncoderCpuOffload);
setVaeCpuOffload(opts.vaeCpuOffload);
setImageEncoderCpuOffload(opts.imageEncoderCpuOffload);
setUseFsdpInference(opts.useFsdpInference);
setEnableTorchCompile(opts.enableTorchCompile);
setVsaSparsity(opts.vsaSparsity);
setTpSize(opts.tpSize);
setSpSize(opts.spSize);
setModelId(
getDefaultModelForWorkload(
opts,
inferenceWorkload as 't2v' | 'i2v' | 't2i',
),
);
setImagePath('');
setImageFileName('');
setSelectedDatasetId('');
setSelectedValidationDatasetId('');
if (workloadType === 'dmd_t2v') {
setDmdUseVsa(false);
setDmdVsaSparsity(0.8);
setDmdDenoisingSteps('1000,757,522');
setRealScoreGuidanceScale(3.5);
setGeneratorUpdateInterval(5);
setRealScoreModelPath('');
setFakeScoreModelPath('');
}
}, [isOpen, workloadType, inferenceWorkload, options]);
// Load the models available for this workload.
React.useEffect(() => {
if (!isOpen) return;
// Ignore a superseded response so a slow fetch for a previous workload
// can't overwrite the current workload's model list/selection.
let stale = false;
setIsLoadingModels(true);
getModels(inferenceWorkload)
.then((list) => {
if (stale) return;
setModels(list);
const ids = list.map((m) => m.id);
const opts = defaultOptionsStore.get().options;
const defaultId = getDefaultModelForWorkload(
opts,
inferenceWorkload as 't2v' | 'i2v' | 't2i',
);
const chosen = ids.includes(defaultId) ? defaultId : (list[0]?.id ?? '');
setModelId(chosen);
if (workloadType === 'dmd_t2v') {
setRealScoreModelPath(chosen);
setFakeScoreModelPath(chosen);
}
})
.catch((e) => {
if (!stale) console.error('Failed to load models:', e);
})
.finally(() => {
if (!stale) setIsLoadingModels(false);
});
return () => {
stale = true;
};
}, [isOpen, inferenceWorkload, workloadType]);
// Training jobs need a dataset; load the ready datasets when relevant.
React.useEffect(() => {
if (isOpen && !isInference) {
getDatasets()
.then(setReadyDatasets)
.catch(() => setReadyDatasets([]));
} else {
setReadyDatasets([]);
}
}, [isOpen, isInference]);
async function handleImageChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) {
setImagePath('');
setImageFileName('');
return;
}
setIsUploadingImage(true);
setImageFileName(file.name);
try {
const { path } = await uploadImage(file);
setImagePath(path);
} catch {
setImagePath('');
setImageFileName('');
} finally {
setIsUploadingImage(false);
}
}
function clearImage() {
setImagePath('');
setImageFileName('');
if (imageInputRef.current) imageInputRef.current.value = '';
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (isInference && workloadType === 'i2v' && !imagePath) return;
// Send the dataset id; the backend resolves it to the on-disk media dir.
const effectiveDataPath = selectedDatasetId ?? '';
if (!isInference && !selectedDatasetId) return;
// `lora_t2v` jobs are persisted with a dedicated backend job_type that the
// front-end JobType enum does not model; cast to keep payload parity.
const effectiveJobType = (
workloadType === 'lora_t2v' ? 'lora' : jobType
) as JobType;
setIsSubmitting(true);
try {
const payload: CreateJobRequest = {
model_id: modelId,
prompt,
workload_type: workloadType,
job_type: effectiveJobType,
...(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,
}
: {
data_path: effectiveDataPath.trim(),
max_train_steps: maxTrainSteps,
train_batch_size: trainBatchSize,
learning_rate: learningRate,
num_latent_t: numLatentT,
validation_dataset_file: selectedValidationDatasetId || undefined,
lora_rank: loraRank,
...(workloadType === 'dmd_t2v'
? {
dmd_use_vsa: dmdUseVsa,
dmd_vsa_sparsity: dmdVsaSparsity,
dmd_denoising_steps: dmdDenoisingSteps,
real_score_guidance_scale: realScoreGuidanceScale,
generator_update_interval: generatorUpdateInterval,
real_score_model_path: realScoreModelPath || modelId,
fake_score_model_path: fakeScoreModelPath || modelId,
}
: {}),
}),
};
await createJob(payload);
onSuccess();
onClose();
} catch (err) {
console.error('Failed to create job:', err);
} finally {
setIsSubmitting(false);
}
}
function handleClose() {
if (isSubmitting) return;
onClose();
}
const workloadLabel =
WORKLOAD_OPTIONS[jobType]?.find((o) => o.type === workloadType)?.label ?? '';
const title = `New ${jobType.charAt(0).toUpperCase() + jobType.slice(1)} Job${
workloadLabel ? ` (${workloadLabel})` : ''
}`;
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) handleClose();
}}
>
<DialogContent
className="max-h-[90vh] w-[90vw] max-w-[850px] overflow-y-auto"
onEscapeKeyDown={(e) => {
if (isSubmitting) e.preventDefault();
}}
onInteractOutside={(e) => {
if (isSubmitting) e.preventDefault();
}}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<form
onSubmit={handleSubmit}
autoComplete="off"
className="flex flex-col gap-3.5"
>
<FieldRow htmlFor="modal-modelId" label="Model">
<NativeSelect
id="modal-modelId"
value={modelId}
onChange={(e) => setModelId(e.target.value)}
required
disabled={isSubmitting || isLoadingModels}
>
<option value="" disabled>
{isLoadingModels
? 'Loading models…'
: models.length === 0
? 'No models available for this workload'
: 'Select a model…'}
</option>
{models.map((model) => (
<option key={model.id} value={model.id}>
{model.label} ({model.id})
</option>
))}
</NativeSelect>
</FieldRow>
{isInference && workloadType === 'i2v' && (
<FieldRow htmlFor="modal-image" label="Image">
<Input
ref={imageInputRef}
id="modal-image"
type="file"
accept=".png,.jpg,.jpeg,.webp,.bmp"
onChange={handleImageChange}
disabled={isSubmitting || isUploadingImage}
required
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 && (
<span className="mt-0.5 text-xs text-muted-foreground">
{isUploadingImage ? 'Uploading…' : imageFileName} ·{' '}
<button
type="button"
onClick={clearImage}
disabled={isSubmitting || isUploadingImage}
className="text-accent-blue underline-offset-2 hover:underline disabled:cursor-not-allowed disabled:opacity-50"
>
Clear
</button>
</span>
)}
</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 && (
<FieldRow htmlFor="modal-negative-prompt" label="Negative Prompt">
<Textarea
id="modal-negative-prompt"
value={negativePrompt}
onChange={(e) => setNegativePrompt(e.target.value)}
rows={2}
placeholder="Optional: things to avoid in the output…"
disabled={isSubmitting}
/>
</FieldRow>
)}
{!isInference && (
<>
<div className="flex gap-4">
<FieldRow
htmlFor="modal-dataset"
label="Dataset *"
className="min-w-0 flex-1"
>
<NativeSelect
id="modal-dataset"
value={selectedDatasetId}
onChange={(e) => setSelectedDatasetId(e.target.value)}
disabled={isSubmitting}
>
<option value="" disabled>
{readyDatasets.length === 0
? 'No datasets (add in Datasets tab)'
: 'Select a dataset…'}
</option>
{readyDatasets.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</NativeSelect>
</FieldRow>
<FieldRow
htmlFor="modal-validation-dataset"
label="Validation Dataset (optional)"
className="min-w-0 flex-1"
>
<NativeSelect
id="modal-validation-dataset"
value={selectedValidationDatasetId}
onChange={(e) =>
setSelectedValidationDatasetId(e.target.value)
}
disabled={isSubmitting}
>
<option value="">None</option>
{readyDatasets.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</NativeSelect>
</FieldRow>
</div>
<details>
<summary className="mb-2 cursor-pointer select-none text-sm font-medium text-accent-blue">
Options
</summary>
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-x-3 gap-y-2">
<SliderRow
id="modal-max-train-steps"
label="Max Train Steps"
min={100}
max={50000}
step={100}
value={maxTrainSteps}
onChange={setMaxTrainSteps}
disabled={isSubmitting}
/>
<SliderRow
id="modal-train-batch-size"
label="Train Batch Size"
min={1}
max={8}
step={1}
value={trainBatchSize}
onChange={setTrainBatchSize}
disabled={isSubmitting}
/>
<NumberRow
id="modal-learning-rate"
label="Learning Rate"
step="1e-6"
min={1e-6}
max={1}
value={learningRate}
onChange={setLearningRate}
disabled={isSubmitting}
/>
<SliderRow
id="modal-num-latent-t"
label="Num Latent T"
min={8}
max={40}
step={1}
value={numLatentT}
onChange={setNumLatentT}
disabled={isSubmitting}
/>
{workloadType === 'lora_t2v' && (
<SliderRow
id="modal-lora-rank"
label="LoRA Rank"
min={8}
max={128}
step={8}
value={loraRank}
onChange={setLoraRank}
disabled={isSubmitting}
/>
)}
{workloadType === 'dmd_t2v' && (
<>
<ToggleRow
id="modal-dmd-use-vsa"
label="Video Sparse Attention (VSA)"
title="Use Video Sparse Attention for DMD"
checked={dmdUseVsa}
onChange={setDmdUseVsa}
disabled={isSubmitting}
/>
{dmdUseVsa && (
<SliderRow
id="modal-dmd-vsa-sparsity"
label="VSA Sparsity"
title="VSA sparsity (0–1)"
min={0}
max={1}
step={0.05}
value={dmdVsaSparsity}
onChange={setDmdVsaSparsity}
disabled={isSubmitting}
format={(v) => v.toFixed(2)}
/>
)}
<FieldRow
htmlFor="modal-dmd-denoising-steps"
label="DMD Denoising Steps"
title="Comma-separated denoising steps, e.g. 1000,757,522"
>
<Input
id="modal-dmd-denoising-steps"
type="text"
value={dmdDenoisingSteps}
onChange={(e) => setDmdDenoisingSteps(e.target.value)}
placeholder="1000,757,522"
disabled={isSubmitting}
/>
</FieldRow>
<SliderRow
id="modal-real-score-guidance-scale"
label="Real Score Guidance Scale"
min={1}
max={10}
step={0.1}
value={realScoreGuidanceScale}
onChange={setRealScoreGuidanceScale}
disabled={isSubmitting}
format={(v) => v.toFixed(1)}
/>
<SliderRow
id="modal-generator-update-interval"
label="Generator Update Interval"
min={1}
max={20}
step={1}
value={generatorUpdateInterval}
onChange={setGeneratorUpdateInterval}
disabled={isSubmitting}
/>
{(
[
{
id: 'modal-real-score-model',
label: 'Real Score Model',
value: realScoreModelPath,
onChange: setRealScoreModelPath,
},
{
id: 'modal-fake-score-model',
label: 'Fake Score Model',
value: fakeScoreModelPath,
onChange: setFakeScoreModelPath,
},
] as const
).map((select) => (
<FieldRow
key={select.id}
htmlFor={select.id}
label={select.label}
>
<NativeSelect
id={select.id}
value={select.value}
onChange={(e) => select.onChange(e.target.value)}
disabled={isSubmitting || isLoadingModels}
>
<option value="">Same as main model</option>
{models.map((model) => (
<option key={model.id} value={model.id}>
{model.label} ({model.id})
</option>
))}
</NativeSelect>
</FieldRow>
))}
</>
)}
</div>
</details>
</>
)}
{isInference && (
<details>
<summary className="mb-2 cursor-pointer select-none text-sm font-medium text-accent-blue">
Options
</summary>
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-x-3 gap-y-2">
{workloadType !== 't2i' && (
<SliderRow
id="modal-num-frames"
label="Frames"
min={1}
max={500}
step={1}
value={numFrames}
onChange={setNumFrames}
disabled={isSubmitting}
/>
)}
<SliderRow
id="modal-height"
label="Height"
min={64}
max={1080}
step={16}
value={height}
onChange={setHeight}
disabled={isSubmitting}
/>
<SliderRow
id="modal-width"
label="Width"
min={64}
max={1920}
step={16}
value={width}
onChange={setWidth}
disabled={isSubmitting}
/>
<SliderRow
id="modal-num-steps"
label="Inference Steps"
min={1}
max={200}
step={1}
value={numInferenceSteps}
onChange={setNumInferenceSteps}
disabled={isSubmitting}
/>
<SliderRow
id="modal-vsa-sparsity"
label="VSA Sparsity"
title="VSA sparsity (0–1)"
min={0}
max={1}
step={0.05}
value={vsaSparsity}
onChange={setVsaSparsity}
disabled={isSubmitting}
format={(v) => v.toFixed(2)}
/>
<SliderRow
id="modal-guidance"
label="Guidance Scale"
min={0}
max={20}
step={0.1}
value={guidanceScale}
onChange={setGuidanceScale}
disabled={isSubmitting}
format={(v) => v.toFixed(1)}
/>
<SliderRow
id="modal-guidance-rescale"
label="Guidance Rescale"
title="0 = disabled"
min={0}
max={1}
step={0.05}
value={guidanceRescale}
onChange={setGuidanceRescale}
disabled={isSubmitting}
format={(v) => v.toFixed(2)}
/>
<SliderRow
id="modal-tp-size"
label="TP Size"
title="-1 = auto"
min={-1}
max={8}
step={1}
value={tpSize}
onChange={setTpSize}
disabled={isSubmitting}
format={(v) => (v === -1 ? 'Auto' : String(v))}
/>
<SliderRow
id="modal-sp-size"
label="SP Size"
title="-1 = auto"
min={-1}
max={8}
step={1}
value={spSize}
onChange={setSpSize}
disabled={isSubmitting}
format={(v) => (v === -1 ? 'Auto' : String(v))}
/>
{workloadType !== 't2i' && (
<SliderRow
id="modal-fps"
label="FPS"
min={1}
max={60}
step={1}
value={fps}
onChange={setFps}
disabled={isSubmitting}
/>
)}
<ToggleRow
id="modal-dit-cpu-offload"
label="DiT CPU Offload"
checked={ditCpuOffload}
onChange={setDitCpuOffload}
disabled={isSubmitting}
/>
<ToggleRow
id="modal-text-encoder-cpu-offload"
label="Text Encoder CPU Offload"
checked={textEncoderCpuOffload}
onChange={setTextEncoderCpuOffload}
disabled={isSubmitting}
/>
<ToggleRow
id="modal-use-fsdp-inference"
label="Use FSDP Inference"
checked={useFsdpInference}
onChange={setUseFsdpInference}
disabled={isSubmitting}
/>
<ToggleRow
id="modal-vae-cpu-offload"
label="VAE CPU Offload"
checked={vaeCpuOffload}
onChange={setVaeCpuOffload}
disabled={isSubmitting}
/>
<ToggleRow
id="modal-image-encoder-cpu-offload"
label="Image Encoder CPU Offload"
checked={imageEncoderCpuOffload}
onChange={setImageEncoderCpuOffload}
disabled={isSubmitting}
/>
<ToggleRow
id="modal-enable-torch-compile"
label="Torch Compile"
checked={enableTorchCompile}
onChange={setEnableTorchCompile}
disabled={isSubmitting}
/>
<SliderRow
id="modal-num-gpus"
label="GPUs"
min={1}
max={8}
step={1}
value={numGpus}
onChange={setNumGpus}
disabled={isSubmitting}
/>
<NumberRow
id="modal-seed"
label="Seed"
min={0}
value={seed}
onChange={setSeed}
disabled={isSubmitting}
/>
</div>
</details>
)}
<div>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create Job'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,117 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import JobCard from '@/components/jobs/JobCard';
import {
deleteJob,
downloadJobVideo,
startJob,
stopJob,
} from '@/lib/api';
import type { Job } from '@/lib/types';
import { activeJobStore, setActiveJobId } from '@/stores/activeJob';
import { makeJob as makeBaseJob } from '@/test/factories';
vi.mock('@/lib/api', () => ({
startJob: vi.fn(),
stopJob: vi.fn(),
deleteJob: vi.fn(),
downloadJobVideo: vi.fn(),
}));
const makeJob = (overrides: Partial<Job> = {}): Job =>
makeBaseJob({
model_id: 'Wan2.1-T2V',
prompt: 'a cat surfing a wave',
created_at: 1_700_000_000,
num_inference_steps: 50,
num_frames: 81,
height: 480,
width: 832,
guidance_scale: 5,
seed: 42,
num_gpus: 1,
...overrides,
});
beforeEach(() => {
setActiveJobId(null);
vi.mocked(startJob).mockResolvedValue({} as Job);
vi.mocked(stopJob).mockResolvedValue({} as Job);
vi.mocked(deleteJob).mockResolvedValue(undefined);
vi.mocked(downloadJobVideo).mockResolvedValue(new Blob());
vi.spyOn(window, 'confirm').mockReturnValue(true);
vi.spyOn(window, 'alert').mockImplementation(() => {});
});
describe('JobCard', () => {
it('renders the model, prompt, status and inference meta', () => {
render(<JobCard job={makeJob()} />);
expect(screen.getByText('Wan2.1-T2V')).toBeInTheDocument();
expect(screen.getByText('a cat surfing a wave')).toBeInTheDocument();
expect(screen.getByText('pending')).toBeInTheDocument();
expect(screen.getByText('81 frames')).toBeInTheDocument();
expect(screen.getByText('480×832')).toBeInTheDocument();
});
it('shows the workload type (not frames) for non-inference jobs', () => {
render(
<JobCard
job={makeJob({ job_type: 'finetuning', workload_type: 'lora_t2v' })}
/>,
);
expect(screen.getByText('lora t2v')).toBeInTheDocument();
expect(screen.queryByText('81 frames')).not.toBeInTheDocument();
});
it('starts a pending job and notifies the parent', async () => {
const onJobUpdated = vi.fn();
render(<JobCard job={makeJob({ status: 'pending' })} onJobUpdated={onJobUpdated} />);
await userEvent.click(screen.getByRole('button', { name: 'Start' }));
await waitFor(() => expect(startJob).toHaveBeenCalledWith('job-1'));
expect(onJobUpdated).toHaveBeenCalled();
});
it('stops a running job', async () => {
render(<JobCard job={makeJob({ status: 'running', started_at: Date.now() })} />);
await userEvent.click(screen.getByRole('button', { name: 'Stop' }));
await waitFor(() => expect(stopJob).toHaveBeenCalledWith('job-1'));
});
it('restarts a failed job via startJob', async () => {
render(<JobCard job={makeJob({ status: 'failed' })} />);
await userEvent.click(screen.getByRole('button', { name: 'Restart' }));
await waitFor(() => expect(startJob).toHaveBeenCalledWith('job-1'));
});
it('deletes when confirmed and skips when cancelled', async () => {
const { rerender } = render(<JobCard job={makeJob()} />);
await userEvent.click(screen.getByRole('button', { name: 'Delete' }));
await waitFor(() => expect(deleteJob).toHaveBeenCalledWith('job-1'));
vi.mocked(deleteJob).mockClear();
vi.mocked(window.confirm).mockReturnValue(false);
rerender(<JobCard job={makeJob()} />);
await userEvent.click(screen.getByRole('button', { name: 'Delete' }));
expect(deleteJob).not.toHaveBeenCalled();
});
it('downloads the video for a completed inference job', async () => {
render(
<JobCard
job={makeJob({ status: 'completed', output_path: '/out/video.mp4' })}
/>,
);
await userEvent.click(
screen.getByRole('button', { name: 'Download Video' }),
);
await waitFor(() => expect(downloadJobVideo).toHaveBeenCalledWith('job-1'));
});
it('selects the job when the card body is clicked', async () => {
render(<JobCard job={makeJob()} />);
await userEvent.click(screen.getByText('Wan2.1-T2V'));
expect(activeJobStore.get().activeJobId).toBe('job-1');
});
});
@@ -0,0 +1,245 @@
'use client';
import * as React from 'react';
import { Timer } from 'lucide-react';
import { Badge, type BadgeProps } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useStore } from '@/hooks/useStore';
import {
deleteJob,
downloadJobVideo,
startJob,
stopJob,
} from '@/lib/api';
import type { Job } from '@/lib/types';
import { cn, downloadBlob } from '@/lib/utils';
import { activeJobStore, setActiveJobId } from '@/stores/activeJob';
interface JobCardProps {
job: Job;
onJobUpdated?: () => void;
}
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`;
}
function computeElapsed(job: Job, currentTime: number): string | null {
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);
}
const BADGE_VARIANTS: Record<string, BadgeProps['variant']> = {
pending: 'secondary',
running: 'warning',
completed: 'success',
ready: 'success',
failed: 'destructive',
stopped: 'secondary',
preprocessing: 'default',
};
export default function JobCard({ job, onJobUpdated }: JobCardProps) {
const { activeJobId } = useStore(activeJobStore);
const isSelected = activeJobId === job.id;
const [isLoading, setIsLoading] = React.useState(false);
const [currentTime, setCurrentTime] = React.useState(() => Date.now());
const elapsedTime = computeElapsed(job, currentTime);
React.useEffect(() => {
if (job.status !== 'running' || !job.started_at) return;
const interval = setInterval(() => setCurrentTime(Date.now()), 1000);
return () => clearInterval(interval);
}, [job.status, job.started_at]);
async function handleStart(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (isLoading || job.status === 'running' || job.status === 'completed')
return;
setIsLoading(true);
try {
await startJob(job.id);
onJobUpdated?.();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to start job');
} finally {
setIsLoading(false);
}
}
async function handleStop(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (isLoading || job.status !== 'running') return;
setIsLoading(true);
try {
await stopJob(job.id);
onJobUpdated?.();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to stop job');
} finally {
setIsLoading(false);
}
}
async function handleDelete(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (isLoading) return;
if (!confirm('Delete this job?')) return;
setIsLoading(true);
try {
await deleteJob(job.id);
onJobUpdated?.();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to delete job');
} finally {
setIsLoading(false);
}
}
function handleSelectJob(e: React.MouseEvent | React.KeyboardEvent) {
if ((e.target as HTMLElement).closest('button')) return;
setActiveJobId(isSelected ? null : job.id);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelectJob(e);
}
}
async function handleDownloadVideo(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (isLoading || !job.output_path) return;
setIsLoading(true);
try {
const blob = await downloadJobVideo(job.id);
const ext = job.output_path.endsWith('.png') ? 'png' : 'mp4';
downloadBlob(blob, `job_${job.id}.${ext}`);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to download video');
} finally {
setIsLoading(false);
}
}
return (
<div
role="button"
tabIndex={0}
onClick={handleSelectJob}
onKeyDown={handleKeyDown}
className={cn(
'mb-3 flex cursor-pointer flex-col gap-2.5 rounded-lg border bg-background p-4 transition-colors last:mb-0',
isSelected
? 'border-accent-blue bg-accent-blue/5'
: 'border-border hover:border-muted-foreground/40',
)}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-[0.95rem] font-semibold text-foreground">
{job.model_id}
</span>
<Badge variant={BADGE_VARIANTS[job.status] ?? 'secondary'}>
{job.status}
</Badge>
</div>
<p className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-muted-foreground">
{job.prompt}
</p>
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
{job.job_type === 'inference' ? (
<>
<span>{job.num_frames} frames</span>
<span>
{job.height}×{job.width}
</span>
</>
) : (
<span>{job.workload_type?.replace(/_/g, ' ') ?? job.job_type}</span>
)}
{elapsedTime && (
<span className="inline-flex items-center gap-1">
<Timer className="size-3.5" aria-hidden />
{elapsedTime}
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-1.5">
{job.status === 'running' ? (
<Button
size="sm"
onClick={handleStop}
disabled={isLoading}
className="border-transparent bg-amber-500 text-black shadow-md hover:bg-amber-400"
>
Stop
</Button>
) : job.status === 'failed' ? (
<Button
size="sm"
onClick={handleStart}
disabled={isLoading}
className="border-transparent bg-emerald-600 text-white shadow-md hover:bg-emerald-500"
>
Restart
</Button>
) : job.status === 'pending' || job.status === 'stopped' ? (
<Button
size="sm"
onClick={handleStart}
disabled={isLoading}
className="border-transparent bg-emerald-600 text-white shadow-md hover:bg-emerald-500"
>
Start
</Button>
) : null}
{job.status === 'completed' &&
job.output_path &&
job.job_type === 'inference' && (
<Button
size="sm"
variant="outline"
onClick={handleDownloadVideo}
disabled={isLoading}
title="Download video"
>
Download Video
</Button>
)}
<Button
size="sm"
variant="destructive"
onClick={handleDelete}
disabled={isLoading}
>
Delete
</Button>
</div>
</div>
);
}
@@ -0,0 +1,110 @@
import { act, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import JobDetailsSidebar from './JobDetailsSidebar';
import { getJobLogs } from '@/lib/api';
import type { Job } from '@/lib/types';
import { makeJob as makeBaseJob } from '@/test/factories';
vi.mock('@/lib/api', () => ({
getJobLogs: vi.fn(),
downloadJobLog: vi.fn(),
}));
const makeJob = (overrides: Partial<Job> = {}): Job =>
makeBaseJob({
status: 'running',
log_file_path: '/logs/job-1.log',
...overrides,
});
describe('JobDetailsSidebar', () => {
it('renders log lines streamed from the job log poll', async () => {
vi.mocked(getJobLogs).mockResolvedValue({
lines: ['boot sequence started', 'loading model weights'],
total: 2,
progress: 0,
progress_msg: '',
phase: '',
});
render(
<JobDetailsSidebar job={makeJob({ status: 'running' })} onClose={vi.fn()} />,
);
expect(await screen.findByText(/boot sequence started/)).toBeInTheDocument();
expect(screen.getByText(/loading model weights/)).toBeInTheDocument();
expect(getJobLogs).toHaveBeenCalledWith('job-1', 0);
});
it('keeps polling while the job is running', async () => {
vi.useFakeTimers();
try {
vi.mocked(getJobLogs).mockResolvedValue({
lines: [],
total: 0,
progress: 0,
progress_msg: '',
phase: '',
});
render(
<JobDetailsSidebar
job={makeJob({ status: 'running' })}
onClose={vi.fn()}
/>,
);
// Flush the immediate poll fired on mount.
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
const initialCalls = vi.mocked(getJobLogs).mock.calls.length;
// Two 2s interval ticks should fire while the job is running.
await act(async () => {
await vi.advanceTimersByTimeAsync(4000);
});
expect(vi.mocked(getJobLogs).mock.calls.length).toBeGreaterThan(
initialCalls,
);
} finally {
vi.useRealTimers();
}
});
it('stops polling once the job is completed', async () => {
vi.useFakeTimers();
try {
vi.mocked(getJobLogs).mockResolvedValue({
lines: ['final line'],
total: 1,
progress: 0,
progress_msg: '',
phase: '',
});
render(
<JobDetailsSidebar
job={makeJob({ status: 'completed' })}
onClose={vi.fn()}
/>,
);
// The component fetches once on mount even for terminal jobs.
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(getJobLogs).toHaveBeenCalledTimes(1);
// No interval is registered, so advancing the clock must not re-poll.
await act(async () => {
await vi.advanceTimersByTimeAsync(10000);
});
expect(getJobLogs).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
});
@@ -0,0 +1,208 @@
'use client';
import * as React from 'react';
import { X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useResizable } from '@/hooks/useResizable';
import { downloadJobLog, getJobLogs } from '@/lib/api';
import type { Job } from '@/lib/types';
import { cn, downloadBlob } from '@/lib/utils';
const SIDEBAR_MIN_WIDTH = 280;
const SIDEBAR_MAX_WIDTH = 750;
const POLL_INTERVAL_MS = 2000;
export default function JobDetailsSidebar({
job,
onClose,
onWidthChange,
}: {
job: Job;
onClose: () => void;
onWidthChange?: (w: number) => void;
}) {
const [width, setWidth] = React.useState(360);
const [isDragging, setIsDragging] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(false);
const [logs, setLogs] = React.useState('');
// Race-guard ref (mirrors the Svelte original): the cursor + accumulated
// text live here so in-flight polls don't read stale React state. Do NOT
// swap these for effect deps — they must update synchronously, outside
// React's render cycle. The log is one growing string (amortized O(new)
// appends), not an array re-copied and re-joined on every poll tick.
const stateRef = React.useRef<{ text: string; logAfter: number }>({
text: '',
logAfter: 0,
});
const previousJobId = React.useRef<string | null>(null);
const previousStatus = React.useRef<string | null>(null);
const consoleRef = React.useRef<HTMLPreElement | null>(null);
const { onMouseDown } = useResizable({
// Right-docked panel with the drag handle on its left edge: dragging the
// handle left must grow the panel, which is `edge: 'right'` (matches the
// Svelte original). `edge: 'left'` would invert the drag.
edge: 'right',
minWidth: SIDEBAR_MIN_WIDTH,
maxWidth: SIDEBAR_MAX_WIDTH,
getWidth: () => width,
onWidth: setWidth,
onDragChange: setIsDragging,
});
React.useEffect(() => {
onWidthChange?.(width);
}, [width, onWidthChange]);
// Auto-scroll the console to the bottom whenever new lines land. Runs after
// commit so scrollHeight reflects the freshly-rendered output.
React.useEffect(() => {
const el = consoleRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [logs]);
// Reset on job switch or restart. Declared before the polling effect so the
// cursor is cleared before the next poll reads it.
React.useEffect(() => {
const wasTerminal =
previousStatus.current === 'failed' ||
previousStatus.current === 'stopped' ||
previousStatus.current === 'completed';
const isRestarting =
previousJobId.current === job.id &&
wasTerminal &&
(job.status === 'pending' || job.status === 'running');
if (previousJobId.current !== job.id || isRestarting) {
stateRef.current.text = '';
stateRef.current.logAfter = 0;
setLogs('');
}
previousJobId.current = job.id;
previousStatus.current = job.status;
}, [job.id, job.status]);
React.useEffect(() => {
const shouldPoll = job.status === 'running' || job.status === 'pending';
let pollInterval: ReturnType<typeof setInterval> | null = null;
let mounted = true;
// Effect-local lock so this job's first fetch is never blocked by a
// previous job's in-flight poll (stale writes are dropped via `mounted`).
let locked = false;
async function pollLogs() {
if (!mounted || locked) return;
locked = true;
try {
const logData = await getJobLogs(job.id, stateRef.current.logAfter);
if (mounted && logData.lines.length > 0) {
const chunk = logData.lines.join('\n');
stateRef.current.text = stateRef.current.text
? `${stateRef.current.text}\n${chunk}`
: chunk;
stateRef.current.logAfter = logData.total;
setLogs(stateRef.current.text);
}
} catch (e) {
console.error('Failed to fetch logs:', e);
} finally {
locked = false;
}
}
pollLogs();
if (shouldPoll) pollInterval = setInterval(pollLogs, POLL_INTERVAL_MS);
return () => {
mounted = false;
if (pollInterval) clearInterval(pollInterval);
};
}, [job.id, job.status]);
async function handleDownloadLog() {
if (isLoading) return;
setIsLoading(true);
try {
const blob = await downloadJobLog(job.id);
downloadBlob(blob, `job_${job.id}.log`);
} catch (err) {
console.error('Failed to download log:', err);
alert(err instanceof Error ? err.message : 'Failed to download log');
} finally {
setIsLoading(false);
}
}
return (
<aside
className="fixed bottom-0 right-0 top-[var(--header-height)] z-50 flex max-h-[calc(100vh-var(--header-height))] min-w-[280px] shrink-0 flex-col border-l border-border bg-card"
style={{ width, maxWidth: SIDEBAR_MAX_WIDTH }}
>
<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="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleDownloadLog}
disabled={isLoading || !job.log_file_path}
title="Download log file"
>
Download Log
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={onClose}
title="Close"
aria-label="Close"
>
<X className="h-[18px] w-[18px]" />
</Button>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col px-5 py-4">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Console Output
</span>
{job.status === 'running' && (
<span className="text-[0.7rem] font-medium text-emerald-600 dark:text-emerald-400">
● Live
</span>
)}
</div>
<pre
ref={consoleRef}
className="m-0 min-h-0 flex-1 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-border bg-background p-3 font-mono text-xs leading-normal text-foreground"
>
{logs === '' ? (
<span className="italic text-muted-foreground">
{job.status === 'running'
? 'Waiting for logs...'
: 'No logs available'}
</span>
) : (
logs
)}
</pre>
</div>
<div
role="presentation"
onMouseDown={onMouseDown}
className={cn(
'absolute bottom-0 left-0 top-0 z-[1] w-1.5 cursor-col-resize hover:bg-accent-blue/25',
isDragging && 'bg-accent-blue/25',
)}
/>
</aside>
);
}
@@ -0,0 +1,82 @@
import { act, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import JobQueue from '@/components/jobs/JobQueue';
import { getJobsList } from '@/lib/api';
import type { Job, JobType } from '@/lib/types';
import { setActiveJobId } from '@/stores/activeJob';
import { triggerRefresh } from '@/stores/jobsRefresh';
import { makeJob as makeBaseJob } from '@/test/factories';
vi.mock('@/lib/api', () => ({
getJobsList: vi.fn(),
startJob: vi.fn(),
stopJob: vi.fn(),
deleteJob: vi.fn(),
downloadJobVideo: vi.fn(),
}));
const makeJob = (overrides: Partial<Job> = {}): Job =>
makeBaseJob({
model_id: 'Wan2.1-T2V',
status: 'completed',
created_at: 1_700_000_000,
num_inference_steps: 50,
num_frames: 81,
height: 480,
width: 832,
guidance_scale: 5,
seed: 42,
num_gpus: 1,
...overrides,
});
beforeEach(() => {
setActiveJobId(null);
vi.mocked(getJobsList).mockResolvedValue([]);
});
describe('JobQueue', () => {
it('shows an empty placeholder and fetches for the single job type', async () => {
render(<JobQueue jobType="inference" />);
expect(
await screen.findByText('No inference jobs yet. Create one above.'),
).toBeInTheDocument();
expect(getJobsList).toHaveBeenCalledWith('inference');
});
it('renders a JobCard for each fetched job', async () => {
vi.mocked(getJobsList).mockResolvedValue([
makeJob({ id: 'a', model_id: 'Model-A' }),
]);
render(<JobQueue jobType="distillation" />);
expect(await screen.findByText('Model-A')).toBeInTheDocument();
});
it('merges all job types when jobTypesForList is provided', async () => {
vi.mocked(getJobsList).mockImplementation((t?: JobType) =>
Promise.resolve(
t === ('lora' as JobType)
? [makeJob({ id: 'l', model_id: 'Lora-Model', created_at: 2 })]
: [makeJob({ id: 'f', model_id: 'Full-Model', created_at: 1 })],
),
);
render(
<JobQueue
jobType="finetuning"
jobTypesForList={['finetuning', 'lora'] as JobType[]}
/>,
);
expect(await screen.findByText('Lora-Model')).toBeInTheDocument();
expect(screen.getByText('Full-Model')).toBeInTheDocument();
expect(getJobsList).toHaveBeenCalledWith('finetuning');
expect(getJobsList).toHaveBeenCalledWith('lora');
});
it('refetches when a refresh is triggered', async () => {
render(<JobQueue jobType="inference" />);
await waitFor(() => expect(getJobsList).toHaveBeenCalledTimes(1));
act(() => triggerRefresh());
await waitFor(() => expect(getJobsList).toHaveBeenCalledTimes(2));
});
});
@@ -0,0 +1,141 @@
'use client';
import * as React from 'react';
import JobCard from '@/components/jobs/JobCard';
import { useStore } from '@/hooks/useStore';
import { getJobsList } from '@/lib/api';
import type { Job, JobType } from '@/lib/types';
import {
activeJobStore,
setActiveJob,
setActiveJobId,
} from '@/stores/activeJob';
import { jobsRefreshStore } from '@/stores/jobsRefresh';
interface JobQueueProps {
jobType: JobType;
jobTypesForList?: JobType[];
}
// Jobs are flat objects of primitives, so a shallow compare detects "nothing
// changed" across poll responses (which are referentially fresh every fetch).
function jobsShallowEqual(a: Job | null, b: Job | null): boolean {
if (a === b) return true;
if (!a || !b) return false;
const aKeys = Object.keys(a) as (keyof Job)[];
return (
aKeys.length === Object.keys(b).length &&
aKeys.every((k) => a[k] === b[k])
);
}
export default function JobQueue({ jobType, jobTypesForList }: JobQueueProps) {
const [jobs, setJobs] = React.useState<Job[]>([]);
const { nonce } = useStore(jobsRefreshStore);
const { activeJobId } = useStore(activeJobStore);
// Stable primitive key so the memoized list below keeps its identity when an
// inline array prop (e.g. ['finetuning', 'lora']) gets a fresh reference each
// render, which would otherwise re-run the fetch effects needlessly.
const typesKey = (jobTypesForList ?? [jobType]).join(',');
const typesToFetch = React.useMemo<JobType[]>(
() => jobTypesForList ?? [jobType],
// eslint-disable-next-line react-hooks/exhaustive-deps
[typesKey],
);
// Guard the poll against slow responses: `inFlight` lets the interval skip
// a tick instead of stacking requests, and the sequence counter drops
// out-of-order responses so a delayed older payload can't overwrite a newer
// job list (direct refetches always run and supersede in-flight polls).
const fetchSeq = React.useRef(0);
const inFlight = React.useRef(false);
const fetchJobs = React.useCallback(async () => {
const seq = ++fetchSeq.current;
inFlight.current = true;
try {
let next: Job[];
if (typesToFetch.length === 1) {
next = await getJobsList(typesToFetch[0]);
} else {
const results = await Promise.all(
typesToFetch.map((t) => getJobsList(t)),
);
next = results
.flat()
.sort(
(a, b) =>
new Date(b.created_at ?? 0).getTime() -
new Date(a.created_at ?? 0).getTime(),
);
}
if (seq === fetchSeq.current) setJobs(next);
} catch (e) {
console.error('Failed to fetch jobs:', e);
} finally {
if (seq === fetchSeq.current) inFlight.current = false;
}
}, [typesKey]);
// Keep the latest fetchJobs reachable from the polling interval without
// tearing it down/recreating it on every fetchJobs identity change.
const fetchJobsRef = React.useRef(fetchJobs);
React.useEffect(() => {
fetchJobsRef.current = fetchJobs;
}, [fetchJobs]);
// Initial fetch + refetch whenever an external refresh is triggered.
React.useEffect(() => {
fetchJobs();
}, [fetchJobs, nonce]);
// Poll every second while any job is running/pending; stop otherwise.
const hasActive = jobs.some(
(j) => j.status === 'running' || j.status === 'pending',
);
React.useEffect(() => {
if (!hasActive) return;
const interval = setInterval(() => {
if (!inFlight.current) fetchJobsRef.current();
}, 1000);
return () => clearInterval(interval);
}, [hasActive]);
// Keep the active job in sync with the selected id, and void the selection
// if its job disappeared (e.g. deleted). Skip the store write when nothing
// changed — each poll returns fresh objects, and an unconditional write
// would re-render every subscriber (shell, sidebars, cards) once a second.
React.useEffect(() => {
if (!activeJobId) {
if (activeJobStore.get().activeJob) setActiveJob(null);
return;
}
const activeJob = jobs.find((j) => j.id === activeJobId) ?? null;
if (!jobsShallowEqual(activeJob, activeJobStore.get().activeJob)) {
setActiveJob(activeJob);
}
if (!activeJob) setActiveJobId(null);
}, [activeJobId, jobs]);
const multiType = typesToFetch.length > 1;
return (
<main className="mx-auto flex w-full max-w-[850px] flex-col gap-6 px-4 pb-12">
<section className="p-6">
<div>
{jobs.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">
No {multiType ? 'jobs' : `${jobType} jobs`} yet. Create one above.
</p>
) : (
jobs.map((job) => (
<JobCard key={job.id} job={job} onJobUpdated={fetchJobs} />
))
)}
</div>
</section>
</main>
);
}
@@ -0,0 +1,88 @@
'use client';
import * as React from 'react';
import { usePathname } from 'next/navigation';
import DatasetSidebar from '@/components/datasets/DatasetSidebar';
import Header from '@/components/shell/Header';
import { HeaderActionsProvider } from '@/components/shell/HeaderActionsContext';
import PrimarySidebar from '@/components/shell/PrimarySidebar';
import JobDetailsSidebar from '@/components/jobs/JobDetailsSidebar';
import { Toaster } from '@/components/ui/sonner';
import { useStore } from '@/hooks/useStore';
import {
activeDatasetStore,
setActiveDatasetId,
} from '@/stores/activeDataset';
import { activeJobStore, setActiveJobId } from '@/stores/activeJob';
import { initDefaultOptions } from '@/stores/defaultOptions';
const JOB_ROUTES = ['/inference', '/finetuning', '/distillation'];
export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const { activeJob } = useStore(activeJobStore);
const { activeDataset } = useStore(activeDatasetStore);
const [primaryWidth, setPrimaryWidth] = React.useState(220);
const [secondaryWidth, setSecondaryWidth] = React.useState(0);
const jobSidebarOpen = JOB_ROUTES.includes(pathname) && activeJob != null;
const datasetSidebarOpen =
pathname === '/datasets' && activeDataset != null;
const secondaryOpen = jobSidebarOpen || datasetSidebarOpen;
React.useEffect(() => {
initDefaultOptions();
}, []);
React.useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && !document.querySelector('[data-modal]')) {
if (activeJobStore.get().activeJob) setActiveJobId(null);
if (activeDatasetStore.get().activeDataset) setActiveDatasetId(null);
}
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
return (
<HeaderActionsProvider>
<Header />
<div
className="flex overflow-hidden"
style={{
marginTop: 'var(--header-height)',
height: 'calc(100vh - var(--header-height))',
}}
>
<PrimarySidebar onWidthChange={setPrimaryWidth} />
<main
className="flex min-w-0 flex-1 flex-col overflow-auto"
style={{
marginLeft: primaryWidth,
marginRight: secondaryOpen ? secondaryWidth : 0,
}}
>
{children}
</main>
{jobSidebarOpen && activeJob && (
<JobDetailsSidebar
job={activeJob}
onClose={() => setActiveJobId(null)}
onWidthChange={setSecondaryWidth}
/>
)}
{datasetSidebarOpen && activeDataset && (
<DatasetSidebar
dataset={activeDataset}
onClose={() => setActiveDatasetId(null)}
onWidthChange={setSecondaryWidth}
/>
)}
</div>
<Toaster />
</HeaderActionsProvider>
);
}
@@ -0,0 +1,42 @@
'use client';
import { usePathname } from 'next/navigation';
import { useHeaderActions } from '@/components/shell/HeaderActionsContext';
import { ThemeToggle } from '@/components/ui/theme-toggle';
const TAB_TITLES: Record<string, string> = {
'/inference': 'Jobs',
'/finetuning': 'Jobs',
'/distillation': 'Jobs',
'/datasets': 'Datasets',
'/gallery': 'Gallery',
'/gpus': 'GPUs',
'/settings': 'Settings',
};
export default function Header() {
const pathname = usePathname();
const { actions } = useHeaderActions();
const title = TAB_TITLES[pathname] ?? 'FastVideo';
return (
<header className="fixed inset-x-0 top-0 z-[100] flex h-[var(--header-height)] items-center gap-6 border-b border-border bg-background/80 px-6 backdrop-blur">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/logo.svg"
alt="FastVideo Logo"
width={100}
height={42}
className="block h-[42px] w-[100px]"
/>
<h1 className="m-0 flex-1 text-xl font-semibold tracking-tight">
{title}
</h1>
<div className="flex items-center gap-3">
{actions}
<ThemeToggle />
</div>
</header>
);
}
@@ -0,0 +1,52 @@
'use client';
import * as React from 'react';
interface HeaderActionsContextValue {
actions: React.ReactNode;
setActions: (node: React.ReactNode) => void;
}
const HeaderActionsContext =
React.createContext<HeaderActionsContextValue | null>(null);
export function HeaderActionsProvider({
children,
}: {
children: React.ReactNode;
}) {
const [actions, setActions] = React.useState<React.ReactNode>(null);
const value = React.useMemo(() => ({ actions, setActions }), [actions]);
return (
<HeaderActionsContext.Provider value={value}>
{children}
</HeaderActionsContext.Provider>
);
}
export function useHeaderActions(): HeaderActionsContextValue {
const ctx = React.useContext(HeaderActionsContext);
if (!ctx) {
throw new Error(
'useHeaderActions must be used within a HeaderActionsProvider',
);
}
return ctx;
}
/**
* Declaratively publish the current page's header actions: renders nothing,
* registers `children` in the header on mount and clears them on unmount.
* Pages without actions simply don't render it.
*/
export function HeaderActions({ children }: { children: React.ReactNode }) {
const { setActions } = useHeaderActions();
// Mount-only on purpose: pages pass inline JSX, which is referentially new
// every render and would re-register per render if it were a dependency.
React.useEffect(() => {
setActions(children);
return () => setActions(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setActions]);
return null;
}
@@ -0,0 +1,176 @@
'use client';
import * as React from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useResizable } from '@/hooks/useResizable';
import { cn } from '@/lib/utils';
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;
const TAB_BASE =
'block px-5 py-[0.65rem] text-left text-sm text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground';
const TAB_ACTIVE = 'bg-accent-blue/10 font-medium text-accent-blue';
export default function PrimarySidebar({
onWidthChange,
}: {
onWidthChange?: (w: number) => void;
}) {
const pathname = usePathname();
const [width, setWidth] = React.useState(220);
const [isCollapsed, setIsCollapsed] = React.useState(false);
const [isDragging, setIsDragging] = React.useState(false);
const [jobsOpen, setJobsOpen] = React.useState(true);
const effectiveWidth = isCollapsed ? SIDEBAR_COLLAPSED_WIDTH : width;
const layoutWidth = isCollapsed ? SIDEBAR_COLLAPSED_VISIBLE_WIDTH : width;
const isJobsActive = JOB_ROUTES.some((r) => pathname === r.href);
React.useEffect(() => {
onWidthChange?.(layoutWidth);
}, [layoutWidth, onWidthChange]);
React.useEffect(() => {
if (JOB_ROUTES.some((r) => pathname === r.href)) {
setJobsOpen(true);
}
}, [pathname]);
const { onMouseDown } = useResizable({
edge: 'left',
minWidth: SIDEBAR_MIN_WIDTH,
maxWidth: SIDEBAR_MAX_WIDTH,
getWidth: () => width,
onWidth: setWidth,
onDragChange: setIsDragging,
});
return (
<aside
className="fixed bottom-0 left-0 top-[var(--header-height)] z-50 flex max-h-[calc(100vh-var(--header-height))] shrink-0 flex-col border-r border-border bg-card"
style={{ width: effectiveWidth }}
>
{!isCollapsed && (
<nav className="flex flex-col py-2">
<div className="flex flex-col">
<button
type="button"
onClick={() => setJobsOpen((v) => !v)}
aria-expanded={jobsOpen}
aria-haspopup="true"
className={cn(
TAB_BASE,
'flex w-full cursor-pointer items-center justify-between',
isJobsActive && TAB_ACTIVE,
)}
>
<span>Jobs</span>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
className={cn(
'h-4 w-4 shrink-0 opacity-85 transition-transform',
jobsOpen && 'rotate-180',
)}
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{jobsOpen && (
<div className="mb-1 ml-4 flex flex-col border-l-2 border-border pl-2">
{JOB_ROUTES.map((route) => (
<Link
key={route.href}
href={route.href}
className={cn(
TAB_BASE,
'px-4 py-2 text-[0.85rem]',
pathname === route.href && TAB_ACTIVE,
)}
>
{route.label}
</Link>
))}
</div>
)}
</div>
<Link
href="/datasets"
className={cn(TAB_BASE, pathname === '/datasets' && TAB_ACTIVE)}
>
Datasets
</Link>
<Link
href="/gallery"
className={cn(TAB_BASE, pathname === '/gallery' && TAB_ACTIVE)}
>
Gallery
</Link>
<Link
href="/gpus"
className={cn(TAB_BASE, pathname === '/gpus' && TAB_ACTIVE)}
>
GPUs
</Link>
<Link
href="/settings"
className={cn(TAB_BASE, pathname === '/settings' && TAB_ACTIVE)}
>
Settings
</Link>
</nav>
)}
<div
className={cn(
'absolute bottom-0 p-2',
isCollapsed ? '-right-[60px] top-0' : 'right-0',
)}
>
<button
type="button"
onClick={() => setIsCollapsed((v) => !v)}
title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
className={cn(
'flex items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground',
isCollapsed ? 'p-3' : 'p-2',
)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
className="h-[18px] w-[18px]"
>
<path d={isCollapsed ? 'M9 18l6-6-6-6' : 'M15 18l-6-6 6-6'} />
</svg>
</button>
</div>
{!isCollapsed && (
<div
role="presentation"
onMouseDown={onMouseDown}
className={cn(
'absolute bottom-0 right-0 top-0 z-[1] w-1.5 cursor-col-resize hover:bg-accent-blue/25',
isDragging && 'bg-accent-blue/25',
)}
/>
)}
</aside>
);
}
@@ -0,0 +1,76 @@
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import GpuGrid from './GpuGrid';
import { getGpus } from '@/lib/api';
import type { GpuSnapshot } from '@/lib/api';
vi.mock('@/lib/api', () => ({
getGpus: vi.fn(),
}));
const SNAPSHOT: GpuSnapshot = {
available: true,
error: null,
gpus: [
{
index: 0,
name: 'NVIDIA B200',
utilization: 62,
memory_used_mib: 61_440,
memory_total_mib: 183_359,
temperature_c: 41,
power_watts: 312.4,
power_limit_watts: 1000,
},
{
index: 1,
name: 'NVIDIA B200',
utilization: 0,
memory_used_mib: 1_024,
memory_total_mib: 183_359,
temperature_c: null,
power_watts: null,
power_limit_watts: null,
},
],
};
beforeEach(() => {
vi.mocked(getGpus).mockResolvedValue(SNAPSHOT);
});
describe('GpuGrid', () => {
it('renders a card per GPU with utilization and memory', async () => {
render(<GpuGrid />);
expect(await screen.findAllByText('NVIDIA B200')).toHaveLength(2);
expect(screen.getByText('GPU 0')).toBeInTheDocument();
expect(screen.getByText('GPU 1')).toBeInTheDocument();
expect(screen.getByText('62%')).toBeInTheDocument();
expect(screen.getByText('60.0 GiB / 179.1 GiB')).toBeInTheDocument();
// Optional sensors render only when present.
expect(screen.getByText('41°C')).toBeInTheDocument();
expect(screen.getByText('312 W / 1000 W')).toBeInTheDocument();
});
it('shows the backend-reported reason when telemetry is unavailable', async () => {
vi.mocked(getGpus).mockResolvedValue({
available: false,
gpus: [],
error: 'NVML Shared Library Not Found',
});
render(<GpuGrid />);
expect(
await screen.findByText(/GPU telemetry unavailable: NVML/),
).toBeInTheDocument();
});
it('explains when the API server is unreachable', async () => {
vi.mocked(getGpus).mockRejectedValue(new Error('network down'));
render(<GpuGrid />);
expect(
await screen.findByText(/Could not reach the API server/),
).toBeInTheDocument();
});
});
@@ -0,0 +1,164 @@
'use client';
import * as React from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { getGpus, type GpuInfo, type GpuSnapshot } from '@/lib/api';
import { cn } from '@/lib/utils';
const POLL_INTERVAL_MS = 3000;
function formatGib(mib: number): string {
return `${(mib / 1024).toFixed(1)} GiB`;
}
function Meter({
label,
percent,
detail,
warnAt,
}: {
label: string;
percent: number;
detail: string;
/** Turn the fill rose at this percentage (e.g. VRAM pressure). */
warnAt?: number;
}) {
const clamped = Math.max(0, Math.min(100, percent));
return (
<div className="flex flex-col gap-1">
<div className="flex items-baseline justify-between gap-2 text-xs">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums text-foreground">
{detail}
</span>
</div>
<div
role="meter"
aria-label={label}
aria-valuenow={Math.round(clamped)}
aria-valuemin={0}
aria-valuemax={100}
className="h-1.5 overflow-hidden rounded-full bg-muted"
>
<div
className={cn(
'h-full rounded-full bg-accent-blue transition-[width] duration-500',
warnAt !== undefined && clamped >= warnAt && 'bg-rose-500',
)}
style={{ width: `${clamped}%` }}
/>
</div>
</div>
);
}
function GpuCard({ gpu }: { gpu: GpuInfo }) {
const memPercent =
gpu.memory_total_mib > 0
? (gpu.memory_used_mib / gpu.memory_total_mib) * 100
: 0;
return (
<Card>
<CardContent className="flex flex-col gap-4 p-5">
<div className="flex items-baseline justify-between gap-2">
<span className="min-w-0 truncate text-sm font-semibold">
{gpu.name}
</span>
<span className="shrink-0 text-xs font-medium uppercase tracking-wider text-muted-foreground">
GPU {gpu.index}
</span>
</div>
<Meter
label="Utilization"
percent={gpu.utilization}
detail={`${gpu.utilization}%`}
/>
<Meter
label="Memory"
percent={memPercent}
warnAt={90}
detail={`${formatGib(gpu.memory_used_mib)} / ${formatGib(gpu.memory_total_mib)}`}
/>
<div className="flex flex-wrap gap-x-5 gap-y-1 text-xs tabular-nums text-muted-foreground">
{gpu.temperature_c != null && <span>{gpu.temperature_c}°C</span>}
{gpu.power_watts != null && (
<span>
{Math.round(gpu.power_watts)} W
{gpu.power_limit_watts != null &&
` / ${Math.round(gpu.power_limit_watts)} W`}
</span>
)}
</div>
</CardContent>
</Card>
);
}
export default function GpuGrid() {
const [snapshot, setSnapshot] = React.useState<GpuSnapshot | null>(null);
const [fetchError, setFetchError] = React.useState(false);
React.useEffect(() => {
let mounted = true;
let inFlight = false;
async function poll() {
if (inFlight) return;
inFlight = true;
try {
const next = await getGpus();
if (mounted) {
setSnapshot(next);
setFetchError(false);
}
} catch {
if (mounted) setFetchError(true);
} finally {
inFlight = false;
}
}
poll();
const interval = setInterval(poll, POLL_INTERVAL_MS);
return () => {
mounted = false;
clearInterval(interval);
};
}, []);
if (fetchError && !snapshot) {
return (
<p className="py-8 text-center text-muted-foreground">
Could not reach the API server. GPU status needs the studio API server
running.
</p>
);
}
if (!snapshot) {
return <p className="py-8 text-center text-muted-foreground">Loading…</p>;
}
if (!snapshot.available) {
return (
<p className="py-8 text-center text-muted-foreground">
GPU telemetry unavailable
{snapshot.error ? `: ${snapshot.error}` : '.'}
</p>
);
}
return (
<div className="flex flex-col gap-4">
{fetchError && (
<p className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-600 dark:text-amber-400">
Lost contact with the API server — showing the last known values.
</p>
)}
<div className="grid gap-4 [grid-template-columns:repeat(auto-fill,minmax(280px,1fr))]">
{snapshot.gpus.map((gpu) => (
<GpuCard key={gpu.index} gpu={gpu} />
))}
</div>
</div>
);
}
@@ -0,0 +1,39 @@
'use client';
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] transition-colors',
{
variants: {
variant: {
default:
'border-blue-400/35 bg-blue-500/15 text-blue-900 dark:text-blue-100',
secondary: 'border-border bg-secondary text-secondary-foreground',
outline: 'border-border bg-transparent text-muted-foreground',
success:
'border-emerald-400/30 bg-emerald-500/15 text-emerald-800 dark:text-emerald-100',
warning:
'border-amber-400/30 bg-amber-500/15 text-amber-800 dark:text-amber-100',
destructive:
'border-rose-400/30 bg-rose-500/15 text-rose-800 dark:text-rose-100',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

Some files were not shown because too many files have changed in this diff Show More