Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f8e84d22e | ||
|
|
698173402f | ||
|
|
627ca97fda | ||
|
|
117302d153 | ||
|
|
ec36f7dfd6 | ||
|
|
61f213403d | ||
|
|
e6481b691c | ||
|
|
5d7b8690e1 | ||
|
|
e43336d31c | ||
|
|
a587f1d3e4 | ||
|
|
14ad388ee3 | ||
|
|
f962352c90 | ||
|
|
8c1170ca06 | ||
|
|
562b1d2361 | ||
|
|
a7c8d8bb79 | ||
|
|
e85c566f2f | ||
|
|
b446a6d9cf | ||
|
|
1d4505509a | ||
|
|
ec93f73983 | ||
|
|
6ac2174602 | ||
|
|
dff6f7757f | ||
|
|
0cd4a64aa3 | ||
|
|
d65406c48f | ||
|
|
842f24d974 | ||
|
|
1b2b2a0161 | ||
|
|
98d65835b5 | ||
|
|
422585d08f | ||
|
|
e59a1ce16a | ||
|
|
d71acc0eb5 | ||
|
|
af2934dd6b | ||
|
|
1801512818 | ||
|
|
5ae05b032e | ||
|
|
7a592ff09a | ||
|
|
8b23984c79 | ||
|
|
bf18371afe | ||
|
|
69349dd2aa | ||
|
|
8d89f30d3f | ||
|
|
10546353da | ||
|
|
9fb74b9732 | ||
|
|
521dee0e82 | ||
|
|
229419208e | ||
|
|
65f3b946b9 | ||
|
|
191fcbf46c | ||
|
|
755a4e4470 | ||
|
|
9709b7513b | ||
|
|
32cd603515 | ||
|
|
d4bdd3621a | ||
|
|
e2f8322842 | ||
|
|
6966f9e0bc | ||
|
|
743f4ed5f9 | ||
|
|
1c04ace573 | ||
|
|
6cbff73687 | ||
|
|
dec8b10939 | ||
|
|
133a5278af | ||
|
|
da856274cc |
@@ -1,6 +1,8 @@
|
||||
env:
|
||||
IMAGE_VERSION: "py3.12-latest"
|
||||
BUILDKITE_CLEAN_CHECKOUT: true
|
||||
# Buildkite only launches Modal; remote jobs initialize their own submodules.
|
||||
BUILDKITE_GIT_SUBMODULES: false
|
||||
|
||||
notify:
|
||||
- github_commit_status:
|
||||
@@ -402,7 +404,7 @@ steps:
|
||||
- "pyproject.toml"
|
||||
- "docker/Dockerfile"
|
||||
config:
|
||||
command: "timeout 15m .buildkite/scripts/pr_test.sh"
|
||||
command: "timeout 25m .buildkite/scripts/pr_test.sh"
|
||||
label: ":test_tube: Training Tests"
|
||||
env:
|
||||
- TEST_TYPE=training
|
||||
@@ -413,7 +415,7 @@ steps:
|
||||
- "pyproject.toml"
|
||||
- "docker/Dockerfile"
|
||||
config:
|
||||
command: "timeout 15m .buildkite/scripts/pr_test.sh"
|
||||
command: "timeout 25m .buildkite/scripts/pr_test.sh"
|
||||
label: ":test_tube: Distillation DMD Tests"
|
||||
env:
|
||||
- TEST_TYPE=distillation_dmd
|
||||
|
||||
@@ -27,14 +27,25 @@ jobs:
|
||||
ref: ${{ inputs.ref || '' }}
|
||||
# For PR events, lint the PR head — but keep the hook definitions from
|
||||
# the base branch so an untrusted PR cannot alter what gets executed.
|
||||
- name: Save trusted hook config
|
||||
# The gate scripts are saved too: the self-test step below executes them,
|
||||
# so it must run the base-branch copies, not the PR head's.
|
||||
- name: Save trusted hook config and gate scripts
|
||||
if: github.event_name == 'pull_request_target'
|
||||
run: cp .pre-commit-config.yaml "$RUNNER_TEMP/trusted-pre-commit-config.yaml"
|
||||
- uses: actions/checkout@v4
|
||||
run: |
|
||||
cp .pre-commit-config.yaml "$RUNNER_TEMP/trusted-pre-commit-config.yaml"
|
||||
cp -a .github/scripts "$RUNNER_TEMP/trusted-scripts"
|
||||
echo "GATE_SCRIPTS_DIR=$RUNNER_TEMP/trusted-scripts" >> "$GITHUB_ENV"
|
||||
# allow-unsafe-pr-checkout acknowledges checkout's pull_request_target
|
||||
# guard: the head is data for the trusted hooks to lint; nothing from it
|
||||
# is executed (config and gate scripts are pinned to the base branch
|
||||
# above) and credentials are not persisted. SHA-pinned to v4.4.0 because
|
||||
# actionlint's action schema does not know the new input yet.
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
if: github.event_name == 'pull_request_target'
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
persist-credentials: false
|
||||
allow-unsafe-pr-checkout: true
|
||||
- name: Restore trusted hook config
|
||||
if: github.event_name == 'pull_request_target'
|
||||
run: cp "$RUNNER_TEMP/trusted-pre-commit-config.yaml" .pre-commit-config.yaml
|
||||
@@ -48,5 +59,7 @@ jobs:
|
||||
with:
|
||||
extra_args: --all-files --hook-stage manual
|
||||
# After pre-commit so a self-test failure cannot mask lint failures.
|
||||
# GATE_SCRIPTS_DIR points at the base-branch copy on fork PRs (set above);
|
||||
# push / workflow_call runs use the checked-out tree directly.
|
||||
- name: Full-suite gate self-test
|
||||
run: bash .github/scripts/test_gate_full_suite.sh
|
||||
run: bash "${GATE_SCRIPTS_DIR:-.github/scripts}/test_gate_full_suite.sh"
|
||||
|
||||
@@ -13,16 +13,23 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
# Auto-rebuild the CUDA images when their Dockerfile changes on main. The CUDA
|
||||
# matrix is the only lane that builds from docker/Dockerfile, so a path-scoped
|
||||
# push trigger is a sufficient change detector on its own -- no separate
|
||||
# detect-changes/paths-filter job is needed now that there is a single
|
||||
# in-scope Dockerfile. Dreamverse (apps/dreamverse/docker/Dockerfile) and the
|
||||
# rocm Dockerfile stay manual-dispatch only.
|
||||
# Auto-rebuild the CUDA images when a repository-controlled image input
|
||||
# changes on main. This includes the trusted SM89 kernel artifact's source,
|
||||
# metadata/key helper, ABI dependency metadata, and build orchestration.
|
||||
# Dreamverse (apps/dreamverse/docker/Dockerfile) and the ROCm Dockerfile stay
|
||||
# manual-dispatch only.
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '.dockerignore'
|
||||
- '.github/workflows/_template-build-image.yml'
|
||||
- '.github/workflows/infra-build-image.yml'
|
||||
- '.gitmodules'
|
||||
- 'docker/Dockerfile'
|
||||
- 'docker/uv-excludes'
|
||||
- 'fastvideo-kernel/**'
|
||||
- 'fastvideo/tests/modal/kernel_build_cache.py'
|
||||
- 'pyproject.toml'
|
||||
|
||||
|
||||
permissions:
|
||||
@@ -50,7 +57,7 @@ jobs:
|
||||
# 2.8.3 comes from the architecture-specific prebuilt releases.
|
||||
build-cuda-images:
|
||||
# Runs on a manual dispatch when build_cuda_matrix is set, or automatically
|
||||
# on a push that changed docker/Dockerfile (inputs are null on push). The
|
||||
# on an in-scope main push (inputs are null on push). The
|
||||
# repository guard keeps fork syncs from auto-building; manual dispatch
|
||||
# still works in forks.
|
||||
if: ${{ (github.event_name == 'push' && github.repository == 'hao-ai-lab/FastVideo') || github.event.inputs.build_cuda_matrix == 'true' }}
|
||||
|
||||
@@ -6,6 +6,7 @@ results/
|
||||
wandb/
|
||||
*.ipynb
|
||||
*.jpg
|
||||
!examples/dataset/lingbotworld2/image.jpg
|
||||
*.safetensors
|
||||
*.mp4
|
||||
*.png
|
||||
@@ -34,6 +35,7 @@ env
|
||||
*.log
|
||||
weights/
|
||||
logs/
|
||||
/Z-Image/
|
||||
official_weights/
|
||||
converted_weights/
|
||||
|
||||
@@ -73,6 +75,7 @@ docs/distillation/examples/
|
||||
*.pkl
|
||||
|
||||
# Reference videos (negations must come after the catch-all on line below)
|
||||
!fastvideo/tests/nightly/reference_video_*.mp4
|
||||
|
||||
# Static images
|
||||
!docs/assets/images/**/*.png
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
**FastVideo is a unified post-training and real-time inference framework for accelerated video generation.**
|
||||
|
||||
## NEWS
|
||||
- `2026/06/23`: Release FastWan-QAD: 5s of Video generated in 1.8s E2E. [FastWan-QAD models](https://huggingface.co/FastVideo/FastWan-QAD-FP8-1.3B), check out the [Blog](https://haoailab.com/blogs/fastwan-qad/).
|
||||
- `2026/06/23`: Release FastWan-QAD: 5s of Video generated in 1.8s E2E. See the [FastWan-QAD models](https://huggingface.co/FastVideo/FastWan-QAD-FP8-1.3B), [Attn-QAT training guide](https://haoailab.com/FastVideo/training/attn_qat/), and [blog](https://haoailab.com/blogs/fastwan-qad/).
|
||||
- `2026/03/17`: Release demo: Into the Dreamverse: Vibe Directing in FastVideo, check out the [Blog](https://haoailab.com/blogs/dreamverse/).
|
||||
- `2026/03/13`: Release demo: Create a 5s 1080p Video in 4.5s with FastVideo on a Single GPU, check out the [Blog](https://haoailab.com/blogs/fastvideo_realtime_1080p/).
|
||||
- `2025/11/19`: Release [CausalWan2.2 I2V A14B Preview](https://huggingface.co/FastVideo/CausalWan2.2-I2V-A14B-Preview-Diffusers) models, [Blog](https://hao-ai-lab.github.io/blogs/fastvideo_causalwan_preview/) and [Inference Code!](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_self_forcing_causal_wan2_2_i2v.py).
|
||||
|
||||
@@ -39,6 +39,17 @@ export FASTVIDEO_GENERATION_SEGMENT_CAP="${FASTVIDEO_GENERATION_SEGMENT_CAP:-6}"
|
||||
export FASTVIDEO_PROMPT_AUTO_SLEEP_MS="${FASTVIDEO_PROMPT_AUTO_SLEEP_MS:-120}"
|
||||
export FASTVIDEO_PROMPT_AUTO_TIMEOUT_MS="${FASTVIDEO_PROMPT_AUTO_TIMEOUT_MS:-1800}"
|
||||
|
||||
if [[ "${ENABLE_TORCH_COMPILE}" == "1" ]]; then
|
||||
# Persist Inductor, AOTAutograd, and Triton artifacts across launches.
|
||||
export DREAMVERSE_TORCH_COMPILE_CACHE_ROOT="${DREAMVERSE_TORCH_COMPILE_CACHE_ROOT:-${HOME}/.cache/dreamverse/torch_compile}"
|
||||
export TORCHINDUCTOR_CACHE_DIR="${TORCHINDUCTOR_CACHE_DIR:-${DREAMVERSE_TORCH_COMPILE_CACHE_ROOT}/inductor}"
|
||||
export TRITON_CACHE_DIR="${TRITON_CACHE_DIR:-${DREAMVERSE_TORCH_COMPILE_CACHE_ROOT}/triton}"
|
||||
export TORCHINDUCTOR_FX_GRAPH_CACHE="${TORCHINDUCTOR_FX_GRAPH_CACHE:-1}"
|
||||
export TORCHINDUCTOR_AUTOGRAD_CACHE="${TORCHINDUCTOR_AUTOGRAD_CACHE:-1}"
|
||||
mkdir -p "${TORCHINDUCTOR_CACHE_DIR}" "${TRITON_CACHE_DIR}"
|
||||
echo "[launch-demo] torch.compile cache: ${DREAMVERSE_TORCH_COMPILE_CACHE_ROOT}"
|
||||
fi
|
||||
|
||||
cd "${DREAMVERSE_ROOT}"
|
||||
|
||||
if ! command -v dreamverse-server >/dev/null 2>&1; then
|
||||
|
||||
@@ -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,42 @@
|
||||
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 trigger opens a real menu on click, so this path works for touch,
|
||||
// mouse, and keyboard users.
|
||||
await page.getByRole('button', { name: /create job/i }).click();
|
||||
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,44 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { API_BASE, skipWithoutMock } from './helpers';
|
||||
|
||||
/**
|
||||
* Gallery page: the seeded completed inference job surfaces as a media tile
|
||||
* with playback controls or an explicit media-error fallback.
|
||||
*/
|
||||
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();
|
||||
|
||||
const tile = page.locator('article').filter({ hasText: completed!.prompt });
|
||||
await expect(tile).toBeVisible();
|
||||
await expect(
|
||||
tile.locator('video').or(tile.getByText('Preview unavailable')),
|
||||
).toBeVisible();
|
||||
|
||||
const video = tile.locator('video');
|
||||
if (await video.isVisible()) {
|
||||
await expect(video).toHaveAttribute('controls', '');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,115 @@
|
||||
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();
|
||||
await expect(page.getByRole('main')).toHaveCount(1);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps navigation and content usable at responsive breakpoints', async ({
|
||||
page,
|
||||
}) => {
|
||||
for (const width of [320, 375, 414, 768]) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
await page.goto('/inference');
|
||||
|
||||
const main = page.getByRole('main');
|
||||
await expect(main).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Create Job/i }),
|
||||
).toBeVisible();
|
||||
|
||||
const initialBox = await main.boundingBox();
|
||||
expect(initialBox?.x).toBe(width < 768 ? 0 : 220);
|
||||
expect(initialBox?.width).toBe(width < 768 ? width : width - 220);
|
||||
|
||||
const navigation = page.getByRole('navigation', {
|
||||
name: 'Primary navigation',
|
||||
});
|
||||
if (width < 768) {
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Open navigation' }),
|
||||
).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Open navigation' }).click();
|
||||
}
|
||||
await expect(navigation).toBeVisible();
|
||||
await navigation.getByRole('link', { name: 'Datasets' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/datasets$/);
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('uses full-width detail drawers on mobile', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 320, height: 800 });
|
||||
await page.goto('/inference');
|
||||
|
||||
await page
|
||||
.locator('article button[aria-pressed="false"]')
|
||||
.first()
|
||||
.click();
|
||||
const jobDrawer = page.getByRole('dialog', { name: 'Job details' });
|
||||
await expect(jobDrawer).toBeVisible();
|
||||
expect(await jobDrawer.boundingBox()).toMatchObject({ x: 0, width: 320 });
|
||||
await jobDrawer.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
await page.goto('/datasets');
|
||||
await page
|
||||
.locator('article button[aria-pressed="false"]')
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const datasetDrawer = page.getByRole('dialog', {
|
||||
name: /dataset details$/,
|
||||
});
|
||||
await expect(datasetDrawer).toBeVisible();
|
||||
expect(await datasetDrawer.boundingBox()).toMatchObject({
|
||||
x: 0,
|
||||
width: 320,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
+5299
-1038
File diff suppressed because it is too large
Load Diff
@@ -4,33 +4,55 @@
|
||||
"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-dropdown-menu": "^2.1.24",
|
||||
"@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,64 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { HeaderActionsProvider } from '@/components/shell/HeaderActionsContext';
|
||||
import { getDatasets, type Dataset } from '@/lib/api';
|
||||
|
||||
import DatasetsPage from './page';
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
getDatasets: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/datasets/AddDatasetButton', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/datasets/CreateDatasetModal', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/datasets/DatasetCard', () => ({
|
||||
default: ({ dataset }: { dataset: Dataset }) => <div>{dataset.name}</div>,
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<HeaderActionsProvider>
|
||||
<DatasetsPage />
|
||||
</HeaderActionsProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('DatasetsPage', () => {
|
||||
it('shows loading content before the initial request settles', async () => {
|
||||
let resolveDatasets: (datasets: Dataset[]) => void = () => {};
|
||||
vi.mocked(getDatasets).mockReturnValue(
|
||||
new Promise<Dataset[]>((resolve) => {
|
||||
resolveDatasets = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
renderPage();
|
||||
expect(screen.getByLabelText('Loading datasets')).toBeInTheDocument();
|
||||
expect(screen.queryByText('No datasets yet.')).not.toBeInTheDocument();
|
||||
|
||||
act(() => resolveDatasets([]));
|
||||
expect(await screen.findByText('No datasets yet.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows API failures separately from an empty list and retries', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.mocked(getDatasets).mockRejectedValueOnce(new Error('network down'));
|
||||
|
||||
renderPage();
|
||||
expect(
|
||||
await screen.findByText(/Could not load datasets from the Studio API/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('No datasets yet.')).not.toBeInTheDocument();
|
||||
|
||||
vi.mocked(getDatasets).mockResolvedValueOnce([]);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Try Again' }));
|
||||
expect(await screen.findByText('No datasets yet.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { AlertTriangle } from 'lucide-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 { Button } from '@/components/ui/button';
|
||||
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 [isInitialLoading, setIsInitialLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const { open } = useStore(createDatasetModalStore);
|
||||
const fetchSequence = React.useRef(0);
|
||||
|
||||
const fetchDatasets = React.useCallback(async () => {
|
||||
const sequence = ++fetchSequence.current;
|
||||
try {
|
||||
const next = await getDatasets();
|
||||
if (sequence === fetchSequence.current) {
|
||||
setDatasets(next);
|
||||
setError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch datasets:', err);
|
||||
if (sequence === fetchSequence.current) {
|
||||
setError(
|
||||
'Could not load datasets from the Studio API. Check the server and try again.',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (sequence === fetchSequence.current) setIsInitialLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchDatasets();
|
||||
}, [fetchDatasets]);
|
||||
|
||||
function handleSelectDataset(ds: Dataset) {
|
||||
setActiveDataset(ds);
|
||||
setActiveDatasetId(ds.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderActions>
|
||||
<AddDatasetButton />
|
||||
</HeaderActions>
|
||||
<div className="mx-auto flex w-full max-w-[850px] flex-col gap-6 px-4 pb-12">
|
||||
<Card className="p-6">
|
||||
<div aria-busy={isInitialLoading}>
|
||||
{isInitialLoading ? (
|
||||
<div
|
||||
aria-label="Loading datasets"
|
||||
className="flex flex-col gap-3 py-2"
|
||||
>
|
||||
{[0, 1, 2].map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="h-24 animate-pulse rounded-lg border border-border bg-muted/50"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : error && datasets.length === 0 ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col items-center gap-3 py-8 text-center"
|
||||
>
|
||||
<AlertTriangle
|
||||
className="size-6 text-destructive"
|
||||
aria-hidden
|
||||
/>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
{error}
|
||||
</p>
|
||||
<Button type="button" variant="outline" onClick={fetchDatasets}>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{error && (
|
||||
<p
|
||||
role="status"
|
||||
className="mb-3 rounded-lg border border-amber-500/50 bg-amber-500/10 px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
Dataset updates are temporarily unavailable. Showing the
|
||||
most recent results.
|
||||
</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>
|
||||
</div>
|
||||
<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,76 @@
|
||||
import { fireEvent, 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('provides video controls and a visible fallback when media fails', async () => {
|
||||
vi.mocked(getJobsList).mockResolvedValue([makeJob()]);
|
||||
renderGallery();
|
||||
|
||||
const video = await screen.findByLabelText(
|
||||
'Generated video: a cat surfing a wave',
|
||||
);
|
||||
expect(video).toHaveAttribute('controls');
|
||||
|
||||
fireEvent.error(video);
|
||||
expect(screen.getByText('Preview unavailable')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('The generated file could not be loaded.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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,143 @@
|
||||
'use client';
|
||||
|
||||
import { ImageOff, 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;
|
||||
}
|
||||
|
||||
function GalleryMedia({ job }: { job: Job }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center text-muted-foreground"
|
||||
>
|
||||
<ImageOff className="size-7" aria-hidden />
|
||||
<span className="text-sm font-medium">Preview unavailable</span>
|
||||
<span className="text-xs">
|
||||
The generated file could not be loaded.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isImage(job)) {
|
||||
return (
|
||||
// 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"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<video
|
||||
src={getJobVideoUrl(job.id)}
|
||||
aria-label={
|
||||
job.prompt ? `Generated video: ${job.prompt}` : 'Generated video'
|
||||
}
|
||||
className="block h-full w-full object-contain"
|
||||
controls
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<GalleryMedia job={job} />
|
||||
</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,238 @@
|
||||
@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: #1d4ed8;
|
||||
|
||||
--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: #7dd3fc;
|
||||
}
|
||||
|
||||
@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%;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
:where(
|
||||
a,
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
summary,
|
||||
[role="button"],
|
||||
[role="menuitem"],
|
||||
[role="slider"],
|
||||
[tabindex]
|
||||
):focus-visible {
|
||||
outline: 3px solid var(--ring) !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
|
||||
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,55 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const css = readFileSync(join(process.cwd(), 'src/app/globals.css'), 'utf8');
|
||||
|
||||
function token(block: string, name: string): string {
|
||||
const match = block.match(new RegExp(`--${name}:\\s*(#[0-9a-fA-F]{6})`));
|
||||
if (!match) throw new Error(`Missing --${name} token`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function luminance(hex: string): number {
|
||||
const channels = hex
|
||||
.slice(1)
|
||||
.match(/.{2}/g)!
|
||||
.map((channel) => parseInt(channel, 16) / 255)
|
||||
.map((channel) =>
|
||||
channel <= 0.04045
|
||||
? channel / 12.92
|
||||
: ((channel + 0.055) / 1.055) ** 2.4,
|
||||
);
|
||||
return (
|
||||
0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]
|
||||
);
|
||||
}
|
||||
|
||||
function contrast(first: string, second: string): number {
|
||||
const firstLuminance = luminance(first);
|
||||
const secondLuminance = luminance(second);
|
||||
return (
|
||||
(Math.max(firstLuminance, secondLuminance) + 0.05) /
|
||||
(Math.min(firstLuminance, secondLuminance) + 0.05)
|
||||
);
|
||||
}
|
||||
|
||||
describe('global focus styles', () => {
|
||||
it('keeps focus tokens above 3:1 against both page themes', () => {
|
||||
const light = css.match(/:root\s*{([\s\S]*?)\n}/)?.[1] ?? '';
|
||||
const dark = css.match(/\.dark\s*{([\s\S]*?)\n}/)?.[1] ?? '';
|
||||
|
||||
expect(contrast(token(light, 'ring'), token(light, 'background'))).toBeGreaterThanOrEqual(
|
||||
3,
|
||||
);
|
||||
expect(contrast(token(dark, 'ring'), token(dark, 'background'))).toBeGreaterThanOrEqual(
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
it('applies a non-animated three-pixel outline to focus-visible controls', () => {
|
||||
expect(css).toContain('):focus-visible {');
|
||||
expect(css).toContain('outline: 3px solid var(--ring) !important;');
|
||||
expect(css).toContain('outline-offset: 2px !important;');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import GpuGrid from '@/components/system/GpuGrid';
|
||||
|
||||
export default function GpusPage() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[1100px] flex-col gap-6 px-4 pb-12 pt-6">
|
||||
<GpuGrid />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,74 @@
|
||||
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('gives every slider an accessible name', () => {
|
||||
renderPage();
|
||||
|
||||
const sliders = screen.getAllByRole('slider');
|
||||
expect(sliders).toHaveLength(11);
|
||||
for (const slider of sliders) {
|
||||
expect(slider).toHaveAccessibleName();
|
||||
}
|
||||
expect(screen.getByRole('slider', { name: 'Frames' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('slider', { name: 'Guidance Scale' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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,119 @@
|
||||
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('keeps selection and delete buttons as semantic siblings', () => {
|
||||
render(<DatasetCard dataset={dataset} onUpdated={() => {}} />);
|
||||
|
||||
const selectButton = screen.getByRole('button', { pressed: false });
|
||||
const deleteButton = screen.getByRole('button', { name: 'Delete' });
|
||||
|
||||
expect(selectButton).toHaveTextContent('My Dataset');
|
||||
expect(selectButton).not.toContainElement(deleteButton);
|
||||
});
|
||||
|
||||
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('keeps the selection and delete actions separate', () => {
|
||||
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 dedicated selection button selects the dataset.
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: /My Dataset.*3 files.*2.0 KB/,
|
||||
}),
|
||||
);
|
||||
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,87 @@
|
||||
'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);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
'mb-3 flex items-start gap-3 rounded-lg border border-border bg-background px-[1.15rem] py-4',
|
||||
isSelected && 'border-accent-blue bg-accent-blue/5',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={isSelected}
|
||||
onClick={onSelect}
|
||||
className="flex min-w-0 flex-1 cursor-pointer flex-col gap-[0.6rem] rounded-md text-left"
|
||||
>
|
||||
<span className="text-[0.95rem] font-semibold">{dataset.name}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{fileCount} {fileCount === 1 ? 'file' : 'files'} · {sizeLabel}
|
||||
</span>
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
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');
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { error: vi.fn() },
|
||||
}));
|
||||
|
||||
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('fills the mobile viewport without reserving main-content width', async () => {
|
||||
const onWidthChange = vi.fn();
|
||||
|
||||
render(
|
||||
<DatasetSidebar
|
||||
dataset={dataset}
|
||||
isMobile
|
||||
onClose={() => {}}
|
||||
onWidthChange={onWidthChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const drawer = screen.getByRole('dialog', {
|
||||
name: 'My Dataset dataset details',
|
||||
});
|
||||
expect(drawer).toHaveStyle({ width: '100%', maxWidth: 'none' });
|
||||
expect(drawer).toHaveAttribute('aria-modal', 'true');
|
||||
expect(drawer).toHaveFocus();
|
||||
expect(onWidthChange).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
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('shows a fallback when a dataset preview cannot load', async () => {
|
||||
render(<DatasetSidebar dataset={dataset} onClose={() => {}} />);
|
||||
|
||||
const preview = await screen.findByLabelText('Preview of a.mp4');
|
||||
fireEvent.error(preview);
|
||||
|
||||
expect(screen.getByText('Preview unavailable')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Preview of a.mp4')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
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('shows a failed save and lets the user retry it', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockedApi.updateDatasetCaption
|
||||
.mockRejectedValueOnce(new Error('network down'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
render(<DatasetSidebar dataset={dataset} onClose={() => {}} />);
|
||||
const textarea = await screen.findByDisplayValue('cap a');
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
fireEvent.change(textarea, { target: { value: 'needs retry' } });
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Not saved/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockedApi.updateDatasetCaption).toHaveBeenCalledTimes(2);
|
||||
expect(mockedApi.updateDatasetCaption).toHaveBeenLastCalledWith(
|
||||
'ds-1',
|
||||
'a.mp4',
|
||||
'needs retry',
|
||||
);
|
||||
expect(screen.getByText('Saved')).toBeInTheDocument();
|
||||
} 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,347 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ImageOff, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
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';
|
||||
import { useDrawerFocus } from '@/hooks/useDrawerFocus';
|
||||
|
||||
const SIDEBAR_MIN_WIDTH = 320;
|
||||
const SIDEBAR_MAX_WIDTH = 900;
|
||||
const INITIAL_PAGE_SIZE = 24;
|
||||
const PAGE_SIZE = 24;
|
||||
const SCROLL_THRESHOLD = 200;
|
||||
type CaptionSaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
|
||||
// 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,
|
||||
saveState,
|
||||
onCaptionChange,
|
||||
onCaptionRetry,
|
||||
onThumbLoaded,
|
||||
}: {
|
||||
fileName: string;
|
||||
mediaUrl: string;
|
||||
caption: string;
|
||||
thumbLoaded: boolean;
|
||||
saveState: CaptionSaveState;
|
||||
onCaptionChange: (fileName: string, value: string) => void;
|
||||
onCaptionRetry: (fileName: string, value: string) => void;
|
||||
onThumbLoaded: (fileName: string) => void;
|
||||
}) {
|
||||
const [mediaFailed, setMediaFailed] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMediaFailed(false);
|
||||
}, [mediaUrl]);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col overflow-hidden rounded-lg border border-border bg-background">
|
||||
{!thumbLoaded && !mediaFailed && (
|
||||
<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>
|
||||
)}
|
||||
{mediaFailed ? (
|
||||
<div
|
||||
role="status"
|
||||
className="flex aspect-video w-full flex-col items-center justify-center gap-1 bg-muted px-2 text-center text-muted-foreground"
|
||||
>
|
||||
<ImageOff className="size-5" aria-hidden />
|
||||
<span className="text-xs">Preview unavailable</span>
|
||||
</div>
|
||||
) : (
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<video
|
||||
src={mediaUrl}
|
||||
aria-label={`Preview of ${fileName}`}
|
||||
className="aspect-video w-full bg-border object-cover"
|
||||
muted
|
||||
autoPlay
|
||||
loop
|
||||
playsInline
|
||||
onLoadedData={() => onThumbLoaded(fileName)}
|
||||
onError={() => {
|
||||
setMediaFailed(true);
|
||||
onThumbLoaded(fileName);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Textarea
|
||||
aria-label={`Caption for ${fileName}`}
|
||||
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
|
||||
aria-live="polite"
|
||||
className="flex min-h-6 items-center px-1.5 pb-1 text-[0.7rem] text-muted-foreground"
|
||||
>
|
||||
{saveState === 'saving' && <span>Saving…</span>}
|
||||
{saveState === 'saved' && <span>Saved</span>}
|
||||
{saveState === 'error' && (
|
||||
<span role="alert" className="text-destructive">
|
||||
Not saved.{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCaptionRetry(fileName, caption)}
|
||||
className="inline-flex min-h-11 items-center font-medium underline underline-offset-2"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default function DatasetSidebar({
|
||||
dataset,
|
||||
isMobile = false,
|
||||
onClose,
|
||||
onWidthChange,
|
||||
}: {
|
||||
dataset: Dataset;
|
||||
isMobile?: boolean;
|
||||
onClose: () => void;
|
||||
onWidthChange?: (w: number) => void;
|
||||
}) {
|
||||
const drawerRef = useDrawerFocus<HTMLElement>(isMobile);
|
||||
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>
|
||||
>({});
|
||||
const [captionSaveStates, setCaptionSaveStates] = React.useState<
|
||||
Record<string, CaptionSaveState>
|
||||
>({});
|
||||
|
||||
// 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 captionVersions = React.useRef(new Map<string, number>());
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
onWidthChange?.(isMobile ? 0 : width);
|
||||
}, [isMobile, 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({});
|
||||
setCaptionSaveStates({});
|
||||
captionVersions.current.clear();
|
||||
})
|
||||
.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 persistCaption = React.useCallback(
|
||||
(fileName: string, value: string, version: number) => {
|
||||
setCaptionSaveStates((prev) => ({ ...prev, [fileName]: 'saving' }));
|
||||
void updateDatasetCaption(datasetId, fileName, value)
|
||||
.then(() => {
|
||||
if (captionVersions.current.get(fileName) !== version) return;
|
||||
setCaptionSaveStates((prev) => ({ ...prev, [fileName]: 'saved' }));
|
||||
})
|
||||
.catch((error) => {
|
||||
if (captionVersions.current.get(fileName) !== version) return;
|
||||
console.error('Failed to save caption:', error);
|
||||
setCaptionSaveStates((prev) => ({ ...prev, [fileName]: 'error' }));
|
||||
toast.error('Caption was not saved', {
|
||||
description: `${fileName}: check the Studio API, then retry.`,
|
||||
});
|
||||
});
|
||||
},
|
||||
[datasetId],
|
||||
);
|
||||
|
||||
const handleCaptionChange = React.useCallback(
|
||||
(fileName: string, value: string) => {
|
||||
setCaptions((prev) => ({ ...prev, [fileName]: value }));
|
||||
setCaptionSaveStates((prev) => ({ ...prev, [fileName]: 'idle' }));
|
||||
const pending = pendingSaves.current.get(fileName);
|
||||
if (pending) clearTimeout(pending.timer);
|
||||
const version = (captionVersions.current.get(fileName) ?? 0) + 1;
|
||||
captionVersions.current.set(fileName, version);
|
||||
const save = () => persistCaption(fileName, value, version);
|
||||
const timer = setTimeout(() => {
|
||||
pendingSaves.current.delete(fileName);
|
||||
save();
|
||||
}, 500);
|
||||
pendingSaves.current.set(fileName, { timer, save });
|
||||
},
|
||||
[persistCaption],
|
||||
);
|
||||
|
||||
const handleCaptionRetry = React.useCallback(
|
||||
(fileName: string, value: string) => {
|
||||
const version = (captionVersions.current.get(fileName) ?? 0) + 1;
|
||||
captionVersions.current.set(fileName, version);
|
||||
persistCaption(fileName, value, version);
|
||||
},
|
||||
[persistCaption],
|
||||
);
|
||||
|
||||
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
|
||||
ref={drawerRef}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-label={`${dataset.name} dataset details`}
|
||||
aria-modal={isMobile || undefined}
|
||||
className="fixed bottom-0 right-0 top-[var(--header-height)] z-50 flex max-h-[calc(100dvh-var(--header-height))] min-w-0 shrink-0 flex-col border-l border-border bg-card md:min-w-[320px]"
|
||||
style={{
|
||||
width: isMobile ? '100%' : width,
|
||||
maxWidth: isMobile ? 'none' : 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 size-11 items-center justify-center rounded-lg 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]}
|
||||
saveState={captionSaveStates[fileName] ?? 'idle'}
|
||||
onCaptionChange={handleCaptionChange}
|
||||
onCaptionRetry={handleCaptionRetry}
|
||||
onThumbLoaded={markThumbLoaded}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isMobile && <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,50 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { downloadBlob } from '@/lib/utils';
|
||||
|
||||
import DownloadCaptions from './DownloadCaptions';
|
||||
|
||||
vi.mock('@/lib/utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/utils')>();
|
||||
return { ...actual, downloadBlob: vi.fn() };
|
||||
});
|
||||
|
||||
const props = {
|
||||
fileNames: ['b.mp4', 'a.mp4'],
|
||||
captions: { 'a.mp4': 'cap a', 'b.mp4': 'cap b' },
|
||||
};
|
||||
|
||||
describe('DownloadCaptions', () => {
|
||||
it('opens the format menu on click and downloads the selection', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DownloadCaptions {...props} />);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Download Captions' }),
|
||||
);
|
||||
await user.click(screen.getByRole('menuitem', { name: 'JSON' }));
|
||||
|
||||
expect(vi.mocked(downloadBlob)).toHaveBeenCalledWith(
|
||||
expect.any(Blob),
|
||||
'videos2caption.json',
|
||||
);
|
||||
});
|
||||
|
||||
it('operates entirely from the keyboard', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DownloadCaptions {...props} />);
|
||||
|
||||
screen.getByRole('button', { name: 'Download Captions' }).focus();
|
||||
await user.keyboard('{Enter}');
|
||||
const first = await screen.findByRole('menuitem', { name: 'JSON' });
|
||||
expect(first).toHaveFocus();
|
||||
await user.keyboard('{ArrowDown}{Enter}');
|
||||
|
||||
expect(vi.mocked(downloadBlob)).toHaveBeenCalledWith(
|
||||
expect.any(Blob),
|
||||
'videos.txt',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { downloadBlob } from '@/lib/utils';
|
||||
|
||||
const MENU_ITEM =
|
||||
'block min-h-11 w-full cursor-pointer select-none px-4 py-2 text-left text-sm font-medium text-foreground outline-none transition-colors data-[highlighted]:bg-muted';
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
// Same click/keyboard-accessible menu idiom as CreateJobButton — hover-only
|
||||
// menus exclude touch and keyboard users.
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className="gap-1.5"
|
||||
>
|
||||
Download Captions
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-85" aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
collisionPadding={8}
|
||||
className="z-[200] min-w-40 overflow-hidden rounded-lg border border-border bg-popover py-1 text-popover-foreground shadow-lg"
|
||||
>
|
||||
<DropdownMenu.Item className={MENU_ITEM} onSelect={handleDownloadJson}>
|
||||
JSON
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item className={MENU_ITEM} onSelect={handleDownloadTxt}>
|
||||
TXT
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item className={MENU_ITEM} onSelect={handleDownloadCsv}>
|
||||
CSV
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
}
|
||||
@@ -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,53 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import CreateJobButton from './CreateJobButton';
|
||||
|
||||
vi.mock('./CreateJobModal', () => ({
|
||||
default: ({
|
||||
isOpen,
|
||||
workloadType,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
workloadType: string;
|
||||
}) =>
|
||||
isOpen ? (
|
||||
<div role="dialog" data-workload-type={workloadType}>
|
||||
Create job form
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
describe('CreateJobButton', () => {
|
||||
it('opens the workload menu on click and selects an item', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CreateJobButton jobType="inference" />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create Job' }));
|
||||
await user.click(screen.getByRole('menuitem', { name: /I2V/i }));
|
||||
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute(
|
||||
'data-workload-type',
|
||||
'i2v',
|
||||
);
|
||||
});
|
||||
|
||||
it('opens and operates the workload menu from the keyboard', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CreateJobButton jobType="inference" />);
|
||||
|
||||
const trigger = screen.getByRole('button', { name: 'Create Job' });
|
||||
trigger.focus();
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
const firstItem = await screen.findByRole('menuitem', { name: /T2V/i });
|
||||
expect(firstItem).toHaveFocus();
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute(
|
||||
'data-workload-type',
|
||||
't2v',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
|
||||
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 (
|
||||
<>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<Button type="button" className="gap-1.5">
|
||||
Create Job
|
||||
<ChevronDown className="size-3.5 opacity-85" aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
collisionPadding={8}
|
||||
className="z-[200] min-w-48 overflow-hidden rounded-lg border border-border bg-popover py-1 text-popover-foreground shadow-lg"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<DropdownMenu.Item
|
||||
key={opt.type}
|
||||
onSelect={() => openModal(opt.type)}
|
||||
className="flex min-h-11 cursor-pointer select-none flex-col justify-center px-4 py-2 text-left text-sm font-medium outline-none data-[highlighted]:bg-secondary"
|
||||
>
|
||||
{opt.label}
|
||||
<span className="mt-0.5 block text-xs font-normal text-muted-foreground">
|
||||
{opt.desc}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
<CreateJobModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSuccess={handleSuccess}
|
||||
jobType={jobType}
|
||||
workloadType={workloadType}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
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('shows a model loading error instead of an empty model list', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.mocked(getModels).mockRejectedValueOnce(new Error('network down'));
|
||||
|
||||
renderModal();
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Models could not be loaded/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Model')).toHaveAttribute(
|
||||
'aria-invalid',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the form open and reports job creation failures', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.mocked(createJob).mockRejectedValueOnce(new Error('API rejected job'));
|
||||
const user = userEvent.setup();
|
||||
const { onClose, onSuccess } = renderModal();
|
||||
|
||||
await screen.findByRole('option', { name: 'Wan T2V (wan/t2v-1.3b)' });
|
||||
await user.type(screen.getByLabelText('Prompt'), 'a careful test prompt');
|
||||
await user.click(screen.getByRole('button', { name: 'Create Job' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/API rejected job.*then try again/),
|
||||
).toBeInTheDocument();
|
||||
expect(onSuccess).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports image upload failures next to the file input', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.mocked(uploadImage).mockRejectedValueOnce(new Error('Upload failed'));
|
||||
const user = userEvent.setup();
|
||||
renderModal({ workloadType: 'i2v' });
|
||||
|
||||
await screen.findByRole('option', { name: 'Wan T2V (wan/t2v-1.3b)' });
|
||||
const input = screen.getByLabelText('Image');
|
||||
await user.upload(
|
||||
input,
|
||||
new File(['image'], 'input.png', { type: 'image/png' }),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Upload failed.*Choose the image again/),
|
||||
).toBeInTheDocument();
|
||||
expect(input).toHaveAttribute('aria-invalid', 'true');
|
||||
});
|
||||
|
||||
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,931 @@
|
||||
'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 [isLoadingDatasets, setIsLoadingDatasets] = React.useState(false);
|
||||
const [modelLoadError, setModelLoadError] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [datasetLoadError, setDatasetLoadError] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [imageUploadError, setImageUploadError] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [submitError, setSubmitError] = React.useState<string | null>(null);
|
||||
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('');
|
||||
setModelLoadError(null);
|
||||
setDatasetLoadError(null);
|
||||
setImageUploadError(null);
|
||||
setSubmitError(null);
|
||||
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);
|
||||
setModelLoadError(null);
|
||||
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) return;
|
||||
console.error('Failed to load models:', e);
|
||||
setModels([]);
|
||||
setModelId('');
|
||||
setModelLoadError(
|
||||
'Models could not be loaded. Check the Studio API and reopen this form to try again.',
|
||||
);
|
||||
})
|
||||
.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) {
|
||||
setIsLoadingDatasets(true);
|
||||
setDatasetLoadError(null);
|
||||
getDatasets()
|
||||
.then(setReadyDatasets)
|
||||
.catch((error) => {
|
||||
console.error('Failed to load datasets:', error);
|
||||
setReadyDatasets([]);
|
||||
setDatasetLoadError(
|
||||
'Datasets could not be loaded. Check the Studio API and reopen this form to try again.',
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoadingDatasets(false));
|
||||
} else {
|
||||
setReadyDatasets([]);
|
||||
setIsLoadingDatasets(false);
|
||||
setDatasetLoadError(null);
|
||||
}
|
||||
}, [isOpen, isInference]);
|
||||
|
||||
async function handleImageChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) {
|
||||
setImagePath('');
|
||||
setImageFileName('');
|
||||
setImageUploadError(null);
|
||||
return;
|
||||
}
|
||||
setIsUploadingImage(true);
|
||||
setImageFileName(file.name);
|
||||
setImageUploadError(null);
|
||||
try {
|
||||
const { path } = await uploadImage(file);
|
||||
setImagePath(path);
|
||||
} catch (error) {
|
||||
console.error('Failed to upload image:', error);
|
||||
setImagePath('');
|
||||
setImageFileName('');
|
||||
setImageUploadError(
|
||||
error instanceof Error
|
||||
? `${error.message}. Choose the image again to retry.`
|
||||
: 'The image could not be uploaded. Choose it again to retry.',
|
||||
);
|
||||
} finally {
|
||||
setIsUploadingImage(false);
|
||||
}
|
||||
}
|
||||
|
||||
function clearImage() {
|
||||
setImagePath('');
|
||||
setImageFileName('');
|
||||
setImageUploadError(null);
|
||||
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);
|
||||
setSubmitError(null);
|
||||
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);
|
||||
setSubmitError(
|
||||
err instanceof Error
|
||||
? `${err.message}. Check the form and Studio API, then try again.`
|
||||
: 'The job could not be created. Check the form and Studio API, then try again.',
|
||||
);
|
||||
} 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
|
||||
aria-describedby={
|
||||
modelLoadError ? 'modal-model-error' : undefined
|
||||
}
|
||||
aria-invalid={modelLoadError ? true : undefined}
|
||||
disabled={isSubmitting || isLoadingModels || !!modelLoadError}
|
||||
>
|
||||
<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>
|
||||
{modelLoadError && (
|
||||
<p
|
||||
id="modal-model-error"
|
||||
role="alert"
|
||||
className="text-sm text-destructive"
|
||||
>
|
||||
{modelLoadError}
|
||||
</p>
|
||||
)}
|
||||
</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}
|
||||
aria-describedby={
|
||||
imageUploadError ? 'modal-image-error' : undefined
|
||||
}
|
||||
aria-invalid={imageUploadError ? true : undefined}
|
||||
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>
|
||||
)}
|
||||
{imageUploadError && (
|
||||
<p
|
||||
id="modal-image-error"
|
||||
role="alert"
|
||||
className="text-sm text-destructive"
|
||||
>
|
||||
{imageUploadError}
|
||||
</p>
|
||||
)}
|
||||
</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)}
|
||||
aria-describedby={
|
||||
datasetLoadError ? 'modal-dataset-error' : undefined
|
||||
}
|
||||
aria-invalid={datasetLoadError ? true : undefined}
|
||||
disabled={
|
||||
isSubmitting || isLoadingDatasets || !!datasetLoadError
|
||||
}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{isLoadingDatasets
|
||||
? 'Loading datasets…'
|
||||
: datasetLoadError
|
||||
? 'Datasets unavailable'
|
||||
: 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>
|
||||
{datasetLoadError && (
|
||||
<p
|
||||
id="modal-dataset-error"
|
||||
role="alert"
|
||||
className="text-sm text-destructive"
|
||||
>
|
||||
{datasetLoadError}
|
||||
</p>
|
||||
)}
|
||||
</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 || isLoadingDatasets || !!datasetLoadError
|
||||
}
|
||||
>
|
||||
<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 className="flex flex-col items-start gap-2">
|
||||
{submitError && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{submitError}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
isUploadingImage ||
|
||||
!!modelLoadError ||
|
||||
!!datasetLoadError
|
||||
}
|
||||
>
|
||||
{isSubmitting ? 'Creating…' : 'Create Job'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
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(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/utils')>();
|
||||
return {
|
||||
...actual,
|
||||
downloadBlob: 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('keeps selection and job action buttons as semantic siblings', () => {
|
||||
render(<JobCard job={makeJob()} />);
|
||||
|
||||
const selectButton = screen.getByRole('button', { pressed: false });
|
||||
const deleteButton = screen.getByRole('button', { name: 'Delete' });
|
||||
|
||||
expect(selectButton).toHaveTextContent('Wan2.1-T2V');
|
||||
expect(selectButton).not.toContainElement(deleteButton);
|
||||
});
|
||||
|
||||
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,240 @@
|
||||
'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() {
|
||||
setActiveJobId(isSelected ? null : job.id);
|
||||
}
|
||||
|
||||
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 (
|
||||
<article
|
||||
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',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={isSelected}
|
||||
onClick={handleSelectJob}
|
||||
className="flex w-full flex-col gap-2.5 rounded-md text-left"
|
||||
>
|
||||
<span 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>
|
||||
</span>
|
||||
<span className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-muted-foreground">
|
||||
{job.prompt}
|
||||
</span>
|
||||
<span 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>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<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>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
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('fills the mobile viewport without reserving main-content width', async () => {
|
||||
vi.mocked(getJobLogs).mockResolvedValue({
|
||||
lines: [],
|
||||
total: 0,
|
||||
progress: 0,
|
||||
progress_msg: '',
|
||||
phase: '',
|
||||
});
|
||||
const onWidthChange = vi.fn();
|
||||
|
||||
render(
|
||||
<JobDetailsSidebar
|
||||
job={makeJob({ status: 'completed' })}
|
||||
isMobile
|
||||
onClose={vi.fn()}
|
||||
onWidthChange={onWidthChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const drawer = screen.getByRole('dialog', { name: 'Job details' });
|
||||
expect(drawer).toHaveStyle({ width: '100%', maxWidth: 'none' });
|
||||
expect(drawer).toHaveAttribute('aria-modal', 'true');
|
||||
expect(drawer).toHaveFocus();
|
||||
expect(onWidthChange).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
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,220 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useDrawerFocus } from '@/hooks/useDrawerFocus';
|
||||
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,
|
||||
isMobile = false,
|
||||
onClose,
|
||||
onWidthChange,
|
||||
}: {
|
||||
job: Job;
|
||||
isMobile?: boolean;
|
||||
onClose: () => void;
|
||||
onWidthChange?: (w: number) => void;
|
||||
}) {
|
||||
const drawerRef = useDrawerFocus<HTMLElement>(isMobile);
|
||||
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?.(isMobile ? 0 : width);
|
||||
}, [isMobile, 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
|
||||
ref={drawerRef}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-label="Job details"
|
||||
aria-modal={isMobile || undefined}
|
||||
className="fixed bottom-0 right-0 top-[var(--header-height)] z-50 flex max-h-[calc(100dvh-var(--header-height))] min-w-0 shrink-0 flex-col border-l border-border bg-card md:min-w-[280px]"
|
||||
style={{
|
||||
width: isMobile ? '100%' : width,
|
||||
maxWidth: isMobile ? 'none' : 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>
|
||||
|
||||
{!isMobile && <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,122 @@
|
||||
import { act, fireEvent, 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 a loading placeholder before the initial request settles', async () => {
|
||||
let resolveJobs: (jobs: Job[]) => void = () => {};
|
||||
vi.mocked(getJobsList).mockReturnValue(
|
||||
new Promise<Job[]>((resolve) => {
|
||||
resolveJobs = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
render(<JobQueue jobType="inference" />);
|
||||
|
||||
expect(screen.getByLabelText('Loading jobs')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('No inference jobs yet. Create one above.'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
act(() => resolveJobs([]));
|
||||
expect(
|
||||
await screen.findByText('No inference jobs yet. Create one above.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows request failures separately from an empty queue and retries', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.mocked(getJobsList).mockRejectedValueOnce(new Error('network down'));
|
||||
render(<JobQueue jobType="inference" />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Could not load jobs from the Studio API/),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('No inference jobs yet. Create one above.'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
vi.mocked(getJobsList).mockResolvedValueOnce([]);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Try Again' }));
|
||||
expect(
|
||||
await screen.findByText('No inference jobs yet. Create one above.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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,195 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
import JobCard from '@/components/jobs/JobCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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 [isInitialLoading, setIsInitialLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
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);
|
||||
setError(null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch jobs:', e);
|
||||
if (seq === fetchSeq.current) {
|
||||
setError(
|
||||
'Could not load jobs from the Studio API. Check the server and try again.',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (seq === fetchSeq.current) {
|
||||
inFlight.current = false;
|
||||
setIsInitialLoading(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 (
|
||||
<div className="mx-auto flex w-full max-w-[850px] flex-col gap-6 px-4 pb-12">
|
||||
<section className="p-6">
|
||||
<div aria-busy={isInitialLoading}>
|
||||
{isInitialLoading ? (
|
||||
<div
|
||||
aria-label="Loading jobs"
|
||||
className="flex flex-col gap-3 py-2"
|
||||
>
|
||||
{[0, 1, 2].map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="h-32 animate-pulse rounded-lg border border-border bg-muted/50"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : error && jobs.length === 0 ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col items-center gap-3 py-8 text-center"
|
||||
>
|
||||
<AlertTriangle
|
||||
className="size-6 text-destructive"
|
||||
aria-hidden
|
||||
/>
|
||||
<p className="max-w-md text-sm text-muted-foreground">{error}</p>
|
||||
<Button type="button" variant="outline" onClick={fetchJobs}>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{error && (
|
||||
<p
|
||||
role="status"
|
||||
className="mb-3 rounded-lg border border-amber-500/50 bg-amber-500/10 px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
Job updates are temporarily unavailable. Showing the most
|
||||
recent results.
|
||||
</p>
|
||||
)}
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
'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 { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
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 isMobile = useMediaQuery('(max-width: 767px)');
|
||||
|
||||
const [primaryWidth, setPrimaryWidth] = React.useState(220);
|
||||
const [secondaryWidth, setSecondaryWidth] = React.useState(0);
|
||||
const [primaryOpen, setPrimaryOpen] = React.useState(false);
|
||||
|
||||
const jobSidebarOpen = JOB_ROUTES.includes(pathname) && activeJob != null;
|
||||
const datasetSidebarOpen =
|
||||
pathname === '/datasets' && activeDataset != null;
|
||||
const secondaryOpen = jobSidebarOpen || datasetSidebarOpen;
|
||||
// Mobile detail drawers claim aria-modal, so everything behind them must
|
||||
// actually be inert — the platform enforces what the ARIA claims.
|
||||
const drawerModal = isMobile && secondaryOpen;
|
||||
|
||||
React.useEffect(() => {
|
||||
initDefaultOptions();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
setPrimaryOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
React.useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape' || document.querySelector('[data-modal]')) return;
|
||||
if (primaryOpen) {
|
||||
setPrimaryOpen(false);
|
||||
return;
|
||||
}
|
||||
if (activeJobStore.get().activeJob) setActiveJobId(null);
|
||||
if (activeDatasetStore.get().activeDataset) setActiveDatasetId(null);
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [primaryOpen]);
|
||||
|
||||
return (
|
||||
<HeaderActionsProvider>
|
||||
<div
|
||||
style={{ display: 'contents' }}
|
||||
inert={drawerModal ? true : undefined}
|
||||
>
|
||||
<Header
|
||||
navigationOpen={primaryOpen}
|
||||
onNavigationToggle={() => setPrimaryOpen((open) => !open)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex overflow-hidden"
|
||||
style={{
|
||||
marginTop: 'var(--header-height)',
|
||||
height: 'calc(100dvh - var(--header-height))',
|
||||
}}
|
||||
>
|
||||
<PrimarySidebar
|
||||
isMobile={isMobile}
|
||||
mobileOpen={primaryOpen}
|
||||
onMobileClose={() => setPrimaryOpen(false)}
|
||||
onWidthChange={setPrimaryWidth}
|
||||
/>
|
||||
{primaryOpen && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close navigation"
|
||||
onClick={() => setPrimaryOpen(false)}
|
||||
className="fixed inset-x-0 bottom-0 top-[var(--header-height)] z-40 bg-black/55 md:hidden"
|
||||
/>
|
||||
)}
|
||||
<main
|
||||
className="flex min-w-0 flex-1 flex-col overflow-auto"
|
||||
inert={drawerModal ? true : undefined}
|
||||
style={{
|
||||
marginLeft: isMobile ? 0 : primaryWidth,
|
||||
marginRight: isMobile || !secondaryOpen ? 0 : secondaryWidth,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
{jobSidebarOpen && activeJob && (
|
||||
<JobDetailsSidebar
|
||||
job={activeJob}
|
||||
isMobile={isMobile}
|
||||
onClose={() => setActiveJobId(null)}
|
||||
onWidthChange={setSecondaryWidth}
|
||||
/>
|
||||
)}
|
||||
{datasetSidebarOpen && activeDataset && (
|
||||
<DatasetSidebar
|
||||
dataset={activeDataset}
|
||||
isMobile={isMobile}
|
||||
onClose={() => setActiveDatasetId(null)}
|
||||
onWidthChange={setSecondaryWidth}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Toaster />
|
||||
</HeaderActionsProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { Menu, X } from 'lucide-react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { useHeaderActions } from '@/components/shell/HeaderActionsContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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({
|
||||
navigationOpen,
|
||||
onNavigationToggle,
|
||||
}: {
|
||||
navigationOpen: boolean;
|
||||
onNavigationToggle: () => void;
|
||||
}) {
|
||||
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-2 border-b border-border bg-background/80 px-2 backdrop-blur sm:px-4 md:gap-6 md:px-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={navigationOpen ? 'Close navigation' : 'Open navigation'}
|
||||
aria-controls="primary-navigation"
|
||||
aria-expanded={navigationOpen}
|
||||
onClick={onNavigationToggle}
|
||||
className="shrink-0 md:hidden"
|
||||
>
|
||||
{navigationOpen ? (
|
||||
<X className="size-5" aria-hidden />
|
||||
) : (
|
||||
<Menu className="size-5" aria-hidden />
|
||||
)}
|
||||
</Button>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="FastVideo Logo"
|
||||
width={100}
|
||||
height={42}
|
||||
className="hidden h-[42px] w-[78px] shrink-0 object-contain min-[361px]:block md:w-[100px]"
|
||||
/>
|
||||
<h1 className="sr-only m-0 flex-1 text-xl font-semibold tracking-tight md:not-sr-only">
|
||||
{title}
|
||||
</h1>
|
||||
<div className="ml-auto flex min-w-0 items-center gap-2 md: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,218 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { X } from 'lucide-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 min-h-11 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({
|
||||
isMobile,
|
||||
mobileOpen,
|
||||
onMobileClose,
|
||||
onWidthChange,
|
||||
}: {
|
||||
isMobile: boolean;
|
||||
mobileOpen: boolean;
|
||||
onMobileClose: () => void;
|
||||
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?.(isMobile ? 0 : layoutWidth);
|
||||
}, [isMobile, 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
|
||||
id="primary-navigation"
|
||||
aria-hidden={isMobile && !mobileOpen}
|
||||
inert={isMobile && !mobileOpen ? true : undefined}
|
||||
className={cn(
|
||||
'fixed bottom-0 left-0 top-[var(--header-height)] z-50 flex max-h-[calc(100dvh-var(--header-height))] shrink-0 flex-col border-r border-border bg-card transition-transform duration-200 md:translate-x-0',
|
||||
mobileOpen ? 'translate-x-0' : '-translate-x-full',
|
||||
)}
|
||||
style={{
|
||||
width: isMobile
|
||||
? 'min(18rem, calc(100vw - 3rem))'
|
||||
: effectiveWidth,
|
||||
}}
|
||||
>
|
||||
{isMobile && (
|
||||
<div className="flex h-14 items-center justify-between border-b border-border px-4">
|
||||
<span className="text-sm font-semibold">Navigation</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMobileClose}
|
||||
aria-label="Close navigation"
|
||||
className="flex size-11 items-center justify-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<X className="size-5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<nav
|
||||
aria-label="Primary navigation"
|
||||
className="flex flex-col overflow-y-auto 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}
|
||||
aria-current={pathname === route.href ? 'page' : undefined}
|
||||
onClick={onMobileClose}
|
||||
className={cn(
|
||||
TAB_BASE,
|
||||
'px-4 py-2 text-[0.85rem]',
|
||||
pathname === route.href && TAB_ACTIVE,
|
||||
)}
|
||||
>
|
||||
{route.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href="/datasets"
|
||||
aria-current={pathname === '/datasets' ? 'page' : undefined}
|
||||
onClick={onMobileClose}
|
||||
className={cn(TAB_BASE, pathname === '/datasets' && TAB_ACTIVE)}
|
||||
>
|
||||
Datasets
|
||||
</Link>
|
||||
<Link
|
||||
href="/gallery"
|
||||
aria-current={pathname === '/gallery' ? 'page' : undefined}
|
||||
onClick={onMobileClose}
|
||||
className={cn(TAB_BASE, pathname === '/gallery' && TAB_ACTIVE)}
|
||||
>
|
||||
Gallery
|
||||
</Link>
|
||||
<Link
|
||||
href="/gpus"
|
||||
aria-current={pathname === '/gpus' ? 'page' : undefined}
|
||||
onClick={onMobileClose}
|
||||
className={cn(TAB_BASE, pathname === '/gpus' && TAB_ACTIVE)}
|
||||
>
|
||||
GPUs
|
||||
</Link>
|
||||
<Link
|
||||
href="/settings"
|
||||
aria-current={pathname === '/settings' ? 'page' : undefined}
|
||||
onClick={onMobileClose}
|
||||
className={cn(TAB_BASE, pathname === '/settings' && TAB_ACTIVE)}
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{!isMobile && <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 size-11 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<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>}
|
||||
|
||||
{!isMobile && !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,102 @@
|
||||
import { act, 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();
|
||||
});
|
||||
|
||||
it('keeps the last snapshot visible and warns when a refresh fails', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.mocked(getGpus)
|
||||
.mockResolvedValueOnce(SNAPSHOT)
|
||||
.mockRejectedValueOnce(new Error('network down'));
|
||||
|
||||
render(<GpuGrid />);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(screen.getAllByText('NVIDIA B200')).toHaveLength(2);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(/values below may be stale/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getAllByText('NVIDIA B200')).toHaveLength(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
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<string | null>(null);
|
||||
const [retryToken, setRetryToken] = React.useState(0);
|
||||
|
||||
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(null);
|
||||
}
|
||||
} catch {
|
||||
if (mounted) {
|
||||
setFetchError(
|
||||
'GPU status could not be refreshed. The values below may be stale.',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
const interval = setInterval(poll, POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
mounted = false;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [retryToken]);
|
||||
|
||||
if (fetchError && !snapshot) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col items-center gap-3 py-8 text-center"
|
||||
>
|
||||
<AlertTriangle className="size-6 text-destructive" aria-hidden />
|
||||
<p className="text-muted-foreground">
|
||||
Could not reach the API server. GPU status needs the Studio API server
|
||||
running.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setRetryToken((token) => token + 1)}
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="flex flex-wrap items-center gap-3 rounded-lg border border-amber-500/50 bg-amber-500/10 px-3 py-2 text-sm"
|
||||
>
|
||||
<AlertTriangle className="size-4 text-amber-600" aria-hidden />
|
||||
<span className="min-w-0 flex-1">{fetchError}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setRetryToken((token) => token + 1)}
|
||||
>
|
||||
Refresh Now
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<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,48 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { Button } from './button';
|
||||
import { Input } from './input';
|
||||
import { NativeSelect } from './native-select';
|
||||
import { Slider } from './slider';
|
||||
import { Switch } from './switch';
|
||||
|
||||
describe('shared control accessibility', () => {
|
||||
it('keeps button, input, and select targets at least 44px tall', () => {
|
||||
render(
|
||||
<>
|
||||
<Button size="sm">Small action</Button>
|
||||
<Input aria-label="Text value" />
|
||||
<NativeSelect aria-label="Choice" defaultValue="one">
|
||||
<option value="one">One</option>
|
||||
</NativeSelect>
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Small action' })).toHaveClass(
|
||||
'h-11',
|
||||
);
|
||||
expect(screen.getByRole('textbox', { name: 'Text value' })).toHaveClass(
|
||||
'h-11',
|
||||
);
|
||||
expect(screen.getByRole('combobox', { name: 'Choice' })).toHaveClass(
|
||||
'h-11',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses 44px switch and slider interaction surfaces', () => {
|
||||
render(
|
||||
<>
|
||||
<Switch aria-label="Enabled" />
|
||||
<Slider aria-label="Amount" defaultValue={[50]} />
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('switch', { name: 'Enabled' })).toHaveClass(
|
||||
'size-11',
|
||||
);
|
||||
expect(screen.getByRole('slider', { name: 'Amount' })).toHaveClass(
|
||||
'size-11',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
'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<HTMLSpanElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
// A span (phrasing content), so badges stay valid inside buttons and links.
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl border !text-sm !font-semibold transition-colors duration-150 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-slate-700 bg-slate-800 text-white shadow-md hover:bg-slate-700 dark:border-slate-300 dark:bg-slate-300 dark:text-slate-900 dark:hover:bg-slate-200",
|
||||
secondary: "border-input bg-card text-card-foreground hover:bg-accent",
|
||||
outline: "border-input bg-secondary text-secondary-foreground hover:bg-accent",
|
||||
ghost: "border-transparent bg-transparent text-foreground hover:bg-accent",
|
||||
destructive: "border-rose-500/60 bg-rose-600/90 text-white hover:bg-rose-500",
|
||||
},
|
||||
size: {
|
||||
default: "h-11 px-4 py-2",
|
||||
sm: "h-11 rounded-lg px-3 !text-xs",
|
||||
lg: "h-12 px-5 !text-sm",
|
||||
icon: "size-11",
|
||||
"icon-sm": "size-11",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
||||
});
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-2xl border border-border bg-card text-card-foreground shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col gap-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/70 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
data-modal=""
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-2xl border border-border bg-card p-6 text-card-foreground shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-2 top-2 flex size-11 items-center justify-center rounded-lg text-muted-foreground opacity-70 transition-opacity hover:bg-secondary hover:opacity-100 disabled:pointer-events-none sm:right-4 sm:top-4">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col gap-1.5 text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = 'DialogFooter';
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-none tracking-tight',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-xl border border-input bg-card/60 px-3 py-2 text-sm text-foreground shadow-sm backdrop-blur-md transition-colors placeholder:text-muted-foreground focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none text-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const NativeSelect = React.forwardRef<
|
||||
HTMLSelectElement,
|
||||
React.ComponentProps<'select'>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<select
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-11 w-full appearance-none rounded-xl border border-input bg-card px-3 py-2 text-sm text-foreground shadow-sm transition-colors focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
));
|
||||
NativeSelect.displayName = 'NativeSelect';
|
||||
|
||||
export { NativeSelect };
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative overflow-hidden', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = 'vertical', ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none select-none p-0.5 transition-colors',
|
||||
orientation === 'vertical' ? 'h-full w-2.5' : 'h-2.5 flex-col',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-11 w-full items-center justify-between gap-2 rounded-xl border border-input bg-card px-3 py-2 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1 text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName =
|
||||
SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1 text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-80 min-w-[8rem] overflow-hidden rounded-2xl border border-border bg-popover text-popover-foreground shadow-lg backdrop-blur-sm',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-xs font-semibold text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex min-h-11 w-full cursor-default select-none items-center rounded-xl py-2 pl-8 pr-3 text-sm text-foreground outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4 text-accent-blue" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = 'horizontal', decorative = true, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as SliderPrimitive from '@radix-ui/react-slider';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
id,
|
||||
'aria-label': ariaLabel,
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
'aria-describedby': ariaDescribedBy,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex h-11 w-full touch-none select-none items-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-border">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-accent-blue" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb
|
||||
id={id}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
className="relative block size-11 rounded-full bg-transparent after:absolute after:left-1/2 after:top-1/2 after:size-4 after:-translate-x-1/2 after:-translate-y-1/2 after:rounded-full after:border-2 after:border-accent-blue after:bg-accent-blue after:shadow after:content-[''] hover:after:border-accent-blue/80 disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
</SliderPrimitive.Root>
|
||||
),
|
||||
);
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider };
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { Toaster as Sonner, type ToasterProps } from 'sonner';
|
||||
|
||||
export function Toaster(props: ToasterProps) {
|
||||
return (
|
||||
<Sonner
|
||||
theme="dark"
|
||||
position="bottom-center"
|
||||
richColors={true}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
'border border-border bg-card text-card-foreground shadow-lg',
|
||||
description: 'text-muted-foreground',
|
||||
actionButton:
|
||||
'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
cancelButton:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as SwitchPrimitives from '@radix-ui/react-switch';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer relative inline-flex size-11 shrink-0 cursor-pointer items-center justify-center rounded-xl bg-transparent transition-colors before:absolute before:h-5 before:w-9 before:rounded-full before:border before:border-border before:bg-background data-[state=checked]:before:border-accent-blue data-[state=checked]:before:bg-accent-blue disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none absolute left-1.5 top-[15px] z-[1] block h-3.5 w-3.5 rounded-full bg-muted-foreground shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=checked]:bg-white',
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-lg border border-border bg-background p-0.5 text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn('mt-2 focus-visible:outline-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-24 w-full resize-y rounded-xl border border-input bg-card px-3 py-2 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
/**
|
||||
* Light/dark switch (ported from Dreamverse). The studio defaults to dark —
|
||||
* layout.tsx applies the stored choice before paint; this button just
|
||||
* mirrors and persists it.
|
||||
*/
|
||||
function ThemeToggle({ className }: { className?: string }) {
|
||||
const [dark, setDark] = React.useState(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
setDark(document.documentElement.classList.contains('dark'));
|
||||
}, []);
|
||||
|
||||
const transitionTimer =
|
||||
React.useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
function toggle() {
|
||||
const next = !dark;
|
||||
setDark(next);
|
||||
|
||||
clearTimeout(transitionTimer.current);
|
||||
document.documentElement.classList.add('theme-transition');
|
||||
document.documentElement.classList.toggle('dark', next);
|
||||
|
||||
transitionTimer.current = setTimeout(() => {
|
||||
document.documentElement.classList.remove('theme-transition');
|
||||
}, 350);
|
||||
|
||||
try {
|
||||
localStorage.setItem('theme', next ? 'dark' : 'light');
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => clearTimeout(transitionTimer.current);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={toggle}
|
||||
aria-label={dark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
className={className}
|
||||
>
|
||||
{dark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export { ThemeToggle };
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
/**
|
||||
* Move focus into a drawer when it opens as a modal (mobile), and hand it
|
||||
* back to the previously focused element on close. Pairs with `inert` on
|
||||
* the background content — together they make `aria-modal` truthful.
|
||||
*/
|
||||
export function useDrawerFocus<T extends HTMLElement>(active: boolean) {
|
||||
const ref = React.useRef<T | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) return;
|
||||
const previous = document.activeElement;
|
||||
ref.current?.focus();
|
||||
return () => {
|
||||
if (previous instanceof HTMLElement) previous.focus();
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mediaQuery = window.matchMedia(query);
|
||||
const updateMatch = () => setMatches(mediaQuery.matches);
|
||||
|
||||
updateMatch();
|
||||
mediaQuery.addEventListener('change', updateMatch);
|
||||
return () => mediaQuery.removeEventListener('change', updateMatch);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
export type ResizableEdge = 'left' | 'right';
|
||||
|
||||
export interface UseResizableOptions {
|
||||
/** Which edge is fixed: "left" = drag right grows; "right" = drag left grows. */
|
||||
edge: ResizableEdge;
|
||||
minWidth: number;
|
||||
maxWidth: number;
|
||||
/** Return current width (e.g. from parent state). */
|
||||
getWidth: () => number;
|
||||
/** Called with the new width while the user drags. */
|
||||
onWidth: (width: number) => void;
|
||||
/** Optional: notified when a drag starts/stops. */
|
||||
onDragChange?: (dragging: boolean) => void;
|
||||
}
|
||||
|
||||
export interface UseResizableResult {
|
||||
onMouseDown: (event: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* React port of the Svelte `resizable` action. Spread the returned
|
||||
* `onMouseDown` onto a drag handle; the parent owns the width state.
|
||||
*/
|
||||
export function useResizable(
|
||||
options: UseResizableOptions,
|
||||
): UseResizableResult {
|
||||
const optionsRef = React.useRef(options);
|
||||
optionsRef.current = options;
|
||||
|
||||
const dragStart = React.useRef({ x: 0, width: 0 });
|
||||
const dragging = React.useRef(false);
|
||||
|
||||
const onMouseMove = React.useCallback((e: MouseEvent) => {
|
||||
const opts = optionsRef.current;
|
||||
const delta = e.clientX - dragStart.current.x;
|
||||
const sign = opts.edge === 'left' ? 1 : -1;
|
||||
const newWidth = Math.min(
|
||||
opts.maxWidth,
|
||||
Math.max(opts.minWidth, dragStart.current.width + sign * delta),
|
||||
);
|
||||
opts.onWidth(newWidth);
|
||||
}, []);
|
||||
|
||||
const onMouseUp = React.useCallback(() => {
|
||||
dragging.current = false;
|
||||
optionsRef.current.onDragChange?.(false);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
}, [onMouseMove]);
|
||||
|
||||
const onMouseDown = React.useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const opts = optionsRef.current;
|
||||
dragStart.current = { x: e.clientX, width: opts.getWidth() };
|
||||
dragging.current = true;
|
||||
opts.onDragChange?.(true);
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
},
|
||||
[onMouseMove, onMouseUp],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (dragging.current) {
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
}
|
||||
};
|
||||
}, [onMouseMove, onMouseUp]);
|
||||
|
||||
return { onMouseDown };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import type { ManagedStore } from '@/stores/createManagedStore';
|
||||
|
||||
export function useStore<T>(
|
||||
store: ManagedStore<T>,
|
||||
): T {
|
||||
return useSyncExternalStore(
|
||||
store.subscribe,
|
||||
store.get,
|
||||
store.getServerSnapshot,
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
/**
|
||||
* Svelte action: attach horizontal resize behavior to a handle element.
|
||||
* Use on the resize-handle div inside a sidebar; the parent holds width state.
|
||||
*/
|
||||
|
||||
export type ResizableEdge = "left" | "right";
|
||||
|
||||
export interface ResizableOptions {
|
||||
/** Which edge is fixed: "left" = drag right increases width, "right" = drag left increases width */
|
||||
edge: ResizableEdge;
|
||||
minWidth: number;
|
||||
maxWidth: number;
|
||||
/** Return current width (e.g. from parent state) */
|
||||
getWidth: () => number;
|
||||
/** Called with new width when user drags */
|
||||
onWidth: (width: number) => void;
|
||||
/** Optional: called when drag starts/stops so parent can show active state */
|
||||
onDragChange?: (dragging: boolean) => void;
|
||||
}
|
||||
|
||||
export function resizable(
|
||||
node: HTMLElement,
|
||||
options: ResizableOptions,
|
||||
): { update?: (opts: ResizableOptions) => void; destroy?: () => void } {
|
||||
let dragStart = { x: 0, width: 0 };
|
||||
let listenersAttached = false;
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
const delta = e.clientX - dragStart.x;
|
||||
const sign = options.edge === "left" ? 1 : -1;
|
||||
const newWidth = Math.min(
|
||||
options.maxWidth,
|
||||
Math.max(options.minWidth, dragStart.width + sign * delta),
|
||||
);
|
||||
options.onWidth(newWidth);
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
listenersAttached = false;
|
||||
options.onDragChange?.(false);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
document.removeEventListener("mousemove", onMouseMove);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
}
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
dragStart = { x: e.clientX, width: options.getWidth() };
|
||||
listenersAttached = true;
|
||||
options.onDragChange?.(true);
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
document.addEventListener("mousemove", onMouseMove);
|
||||
document.addEventListener("mouseup", onMouseUp);
|
||||
}
|
||||
|
||||
node.addEventListener("mousedown", onMouseDown);
|
||||
|
||||
return {
|
||||
update(newOptions: ResizableOptions) {
|
||||
options = newOptions;
|
||||
},
|
||||
destroy() {
|
||||
node.removeEventListener("mousedown", onMouseDown);
|
||||
if (listenersAttached) {
|
||||
options.onDragChange?.(false);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
document.removeEventListener("mousemove", onMouseMove);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,30 +1,52 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { env } from "$env/dynamic/public";
|
||||
import type { Job, JobType } from "$lib/types";
|
||||
import {
|
||||
STORAGE_KEY,
|
||||
loadDefaultOptions,
|
||||
type DefaultOptions,
|
||||
} from "./defaultOptions";
|
||||
import type { Job, JobType } from "./types";
|
||||
|
||||
const DEFAULT_API_BASE_URL = "http://localhost:8189/api";
|
||||
|
||||
// getApiBaseUrl runs on every request and in render paths (media URL
|
||||
// builders), so cache the configured URL keyed on the raw stored options
|
||||
// string instead of re-parsing the options JSON each call.
|
||||
let cachedRaw: string | null = null;
|
||||
let cachedConfigured = "";
|
||||
|
||||
export function getApiBaseUrl(): string {
|
||||
// 1) Check user-configured value from local storage (Settings page)
|
||||
// 1) Check user-configured value from the persisted options (Settings
|
||||
// page). The settings sync persists the full options object, so the key
|
||||
// holds an empty string until the user sets a URL — treat that as unset
|
||||
// and fall through to the env/default below.
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
const stored = window.localStorage.getItem("fastvideo-default-options");
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as { apiServerBaseUrl?: string };
|
||||
return parsed.apiServerBaseUrl!.trim();
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (raw !== cachedRaw) {
|
||||
cachedRaw = raw;
|
||||
cachedConfigured = stripTrailingSlash(
|
||||
loadDefaultOptions().apiServerBaseUrl.trim(),
|
||||
);
|
||||
}
|
||||
if (cachedConfigured) return cachedConfigured;
|
||||
} catch {
|
||||
// Ignore storage / JSON errors and fall back to env/default
|
||||
// Ignore storage errors and fall back to env/default
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Fall back to env var if set, then hardcoded default
|
||||
const fromEnv = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
const apiUrl =
|
||||
typeof env.PUBLIC_API_BASE_URL === "string" && env.PUBLIC_API_BASE_URL
|
||||
? env.PUBLIC_API_BASE_URL
|
||||
: DEFAULT_API_BASE_URL;
|
||||
return apiUrl;
|
||||
typeof fromEnv === "string" && fromEnv ? fromEnv : DEFAULT_API_BASE_URL;
|
||||
return stripTrailingSlash(apiUrl);
|
||||
}
|
||||
|
||||
// The base URL is used as a prefix in `${base}/jobs` template strings, so a
|
||||
// trailing slash (a natural thing to paste into Settings) would produce `//`
|
||||
// and 404 every request.
|
||||
function stripTrailingSlash(url: string): string {
|
||||
return url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/** Full URL for streaming a job's video/image output (for <video> or <img> src). */
|
||||
@@ -64,41 +86,44 @@ export interface CreateJobRequest {
|
||||
vsa_sparsity?: number;
|
||||
tp_size?: number;
|
||||
sp_size?: number;
|
||||
// DMD distillation extras (sent by CreateJobModal)
|
||||
dmd_use_vsa?: boolean;
|
||||
dmd_vsa_sparsity?: number;
|
||||
dmd_denoising_steps?: string;
|
||||
real_score_guidance_scale?: number;
|
||||
generator_update_interval?: number;
|
||||
real_score_model_path?: string;
|
||||
fake_score_model_path?: string;
|
||||
}
|
||||
|
||||
export interface Model {
|
||||
id: string;
|
||||
label: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
defaultModelId: string;
|
||||
defaultModelIdT2v: string;
|
||||
defaultModelIdI2v: string;
|
||||
defaultModelIdT2i: string;
|
||||
numInferenceSteps: number;
|
||||
numFrames: number;
|
||||
height: number;
|
||||
width: number;
|
||||
guidanceScale: number;
|
||||
guidanceRescale: number;
|
||||
fps: number;
|
||||
seed: number;
|
||||
numGpus: number;
|
||||
ditCpuOffload: boolean;
|
||||
textEncoderCpuOffload: boolean;
|
||||
vaeCpuOffload: boolean;
|
||||
imageEncoderCpuOffload: boolean;
|
||||
useFsdpInference: boolean;
|
||||
enableTorchCompile: boolean;
|
||||
vsaSparsity: number;
|
||||
tpSize: number;
|
||||
spSize: number;
|
||||
autoStartJob: boolean;
|
||||
datasetUploadPath: string;
|
||||
export interface GpuInfo {
|
||||
index: number;
|
||||
name: string;
|
||||
utilization: number;
|
||||
memory_used_mib: number;
|
||||
memory_total_mib: number;
|
||||
temperature_c: number | null;
|
||||
power_watts: number | null;
|
||||
power_limit_watts: number | null;
|
||||
}
|
||||
|
||||
export interface GpuSnapshot {
|
||||
available: boolean;
|
||||
gpus: GpuInfo[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-persisted settings: everything in DefaultOptions except
|
||||
* apiServerBaseUrl, which is purely local (per-browser).
|
||||
*/
|
||||
export type Settings = Omit<DefaultOptions, "apiServerBaseUrl">;
|
||||
|
||||
// MARK: - API Functions
|
||||
|
||||
export async function getSettings(): Promise<Settings> {
|
||||
@@ -177,6 +202,15 @@ export async function getModels(workloadType?: string): Promise<Model[]> {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getGpus(): Promise<GpuSnapshot> {
|
||||
const baseApiUrl = getApiBaseUrl();
|
||||
const response = await fetch(`${baseApiUrl}/gpus`);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch GPU status");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getJobsList(jobType?: JobType): Promise<Job[]> {
|
||||
const baseApiUrl = getApiBaseUrl();
|
||||
const url = jobType
|
||||
@@ -204,15 +238,6 @@ export async function createJob(job: CreateJobRequest): Promise<Job> {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getJobDetails(id: string): Promise<Job> {
|
||||
const baseApiUrl = getApiBaseUrl();
|
||||
const response = await fetch(`${baseApiUrl}/jobs/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch job");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function startJob(id: string): Promise<Job> {
|
||||
const baseApiUrl = getApiBaseUrl();
|
||||
const response = await fetch(`${baseApiUrl}/jobs/${id}/start`, {
|
||||
@@ -326,15 +351,6 @@ export async function getDatasets(): Promise<Dataset[]> {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getDataset(id: string): Promise<Dataset> {
|
||||
const baseApiUrl = getApiBaseUrl();
|
||||
const response = await fetch(`${baseApiUrl}/datasets/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch dataset");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function createDataset(
|
||||
data: CreateDatasetRequest,
|
||||
): Promise<Dataset> {
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { createDatasetModalOpen } from "$lib/stores/createDatasetModalOpen";
|
||||
|
||||
function openModal() {
|
||||
createDatasetModalOpen.set(true);
|
||||
}
|
||||
</script>
|
||||
|
||||
<button type="button" class="btn btnPrimary" onclick={openModal}>
|
||||
Add Dataset
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.btn {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.55rem 1.2rem;
|
||||
}
|
||||
.btnPrimary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -1,514 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { createDataset, uploadRawDataset } from "$lib/api";
|
||||
import UploadZone from "./UploadZone.svelte";
|
||||
import TabSwitch from "./TabSwitch.svelte";
|
||||
import {
|
||||
parseVideos2Caption,
|
||||
parseVideosCaptionsTxt,
|
||||
parseCaptionCsv,
|
||||
} from "$lib/captionParsing";
|
||||
|
||||
const ALLOWED_VIDEO_EXT = ".mp4,.webm,.avi,.mov,.mkv";
|
||||
|
||||
let {
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
} = $props();
|
||||
|
||||
let name = $state("");
|
||||
let isSubmitting = $state(false);
|
||||
let rawPath = $state("");
|
||||
let fileNames = $state<string[]>([]);
|
||||
let isUploading = $state(false);
|
||||
let validationError = $state<string | null>(null);
|
||||
let captionFormat = $state<"json" | "txt" | "csv">("json");
|
||||
let captionMap = $state<Record<string, string> | null>(null);
|
||||
let captionFileName = $state<string | null>(null);
|
||||
let videosTxtLines = $state<string[] | null>(null);
|
||||
let videosTxtFileName = $state<string | null>(null);
|
||||
let captionsTxtLines = $state<string[] | null>(null);
|
||||
let captionsTxtFileName = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (captionFormat === "txt" && captionsTxtLines && fileNames.length > 0) {
|
||||
const hasVideosTxt =
|
||||
videosTxtLines &&
|
||||
videosTxtLines.length > 0 &&
|
||||
videosTxtLines.some((s) => s.trim());
|
||||
if (hasVideosTxt && videosTxtLines) {
|
||||
const { error } = parseVideosCaptionsTxt(
|
||||
videosTxtLines,
|
||||
captionsTxtLines,
|
||||
fileNames,
|
||||
);
|
||||
validationError = error;
|
||||
} else {
|
||||
validationError = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const txtCaptionMap = $derived.by(() => {
|
||||
if (!captionsTxtLines || fileNames.length === 0) return null;
|
||||
const { captions, error } = parseVideosCaptionsTxt(
|
||||
videosTxtLines ?? null,
|
||||
captionsTxtLines,
|
||||
fileNames,
|
||||
);
|
||||
if (error) return null;
|
||||
return Object.keys(captions).length > 0 ? captions : null;
|
||||
});
|
||||
|
||||
const effectiveCaptionMap = $derived(
|
||||
captionFormat === "txt" ? txtCaptionMap : captionMap,
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (!isOpen) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && !isSubmitting) handleClose();
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
});
|
||||
|
||||
function handleClose() {
|
||||
if (isSubmitting) return;
|
||||
name = "";
|
||||
rawPath = "";
|
||||
fileNames = [];
|
||||
validationError = null;
|
||||
captionFormat = "json";
|
||||
captionMap = null;
|
||||
captionFileName = null;
|
||||
videosTxtLines = null;
|
||||
videosTxtFileName = null;
|
||||
captionsTxtLines = null;
|
||||
captionsTxtFileName = null;
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function handleCaptionJsonChange(files: File[]) {
|
||||
validationError = null;
|
||||
captionMap = null;
|
||||
captionFileName = null;
|
||||
if (files.length === 0) return;
|
||||
const file = files[0];
|
||||
try {
|
||||
const text = await file.text();
|
||||
const uploaded = fileNames.length > 0 ? fileNames : [];
|
||||
const { captions, error } = parseVideos2Caption(text, uploaded);
|
||||
if (error) {
|
||||
validationError = error;
|
||||
return;
|
||||
}
|
||||
captionMap = captions;
|
||||
captionFileName = file.name;
|
||||
} catch {
|
||||
validationError = "Could not read the file.";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVideosTxtChange(files: File[]) {
|
||||
validationError = null;
|
||||
videosTxtLines = null;
|
||||
videosTxtFileName = null;
|
||||
if (files.length === 0) return;
|
||||
try {
|
||||
const text = await files[0].text();
|
||||
videosTxtLines = text.split(/\r?\n/).map((s) => s.trim());
|
||||
videosTxtFileName = files[0].name;
|
||||
if (captionsTxtLines && fileNames.length > 0) {
|
||||
const { error } = parseVideosCaptionsTxt(
|
||||
videosTxtLines,
|
||||
captionsTxtLines,
|
||||
fileNames,
|
||||
);
|
||||
if (error) validationError = error;
|
||||
}
|
||||
} catch {
|
||||
validationError = "Could not read videos.txt.";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCaptionsTxtChange(files: File[]) {
|
||||
validationError = null;
|
||||
captionsTxtLines = null;
|
||||
captionsTxtFileName = null;
|
||||
if (files.length === 0) return;
|
||||
try {
|
||||
const text = await files[0].text();
|
||||
captionsTxtLines = text.split(/\r?\n/).map((s) => s.trim());
|
||||
captionsTxtFileName = files[0].name;
|
||||
if (videosTxtLines && fileNames.length > 0) {
|
||||
const { error } = parseVideosCaptionsTxt(
|
||||
videosTxtLines,
|
||||
captionsTxtLines,
|
||||
fileNames,
|
||||
);
|
||||
if (error) validationError = error;
|
||||
}
|
||||
} catch {
|
||||
validationError = "Could not read captions.txt.";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCaptionCsvChange(files: File[]) {
|
||||
validationError = null;
|
||||
captionMap = null;
|
||||
captionFileName = null;
|
||||
if (files.length === 0) return;
|
||||
const file = files[0];
|
||||
try {
|
||||
const text = await file.text();
|
||||
const uploaded = fileNames.length > 0 ? fileNames : [];
|
||||
const { captions, error } = parseCaptionCsv(text, uploaded);
|
||||
if (error) {
|
||||
validationError = error;
|
||||
return;
|
||||
}
|
||||
captionMap = captions;
|
||||
captionFileName = file.name;
|
||||
} catch {
|
||||
validationError = "Could not read the file.";
|
||||
}
|
||||
}
|
||||
|
||||
function handleCaptionFormatChange(format: string) {
|
||||
captionFormat = format as "json" | "txt" | "csv";
|
||||
validationError = null;
|
||||
captionMap = null;
|
||||
captionFileName = null;
|
||||
videosTxtLines = null;
|
||||
videosTxtFileName = null;
|
||||
captionsTxtLines = null;
|
||||
captionsTxtFileName = null;
|
||||
}
|
||||
|
||||
async function handleMediaChange(files: File[]) {
|
||||
validationError = null;
|
||||
if (files.length === 0) {
|
||||
rawPath = "";
|
||||
fileNames = [];
|
||||
return;
|
||||
}
|
||||
isUploading = true;
|
||||
try {
|
||||
const res = await uploadRawDataset(files);
|
||||
rawPath = res.path;
|
||||
fileNames = res.file_names;
|
||||
if (res.file_names.length === 0) {
|
||||
validationError = `No video files found. Allowed: ${ALLOWED_VIDEO_EXT}`;
|
||||
}
|
||||
} catch (err) {
|
||||
rawPath = "";
|
||||
fileNames = [];
|
||||
validationError =
|
||||
err instanceof Error ? err.message : "Upload failed";
|
||||
} finally {
|
||||
isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
validationError = null;
|
||||
if (!name.trim()) return;
|
||||
if (!rawPath || fileNames.length === 0) {
|
||||
validationError = "No data was found. Upload at least one video.";
|
||||
return;
|
||||
}
|
||||
if (captionFormat === "json" || captionFormat === "csv") {
|
||||
if (captionFileName && !captionMap) {
|
||||
validationError =
|
||||
"Caption file has errors. Fix or remove it before creating the dataset.";
|
||||
return;
|
||||
}
|
||||
} else if (captionFormat === "txt") {
|
||||
if (videosTxtFileName || captionsTxtFileName) {
|
||||
if (!captionsTxtFileName) {
|
||||
validationError = "Upload captions.txt to use TXT captions.";
|
||||
return;
|
||||
}
|
||||
if (validationError) return;
|
||||
}
|
||||
}
|
||||
|
||||
const finalCaptionMap =
|
||||
effectiveCaptionMap && Object.keys(effectiveCaptionMap).length > 0
|
||||
? effectiveCaptionMap
|
||||
: null;
|
||||
if (finalCaptionMap) {
|
||||
const missing = fileNames.filter((fn) => !(fn in finalCaptionMap));
|
||||
if (missing.length > 0) {
|
||||
const list =
|
||||
missing.length <= 5
|
||||
? missing.join(", ")
|
||||
: `${missing.slice(0, 5).join(", ")} and ${missing.length - 5} more`;
|
||||
const ok = confirm(
|
||||
`The caption file does not include captions for ${missing.length} video(s): ${list}. They will get empty captions. Continue?`,
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
try {
|
||||
await createDataset({
|
||||
name: name.trim(),
|
||||
upload_path: rawPath,
|
||||
file_names: fileNames,
|
||||
...(finalCaptionMap ? { captions: finalCaptionMap } : {}),
|
||||
});
|
||||
onSuccess();
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
validationError =
|
||||
err instanceof Error ? err.message : "Failed to create dataset";
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="modal" data-modal>
|
||||
<div class="modalBackdrop" onclick={handleClose} role="presentation"></div>
|
||||
<div class="modalContent modalForm">
|
||||
<button
|
||||
class="modalClose"
|
||||
onclick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div class="card">
|
||||
<h2>Add Dataset — Raw</h2>
|
||||
<form onsubmit={handleSubmit} autocomplete="off">
|
||||
<div class="formRow">
|
||||
<label for="add-dataset-name">Name</label>
|
||||
<input
|
||||
id="add-dataset-name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder="My dataset"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<label>Videos</label>
|
||||
<UploadZone
|
||||
label="Upload video files"
|
||||
hint="Select files or a folder (.mp4, .webm, .avi, .mov, .mkv)"
|
||||
accept={ALLOWED_VIDEO_EXT}
|
||||
multiple={true}
|
||||
directory={true}
|
||||
allowBothFileAndDirectory={true}
|
||||
value={rawPath}
|
||||
fileName={fileNames.length > 0 ? `${fileNames.length} file(s)` : undefined}
|
||||
onFileChange={handleMediaChange}
|
||||
onClear={() => {
|
||||
rawPath = "";
|
||||
fileNames = [];
|
||||
captionMap = null;
|
||||
captionFileName = null;
|
||||
validationError = null;
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
uploading={isUploading}
|
||||
/>
|
||||
</div>
|
||||
<div class="formRow">
|
||||
<div class="captionRow">
|
||||
<label>Captions (optional)</label>
|
||||
<TabSwitch
|
||||
options={[
|
||||
{ id: "json", label: "JSON" },
|
||||
{ id: "txt", label: "TXT" },
|
||||
{ id: "csv", label: "CSV" },
|
||||
]}
|
||||
value={captionFormat}
|
||||
onChange={handleCaptionFormatChange}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
{#if captionFormat === "json"}
|
||||
<UploadZone
|
||||
label="Upload videos2caption.json"
|
||||
hint={'Array of { path, cap } or object mapping file names to captions'}
|
||||
accept=".json,application/json"
|
||||
value={captionFileName ? "1" : ""}
|
||||
fileName={captionFileName ?? undefined}
|
||||
onFileChange={handleCaptionJsonChange}
|
||||
onClear={() => {
|
||||
captionMap = null;
|
||||
captionFileName = null;
|
||||
validationError = null;
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{:else if captionFormat === "txt"}
|
||||
<div class="twoCol">
|
||||
<UploadZone
|
||||
label="Upload videos.txt (optional)"
|
||||
hint="One video path per line, or leave empty to match captions to videos in alphabetical order"
|
||||
accept=".txt,text/plain"
|
||||
value={videosTxtFileName ? "1" : ""}
|
||||
fileName={videosTxtFileName ?? undefined}
|
||||
onFileChange={handleVideosTxtChange}
|
||||
onClear={() => {
|
||||
videosTxtLines = null;
|
||||
videosTxtFileName = null;
|
||||
validationError = null;
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<UploadZone
|
||||
label="Upload captions.txt"
|
||||
hint="One caption per line (same order as videos.txt or alphabetical)"
|
||||
accept=".txt,text/plain"
|
||||
value={captionsTxtFileName ? "1" : ""}
|
||||
fileName={captionsTxtFileName ?? undefined}
|
||||
onFileChange={handleCaptionsTxtChange}
|
||||
onClear={() => {
|
||||
captionsTxtLines = null;
|
||||
captionsTxtFileName = null;
|
||||
validationError = null;
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<UploadZone
|
||||
label="Upload captions CSV"
|
||||
hint="Header: video_name, caption"
|
||||
accept=".csv,text/csv"
|
||||
value={captionFileName ? "1" : ""}
|
||||
fileName={captionFileName ?? undefined}
|
||||
onFileChange={handleCaptionCsvChange}
|
||||
onClear={() => {
|
||||
captionMap = null;
|
||||
captionFileName = null;
|
||||
validationError = null;
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{#if validationError}
|
||||
<p class="validationError">{validationError}</p>
|
||||
{/if}
|
||||
<button type="submit" class="btn btnPrimary" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Creating…" : "Create Dataset"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modalBackdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
.modalContent {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0;
|
||||
max-width: 850px;
|
||||
width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modalClose {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.75rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 1.6rem;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
}
|
||||
.card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.card h2 {
|
||||
font-size: 1.15rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.formRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.formRow label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.captionRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.captionRow label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.twoCol {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.twoCol :global(> *) {
|
||||
flex: 1 1 0;
|
||||
min-width: 200px;
|
||||
}
|
||||
.validationError {
|
||||
color: var(--red);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.btn {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
padding: 0.55rem 1.2rem;
|
||||
}
|
||||
.btnPrimary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -1,126 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { JobType } from "$lib/types";
|
||||
import { WORKLOAD_OPTIONS } from "$lib/jobConfig";
|
||||
import { triggerRefresh } from "$lib/stores/jobsRefresh";
|
||||
import CreateJobModal from "./CreateJobModal.svelte";
|
||||
|
||||
let { jobType }: { jobType: JobType } = $props();
|
||||
|
||||
let modalOpen = $state(false);
|
||||
const options = $derived(WORKLOAD_OPTIONS[jobType] ?? []);
|
||||
let workloadType = $state(options[0]?.type ?? "t2v");
|
||||
|
||||
function openModal(type: string) {
|
||||
workloadType = type;
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function handleSuccess() {
|
||||
triggerRefresh();
|
||||
modalOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="wrapper">
|
||||
<button type="button" class="btn btnPrimary trigger">Create Job</button>
|
||||
<div class="menu" role="menu">
|
||||
{#each options as opt}
|
||||
<button
|
||||
type="button"
|
||||
class="menuItem"
|
||||
role="menuitem"
|
||||
onclick={() => openModal(opt.type)}
|
||||
>
|
||||
{opt.label}
|
||||
<div class="menuItemDesc">{opt.desc}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<CreateJobModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => (modalOpen = false)}
|
||||
onSuccess={handleSuccess}
|
||||
{jobType}
|
||||
{workloadType}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.trigger::after {
|
||||
content: "";
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 5px solid currentColor;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 0.25rem;
|
||||
min-width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
padding: 0.25rem 0;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-4px);
|
||||
transition: opacity 0.15s, visibility 0.15s, transform 0.15s;
|
||||
z-index: 200;
|
||||
}
|
||||
.wrapper:hover .menu {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.menuItem {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.menuItem:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
.menuItemDesc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
font-weight: 400;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
.btn {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.55rem 1.2rem;
|
||||
}
|
||||
.btnPrimary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user