Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3164e57e1f | ||
|
|
743f4ed5f9 | ||
|
|
1c04ace573 | ||
|
|
6cbff73687 | ||
|
|
dec8b10939 | ||
|
|
133a5278af | ||
|
|
da856274cc |
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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…</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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 +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 @@
|
||||
PUBLIC_API_BASE_URL=http://localhost:8189/api
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:8189/api
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"tabWidth": 4
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,2 +0,0 @@
|
||||
// Minimal ESLint config for SvelteKit (add typescript-eslint / eslint-plugin-svelte as needed)
|
||||
export default [];
|
||||
@@ -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)}
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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;
|
||||
Generated
+4618
-1038
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
/* CSS Variables */
|
||||
:root {
|
||||
--header-height: 74px;
|
||||
--bg: #0f1117;
|
||||
--surface: #1a1d27;
|
||||
--border: #2a2d3a;
|
||||
--text: #e4e4e7;
|
||||
--text-dim: #9ca3af;
|
||||
--accent: #356cff;
|
||||
--accent-h: #818cf8;
|
||||
--green: #22c55e;
|
||||
--red: #ef4444;
|
||||
--yellow: #eab308;
|
||||
--blue: #3b82f6;
|
||||
--radius: 8px;
|
||||
--shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* Reset */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Base body styles */
|
||||
body {
|
||||
font-family:
|
||||
"Inter",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Base form input styles (used globally) */
|
||||
select,
|
||||
input,
|
||||
textarea {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
select:focus,
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/fastvideo.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>FastVideo</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body class="antialiased" data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user