Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3164e57e1f |
+29
-8
@@ -1,8 +1,6 @@
|
||||
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:
|
||||
@@ -68,8 +66,8 @@ steps:
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "default"
|
||||
- label: ":vertical_traffic_light: Golden-Gate Tests"
|
||||
if: build.env("TEST_SCOPE") == "direct" && build.env("TEST_TYPE") == "golden_gate"
|
||||
- label: ":microscope: Unit Tests"
|
||||
if: build.env("TEST_SCOPE") == "direct" && build.env("TEST_TYPE") == "unit_test"
|
||||
command: "timeout 90m .buildkite/scripts/pr_test.sh"
|
||||
retry:
|
||||
automatic:
|
||||
@@ -79,8 +77,8 @@ steps:
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "default"
|
||||
- label: ":microscope: Unit Tests"
|
||||
if: build.env("TEST_SCOPE") == "direct" && build.env("TEST_TYPE") == "unit_test"
|
||||
- label: ":microscope: Preprocessing Tests"
|
||||
if: build.env("TEST_SCOPE") == "direct" && build.env("TEST_TYPE") == "preprocessing"
|
||||
command: "timeout 90m .buildkite/scripts/pr_test.sh"
|
||||
retry:
|
||||
automatic:
|
||||
@@ -335,6 +333,29 @@ steps:
|
||||
- TEST_TYPE=unit_test
|
||||
agents:
|
||||
queue: "default"
|
||||
- path:
|
||||
- "fastvideo/pipelines/preprocess/**"
|
||||
- "fastvideo/workflow/preprocess/**"
|
||||
- "fastvideo/dataset/dataloader/parquet_io.py"
|
||||
- "fastvideo/dataset/dataloader/record_schema.py"
|
||||
- "fastvideo/dataset/dataloader/schema.py"
|
||||
- "fastvideo/configs/configs.py"
|
||||
- "fastvideo/fastvideo_args.py"
|
||||
- "fastvideo/tests/workflow/test_t2v_preprocessing_e2e.py"
|
||||
- "fastvideo/tests/nightly/reference_video_1_sample_v0.mp4"
|
||||
- "fastvideo/tests/modal/pr_test.py"
|
||||
- ".buildkite/pipeline.yml"
|
||||
- ".buildkite/scripts/pr_test.sh"
|
||||
- ".github/workflows/ci-slash-commands.yml"
|
||||
- "pyproject.toml"
|
||||
- "docker/Dockerfile"
|
||||
config:
|
||||
command: "timeout 20m .buildkite/scripts/pr_test.sh"
|
||||
label: ":microscope: Preprocessing Tests"
|
||||
env:
|
||||
- TEST_TYPE=preprocessing
|
||||
agents:
|
||||
queue: "default"
|
||||
- path:
|
||||
- "apps/dreamverse/**"
|
||||
- "pyproject.toml"
|
||||
@@ -415,7 +436,7 @@ steps:
|
||||
- "pyproject.toml"
|
||||
- "docker/Dockerfile"
|
||||
config:
|
||||
command: "timeout 25m .buildkite/scripts/pr_test.sh"
|
||||
command: "timeout 15m .buildkite/scripts/pr_test.sh"
|
||||
label: ":test_tube: Training Tests"
|
||||
env:
|
||||
- TEST_TYPE=training
|
||||
@@ -426,7 +447,7 @@ steps:
|
||||
- "pyproject.toml"
|
||||
- "docker/Dockerfile"
|
||||
config:
|
||||
command: "timeout 25m .buildkite/scripts/pr_test.sh"
|
||||
command: "timeout 15m .buildkite/scripts/pr_test.sh"
|
||||
label: ":test_tube: Distillation DMD Tests"
|
||||
env:
|
||||
- TEST_TYPE=distillation_dmd
|
||||
|
||||
@@ -187,10 +187,6 @@ case "$TEST_TYPE" in
|
||||
log "Running transformer tests..."
|
||||
MODAL_COMMAND="$MODAL_ENV HF_API_KEY=$HF_API_KEY python3 -m modal run $MODAL_TEST_FILE::run_transformer_tests"
|
||||
;;
|
||||
"golden_gate")
|
||||
log "Running golden-gate tests..."
|
||||
MODAL_COMMAND="$MODAL_ENV HF_API_KEY=$HF_API_KEY python3 -m modal run $MODAL_TEST_FILE::run_golden_gate_tests"
|
||||
;;
|
||||
"ssim")
|
||||
log "Running SSIM tests..."
|
||||
SSIM_BOOTSTRAP_ARGS=$(ssim_bootstrap_args)
|
||||
@@ -237,6 +233,10 @@ case "$TEST_TYPE" in
|
||||
log "Running unit tests..."
|
||||
MODAL_COMMAND="$MODAL_ENV python3 -m modal run $MODAL_TEST_FILE::run_unit_test"
|
||||
;;
|
||||
"preprocessing")
|
||||
log "Running preprocessing integration test..."
|
||||
MODAL_COMMAND="$MODAL_ENV HF_API_KEY=$HF_API_KEY python3 -m modal run $MODAL_TEST_FILE::run_preprocessing_tests"
|
||||
;;
|
||||
"dreamverse_app")
|
||||
log "Running DreamVerse app tests..."
|
||||
MODAL_COMMAND="$MODAL_ENV python3 -m modal run $MODAL_TEST_FILE::run_dreamverse_app_tests"
|
||||
|
||||
@@ -27,25 +27,14 @@ 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.
|
||||
# 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
|
||||
- name: Save trusted hook config
|
||||
if: github.event_name == 'pull_request_target'
|
||||
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
|
||||
run: cp .pre-commit-config.yaml "$RUNNER_TEMP/trusted-pre-commit-config.yaml"
|
||||
- uses: actions/checkout@v4
|
||||
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
|
||||
@@ -59,7 +48,5 @@ 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 "${GATE_SCRIPTS_DIR:-.github/scripts}/test_gate_full_suite.sh"
|
||||
run: bash .github/scripts/test_gate_full_suite.sh
|
||||
|
||||
@@ -129,7 +129,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
TEST_NAME=$(echo "$COMMENT" | grep -oP '(?<=/test\s)\S+' | head -1 || true)
|
||||
|
||||
VALID="encoder vae transformer kernel unit dreamverse ssim golden-gate training lora-inference lora-training lora-extraction distillation self-forcing vsa vmoba performance api train-framework eval full fastcheck pre-commit"
|
||||
VALID="encoder vae transformer kernel unit preprocessing dreamverse ssim training lora-inference lora-training lora-extraction distillation self-forcing vsa vmoba performance api train-framework eval full fastcheck pre-commit"
|
||||
if [ -z "$TEST_NAME" ] || ! echo "$VALID" | grep -qw "$TEST_NAME"; then
|
||||
echo "Unknown test: '$TEST_NAME'. Valid: $VALID"
|
||||
exit 1
|
||||
@@ -137,8 +137,9 @@ jobs:
|
||||
|
||||
declare -A MAP=(
|
||||
[encoder]=encoder [vae]=vae [transformer]=transformer
|
||||
[kernel]=kernel_tests [unit]=unit_test [dreamverse]=dreamverse_app
|
||||
[ssim]=ssim [golden-gate]=golden_gate [training]=training
|
||||
[kernel]=kernel_tests [unit]=unit_test [preprocessing]=preprocessing
|
||||
[dreamverse]=dreamverse_app
|
||||
[ssim]=ssim [training]=training
|
||||
[lora-inference]=inference_lora [lora-training]=training_lora
|
||||
[lora-extraction]=lora_extraction
|
||||
[distillation]=distillation_dmd [self-forcing]=self_forcing
|
||||
|
||||
@@ -13,23 +13,16 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
# 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.
|
||||
# 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.
|
||||
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:
|
||||
@@ -57,7 +50,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 an in-scope main push (inputs are null on push). The
|
||||
# on a push that changed docker/Dockerfile (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' }}
|
||||
|
||||
@@ -11,7 +11,9 @@ on:
|
||||
- 'requirements-mkdocs.txt'
|
||||
- 'scripts/check_docs_links.py'
|
||||
- '.github/workflows/infra-docs.yml'
|
||||
pull_request:
|
||||
# Run the trusted base-branch workflow so fork PRs can be skipped without
|
||||
# waiting for maintainer approval.
|
||||
pull_request_target:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
@@ -24,21 +26,19 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# MkDocs executes repository code; only trusted same-repository PRs run it.
|
||||
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
@@ -52,6 +52,7 @@ jobs:
|
||||
run: uv pip install --system -r requirements-mkdocs.txt
|
||||
|
||||
- name: Setup Pages
|
||||
if: github.event_name == 'push'
|
||||
uses: actions/configure-pages@v4
|
||||
|
||||
- name: Build documentation
|
||||
@@ -61,17 +62,22 @@ jobs:
|
||||
run: python scripts/check_docs_links.py
|
||||
|
||||
- name: Upload artifact
|
||||
if: github.event_name == 'push'
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: ./site
|
||||
|
||||
deploy:
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
concurrency: pages
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: github.ref == 'refs/heads/main'
|
||||
if: github.event_name == 'push'
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
|
||||
@@ -6,7 +6,6 @@ results/
|
||||
wandb/
|
||||
*.ipynb
|
||||
*.jpg
|
||||
!examples/datasets/lingbotworld2/image.jpg
|
||||
*.safetensors
|
||||
*.mp4
|
||||
*.png
|
||||
@@ -35,7 +34,6 @@ env
|
||||
*.log
|
||||
weights/
|
||||
logs/
|
||||
/Z-Image/
|
||||
official_weights/
|
||||
converted_weights/
|
||||
|
||||
@@ -75,7 +73,6 @@ 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. 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/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/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).
|
||||
@@ -33,7 +33,7 @@ FastVideo has the following features:
|
||||
- [Sparse distillation](https://hao-ai-lab.github.io/blogs/fastvideo_post_training/) to achieve >50x denoising speedup
|
||||
- Scalable training with FSDP2, sequence parallelism, and selective activation checkpointing.
|
||||
- Causal distillation through Self-Forcing
|
||||
- See this [page](https://hao-ai-lab.github.io/FastVideo/training/overview/) for the supported training workflows, and the [support matrix](https://hao-ai-lab.github.io/FastVideo/inference/support_matrix/) for supported models.
|
||||
- See this [page](https://hao-ai-lab.github.io/FastVideo/training/overview/) for full list of supported models and recipes.
|
||||
- State-of-the-art performance optimizations for inference
|
||||
- Sequence Parallelism for distributed inference
|
||||
- Multiple state-of-the-art attention backends
|
||||
|
||||
@@ -39,17 +39,6 @@ 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
|
||||
|
||||
@@ -13,9 +13,10 @@ test.describe('create inference job', () => {
|
||||
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();
|
||||
// The "Create Job" button reveals a workload menu on hover; wait for the
|
||||
// T2V item to become visible before clicking so the CSS hover transition
|
||||
// can't race the click.
|
||||
await page.getByRole('button', { name: /create job/i }).hover();
|
||||
const t2vItem = page.getByRole('menuitem', { name: /T2V/i });
|
||||
await expect(t2vItem).toBeVisible();
|
||||
await t2vItem.click();
|
||||
|
||||
@@ -4,7 +4,7 @@ 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.
|
||||
* (an <article> wrapping a <video>) captioned with its prompt.
|
||||
*/
|
||||
test.describe('gallery', () => {
|
||||
skipWithoutMock();
|
||||
@@ -30,15 +30,12 @@ test.describe('gallery', () => {
|
||||
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();
|
||||
// The completed job renders as an <article> containing a <video> tile.
|
||||
const tile = page
|
||||
.locator('article')
|
||||
.filter({ has: page.locator('video') });
|
||||
await expect(tile.first()).toBeVisible();
|
||||
|
||||
const video = tile.locator('video');
|
||||
if (await video.isVisible()) {
|
||||
await expect(video).toHaveAttribute('controls', '');
|
||||
}
|
||||
await expect(page.getByText(completed!.prompt)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { skipWithoutMock } from './helpers';
|
||||
|
||||
/**
|
||||
* Warm-model slot: load a model through the mock (which flips
|
||||
* loading -> ready after ~2s, surfaced by the panel's 5s poll), then unload it.
|
||||
*/
|
||||
test.describe('generators', () => {
|
||||
skipWithoutMock();
|
||||
|
||||
test('loads and unloads the resident model', async ({ page }) => {
|
||||
await page.goto('/inference');
|
||||
|
||||
const panel = page.getByRole('region', { name: 'Warm models' });
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel.getByText('No model loaded')).toBeVisible();
|
||||
|
||||
await panel
|
||||
.getByLabel('Model to load')
|
||||
.selectOption('Wan-AI/Wan2.1-T2V-1.3B-Diffusers');
|
||||
await panel.getByRole('button', { name: 'Load model' }).click();
|
||||
|
||||
await expect(panel.getByText('Wan2.1 T2V 1.3B Diffusers')).toBeVisible();
|
||||
await expect(panel.getByText('ready')).toBeVisible();
|
||||
|
||||
await panel.getByRole('button', { name: 'Unload' }).click();
|
||||
await expect(panel.getByText('No model loaded')).toBeVisible();
|
||||
});
|
||||
|
||||
test('engine console streams output while open', async ({ page }) => {
|
||||
await page.goto('/inference');
|
||||
|
||||
const engineConsole = page.getByRole('region', { name: 'Engine output' });
|
||||
await engineConsole.getByRole('button', { name: 'Engine output' }).click();
|
||||
|
||||
await expect(engineConsole.getByText(/\[engine\]/).first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -42,74 +42,6 @@ test.describe('app shell', () => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,90 +72,3 @@ def get_gpu_snapshot() -> dict[str, Any]:
|
||||
except Exception as exc: # NVMLError, driver issues, …
|
||||
logger.warning("GPU snapshot failed: %s", exc)
|
||||
return {"available": False, "gpus": [], "error": str(exc)}
|
||||
|
||||
|
||||
def _remote_gpu_probe() -> dict[str, Any]:
|
||||
"""Self-contained per-node NVML probe (runs as a ray task on each node;
|
||||
no fastvideo_studio import — worker environments don't have apps/ on
|
||||
their path, so cloudpickle must carry this by value)."""
|
||||
import contextlib as _ctx
|
||||
import socket as _socket
|
||||
out: dict[str, Any] = {"hostname": _socket.gethostname(), "available": False, "gpus": [], "error": None}
|
||||
try:
|
||||
import pynvml
|
||||
pynvml.nvmlInit()
|
||||
for i in range(pynvml.nvmlDeviceGetCount()):
|
||||
h = pynvml.nvmlDeviceGetHandleByIndex(i)
|
||||
name = pynvml.nvmlDeviceGetName(h)
|
||||
if isinstance(name, bytes):
|
||||
name = name.decode()
|
||||
mem = pynvml.nvmlDeviceGetMemoryInfo(h)
|
||||
util = pynvml.nvmlDeviceGetUtilizationRates(h)
|
||||
temp = power = plimit = None
|
||||
with _ctx.suppress(pynvml.NVMLError):
|
||||
temp = int(pynvml.nvmlDeviceGetTemperature(h, pynvml.NVML_TEMPERATURE_GPU))
|
||||
with _ctx.suppress(pynvml.NVMLError):
|
||||
power = pynvml.nvmlDeviceGetPowerUsage(h) / 1000.0
|
||||
plimit = pynvml.nvmlDeviceGetEnforcedPowerLimit(h) / 1000.0
|
||||
out["gpus"].append({
|
||||
"index": i,
|
||||
"name": name,
|
||||
"utilization": int(util.gpu),
|
||||
"memory_used_mib": int(mem.used / (1024 * 1024)),
|
||||
"memory_total_mib": int(mem.total / (1024 * 1024)),
|
||||
"temperature_c": temp,
|
||||
"power_watts": power,
|
||||
"power_limit_watts": plimit,
|
||||
})
|
||||
out["available"] = True
|
||||
except Exception as exc: # noqa: BLE001 -- reported per node
|
||||
out["error"] = str(exc)
|
||||
return out
|
||||
|
||||
|
||||
def get_cluster_snapshot() -> dict[str, Any]:
|
||||
"""Cluster-wide GPU/host telemetry.
|
||||
|
||||
When this process is connected to a ray cluster (a model has been
|
||||
loaded), probe every alive node via per-node ray tasks. Otherwise fall
|
||||
back to this host's NVML snapshot. Never raises.
|
||||
"""
|
||||
import socket
|
||||
|
||||
local = get_gpu_snapshot()
|
||||
local_node = {"hostname": socket.gethostname(), "ip": None, "is_this_host": True,
|
||||
"cpus": None, "ray_gpus": None, **local}
|
||||
out: dict[str, Any] = {"mode": "local", "nodes": [local_node],
|
||||
"resources": None, "error": None}
|
||||
try:
|
||||
import ray
|
||||
if not ray.is_initialized():
|
||||
out["error"] = "not connected to a ray cluster yet (load a model first); showing the API host only"
|
||||
return out
|
||||
from ray.util.scheduling_strategies import NodeAffinitySchedulingStrategy
|
||||
alive = [n for n in ray.nodes() if n.get("Alive")]
|
||||
probe = ray.remote(num_cpus=0)(_remote_gpu_probe)
|
||||
refs = [probe.options(scheduling_strategy=NodeAffinitySchedulingStrategy(
|
||||
node_id=n["NodeID"], soft=True)).remote() for n in alive]
|
||||
snaps = ray.get(refs, timeout=15)
|
||||
nodes = []
|
||||
for n, snap in zip(alive, snaps, strict=True):
|
||||
nodes.append({
|
||||
"ip": n.get("NodeManagerAddress"),
|
||||
"is_this_host": snap.get("hostname") == socket.gethostname(),
|
||||
"cpus": n.get("Resources", {}).get("CPU"),
|
||||
"ray_gpus": n.get("Resources", {}).get("GPU"),
|
||||
**snap,
|
||||
})
|
||||
out["mode"] = "ray"
|
||||
out["nodes"] = nodes
|
||||
out["resources"] = {
|
||||
"gpus_total": ray.cluster_resources().get("GPU", 0.0),
|
||||
"gpus_available": ray.available_resources().get("GPU", 0.0),
|
||||
}
|
||||
except Exception as exc: # noqa: BLE001 -- degrade to the local view
|
||||
logger.warning("cluster snapshot failed: %s", exc)
|
||||
out["mode"] = "local"
|
||||
out["nodes"] = [local_node]
|
||||
out["error"] = f"cluster probe failed: {exc}"
|
||||
return out
|
||||
|
||||
@@ -41,11 +41,6 @@ _TQDM_FRAC_RE = re.compile(r"\b(\d+)/(\d+)\b")
|
||||
|
||||
_MAX_LOG_LINES = 2000 # ring-buffer cap per job
|
||||
|
||||
# ray's log relay prefixes worker lines like "(RayWorkerWrapper pid=123, ip=…)"
|
||||
# — usually wrapped in ANSI color codes, which must be stripped before matching.
|
||||
_RAY_RELAY_RE = re.compile(r"^\(\w+ pid=")
|
||||
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
|
||||
class JobStatus(str, enum.Enum):
|
||||
PENDING = "pending"
|
||||
@@ -264,36 +259,13 @@ class JobRunner:
|
||||
self._jobs_lock = threading.Lock()
|
||||
self._load_jobs()
|
||||
|
||||
# Exactly one generator lives in memory at a time. Loading a new
|
||||
# config always releases the old instance first (shutdown + placement
|
||||
# group teardown); unload deletes it outright.
|
||||
self._generator: Any | None = None
|
||||
self._generator_config: dict[str, Any] | None = None
|
||||
self._generator_state: str = "empty" # empty | loading | ready | failed
|
||||
self._generator_error: str | None = None
|
||||
self._generator_lock = threading.Lock() # guards the fields above
|
||||
# Serializes every slot transition (preload, job-triggered replace,
|
||||
# unload). Held for the full duration of a load.
|
||||
self._load_lock = threading.Lock()
|
||||
# All generator creations run on this ONE persistent thread: the mp
|
||||
# executor arms prctl(PR_SET_PDEATHSIG, SIGKILL) in its workers, and
|
||||
# on Linux that fires when the CREATING THREAD exits — a generator
|
||||
# spawned from a short-lived thread loses all its workers (silent
|
||||
# SIGKILL, zombies) the moment that thread finishes.
|
||||
import queue as _queue
|
||||
self._loader_queue: _queue.Queue = _queue.Queue()
|
||||
threading.Thread(target=self._loader_loop, daemon=True,
|
||||
name="generator-loader").start()
|
||||
# The inference job currently generating, fed by the engine log tee
|
||||
# (ray relays worker output to the driver; tqdm lines land there).
|
||||
self._active_inference_job: Job | None = None
|
||||
# Cache of loaded generators keyed by model config so that we only pay
|
||||
# the model-loading cost once per model configuration.
|
||||
self._generators: dict[tuple, Any] = {}
|
||||
self._generators_lock = threading.Lock()
|
||||
|
||||
# Shared Manager for log queues (avoids spawning a new process per job)
|
||||
self._mp_manager = get_mp_context().Manager()
|
||||
# One queue for the generator's whole life: mp workers get it at spawn
|
||||
# (creation-time attach). Sending a Manager proxy over the executor's
|
||||
# worker pipes post-hoc (set_log_queue RPC) breaks the pipe.
|
||||
self._worker_log_queue = self._mp_manager.Queue()
|
||||
atexit.register(self._shutdown)
|
||||
|
||||
# Ensure directories exist
|
||||
@@ -645,211 +617,6 @@ class JobRunner:
|
||||
"phase": job._log_buf.phase,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _generator_config_dict(
|
||||
model_id: str,
|
||||
workload_type: str,
|
||||
num_gpus: int,
|
||||
dit_cpu_offload: bool = False,
|
||||
text_encoder_cpu_offload: bool = False,
|
||||
vae_cpu_offload: bool = False,
|
||||
image_encoder_cpu_offload: bool = False,
|
||||
use_fsdp_inference: bool = False,
|
||||
enable_torch_compile: bool = False,
|
||||
vsa_sparsity: float = 0.0,
|
||||
tp_size: int = -1,
|
||||
sp_size: int = -1,
|
||||
) -> dict[str, Any]:
|
||||
"""Canonical engine-config dict; equality here == same generator."""
|
||||
return {
|
||||
"model_id": model_id,
|
||||
"workload_type": workload_type,
|
||||
"num_gpus": num_gpus,
|
||||
"dit_cpu_offload": dit_cpu_offload,
|
||||
"text_encoder_cpu_offload": text_encoder_cpu_offload,
|
||||
"vae_cpu_offload": vae_cpu_offload,
|
||||
"image_encoder_cpu_offload": image_encoder_cpu_offload,
|
||||
"use_fsdp_inference": use_fsdp_inference,
|
||||
"enable_torch_compile": enable_torch_compile,
|
||||
"vsa_sparsity": vsa_sparsity,
|
||||
"tp_size": tp_size,
|
||||
"sp_size": sp_size,
|
||||
}
|
||||
|
||||
def _slot_entry(self) -> dict[str, Any]:
|
||||
return {
|
||||
"state": self._generator_state,
|
||||
"error": self._generator_error,
|
||||
**(self._generator_config or {}),
|
||||
}
|
||||
|
||||
def _running_inference_jobs(self) -> list[str]:
|
||||
with self._jobs_lock:
|
||||
return [j.id for j in self._jobs.values()
|
||||
if j.status == JobStatus.RUNNING and j.job_type == "inference"]
|
||||
|
||||
def _loader_loop(self) -> None:
|
||||
while True:
|
||||
fn = self._loader_queue.get()
|
||||
try:
|
||||
fn()
|
||||
except BaseException: # noqa: BLE001 -- surfaced via the caller's box
|
||||
pass
|
||||
finally:
|
||||
self._loader_queue.task_done()
|
||||
|
||||
def _run_on_loader(self, fn: Any) -> Any:
|
||||
"""Run ``fn`` on the persistent loader thread and return its result."""
|
||||
box: dict[str, Any] = {}
|
||||
done = threading.Event()
|
||||
|
||||
def wrapped() -> None:
|
||||
try:
|
||||
box["r"] = fn()
|
||||
except BaseException as exc: # noqa: BLE001 -- re-raised below
|
||||
box["e"] = exc
|
||||
finally:
|
||||
done.set()
|
||||
|
||||
self._loader_queue.put(wrapped)
|
||||
done.wait()
|
||||
if "e" in box:
|
||||
raise box["e"]
|
||||
return box["r"]
|
||||
|
||||
def preload_generator(self, **params: Any) -> dict[str, Any]:
|
||||
"""Load a model into memory ahead of time. One load at a time; loading
|
||||
a different config always releases the current instance first."""
|
||||
config = self._generator_config_dict(**params)
|
||||
with self._generator_lock:
|
||||
if self._generator_state == "ready" and self._generator_config == config:
|
||||
return self._slot_entry()
|
||||
if not self._load_lock.acquire(blocking=False):
|
||||
raise RuntimeError("a model load is already in progress")
|
||||
try:
|
||||
if self._running_inference_jobs():
|
||||
raise RuntimeError("cannot swap models while inference jobs are running")
|
||||
with self._generator_lock:
|
||||
self._generator_state = "loading"
|
||||
self._generator_config = config
|
||||
self._generator_error = None
|
||||
entry = self._slot_entry()
|
||||
except BaseException:
|
||||
self._load_lock.release()
|
||||
raise
|
||||
|
||||
def _load() -> None:
|
||||
try: # the preload owns _load_lock until the load resolves
|
||||
self._run_on_loader(lambda: self._load_into_slot_locked(config))
|
||||
except Exception: # noqa: BLE001 -- state already set to failed
|
||||
pass
|
||||
finally:
|
||||
self._load_lock.release()
|
||||
|
||||
threading.Thread(target=_load, daemon=True, name="generator-preload").start()
|
||||
return entry
|
||||
|
||||
def _load_into_slot_locked(self, config: dict[str, Any]) -> Any:
|
||||
"""Release whatever is resident and load ``config``. Caller MUST hold
|
||||
``_load_lock``. State is 'loading' on entry or set here."""
|
||||
with self._generator_lock:
|
||||
gen = self._generator
|
||||
self._generator = None
|
||||
self._generator_state = "loading"
|
||||
self._generator_config = config
|
||||
self._generator_error = None
|
||||
if gen is not None:
|
||||
logger.info("Releasing resident generator before loading a new one")
|
||||
gen.shutdown()
|
||||
del gen
|
||||
|
||||
# Import lazily so starting the server is fast even without a GPU.
|
||||
from fastvideo import VideoGenerator
|
||||
|
||||
# Deployment-level knobs (set where the API server is launched):
|
||||
# FASTVIDEO_STUDIO_MODEL_PATHS="id=/local/dir,..." serves a registered
|
||||
# model id from local weights instead of the HF hub;
|
||||
# FASTVIDEO_STUDIO_EXECUTOR_BACKEND=ray runs workers on an existing
|
||||
# Ray cluster (the multi-node path — "mp" spawns local processes only).
|
||||
model_path = config["model_id"]
|
||||
for pair in os.environ.get("FASTVIDEO_STUDIO_MODEL_PATHS", "").split(","):
|
||||
mid, sep, path = pair.partition("=")
|
||||
if sep and mid.strip() == config["model_id"]:
|
||||
model_path = path.strip()
|
||||
executor_kwargs: dict[str, Any] = {}
|
||||
backend = os.environ.get("FASTVIDEO_STUDIO_EXECUTOR_BACKEND")
|
||||
if backend:
|
||||
executor_kwargs["distributed_executor_backend"] = backend
|
||||
|
||||
logger.info("Loading model %s (%s)", config["model_id"],
|
||||
", ".join(f"{k}={v}" for k, v in config.items() if k != "model_id"))
|
||||
try:
|
||||
new_gen = VideoGenerator.from_pretrained(
|
||||
model_path,
|
||||
workload_type=config["workload_type"],
|
||||
num_gpus=config["num_gpus"],
|
||||
dit_cpu_offload=config["dit_cpu_offload"],
|
||||
# FastVideoArgs defaults this True, which disables FSDP and
|
||||
# parks a full DiT copy in host RAM per worker — the mp
|
||||
# executor's 4 workers OOM-killed the node silently. The UI's
|
||||
# offload toggles are the studio's contract; layerwise off.
|
||||
dit_layerwise_offload=False,
|
||||
text_encoder_cpu_offload=config["text_encoder_cpu_offload"],
|
||||
vae_cpu_offload=config["vae_cpu_offload"],
|
||||
image_encoder_cpu_offload=config["image_encoder_cpu_offload"],
|
||||
use_fsdp_inference=config["use_fsdp_inference"],
|
||||
enable_torch_compile=config["enable_torch_compile"],
|
||||
VSA_sparsity=config["vsa_sparsity"],
|
||||
tp_size=config["tp_size"],
|
||||
sp_size=config["sp_size"],
|
||||
log_queue=self._worker_log_queue,
|
||||
**executor_kwargs,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Model load failed for %s", config["model_id"])
|
||||
with self._generator_lock:
|
||||
self._generator_state = "failed"
|
||||
self._generator_error = str(exc)
|
||||
raise
|
||||
with self._generator_lock:
|
||||
self._generator = new_gen
|
||||
self._generator_config = config
|
||||
self._generator_state = "ready"
|
||||
self._generator_error = None
|
||||
return new_gen
|
||||
|
||||
def list_generators(self) -> list[dict[str, Any]]:
|
||||
"""The resident slot, or empty when nothing is loaded."""
|
||||
with self._generator_lock:
|
||||
if self._generator_state == "empty":
|
||||
return []
|
||||
return [self._slot_entry()]
|
||||
|
||||
def unload_generator(self, **_ignored: Any) -> bool:
|
||||
"""Shut down and delete the resident generator, freeing GPU memory."""
|
||||
if not self._load_lock.acquire(blocking=False):
|
||||
raise RuntimeError("cannot unload while a model load is in progress")
|
||||
try:
|
||||
running = self._running_inference_jobs()
|
||||
if running:
|
||||
raise RuntimeError(f"cannot unload while inference jobs are running: {running}")
|
||||
with self._generator_lock:
|
||||
gen = self._generator
|
||||
empty = self._generator_state == "empty"
|
||||
self._generator = None
|
||||
self._generator_state = "empty"
|
||||
self._generator_config = None
|
||||
self._generator_error = None
|
||||
if empty:
|
||||
return False
|
||||
if gen is not None:
|
||||
logger.info("Releasing resident generator")
|
||||
gen.shutdown()
|
||||
del gen
|
||||
return True
|
||||
finally:
|
||||
self._load_lock.release()
|
||||
|
||||
def _get_or_create_generator(
|
||||
self,
|
||||
model_id: str,
|
||||
@@ -866,53 +633,69 @@ class JobRunner:
|
||||
sp_size: int = -1,
|
||||
log_queue: mp.Queue | None = None,
|
||||
) -> Any:
|
||||
"""Return the resident generator if the config matches; otherwise
|
||||
replace the slot. Blocks behind any in-flight load — every slot
|
||||
transition happens under ``_load_lock``, so a job can never observe a
|
||||
half-replaced slot."""
|
||||
del log_queue # single-slot generators log via the engine tee
|
||||
config = self._generator_config_dict(
|
||||
model_id=model_id,
|
||||
cache_key = (
|
||||
model_id,
|
||||
workload_type,
|
||||
num_gpus,
|
||||
dit_cpu_offload,
|
||||
text_encoder_cpu_offload,
|
||||
vae_cpu_offload,
|
||||
image_encoder_cpu_offload,
|
||||
use_fsdp_inference,
|
||||
enable_torch_compile,
|
||||
vsa_sparsity,
|
||||
tp_size,
|
||||
sp_size,
|
||||
)
|
||||
|
||||
# Generators are cached by model_id and configuration parameters
|
||||
with self._generators_lock:
|
||||
if cache_key in self._generators:
|
||||
return self._generators[cache_key]
|
||||
|
||||
# Import lazily so starting the server is fast even without a GPU.
|
||||
from fastvideo import VideoGenerator
|
||||
|
||||
logger.info(
|
||||
"Loading model %s (workload=%s, num_gpus=%d, offloads: "
|
||||
"dit=%s text_encoder=%s vae=%s image_encoder=%s, fsdp=%s, "
|
||||
"torch_compile=%s, vsa_sparsity=%.2f, tp=%d sp=%d) …",
|
||||
model_id,
|
||||
workload_type,
|
||||
num_gpus,
|
||||
dit_cpu_offload,
|
||||
text_encoder_cpu_offload,
|
||||
vae_cpu_offload,
|
||||
image_encoder_cpu_offload,
|
||||
use_fsdp_inference,
|
||||
enable_torch_compile,
|
||||
vsa_sparsity,
|
||||
tp_size,
|
||||
sp_size,
|
||||
)
|
||||
|
||||
gen = VideoGenerator.from_pretrained(
|
||||
model_id,
|
||||
workload_type=workload_type,
|
||||
num_gpus=num_gpus,
|
||||
dit_cpu_offload=dit_cpu_offload,
|
||||
text_encoder_cpu_offload=text_encoder_cpu_offload,
|
||||
vae_cpu_offload=vae_cpu_offload,
|
||||
image_encoder_cpu_offload=image_encoder_cpu_offload,
|
||||
use_fsdp_inference=use_fsdp_inference,
|
||||
enable_torch_compile=enable_torch_compile,
|
||||
vsa_sparsity=vsa_sparsity,
|
||||
VSA_sparsity=vsa_sparsity,
|
||||
tp_size=tp_size,
|
||||
sp_size=sp_size,
|
||||
log_queue=log_queue,
|
||||
)
|
||||
with self._load_lock: # waits out preloads / other jobs' replaces
|
||||
with self._generator_lock:
|
||||
if (self._generator_state == "ready"
|
||||
and self._generator_config == config
|
||||
and self._generator is not None):
|
||||
return self._generator
|
||||
return self._run_on_loader(lambda: self._load_into_slot_locked(config))
|
||||
|
||||
def feed_engine_line(self, line: str) -> None:
|
||||
"""Bridge ray-relayed worker output into the running job's log buffer.
|
||||
|
||||
On the ray backend worker logs cannot cross nodes via the mp queue,
|
||||
but ray already relays them to the driver's stdout — which the engine
|
||||
tee captures. Lines with ray's actor prefix are attributed to the one
|
||||
running inference job, whose buffer parses tqdm into UI progress.
|
||||
Driver-side logging is excluded (it reaches the buffer via the
|
||||
logging handlers already).
|
||||
"""
|
||||
job = self._active_inference_job
|
||||
if job is None:
|
||||
return
|
||||
line = _ANSI_RE.sub("", line)
|
||||
if not _RAY_RELAY_RE.match(line):
|
||||
return
|
||||
try:
|
||||
job._log_buf.write(line)
|
||||
except Exception: # noqa: BLE001 -- never break the tee
|
||||
pass
|
||||
with self._generators_lock:
|
||||
if cache_key not in self._generators:
|
||||
self._generators[cache_key] = gen
|
||||
else: # Another thread may have created it while we were loading.
|
||||
gen.shutdown()
|
||||
gen = self._generators[cache_key]
|
||||
return gen
|
||||
|
||||
def _run_job(self, job: Job):
|
||||
if job.job_type == "inference":
|
||||
@@ -1058,12 +841,10 @@ class JobRunner:
|
||||
fastvideo_logger.addHandler(buffer_handler)
|
||||
fastvideo_logger.addHandler(file_handler)
|
||||
|
||||
# Worker logs flow through the runner-wide queue the generator was
|
||||
# created with; drain anything stale, then listen for this job.
|
||||
log_queue = self._worker_log_queue
|
||||
with contextlib.suppress(Exception):
|
||||
while True:
|
||||
log_queue.get_nowait()
|
||||
# Queue for worker process logs (fsdp_load, cuda, etc.)
|
||||
# Use Manager().Queue() so it can be shared with spawned workers (spawn
|
||||
# does not inherit memory; mp.Queue only works through inheritance).
|
||||
log_queue = self._mp_manager.Queue()
|
||||
queue_listener = logging.handlers.QueueListener(log_queue,
|
||||
buffer_handler,
|
||||
file_handler,
|
||||
@@ -1145,7 +926,6 @@ class JobRunner:
|
||||
|
||||
generator = _gen_result[0]
|
||||
buf.phase = "generating"
|
||||
self._active_inference_job = job # engine tee feeds tqdm from here
|
||||
logger.info("Starting generation for job %s (model=%s)", job.id, job.model_id)
|
||||
|
||||
gen_kwargs: dict[str, Any] = {
|
||||
@@ -1161,6 +941,7 @@ class JobRunner:
|
||||
"fps": job.fps,
|
||||
"seed": job.seed,
|
||||
"negative_prompt": job.negative_prompt or "",
|
||||
"log_queue": log_queue,
|
||||
}
|
||||
if job.image_path:
|
||||
gen_kwargs["image_path"] = job.image_path
|
||||
@@ -1204,8 +985,6 @@ class JobRunner:
|
||||
buf.phase = "failed"
|
||||
|
||||
finally:
|
||||
if self._active_inference_job is job:
|
||||
self._active_inference_job = None
|
||||
queue_listener.stop()
|
||||
# Remove handlers and close file
|
||||
fastvideo_logger.removeHandler(buffer_handler)
|
||||
|
||||
@@ -36,15 +36,13 @@ 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, GeneratorRequest, SettingsUpdate,
|
||||
UpdateCaptionRequest, model_label)
|
||||
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
|
||||
# How long a preloading generator stays "loading" before it flips to "ready".
|
||||
GENERATOR_READY_AFTER_SECONDS = 2.0
|
||||
FFMPEG_BIN = shutil.which(os.getenv("FASTVIDEO_FFMPEG_BIN", "ffmpeg"))
|
||||
|
||||
# A small catalogue of fake models keyed by workload type. Mirrors the real
|
||||
@@ -90,10 +88,6 @@ _DEFAULT_SETTINGS: dict[str, Any] = {
|
||||
_state_lock = threading.Lock()
|
||||
_settings: dict[str, Any] = dict(_DEFAULT_SETTINGS)
|
||||
_jobs: dict[str, dict[str, Any]] = {}
|
||||
# The engine's single model slot: None when empty, else the state dict.
|
||||
_generator_slot: dict[str, Any] | None = None
|
||||
# Fake engine stdout/stderr tail; grows a little on every poll.
|
||||
_engine_log_lines: list[str] = ["[engine] FastVideo studio mock engine started"]
|
||||
_datasets: dict[str, dict[str, Any]] = {}
|
||||
# dataset_id -> {"file_names": [...], "captions": {file_name: caption}}
|
||||
_dataset_files: dict[str, dict[str, Any]] = {}
|
||||
@@ -399,56 +393,6 @@ def list_models(workload_type: str | None = None) -> list[dict[str, Any]]:
|
||||
return _models_for(workload_type)
|
||||
|
||||
|
||||
# Per-model sampling presets, mirroring the real /api/models/presets shape
|
||||
# (keys the model has no recommendation for are simply absent).
|
||||
_MODEL_PRESETS: dict[str, dict[str, Any]] = {
|
||||
"Wan-AI/Wan2.1-T2V-1.3B-Diffusers": {
|
||||
"height": 480,
|
||||
"width": 832,
|
||||
"num_frames": 81,
|
||||
"fps": 16,
|
||||
"num_inference_steps": 50,
|
||||
"guidance_scale": 3.0,
|
||||
"guidance_rescale": 0.0,
|
||||
"negative_prompt": "Bright tones, overexposed, static, blurred details",
|
||||
"seed": 1024,
|
||||
},
|
||||
"Wan-AI/Wan2.1-I2V-14B-480P-Diffusers": {
|
||||
"height": 480,
|
||||
"width": 832,
|
||||
"num_frames": 81,
|
||||
"fps": 16,
|
||||
"num_inference_steps": 40,
|
||||
"guidance_scale": 5.0,
|
||||
"seed": 1024,
|
||||
},
|
||||
"black-forest-labs/FLUX.1-schnell": {
|
||||
"height": 1024,
|
||||
"width": 1024,
|
||||
"num_frames": 1,
|
||||
"num_inference_steps": 4,
|
||||
"guidance_scale": 0.0,
|
||||
"seed": 42,
|
||||
},
|
||||
}
|
||||
_GENERIC_PRESETS: dict[str, Any] = {
|
||||
"height": 720,
|
||||
"width": 1280,
|
||||
"num_frames": 81,
|
||||
"fps": 24,
|
||||
"num_inference_steps": 50,
|
||||
"guidance_scale": 5.0,
|
||||
"guidance_rescale": 0.0,
|
||||
"seed": 1024,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/models/presets")
|
||||
def model_presets(model_id: str) -> dict[str, Any]:
|
||||
"""Recommended sampling settings; unknown models get generic defaults."""
|
||||
return _MODEL_PRESETS.get(model_id, _GENERIC_PRESETS)
|
||||
|
||||
|
||||
# --- GPUs -------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -470,49 +414,6 @@ def list_gpus() -> dict[str, Any]:
|
||||
return {"available": True, "gpus": gpus, "error": None}
|
||||
|
||||
|
||||
@app.get("/api/cluster")
|
||||
def cluster_status() -> dict[str, Any]:
|
||||
"""Two fake ray nodes x 4 GPUs with per-request jitter, mirroring the
|
||||
real server's /api/cluster shape."""
|
||||
nodes = []
|
||||
for host_idx, (hostname, ip, is_this_host) in enumerate([
|
||||
("mock-node-0", "10.0.0.10", True),
|
||||
("mock-node-1", "10.0.0.11", False),
|
||||
]):
|
||||
gpus = []
|
||||
for index in range(4):
|
||||
base_util = (13 + 29 * index + 41 * host_idx) % 100
|
||||
gpus.append({
|
||||
"index": index,
|
||||
"name": "NVIDIA Mock GPU 80GB",
|
||||
"utilization": max(0, min(100, base_util + random.randint(-5, 5))),
|
||||
"memory_used_mib": 6_144 + 17_408 * index + random.randint(-256, 256),
|
||||
"memory_total_mib": 81_920,
|
||||
"temperature_c": 42 + 6 * index + random.randint(-3, 3),
|
||||
"power_watts": 110.0 + 140.0 * index + random.randint(-20, 20),
|
||||
"power_limit_watts": 700.0,
|
||||
})
|
||||
nodes.append({
|
||||
"hostname": hostname,
|
||||
"ip": ip,
|
||||
"is_this_host": is_this_host,
|
||||
"cpus": 64.0,
|
||||
"ray_gpus": 4.0,
|
||||
"available": True,
|
||||
"error": None,
|
||||
"gpus": gpus,
|
||||
})
|
||||
return {
|
||||
"mode": "ray",
|
||||
"nodes": nodes,
|
||||
"resources": {
|
||||
"gpus_total": 8.0,
|
||||
"gpus_available": 5.0
|
||||
},
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
# --- Uploads ----------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -669,92 +570,6 @@ def download_log(job_id: str) -> PlainTextResponse:
|
||||
return PlainTextResponse("\n".join(lines) + "\n", media_type="text/plain")
|
||||
|
||||
|
||||
# --- Generators (warm models) -------------------------------------------------
|
||||
|
||||
|
||||
def _advance_generator(entry: dict[str, Any]) -> None:
|
||||
"""Flip a loading generator to ready once enough wall-clock time has passed.
|
||||
|
||||
Like job status, generator state is *computed on read*, so polling the
|
||||
generators list naturally shows loading -> ready.
|
||||
"""
|
||||
if entry["state"] == "loading" and time.time() - entry["started_at"] >= GENERATOR_READY_AFTER_SECONDS:
|
||||
entry["state"] = "ready"
|
||||
|
||||
|
||||
def _running_inference_ids() -> list[str]:
|
||||
return [
|
||||
j["id"] for j in _jobs.values() if j.get("job_type") == "inference" and _public_job(j)["status"] == "running"
|
||||
]
|
||||
|
||||
|
||||
@app.get("/api/generators")
|
||||
def list_generators() -> list[dict[str, Any]]:
|
||||
with _state_lock:
|
||||
if _generator_slot is None:
|
||||
return []
|
||||
_advance_generator(_generator_slot)
|
||||
return [dict(_generator_slot)]
|
||||
|
||||
|
||||
@app.post("/api/generators/preload", status_code=202)
|
||||
def preload_generator(req: GeneratorRequest) -> dict[str, Any]:
|
||||
global _generator_slot
|
||||
valid_ids = {m["id"] for m in _models_for(None)}
|
||||
if req.model_id not in valid_ids:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown model_id '{req.model_id}'. Valid options: {sorted(valid_ids)}",
|
||||
)
|
||||
with _state_lock:
|
||||
if _generator_slot is not None:
|
||||
_advance_generator(_generator_slot)
|
||||
if _generator_slot["state"] == "loading":
|
||||
raise HTTPException(status_code=409, detail="a model load is already in progress")
|
||||
if _generator_slot["state"] == "ready" and all(
|
||||
_generator_slot.get(k) == v for k, v in req.model_dump().items()):
|
||||
return dict(_generator_slot)
|
||||
running = _running_inference_ids()
|
||||
if running:
|
||||
raise HTTPException(status_code=409,
|
||||
detail=f"cannot swap models while inference jobs are running: {running}")
|
||||
# Loading a new model always replaces (releases) the resident one.
|
||||
_generator_slot = {"state": "loading", "started_at": time.time(), "error": None, **req.model_dump()}
|
||||
return dict(_generator_slot)
|
||||
|
||||
|
||||
@app.post("/api/generators/unload")
|
||||
def unload_generator() -> dict[str, Any]:
|
||||
global _generator_slot
|
||||
with _state_lock:
|
||||
if _generator_slot is not None:
|
||||
_advance_generator(_generator_slot)
|
||||
if _generator_slot["state"] == "loading":
|
||||
raise HTTPException(status_code=409, detail="cannot unload while a model load is in progress")
|
||||
if _generator_slot is None:
|
||||
raise HTTPException(status_code=404, detail="No model is loaded")
|
||||
running = _running_inference_ids()
|
||||
if running:
|
||||
raise HTTPException(status_code=409, detail=f"cannot unload while inference jobs are running: {running}")
|
||||
_generator_slot = None
|
||||
return {"unloaded": True}
|
||||
|
||||
|
||||
# --- Engine logs --------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/api/engine/logs")
|
||||
def engine_logs(after: int = 0) -> dict[str, Any]:
|
||||
"""Growing fake tail of the engine's stdout/stderr: every poll appends a
|
||||
couple of lines so the console visibly streams."""
|
||||
with _state_lock:
|
||||
n = len(_engine_log_lines)
|
||||
_engine_log_lines.append(f"[engine] step {n}: worker heartbeat ok")
|
||||
_engine_log_lines.append(f"[engine] step {n + 1}: gpu mem {random.randint(20, 80)}% used")
|
||||
total = len(_engine_log_lines)
|
||||
return {"lines": _engine_log_lines[max(0, after):], "total": total}
|
||||
|
||||
|
||||
# --- Datasets ---------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
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.generator_request import GeneratorRequest
|
||||
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
|
||||
@@ -16,7 +15,6 @@ def model_label(model_path: str) -> str:
|
||||
|
||||
__all__ = [
|
||||
"CreateJobRequest",
|
||||
"GeneratorRequest",
|
||||
"SettingsUpdate",
|
||||
"CreateDatasetRequest",
|
||||
"UpdateCaptionRequest",
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Request model for preloading/unloading a resident generator.
|
||||
|
||||
Field names and defaults mirror the engine subset of ``CreateJobRequest`` so
|
||||
the UI can send exactly the values it would put on a job — guaranteeing the
|
||||
job's generator lookup hits this cache entry.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class GeneratorRequest(BaseModel):
|
||||
model_id: str
|
||||
workload_type: str = "t2v"
|
||||
num_gpus: int = 1
|
||||
dit_cpu_offload: bool = False
|
||||
text_encoder_cpu_offload: bool = False
|
||||
vae_cpu_offload: bool = False
|
||||
image_encoder_cpu_offload: bool = False
|
||||
use_fsdp_inference: bool = False
|
||||
enable_torch_compile: bool = False
|
||||
vsa_sparsity: float = 0.0
|
||||
tp_size: int = -1
|
||||
sp_size: int = -1
|
||||
Generated
-681
@@ -9,7 +9,6 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@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",
|
||||
@@ -1937,183 +1936,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu": {
|
||||
"version": "2.1.24",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.24.tgz",
|
||||
"integrity": "sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-context": "1.2.2",
|
||||
"@radix-ui/react-id": "1.1.4",
|
||||
"@radix-ui/react-menu": "2.1.24",
|
||||
"@radix-ui/react-primitive": "2.1.10",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz",
|
||||
"integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
|
||||
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz",
|
||||
"integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-id": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz",
|
||||
"integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz",
|
||||
"integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz",
|
||||
"integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-controllable-state": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz",
|
||||
"integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.7",
|
||||
"@radix-ui/react-use-effect-event": "0.0.5",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-effect-event": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz",
|
||||
"integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
|
||||
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-guards": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz",
|
||||
@@ -2195,494 +2017,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu": {
|
||||
"version": "2.1.24",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.24.tgz",
|
||||
"integrity": "sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.7",
|
||||
"@radix-ui/react-collection": "1.1.15",
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-context": "1.2.2",
|
||||
"@radix-ui/react-direction": "1.1.4",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.19",
|
||||
"@radix-ui/react-focus-guards": "1.1.6",
|
||||
"@radix-ui/react-focus-scope": "1.1.16",
|
||||
"@radix-ui/react-id": "1.1.4",
|
||||
"@radix-ui/react-popper": "1.3.7",
|
||||
"@radix-ui/react-portal": "1.1.17",
|
||||
"@radix-ui/react-presence": "1.1.10",
|
||||
"@radix-ui/react-primitive": "2.1.10",
|
||||
"@radix-ui/react-roving-focus": "1.1.19",
|
||||
"@radix-ui/react-slot": "1.3.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.4",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz",
|
||||
"integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz",
|
||||
"integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-collection": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz",
|
||||
"integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-context": "1.2.2",
|
||||
"@radix-ui/react-primitive": "2.1.10",
|
||||
"@radix-ui/react-slot": "1.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
|
||||
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz",
|
||||
"integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-direction": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz",
|
||||
"integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer": {
|
||||
"version": "1.1.19",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz",
|
||||
"integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.10",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.4",
|
||||
"@radix-ui/react-use-effect-event": "0.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-focus-guards": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz",
|
||||
"integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-focus-scope": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz",
|
||||
"integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.10",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-id": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz",
|
||||
"integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz",
|
||||
"integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.0.0",
|
||||
"@radix-ui/react-arrow": "1.1.15",
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-context": "1.2.2",
|
||||
"@radix-ui/react-primitive": "2.1.10",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.4",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4",
|
||||
"@radix-ui/react-use-rect": "1.1.4",
|
||||
"@radix-ui/react-use-size": "1.1.4",
|
||||
"@radix-ui/rect": "1.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-portal": {
|
||||
"version": "1.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz",
|
||||
"integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.10",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz",
|
||||
"integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz",
|
||||
"integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-roving-focus": {
|
||||
"version": "1.1.19",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz",
|
||||
"integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.7",
|
||||
"@radix-ui/react-collection": "1.1.15",
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-context": "1.2.2",
|
||||
"@radix-ui/react-direction": "1.1.4",
|
||||
"@radix-ui/react-id": "1.1.4",
|
||||
"@radix-ui/react-primitive": "2.1.10",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.4",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.6",
|
||||
"@radix-ui/react-use-is-hydrated": "0.1.3",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz",
|
||||
"integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz",
|
||||
"integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-controllable-state": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz",
|
||||
"integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.7",
|
||||
"@radix-ui/react-use-effect-event": "0.0.5",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-effect-event": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz",
|
||||
"integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
|
||||
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-rect": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz",
|
||||
"integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/rect": "1.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-size": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz",
|
||||
"integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/rect": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz",
|
||||
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.2.tgz",
|
||||
@@ -3076,21 +2410,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-is-hydrated": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz",
|
||||
"integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
|
||||
@@ -32,10 +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_cluster_snapshot, get_gpu_snapshot
|
||||
from fastvideo_studio.gpu import get_gpu_snapshot
|
||||
from fastvideo_studio.job_runner import JobRunner, JobStatus
|
||||
from fastvideo_studio.models import (CreateDatasetRequest, CreateJobRequest, GeneratorRequest, SettingsUpdate,
|
||||
UpdateCaptionRequest, model_label)
|
||||
from fastvideo_studio.models import (CreateDatasetRequest, CreateJobRequest, SettingsUpdate, UpdateCaptionRequest,
|
||||
model_label)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -45,67 +45,6 @@ logger = logging.getLogger("fastvideo.studio.api")
|
||||
|
||||
DEFAULT_OUTPUT_DIR = os.path.join(os.path.dirname(__file__), "..", "outputs", "ui_jobs")
|
||||
|
||||
|
||||
class _EngineLogBuffer:
|
||||
"""Thread-safe ring buffer over the server process's stdout/stderr.
|
||||
|
||||
Because ray relays worker output to the driver (log_to_driver), teeing the
|
||||
server's own streams captures engine output from every rank on every node;
|
||||
under the mp executor, worker logs arrive via the logging handlers which
|
||||
also write to stderr.
|
||||
"""
|
||||
|
||||
def __init__(self, maxlen: int = 5000) -> None:
|
||||
import collections
|
||||
import threading
|
||||
self._lines: collections.deque[str] = collections.deque(maxlen=maxlen)
|
||||
self._dropped = 0
|
||||
self._lock = threading.Lock()
|
||||
self._partial = ""
|
||||
|
||||
on_line = None # optional callable(str), set once at startup
|
||||
|
||||
def write(self, text: str) -> None:
|
||||
with self._lock:
|
||||
buf = self._partial + text
|
||||
*complete, self._partial = buf.split("\n")
|
||||
for line in complete:
|
||||
if len(self._lines) == self._lines.maxlen:
|
||||
self._dropped += 1
|
||||
self._lines.append(line)
|
||||
if self.on_line is not None:
|
||||
for line in complete:
|
||||
# never break stdout on a bad feed
|
||||
with contextlib.suppress(Exception):
|
||||
self.on_line(line)
|
||||
|
||||
def get_lines(self, after: int = 0) -> tuple[list[str], int]:
|
||||
with self._lock:
|
||||
total = self._dropped + len(self._lines)
|
||||
start = max(0, after - self._dropped)
|
||||
return list(self._lines)[start:], total
|
||||
|
||||
|
||||
class _Tee:
|
||||
"""File-like that forwards to the original stream and the ring buffer."""
|
||||
|
||||
def __init__(self, orig: Any, buffer: _EngineLogBuffer) -> None:
|
||||
self._orig = orig
|
||||
self._buffer = buffer
|
||||
|
||||
def write(self, text: str) -> int:
|
||||
self._buffer.write(text)
|
||||
return self._orig.write(text)
|
||||
|
||||
def flush(self) -> None:
|
||||
self._orig.flush()
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self._orig, name)
|
||||
|
||||
|
||||
engine_log = _EngineLogBuffer()
|
||||
|
||||
_available_models: list[dict[str, str]] = [{
|
||||
"id": path,
|
||||
"label": model_label(path)
|
||||
@@ -163,13 +102,6 @@ def list_gpus() -> dict[str, Any]:
|
||||
return get_gpu_snapshot()
|
||||
|
||||
|
||||
@app.get("/api/cluster")
|
||||
def cluster_status() -> dict[str, Any]:
|
||||
"""Cluster-wide GPU/host telemetry (per-node NVML via ray when connected,
|
||||
the local host otherwise)."""
|
||||
return get_cluster_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.
|
||||
@@ -184,25 +116,6 @@ def list_models(workload_type: str | None = None) -> list[dict[str, Any]]:
|
||||
return _available_models
|
||||
|
||||
|
||||
_PRESET_FIELDS = ("height", "width", "num_frames", "fps", "num_inference_steps", "guidance_scale", "guidance_rescale",
|
||||
"negative_prompt", "seed")
|
||||
_preset_cache: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
@app.get("/api/models/presets")
|
||||
def model_presets(model_id: str) -> dict[str, Any]:
|
||||
"""The model's recommended sampling settings (config-only — never loads
|
||||
weights). The UI populates the job form from these on model selection."""
|
||||
if model_id not in _preset_cache:
|
||||
from fastvideo.api.sampling_param import SamplingParam
|
||||
try:
|
||||
sp = SamplingParam.from_pretrained(model_id)
|
||||
except Exception as exc: # noqa: BLE001 -- unknown/unresolvable model
|
||||
raise HTTPException(status_code=404, detail=f"No presets for '{model_id}': {exc}") from exc
|
||||
_preset_cache[model_id] = {f: getattr(sp, f) for f in _PRESET_FIELDS if getattr(sp, f, None) is not None}
|
||||
return _preset_cache[model_id]
|
||||
|
||||
|
||||
ALLOWED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
|
||||
|
||||
|
||||
@@ -340,58 +253,13 @@ def get_job(job_id: str) -> dict[str, Any]:
|
||||
return job.to_dict()
|
||||
|
||||
|
||||
@app.get("/api/generators")
|
||||
def list_generators() -> list[dict[str, Any]]:
|
||||
"""Generators resident in memory plus preloads in flight or failed."""
|
||||
return job_runner.list_generators()
|
||||
|
||||
|
||||
@app.post("/api/generators/preload", status_code=202)
|
||||
def preload_generator(req: GeneratorRequest) -> dict[str, Any]:
|
||||
"""Load a model into memory ahead of time (replacing whatever is
|
||||
resident). One load at a time — 409 while another load is in flight."""
|
||||
valid_ids = {m["id"] for m in _available_models}
|
||||
if req.model_id not in valid_ids and not os.path.isdir(req.model_id):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(f"Unknown model_id '{req.model_id}'. "
|
||||
f"Valid options: {sorted(valid_ids)}"),
|
||||
)
|
||||
try:
|
||||
return job_runner.preload_generator(**req.model_dump())
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post("/api/generators/unload")
|
||||
def unload_generator() -> dict[str, Any]:
|
||||
"""Shut down and delete the resident generator, freeing GPU memory."""
|
||||
try:
|
||||
unloaded = job_runner.unload_generator()
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
if not unloaded:
|
||||
raise HTTPException(status_code=404, detail="No model is loaded")
|
||||
return {"unloaded": True}
|
||||
|
||||
|
||||
@app.get("/api/engine/logs")
|
||||
def engine_logs(after: int = 0) -> dict[str, Any]:
|
||||
"""Incremental tail of the engine's stdout/stderr (driver + relayed
|
||||
worker output). Poll with ?after=<total from the previous response>."""
|
||||
lines, total = engine_log.get_lines(after=after)
|
||||
return {"lines": lines, "total": total}
|
||||
|
||||
|
||||
@app.post("/api/jobs", status_code=201)
|
||||
def create_job(req: CreateJobRequest) -> dict[str, Any]:
|
||||
"""Create a new job (does **not** start it automatically)."""
|
||||
job_type = req.job_type or "inference"
|
||||
if job_type == "inference":
|
||||
valid_ids = {m["id"] for m in _available_models}
|
||||
# A local weights directory is as valid as a registered hub id —
|
||||
# the registry resolves the pipeline from its model_index.
|
||||
if req.model_id not in valid_ids and not os.path.isdir(req.model_id):
|
||||
if req.model_id not in valid_ids:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(f"Unknown model_id '{req.model_id}'. "
|
||||
@@ -754,14 +622,6 @@ def create_local_env(host: str, port: int) -> None:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import sys
|
||||
sys.stdout = _Tee(sys.stdout, engine_log)
|
||||
sys.stderr = _Tee(sys.stderr, engine_log)
|
||||
# handlers created before the tee (module-level basicConfig) hold the
|
||||
# original stream objects — re-point them or their output bypasses the buffer
|
||||
for h in logging.getLogger().handlers:
|
||||
if isinstance(h, logging.StreamHandler) and h.stream in (sys.__stderr__, sys.__stdout__):
|
||||
h.setStream(sys.stderr) # type: ignore[arg-type] # duck-typed file-like
|
||||
global job_runner, database, upload_dir, verbose, datasets_upload_dir # noqa: PLW0603
|
||||
|
||||
# Set up signal handlers to prevent worker crashes from killing the server
|
||||
@@ -826,9 +686,6 @@ def main() -> None:
|
||||
verbose=args.verbose,
|
||||
database=database,
|
||||
)
|
||||
# ray relays worker output (incl. denoising tqdm) to the driver's stdout;
|
||||
# feed those lines to the running job so the UI progress bar moves.
|
||||
engine_log.on_line = job_runner.feed_engine_line
|
||||
|
||||
logger.info("Output directory: %s", output_dir)
|
||||
logger.info("Log directory: %s", log_dir)
|
||||
@@ -838,10 +695,6 @@ def main() -> None:
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
log_level="info",
|
||||
# The engine-output console tails this process's stdout/stderr; the
|
||||
# frontend polls several endpoints every few seconds, so access-log
|
||||
# lines are pure self-noise there. App/job logging is unaffected.
|
||||
access_log=False,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,12 @@
|
||||
'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';
|
||||
@@ -23,28 +21,18 @@ import {
|
||||
|
||||
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);
|
||||
}
|
||||
setDatasets(await getDatasets());
|
||||
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);
|
||||
// Distinguish an API outage from a genuinely empty list, so the user
|
||||
// isn't told they have no datasets when the server is unreachable.
|
||||
setError(err instanceof Error ? err.message : 'Failed to load datasets');
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -62,67 +50,28 @@ export default function DatasetsPage() {
|
||||
<HeaderActions>
|
||||
<AddDatasetButton />
|
||||
</HeaderActions>
|
||||
<div className="mx-auto flex w-full max-w-[850px] flex-col gap-6 px-4 pb-12">
|
||||
<main 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>
|
||||
<div>
|
||||
{error ? (
|
||||
<p className="py-8 text-center text-destructive">{error}</p>
|
||||
) : datasets.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
No datasets yet.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{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)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
datasets.map((ds) => (
|
||||
<DatasetCard
|
||||
key={ds.id}
|
||||
dataset={ds}
|
||||
onUpdated={fetchDatasets}
|
||||
onSelect={() => handleSelectDataset(ds)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
<CreateDatasetModal
|
||||
isOpen={open}
|
||||
onClose={() => setCreateDatasetModalOpen(false)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import GalleryPage from './page';
|
||||
@@ -43,22 +43,6 @@ describe('GalleryPage', () => {
|
||||
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 }),
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { AlertTriangle, ImageOff, Loader2 } from 'lucide-react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { getJobVideoUrl, getJobsList } from '@/lib/api';
|
||||
import type { Job } from '@/lib/types';
|
||||
@@ -12,61 +11,11 @@ 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);
|
||||
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
@@ -91,13 +40,7 @@ export default function GalleryPage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [reloadKey]);
|
||||
|
||||
function retry() {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
setReloadKey((k) => k + 1);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const galleryJobs = jobs.filter(
|
||||
(j) =>
|
||||
@@ -121,16 +64,7 @@ export default function GalleryPage() {
|
||||
<span>Loading gallery…</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<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={retry}>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
<p className="py-8 text-destructive">{error}</p>
|
||||
) : galleryJobs.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
No completed videos yet
|
||||
@@ -143,7 +77,24 @@ export default function GalleryPage() {
|
||||
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} />
|
||||
{isImage(job) ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={getJobVideoUrl(job.id)}
|
||||
alt={job.prompt}
|
||||
className="block h-full w-full object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
src={getJobVideoUrl(job.id)}
|
||||
className="block h-full w-full object-contain"
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className="line-clamp-3 border-t border-border px-4 py-3 text-sm text-muted-foreground"
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
--border: #e2e8f0;
|
||||
--input: #cbd5e1;
|
||||
--ring: #1d4ed8;
|
||||
--ring: #94a3b8;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
--border: #334155;
|
||||
--input: #334155;
|
||||
--ring: #7dd3fc;
|
||||
--ring: #cbd5e1;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -125,7 +125,6 @@
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
html {
|
||||
@@ -164,22 +163,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
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;');
|
||||
});
|
||||
});
|
||||
@@ -1,182 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import GpusPage from './page';
|
||||
import { getClusterStatus } from '@/lib/api';
|
||||
import type { ClusterSnapshot } from '@/lib/api';
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
getClusterStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
const RAY_SNAPSHOT: ClusterSnapshot = {
|
||||
mode: 'ray',
|
||||
error: null,
|
||||
resources: { gpus_total: 8, gpus_available: 5 },
|
||||
nodes: [
|
||||
{
|
||||
hostname: 'node-a',
|
||||
ip: '10.0.0.10',
|
||||
is_this_host: true,
|
||||
cpus: 64,
|
||||
ray_gpus: 4,
|
||||
available: true,
|
||||
error: null,
|
||||
gpus: [
|
||||
{
|
||||
index: 0,
|
||||
name: 'NVIDIA B200',
|
||||
utilization: 62,
|
||||
memory_used_mib: 40_960,
|
||||
memory_total_mib: 81_920,
|
||||
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: 81_920,
|
||||
temperature_c: null,
|
||||
power_watts: null,
|
||||
power_limit_watts: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
hostname: 'node-b',
|
||||
ip: '10.0.0.11',
|
||||
is_this_host: false,
|
||||
cpus: 32,
|
||||
ray_gpus: 2,
|
||||
available: true,
|
||||
error: null,
|
||||
gpus: [
|
||||
{
|
||||
index: 0,
|
||||
name: 'NVIDIA B200',
|
||||
utilization: 90,
|
||||
memory_used_mib: 20_480,
|
||||
memory_total_mib: 81_920,
|
||||
temperature_c: 70,
|
||||
power_watts: 900,
|
||||
power_limit_watts: 1000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const LOCAL_SNAPSHOT: ClusterSnapshot = {
|
||||
mode: 'local',
|
||||
error:
|
||||
'not connected to a ray cluster yet (load a model first); showing the API host only',
|
||||
resources: null,
|
||||
nodes: [
|
||||
{
|
||||
hostname: 'localhost',
|
||||
ip: null,
|
||||
is_this_host: true,
|
||||
cpus: null,
|
||||
ray_gpus: null,
|
||||
available: true,
|
||||
error: null,
|
||||
gpus: [
|
||||
{
|
||||
index: 0,
|
||||
name: 'NVIDIA RTX 5090',
|
||||
utilization: 12,
|
||||
memory_used_mib: 2_048,
|
||||
memory_total_mib: 32_768,
|
||||
temperature_c: 38,
|
||||
power_watts: 80,
|
||||
power_limit_watts: 575,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getClusterStatus).mockResolvedValue(RAY_SNAPSHOT);
|
||||
});
|
||||
|
||||
describe('GpusPage', () => {
|
||||
it('renders the header with mode and GPU totals', async () => {
|
||||
render(<GpusPage />);
|
||||
|
||||
expect(await screen.findByText('ray cluster')).toBeInTheDocument();
|
||||
expect(screen.getByText(/5 \/\s*8 GPUs available/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a section per node with host details and GPU rows', async () => {
|
||||
render(<GpusPage />);
|
||||
|
||||
// Each hostname appears twice: once in the strip, once as a section.
|
||||
expect(await screen.findAllByText('node-a')).toHaveLength(2);
|
||||
expect(screen.getAllByText('node-b')).toHaveLength(2);
|
||||
expect(screen.getByText('10.0.0.10')).toBeInTheDocument();
|
||||
// Only node-a is the API host.
|
||||
expect(screen.getAllByText('API host')).toHaveLength(1);
|
||||
expect(screen.getByText(/64 CPUs · 4 ray GPUs/)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getAllByText('NVIDIA B200')).toHaveLength(3);
|
||||
expect(screen.getByText('GPU 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('62%')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('40960 / 81920 MiB (40.0 GiB / 80.0 GiB)'),
|
||||
).toBeInTheDocument();
|
||||
// Optional sensors render only when present.
|
||||
expect(screen.getByText('41°C')).toBeInTheDocument();
|
||||
expect(screen.getByText('312 W / 1000 W')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('bars reflect utilization and VRAM values', async () => {
|
||||
render(<GpusPage />);
|
||||
await screen.findAllByText('node-a');
|
||||
|
||||
const utilMeters = screen
|
||||
.getAllByRole('meter', { name: 'Utilization' })
|
||||
.map((m) => m.getAttribute('aria-valuenow'));
|
||||
expect(utilMeters).toEqual(['62', '0', '90']);
|
||||
|
||||
const vramMeters = screen
|
||||
.getAllByRole('meter', { name: 'VRAM' })
|
||||
.map((m) => m.getAttribute('aria-valuenow'));
|
||||
// 40960/81920 = 50%, 1024/81920 ≈ 1%, 20480/81920 = 25%
|
||||
expect(vramMeters).toEqual(['50', '1', '25']);
|
||||
});
|
||||
|
||||
it('renders the compact strip with per-GPU segments', async () => {
|
||||
render(<GpusPage />);
|
||||
await screen.findAllByText('node-a');
|
||||
|
||||
const segments = screen.getAllByRole('img');
|
||||
expect(segments).toHaveLength(3);
|
||||
expect(segments[0]).toHaveAccessibleName(
|
||||
'GPU 0: 62% utilization, 40.0 GiB / 80.0 GiB VRAM',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the informational banner and local mode', async () => {
|
||||
vi.mocked(getClusterStatus).mockResolvedValue(LOCAL_SNAPSHOT);
|
||||
render(<GpusPage />);
|
||||
|
||||
expect(await screen.findByText('local host only')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/not connected to a ray cluster yet/),
|
||||
).toBeInTheDocument();
|
||||
// No resources in local mode.
|
||||
expect(screen.queryByText(/GPUs available/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('explains when the API server is unreachable', async () => {
|
||||
vi.mocked(getClusterStatus).mockRejectedValue(new Error('network down'));
|
||||
render(<GpusPage />);
|
||||
expect(
|
||||
await screen.findByText(/Could not reach the API server/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,258 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { AlertTriangle, Info } from 'lucide-react';
|
||||
|
||||
import ClusterStrip, {
|
||||
clampPercent,
|
||||
formatGib,
|
||||
utilizationColor,
|
||||
} from '@/components/cluster/ClusterStrip';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
getClusterStatus,
|
||||
type ClusterGpu,
|
||||
type ClusterNode,
|
||||
type ClusterSnapshot,
|
||||
} from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
|
||||
function Meter({
|
||||
label,
|
||||
percent,
|
||||
detail,
|
||||
fillClass,
|
||||
}: {
|
||||
label: string;
|
||||
percent: number;
|
||||
detail: string;
|
||||
fillClass: string;
|
||||
}) {
|
||||
const clamped = clampPercent(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 transition-[width] duration-500',
|
||||
fillClass,
|
||||
)}
|
||||
style={{ width: `${clamped}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GpuRow({ gpu }: { gpu: ClusterGpu }) {
|
||||
const memPercent =
|
||||
gpu.memory_total_mib > 0
|
||||
? (gpu.memory_used_mib / gpu.memory_total_mib) * 100
|
||||
: 0;
|
||||
return (
|
||||
<div className="grid items-center gap-x-6 gap-y-2 border-t border-border pt-3 first:border-t-0 first:pt-0 md:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)_minmax(0,1.6fr)_auto]">
|
||||
<div className="flex min-w-0 items-baseline 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}%`}
|
||||
fillClass={utilizationColor(gpu.utilization)}
|
||||
/>
|
||||
<Meter
|
||||
label="VRAM"
|
||||
percent={memPercent}
|
||||
detail={`${gpu.memory_used_mib} / ${gpu.memory_total_mib} MiB (${formatGib(gpu.memory_used_mib)} / ${formatGib(gpu.memory_total_mib)})`}
|
||||
fillClass={memPercent >= 90 ? 'bg-rose-500' : 'bg-accent-blue'}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs tabular-nums text-muted-foreground md:w-28 md:justify-end">
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeSection({ node }: { node: ClusterNode }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-3 p-5">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="min-w-0 truncate text-sm font-semibold">
|
||||
{node.hostname}
|
||||
</span>
|
||||
{node.ip && (
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{node.ip}
|
||||
</span>
|
||||
)}
|
||||
{node.is_this_host && <Badge variant="secondary">API host</Badge>}
|
||||
<span className="ml-auto text-xs tabular-nums text-muted-foreground">
|
||||
{node.cpus != null && `${Math.round(node.cpus)} CPUs`}
|
||||
{node.cpus != null && node.ray_gpus != null && ' · '}
|
||||
{node.ray_gpus != null && `${Math.round(node.ray_gpus)} ray GPUs`}
|
||||
</span>
|
||||
</div>
|
||||
{!node.available && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
GPU telemetry unavailable
|
||||
{node.error ? `: ${node.error}` : '.'}
|
||||
</p>
|
||||
)}
|
||||
{node.gpus.map((gpu) => (
|
||||
<GpuRow key={gpu.index} gpu={gpu} />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
import GpuGrid from '@/components/system/GpuGrid';
|
||||
|
||||
export default function GpusPage() {
|
||||
const [snapshot, setSnapshot] = React.useState<ClusterSnapshot | 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 || document.hidden) return;
|
||||
inFlight = true;
|
||||
try {
|
||||
const next = await getClusterStatus();
|
||||
if (mounted) {
|
||||
setSnapshot(next);
|
||||
setFetchError(null);
|
||||
}
|
||||
} catch {
|
||||
if (mounted) {
|
||||
setFetchError(
|
||||
'Cluster status could not be refreshed. The values below may be stale.',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
const interval = setInterval(poll, POLL_INTERVAL_MS);
|
||||
// Refresh immediately when the tab becomes visible again (polls are
|
||||
// skipped while hidden).
|
||||
document.addEventListener('visibilitychange', poll);
|
||||
return () => {
|
||||
mounted = false;
|
||||
clearInterval(interval);
|
||||
document.removeEventListener('visibilitychange', poll);
|
||||
};
|
||||
}, [retryToken]);
|
||||
|
||||
let body: React.ReactNode;
|
||||
if (fetchError && !snapshot) {
|
||||
body = (
|
||||
<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. Cluster status needs the Studio API
|
||||
server running.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setRetryToken((token) => token + 1)}
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
} else if (!snapshot) {
|
||||
body = <p className="py-8 text-center text-muted-foreground">Loading…</p>;
|
||||
} else {
|
||||
body = (
|
||||
<div className="flex flex-col gap-4">
|
||||
<header className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-lg font-semibold">Cluster</h1>
|
||||
<Badge variant="outline">
|
||||
{snapshot.mode === 'ray' ? 'ray cluster' : 'local host only'}
|
||||
</Badge>
|
||||
{snapshot.resources && (
|
||||
<span className="text-sm tabular-nums text-muted-foreground">
|
||||
{Math.round(snapshot.resources.gpus_available)} /{' '}
|
||||
{Math.round(snapshot.resources.gpus_total)} GPUs available
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
{snapshot.error && (
|
||||
<div
|
||||
role="status"
|
||||
className="flex flex-wrap items-center gap-3 rounded-lg border border-blue-400/40 bg-blue-500/10 px-3 py-2 text-sm"
|
||||
>
|
||||
<Info className="size-4 shrink-0 text-blue-600" aria-hidden />
|
||||
<span className="min-w-0 flex-1">{snapshot.error}</span>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
<ClusterStrip nodes={snapshot.nodes} />
|
||||
{snapshot.nodes.map((node, i) => (
|
||||
<NodeSection key={`${node.hostname}-${i}`} node={node} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[1100px] flex-col gap-6 px-4 pb-12 pt-6">
|
||||
{body}
|
||||
</div>
|
||||
<main className="mx-auto flex w-full max-w-[1100px] flex-col gap-6 px-4 pb-12 pt-6">
|
||||
<GpuGrid />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import CreateJobButton from '@/components/jobs/CreateJobButton';
|
||||
import EngineConsole from '@/components/jobs/EngineConsole';
|
||||
import { HeaderActions } from '@/components/shell/HeaderActionsContext';
|
||||
import JobQueue from '@/components/jobs/JobQueue';
|
||||
import WarmModelsPanel from '@/components/jobs/WarmModelsPanel';
|
||||
|
||||
export default function InferencePage() {
|
||||
return (
|
||||
@@ -12,8 +10,6 @@ export default function InferencePage() {
|
||||
<HeaderActions>
|
||||
<CreateJobButton jobType="inference" />
|
||||
</HeaderActions>
|
||||
<WarmModelsPanel />
|
||||
<EngineConsole />
|
||||
<JobQueue jobType="inference" />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -52,20 +52,6 @@ describe('Settings page', () => {
|
||||
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' }));
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import type { ClusterNode } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function formatGib(mib: number): string {
|
||||
return `${(mib / 1024).toFixed(1)} GiB`;
|
||||
}
|
||||
|
||||
/** Bar fill class by load: blue when idle, amber under pressure, rose hot. */
|
||||
export function utilizationColor(percent: number): string {
|
||||
if (percent >= 85) return 'bg-rose-500';
|
||||
if (percent >= 50) return 'bg-amber-500';
|
||||
return 'bg-accent-blue';
|
||||
}
|
||||
|
||||
export function clampPercent(percent: number): number {
|
||||
return Math.max(0, Math.min(100, percent));
|
||||
}
|
||||
|
||||
function GpuSegment({
|
||||
index,
|
||||
utilization,
|
||||
memUsedMib,
|
||||
memTotalMib,
|
||||
}: {
|
||||
index: number;
|
||||
utilization: number;
|
||||
memUsedMib: number;
|
||||
memTotalMib: number;
|
||||
}) {
|
||||
const memPercent =
|
||||
memTotalMib > 0 ? clampPercent((memUsedMib / memTotalMib) * 100) : 0;
|
||||
const label =
|
||||
`GPU ${index}: ${utilization}% utilization, ` +
|
||||
`${formatGib(memUsedMib)} / ${formatGib(memTotalMib)} VRAM`;
|
||||
return (
|
||||
<div
|
||||
role="img"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className="flex w-10 shrink-0 flex-col gap-0.5"
|
||||
>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn('h-full rounded-full', utilizationColor(utilization))}
|
||||
style={{ width: `${clampPercent(utilization)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
memPercent >= 90 ? 'bg-rose-500' : 'bg-accent-blue',
|
||||
)}
|
||||
style={{ width: `${memPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One compact line per node: hostname + tiny util/VRAM bars per GPU. */
|
||||
export default function ClusterStrip({ nodes }: { nodes: ClusterNode[] }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{nodes.map((node, i) => (
|
||||
<div
|
||||
key={`${node.hostname}-${i}`}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<span className="w-40 shrink-0 truncate text-xs font-medium">
|
||||
{node.hostname}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
{node.gpus.map((gpu) => (
|
||||
<GpuSegment
|
||||
key={gpu.index}
|
||||
index={gpu.index}
|
||||
utilization={gpu.utilization}
|
||||
memUsedMib={gpu.memory_used_mib}
|
||||
memTotalMib={gpu.memory_total_mib}
|
||||
/>
|
||||
))}
|
||||
{node.gpus.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground">no GPUs</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,16 +25,6 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -81,7 +71,7 @@ describe('DatasetCard', () => {
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the selection and delete actions separate', () => {
|
||||
it('selects on keyboard activation of the card body but not of the Delete button', () => {
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<DatasetCard dataset={dataset} onUpdated={() => {}} onSelect={onSelect} />,
|
||||
@@ -93,12 +83,8 @@ describe('DatasetCard', () => {
|
||||
});
|
||||
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/,
|
||||
}),
|
||||
);
|
||||
// Activating the card body itself does select.
|
||||
fireEvent.keyDown(screen.getByText('My Dataset'), { key: 'Enter' });
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -55,33 +55,43 @@ export default function DatasetCard({
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
<div
|
||||
className={cn(
|
||||
'mb-3 flex items-start gap-3 rounded-lg border border-border bg-background px-[1.15rem] py-4',
|
||||
'mb-3 flex cursor-pointer flex-col gap-[0.6rem] rounded-lg border border-border bg-background px-[1.15rem] py-4',
|
||||
isSelected && 'border-accent-blue bg-accent-blue/5',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
onSelect();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<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>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{fileCount} {fileCount === 1 ? 'file' : 'files'} · {sizeLabel}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,6 @@ 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);
|
||||
|
||||
@@ -30,27 +27,6 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
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={() => {}} />);
|
||||
|
||||
@@ -67,16 +43,6 @@ describe('DatasetSidebar', () => {
|
||||
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');
|
||||
@@ -133,39 +99,6 @@ describe('DatasetSidebar', () => {
|
||||
}
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ImageOff, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import DownloadCaptions from '@/components/datasets/DownloadCaptions';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -14,14 +13,12 @@ import {
|
||||
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).
|
||||
@@ -30,101 +27,54 @@ const DatasetFileCard = React.memo(function DatasetFileCard({
|
||||
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 && (
|
||||
{!thumbLoaded && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-background/70">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground/40 border-t-accent-blue" />
|
||||
</div>
|
||||
)}
|
||||
{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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<video
|
||||
src={mediaUrl}
|
||||
className="aspect-video w-full bg-border object-cover"
|
||||
muted
|
||||
autoPlay
|
||||
loop
|
||||
playsInline
|
||||
onLoadedData={() => onThumbLoaded(fileName)}
|
||||
onError={() => onThumbLoaded(fileName)}
|
||||
/>
|
||||
<Textarea
|
||||
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[]>([]);
|
||||
@@ -134,21 +84,17 @@ export default function DatasetSidebar({
|
||||
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]);
|
||||
onWidthChange?.(width);
|
||||
}, [width, onWidthChange]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -160,8 +106,6 @@ export default function DatasetSidebar({
|
||||
setCaptions(data.captions);
|
||||
setVisibleCount(INITIAL_PAGE_SIZE);
|
||||
setThumbLoaded({});
|
||||
setCaptionSaveStates({});
|
||||
captionVersions.current.clear();
|
||||
})
|
||||
.catch((err) => console.error('Failed to load dataset files:', err))
|
||||
.finally(() => {
|
||||
@@ -194,51 +138,23 @@ export default function DatasetSidebar({
|
||||
});
|
||||
|
||||
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 save = () => {
|
||||
updateDatasetCaption(datasetId, fileName, value).catch((err) =>
|
||||
console.error('Failed to save caption:', err),
|
||||
);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
pendingSaves.current.delete(fileName);
|
||||
save();
|
||||
}, 500);
|
||||
pendingSaves.current.set(fileName, { timer, save });
|
||||
},
|
||||
[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],
|
||||
[datasetId],
|
||||
);
|
||||
|
||||
function handleScroll() {
|
||||
@@ -273,16 +189,8 @@ export default function DatasetSidebar({
|
||||
|
||||
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,
|
||||
}}
|
||||
className="fixed bottom-0 right-0 top-[var(--header-height)] z-50 flex max-h-[calc(100vh-var(--header-height))] min-w-[320px] shrink-0 flex-col border-l border-border bg-card"
|
||||
style={{ width, maxWidth: SIDEBAR_MAX_WIDTH }}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border px-5 py-4">
|
||||
<h2 className="m-0 min-w-0 truncate text-base font-semibold text-foreground">
|
||||
@@ -295,7 +203,7 @@ export default function DatasetSidebar({
|
||||
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"
|
||||
className="flex items-center justify-center rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<X className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
@@ -323,9 +231,7 @@ export default function DatasetSidebar({
|
||||
mediaUrl={getDatasetMediaUrl(dataset.id, fileName)}
|
||||
caption={captions[fileName] ?? ''}
|
||||
thumbLoaded={!!thumbLoaded[fileName]}
|
||||
saveState={captionSaveStates[fileName] ?? 'idle'}
|
||||
onCaptionChange={handleCaptionChange}
|
||||
onCaptionRetry={handleCaptionRetry}
|
||||
onThumbLoaded={markThumbLoaded}
|
||||
/>
|
||||
))}
|
||||
@@ -334,14 +240,14 @@ export default function DatasetSidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isMobile && <div
|
||||
<div
|
||||
role="presentation"
|
||||
onMouseDown={onMouseDown}
|
||||
className={cn(
|
||||
'absolute bottom-0 left-0 top-0 z-[1] w-1.5 cursor-col-resize hover:bg-accent-blue/25',
|
||||
isDragging && 'bg-accent-blue/25',
|
||||
)}
|
||||
/>}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { downloadBlob } from '@/lib/utils';
|
||||
|
||||
const MENU_ITEM =
|
||||
'block min-h-11 w-full cursor-pointer px-4 py-2 text-left text-sm font-medium text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50';
|
||||
'block w-full cursor-pointer px-4 py-2 text-left text-sm font-medium text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50';
|
||||
|
||||
export default function DownloadCaptions({
|
||||
fileNames,
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
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';
|
||||
@@ -34,35 +33,31 @@ export default function CreateJobButton({ jobType }: CreateJobButtonProps) {
|
||||
|
||||
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>
|
||||
<div className="group relative inline-block">
|
||||
<Button type="button" className="gap-1.5">
|
||||
Create Job
|
||||
<ChevronDown className="size-3.5 opacity-85" aria-hidden />
|
||||
</Button>
|
||||
<div
|
||||
role="menu"
|
||||
className="invisible absolute right-0 top-full z-[200] mt-1 min-w-full -translate-y-1 rounded-lg border border-border bg-popover py-1 opacity-0 shadow-lg transition-all duration-150 group-hover:visible group-hover:translate-y-0 group-hover:opacity-100"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.type}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => openModal(opt.type)}
|
||||
className="block w-full whitespace-nowrap px-4 py-2 text-left text-sm font-medium text-popover-foreground transition-colors hover:bg-secondary"
|
||||
>
|
||||
{opt.label}
|
||||
<span className="mt-0.5 block text-xs font-normal text-muted-foreground">
|
||||
{opt.desc}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<CreateJobModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
|
||||
@@ -4,24 +4,14 @@ import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import CreateJobModal from './CreateJobModal';
|
||||
import {
|
||||
createJob,
|
||||
getDatasets,
|
||||
getModelPresets,
|
||||
getModels,
|
||||
listGenerators,
|
||||
uploadImage,
|
||||
type GeneratorInfo,
|
||||
} from '@/lib/api';
|
||||
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(),
|
||||
getModelPresets: vi.fn(),
|
||||
getDatasets: vi.fn(),
|
||||
listGenerators: vi.fn(),
|
||||
uploadImage: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
updateSettings: vi.fn(),
|
||||
@@ -32,32 +22,11 @@ const MODELS = [
|
||||
{ id: 'wan/t2v-14b', label: 'Wan T2V Large', type: 't2v' },
|
||||
];
|
||||
|
||||
// A resident engine slot whose engine config differs from the persisted
|
||||
// defaults on every field the modal adopts.
|
||||
const WARM_SLOT: GeneratorInfo = {
|
||||
state: 'ready',
|
||||
model_id: 'wan/t2v-14b',
|
||||
workload_type: 't2v',
|
||||
num_gpus: 8,
|
||||
dit_cpu_offload: true,
|
||||
text_encoder_cpu_offload: true,
|
||||
vae_cpu_offload: true,
|
||||
image_encoder_cpu_offload: false,
|
||||
use_fsdp_inference: true,
|
||||
enable_torch_compile: true,
|
||||
vsa_sparsity: 0.5,
|
||||
tp_size: 1,
|
||||
sp_size: 8,
|
||||
error: null,
|
||||
};
|
||||
|
||||
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(getModelPresets).mockResolvedValue({});
|
||||
vi.mocked(getDatasets).mockResolvedValue([]);
|
||||
vi.mocked(listGenerators).mockResolvedValue([]);
|
||||
vi.mocked(uploadImage).mockResolvedValue({ path: '/uploads/x.png' });
|
||||
vi.mocked(createJob).mockResolvedValue({ id: 'job-1' } as never);
|
||||
});
|
||||
@@ -81,57 +50,6 @@ function renderModal(
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -237,173 +155,4 @@ describe('CreateJobModal', () => {
|
||||
|
||||
await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('defaults to the warm resident model and adopts its engine config', async () => {
|
||||
vi.mocked(listGenerators).mockResolvedValue([WARM_SLOT]);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
// Without a warm model the default logic picks the first model
|
||||
// (wan/t2v-1.3b, covered by the seeding test above); the warm slot wins.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText('Model')).toHaveValue('wan/t2v-14b'),
|
||||
);
|
||||
// The warm selection also triggers its presets fetch.
|
||||
await waitFor(() =>
|
||||
expect(getModelPresets).toHaveBeenCalledWith('wan/t2v-14b'),
|
||||
);
|
||||
|
||||
await user.type(screen.getByLabelText('Prompt'), 'warm run');
|
||||
await user.click(screen.getByRole('button', { name: 'Create Job' }));
|
||||
|
||||
await waitFor(() => expect(createJob).toHaveBeenCalledTimes(1));
|
||||
// The engine fields mirror the resident slot (not the persisted defaults:
|
||||
// num_gpus 1, all offloads/fsdp/compile false) so the job reuses the warm
|
||||
// instance instead of replacing it.
|
||||
expect(vi.mocked(createJob).mock.calls[0][0]).toMatchObject({
|
||||
model_id: 'wan/t2v-14b',
|
||||
num_gpus: 8,
|
||||
tp_size: 1,
|
||||
sp_size: 8,
|
||||
dit_cpu_offload: true,
|
||||
text_encoder_cpu_offload: true,
|
||||
vae_cpu_offload: true,
|
||||
image_encoder_cpu_offload: false,
|
||||
use_fsdp_inference: true,
|
||||
enable_torch_compile: true,
|
||||
vsa_sparsity: 0.5,
|
||||
});
|
||||
});
|
||||
|
||||
it('restores persisted-default engine fields when switching away from the warm model', async () => {
|
||||
defaultOptionsStore.set({
|
||||
options: { ...DEFAULT_OPTIONS, numGpus: 2, tpSize: 2 },
|
||||
});
|
||||
vi.mocked(listGenerators).mockResolvedValue([WARM_SLOT]);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText('Model')).toHaveValue('wan/t2v-14b'),
|
||||
);
|
||||
await user.selectOptions(screen.getByLabelText('Model'), 'wan/t2v-1.3b');
|
||||
await user.type(screen.getByLabelText('Prompt'), 'cold run');
|
||||
await user.click(screen.getByRole('button', { name: 'Create Job' }));
|
||||
|
||||
await waitFor(() => expect(createJob).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(createJob).mock.calls[0][0]).toMatchObject({
|
||||
model_id: 'wan/t2v-1.3b',
|
||||
num_gpus: 2,
|
||||
tp_size: 2,
|
||||
use_fsdp_inference: false,
|
||||
enable_torch_compile: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('applies resolution preset chips and the orientation toggle to the payload', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
await screen.findByRole('option', { name: 'Wan T2V (wan/t2v-1.3b)' });
|
||||
await user.click(screen.getByText('Options'));
|
||||
|
||||
// 720p chip sets the /32-rounded dims; the orientation toggle swaps them.
|
||||
await user.click(screen.getByRole('button', { name: '720p' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Landscape' }));
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Portrait' }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByLabelText('Prompt'), 'portrait 720p');
|
||||
await user.click(screen.getByRole('button', { name: 'Create Job' }));
|
||||
|
||||
await waitFor(() => expect(createJob).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(createJob).mock.calls[0][0]).toMatchObject({
|
||||
height: 1280,
|
||||
width: 704,
|
||||
});
|
||||
});
|
||||
|
||||
it('restores the model preset resolution via the Native chip', async () => {
|
||||
vi.mocked(getModelPresets).mockResolvedValue({ height: 720, width: 1280 });
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
await screen.findByRole('option', { name: 'Wan T2V (wan/t2v-1.3b)' });
|
||||
// Native is disabled until the selected model's presets have loaded.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'Native' })).toBeEnabled(),
|
||||
);
|
||||
await user.click(screen.getByText('Options'));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '1080p' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Native' }));
|
||||
|
||||
await user.type(screen.getByLabelText('Prompt'), 'native dims');
|
||||
await user.click(screen.getByRole('button', { name: 'Create Job' }));
|
||||
|
||||
await waitFor(() => expect(createJob).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(createJob).mock.calls[0][0]).toMatchObject({
|
||||
height: 720,
|
||||
width: 1280,
|
||||
});
|
||||
});
|
||||
|
||||
it('populates sampling fields from the selected model presets; engine fields stay from defaults', async () => {
|
||||
defaultOptionsStore.set({
|
||||
options: { ...DEFAULT_OPTIONS, numGpus: 4, tpSize: 2, seed: 999 },
|
||||
});
|
||||
vi.mocked(getModelPresets).mockImplementation(async (id) =>
|
||||
id === 'wan/t2v-14b'
|
||||
? {
|
||||
height: 720,
|
||||
width: 1280,
|
||||
num_frames: 121,
|
||||
fps: 30,
|
||||
num_inference_steps: 40,
|
||||
guidance_scale: 6,
|
||||
guidance_rescale: 0.5,
|
||||
negative_prompt: 'blurry, low quality',
|
||||
seed: 7,
|
||||
}
|
||||
: {},
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
await screen.findByRole('option', { name: 'Wan T2V Large (wan/t2v-14b)' });
|
||||
await user.selectOptions(screen.getByLabelText('Model'), 'wan/t2v-14b');
|
||||
// The negative prompt is the easiest preset-populated field to observe.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText('Negative Prompt')).toHaveValue(
|
||||
'blurry, low quality',
|
||||
),
|
||||
);
|
||||
|
||||
await user.type(screen.getByLabelText('Prompt'), 'preset test');
|
||||
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-14b',
|
||||
// Sampling fields come from the model presets…
|
||||
height: 720,
|
||||
width: 1280,
|
||||
num_frames: 121,
|
||||
fps: 30,
|
||||
num_inference_steps: 40,
|
||||
guidance_scale: 6,
|
||||
guidance_rescale: 0.5,
|
||||
negative_prompt: 'blurry, low quality',
|
||||
seed: 7,
|
||||
// …while engine fields still come from the persisted defaults.
|
||||
num_gpus: 4,
|
||||
tp_size: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,27 +23,15 @@ import { defaultOptionsStore } from '@/stores/defaultOptions';
|
||||
import {
|
||||
createJob,
|
||||
getDatasets,
|
||||
getModelPresets,
|
||||
getModels,
|
||||
listGenerators,
|
||||
uploadImage,
|
||||
type CreateJobRequest,
|
||||
type GeneratorInfo,
|
||||
type Model,
|
||||
} from '@/lib/api';
|
||||
import { getDefaultModelForWorkload } from '@/lib/defaultOptions';
|
||||
import { WORKLOAD_OPTIONS } from '@/lib/jobConfig';
|
||||
import type { JobType } from '@/lib/types';
|
||||
|
||||
// ponytail: dims pre-rounded to multiples of 32 so every model family accepts
|
||||
// them (H3 requires /32) — hence 720p→704 and 1080p→1088. Tooltips show the
|
||||
// exact dims; the labels stay 480p/720p/1080p.
|
||||
const RESOLUTION_PRESETS = [
|
||||
{ label: '480p', height: 480, width: 832 },
|
||||
{ label: '720p', height: 704, width: 1280 },
|
||||
{ label: '1080p', height: 1088, width: 1920 },
|
||||
] as const;
|
||||
|
||||
export interface CreateJobModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -67,9 +55,6 @@ export default function CreateJobModal({
|
||||
|
||||
const [models, setModels] = React.useState<Model[]>([]);
|
||||
const [modelId, setModelId] = React.useState('');
|
||||
// The engine's resident slot (null when empty/failed), fetched on open so
|
||||
// jobs against the warm model can adopt its engine config.
|
||||
const [warmSlot, setWarmSlot] = React.useState<GeneratorInfo | null>(null);
|
||||
const [prompt, setPrompt] = React.useState('');
|
||||
const [imagePath, setImagePath] = React.useState('');
|
||||
const [imageFileName, setImageFileName] = React.useState('');
|
||||
@@ -79,12 +64,6 @@ export default function CreateJobModal({
|
||||
const [numFrames, setNumFrames] = React.useState(81);
|
||||
const [height, setHeight] = React.useState(480);
|
||||
const [width, setWidth] = React.useState(832);
|
||||
// The selected model's preset dims, kept so the "Native" chip can restore
|
||||
// them after a resolution chip or manual slider edit.
|
||||
const [nativeDims, setNativeDims] = React.useState<{
|
||||
height: number;
|
||||
width: number;
|
||||
} | null>(null);
|
||||
const [guidanceScale, setGuidanceScale] = React.useState(5);
|
||||
const [guidanceRescale, setGuidanceRescale] = React.useState(0);
|
||||
const [fps, setFps] = React.useState(24);
|
||||
@@ -124,17 +103,6 @@ export default function CreateJobModal({
|
||||
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
|
||||
@@ -176,10 +144,6 @@ export default function CreateJobModal({
|
||||
setImageFileName('');
|
||||
setSelectedDatasetId('');
|
||||
setSelectedValidationDatasetId('');
|
||||
setModelLoadError(null);
|
||||
setDatasetLoadError(null);
|
||||
setImageUploadError(null);
|
||||
setSubmitError(null);
|
||||
if (workloadType === 'dmd_t2v') {
|
||||
setDmdUseVsa(false);
|
||||
setDmdVsaSparsity(0.8);
|
||||
@@ -198,35 +162,17 @@ export default function CreateJobModal({
|
||||
// can't overwrite the current workload's model list/selection.
|
||||
let stale = false;
|
||||
setIsLoadingModels(true);
|
||||
setModelLoadError(null);
|
||||
// For inference jobs, prefer the warm resident model (if any) over the
|
||||
// persisted default so new jobs hit the already-loaded engine. The warm
|
||||
// slot is a nice-to-have: if the lookup fails, fall back silently.
|
||||
const warmSlotPromise = isInference
|
||||
? listGenerators()
|
||||
.then((gens) =>
|
||||
gens[0] && gens[0].state !== 'failed' ? gens[0] : null,
|
||||
)
|
||||
.catch(() => null)
|
||||
: Promise.resolve(null);
|
||||
Promise.all([getModels(inferenceWorkload), warmSlotPromise])
|
||||
.then(([list, slot]) => {
|
||||
getModels(inferenceWorkload)
|
||||
.then((list) => {
|
||||
if (stale) return;
|
||||
setModels(list);
|
||||
setWarmSlot(slot);
|
||||
const ids = list.map((m) => m.id);
|
||||
const opts = defaultOptionsStore.get().options;
|
||||
const defaultId = getDefaultModelForWorkload(
|
||||
opts,
|
||||
inferenceWorkload as 't2v' | 'i2v' | 't2i',
|
||||
);
|
||||
const warmId = slot?.model_id ?? '';
|
||||
const chosen =
|
||||
warmId && ids.includes(warmId)
|
||||
? warmId
|
||||
: ids.includes(defaultId)
|
||||
? defaultId
|
||||
: (list[0]?.id ?? '');
|
||||
const chosen = ids.includes(defaultId) ? defaultId : (list[0]?.id ?? '');
|
||||
setModelId(chosen);
|
||||
if (workloadType === 'dmd_t2v') {
|
||||
setRealScoreModelPath(chosen);
|
||||
@@ -234,13 +180,7 @@ export default function CreateJobModal({
|
||||
}
|
||||
})
|
||||
.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.',
|
||||
);
|
||||
if (!stale) console.error('Failed to load models:', e);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!stale) setIsLoadingModels(false);
|
||||
@@ -248,101 +188,16 @@ export default function CreateJobModal({
|
||||
return () => {
|
||||
stale = true;
|
||||
};
|
||||
}, [isOpen, isInference, inferenceWorkload, workloadType]);
|
||||
|
||||
// Whenever the selected model changes (including the initial selection on
|
||||
// open), populate the sampling fields from that model's presets. Missing
|
||||
// keys leave the field as-is; engine fields (GPUs, parallelism, offloads)
|
||||
// come from the warm slot or persisted defaults (effect below). Latest
|
||||
// selection wins: the cleanup marks superseded fetches stale, same as the
|
||||
// model-list effect.
|
||||
React.useEffect(() => {
|
||||
if (!isOpen || !isInference || !modelId) return;
|
||||
let stale = false;
|
||||
setNativeDims(null);
|
||||
getModelPresets(modelId)
|
||||
.then((p) => {
|
||||
if (stale) return;
|
||||
if (p.height !== undefined) setHeight(p.height);
|
||||
if (p.width !== undefined) setWidth(p.width);
|
||||
if (p.height !== undefined && p.width !== undefined)
|
||||
setNativeDims({ height: p.height, width: p.width });
|
||||
if (p.num_frames !== undefined)
|
||||
setNumFrames(workloadType === 't2i' ? 1 : p.num_frames);
|
||||
if (p.fps !== undefined) setFps(p.fps);
|
||||
if (p.num_inference_steps !== undefined)
|
||||
setNumInferenceSteps(p.num_inference_steps);
|
||||
if (p.guidance_scale !== undefined) setGuidanceScale(p.guidance_scale);
|
||||
if (p.guidance_rescale !== undefined)
|
||||
setGuidanceRescale(p.guidance_rescale);
|
||||
if (p.negative_prompt !== undefined)
|
||||
setNegativePrompt(p.negative_prompt);
|
||||
if (p.seed !== undefined) setSeed(p.seed);
|
||||
})
|
||||
.catch((e) => {
|
||||
// Presets are a convenience; on failure keep the current values.
|
||||
console.error('Failed to load model presets:', e);
|
||||
});
|
||||
return () => {
|
||||
stale = true;
|
||||
};
|
||||
}, [isOpen, isInference, modelId, workloadType]);
|
||||
|
||||
// When the selected model IS the warm resident one, adopt the slot's engine
|
||||
// config so the job reuses the loaded instance instead of silently replacing
|
||||
// it (e.g. persisted num_gpus=1 vs a warm 8-GPU slot). Switching away from
|
||||
// the warm model restores the persisted-default engine fields; non-warm to
|
||||
// non-warm switches leave the user's engine edits alone (today's behavior).
|
||||
const wasWarmRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
if (!isOpen || !isInference || !modelId) return;
|
||||
const isWarm = warmSlot?.model_id === modelId;
|
||||
if (isWarm && warmSlot) {
|
||||
setNumGpus(warmSlot.num_gpus);
|
||||
setTpSize(warmSlot.tp_size);
|
||||
setSpSize(warmSlot.sp_size);
|
||||
setDitCpuOffload(warmSlot.dit_cpu_offload);
|
||||
setTextEncoderCpuOffload(warmSlot.text_encoder_cpu_offload);
|
||||
setVaeCpuOffload(warmSlot.vae_cpu_offload);
|
||||
setImageEncoderCpuOffload(warmSlot.image_encoder_cpu_offload);
|
||||
setUseFsdpInference(warmSlot.use_fsdp_inference);
|
||||
setEnableTorchCompile(warmSlot.enable_torch_compile);
|
||||
setVsaSparsity(warmSlot.vsa_sparsity);
|
||||
} else if (wasWarmRef.current) {
|
||||
const opts = defaultOptionsStore.get().options;
|
||||
setNumGpus(opts.numGpus);
|
||||
setTpSize(opts.tpSize);
|
||||
setSpSize(opts.spSize);
|
||||
setDitCpuOffload(opts.ditCpuOffload);
|
||||
setTextEncoderCpuOffload(opts.textEncoderCpuOffload);
|
||||
setVaeCpuOffload(opts.vaeCpuOffload);
|
||||
setImageEncoderCpuOffload(opts.imageEncoderCpuOffload);
|
||||
setUseFsdpInference(opts.useFsdpInference);
|
||||
setEnableTorchCompile(opts.enableTorchCompile);
|
||||
setVsaSparsity(opts.vsaSparsity);
|
||||
}
|
||||
wasWarmRef.current = isWarm;
|
||||
}, [isOpen, isInference, modelId, warmSlot]);
|
||||
}, [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));
|
||||
.catch(() => setReadyDatasets([]));
|
||||
} else {
|
||||
setReadyDatasets([]);
|
||||
setIsLoadingDatasets(false);
|
||||
setDatasetLoadError(null);
|
||||
}
|
||||
}, [isOpen, isInference]);
|
||||
|
||||
@@ -351,24 +206,16 @@ export default function CreateJobModal({
|
||||
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);
|
||||
} catch {
|
||||
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);
|
||||
}
|
||||
@@ -377,7 +224,6 @@ export default function CreateJobModal({
|
||||
function clearImage() {
|
||||
setImagePath('');
|
||||
setImageFileName('');
|
||||
setImageUploadError(null);
|
||||
if (imageInputRef.current) imageInputRef.current.value = '';
|
||||
}
|
||||
|
||||
@@ -393,7 +239,6 @@ export default function CreateJobModal({
|
||||
workloadType === 'lora_t2v' ? 'lora' : jobType
|
||||
) as JobType;
|
||||
setIsSubmitting(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const payload: CreateJobRequest = {
|
||||
model_id: modelId,
|
||||
@@ -451,11 +296,6 @@ export default function CreateJobModal({
|
||||
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);
|
||||
}
|
||||
@@ -503,11 +343,7 @@ export default function CreateJobModal({
|
||||
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}
|
||||
disabled={isSubmitting || isLoadingModels}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{isLoadingModels
|
||||
@@ -522,15 +358,6 @@ export default function CreateJobModal({
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
{modelLoadError && (
|
||||
<p
|
||||
id="modal-model-error"
|
||||
role="alert"
|
||||
className="text-sm text-destructive"
|
||||
>
|
||||
{modelLoadError}
|
||||
</p>
|
||||
)}
|
||||
</FieldRow>
|
||||
|
||||
{isInference && workloadType === 'i2v' && (
|
||||
@@ -542,10 +369,6 @@ export default function CreateJobModal({
|
||||
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"
|
||||
/>
|
||||
@@ -562,15 +385,6 @@ export default function CreateJobModal({
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{imageUploadError && (
|
||||
<p
|
||||
id="modal-image-error"
|
||||
role="alert"
|
||||
className="text-sm text-destructive"
|
||||
>
|
||||
{imageUploadError}
|
||||
</p>
|
||||
)}
|
||||
</FieldRow>
|
||||
)}
|
||||
|
||||
@@ -618,22 +432,12 @@ export default function CreateJobModal({
|
||||
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
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{isLoadingDatasets
|
||||
? 'Loading datasets…'
|
||||
: datasetLoadError
|
||||
? 'Datasets unavailable'
|
||||
: readyDatasets.length === 0
|
||||
? 'No datasets (add in Datasets tab)'
|
||||
: 'Select a dataset…'}
|
||||
{readyDatasets.length === 0
|
||||
? 'No datasets (add in Datasets tab)'
|
||||
: 'Select a dataset…'}
|
||||
</option>
|
||||
{readyDatasets.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
@@ -641,15 +445,6 @@ export default function CreateJobModal({
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
{datasetLoadError && (
|
||||
<p
|
||||
id="modal-dataset-error"
|
||||
role="alert"
|
||||
className="text-sm text-destructive"
|
||||
>
|
||||
{datasetLoadError}
|
||||
</p>
|
||||
)}
|
||||
</FieldRow>
|
||||
<FieldRow
|
||||
htmlFor="modal-validation-dataset"
|
||||
@@ -662,9 +457,7 @@ export default function CreateJobModal({
|
||||
onChange={(e) =>
|
||||
setSelectedValidationDatasetId(e.target.value)
|
||||
}
|
||||
disabled={
|
||||
isSubmitting || isLoadingDatasets || !!datasetLoadError
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{readyDatasets.map((d) => (
|
||||
@@ -853,64 +646,6 @@ export default function CreateJobModal({
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
)}
|
||||
<div className="col-span-full flex flex-wrap items-center gap-1.5">
|
||||
<span className="pl-0.5 text-xs font-normal tracking-wide text-muted-foreground">
|
||||
Resolution
|
||||
</span>
|
||||
{RESOLUTION_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
title={`${preset.height}×${preset.width}`}
|
||||
onClick={() => {
|
||||
// Apply in the current orientation so a portrait
|
||||
// setup stays portrait when switching resolution.
|
||||
const portrait = height > width;
|
||||
setHeight(portrait ? preset.width : preset.height);
|
||||
setWidth(portrait ? preset.height : preset.width);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
title={
|
||||
nativeDims
|
||||
? `${nativeDims.height}×${nativeDims.width}`
|
||||
: 'Model preset resolution (unavailable)'
|
||||
}
|
||||
onClick={() => {
|
||||
if (!nativeDims) return;
|
||||
setHeight(nativeDims.height);
|
||||
setWidth(nativeDims.width);
|
||||
}}
|
||||
disabled={isSubmitting || !nativeDims}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
Native
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
title="Swap height and width"
|
||||
onClick={() => {
|
||||
setHeight(width);
|
||||
setWidth(height);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
{height > width ? 'Portrait' : 'Landscape'}
|
||||
</Button>
|
||||
</div>
|
||||
<SliderRow
|
||||
id="modal-height"
|
||||
label="Height"
|
||||
@@ -1076,22 +811,9 @@ export default function CreateJobModal({
|
||||
</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'}
|
||||
<div>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Creating...' : 'Create Job'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import EngineConsole from './EngineConsole';
|
||||
import { getEngineLogs } from '@/lib/api';
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
getEngineLogs: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getEngineLogs).mockResolvedValue({
|
||||
lines: ['[engine] booted', '[engine] worker heartbeat ok'],
|
||||
total: 2,
|
||||
});
|
||||
});
|
||||
|
||||
describe('EngineConsole', () => {
|
||||
it('is collapsed by default and does not fetch', () => {
|
||||
render(<EngineConsole />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Engine output' }),
|
||||
).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(getEngineLogs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows log lines and polls with the cursor while open', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
render(<EngineConsole />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Engine output' }));
|
||||
|
||||
// Flush the immediate poll fired on expand.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(getEngineLogs).toHaveBeenCalledWith(0);
|
||||
expect(screen.getByText(/\[engine\] booted/)).toBeInTheDocument();
|
||||
|
||||
// The 2s interval polls again, from the previous total.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
});
|
||||
expect(getEngineLogs).toHaveBeenLastCalledWith(2);
|
||||
|
||||
// Collapsing stops the polling.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Engine output' }));
|
||||
const calls = vi.mocked(getEngineLogs).mock.calls.length;
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
});
|
||||
expect(getEngineLogs).toHaveBeenCalledTimes(calls);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('clear view empties the scrollback locally', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<EngineConsole />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Engine output' }));
|
||||
expect(await screen.findByText(/\[engine\] booted/)).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Clear view' }));
|
||||
expect(screen.queryByText(/\[engine\] booted/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Waiting for engine output…')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getEngineLogs } from '@/lib/api';
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
// Cap the DOM at the last ~500 lines; the server keeps its own ring buffer.
|
||||
const MAX_LINES = 500;
|
||||
|
||||
// The engine tail includes uvicorn's access log (until a server restart picks
|
||||
// up access_log=False); the frontend's own polling would otherwise flood the
|
||||
// console with "GET /api/... 200 OK" lines. Keep non-GET and non-2xx lines.
|
||||
const ACCESS_LOG_NOISE = /^INFO:\s+[\d.:]+\s+- "(?:GET|HEAD) \S+ HTTP\/[\d.]+" 2\d\d/;
|
||||
|
||||
/**
|
||||
* Collapsible tail of the engine's stdout/stderr (driver + relayed worker
|
||||
* output). Polls only while open; sticks to the bottom unless the user has
|
||||
* scrolled up.
|
||||
*/
|
||||
export default function EngineConsole() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [lines, setLines] = React.useState<string[]>([]);
|
||||
// Poll cursor + stick-to-bottom flag live in refs: they must update
|
||||
// synchronously from async polls / scroll events, outside React's cycle.
|
||||
const afterRef = React.useRef(0);
|
||||
const stickRef = React.useRef(true);
|
||||
const consoleRef = React.useRef<HTMLPreElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
let mounted = true;
|
||||
let locked = false;
|
||||
|
||||
async function poll() {
|
||||
if (!mounted || locked) return;
|
||||
locked = true;
|
||||
try {
|
||||
const data = await getEngineLogs(afterRef.current);
|
||||
afterRef.current = data.total;
|
||||
const fresh = data.lines.filter((l) => !ACCESS_LOG_NOISE.test(l));
|
||||
if (mounted && fresh.length > 0) {
|
||||
setLines((prev) => [...prev, ...fresh].slice(-MAX_LINES));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch engine logs:', e);
|
||||
} finally {
|
||||
locked = false;
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
const interval = setInterval(poll, POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
mounted = false;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Follow the tail after new lines land, unless the user scrolled up.
|
||||
React.useEffect(() => {
|
||||
const el = consoleRef.current;
|
||||
if (el && stickRef.current) el.scrollTop = el.scrollHeight;
|
||||
}, [lines]);
|
||||
|
||||
function handleScroll() {
|
||||
const el = consoleRef.current;
|
||||
if (!el) return;
|
||||
stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Engine output"
|
||||
className="mx-auto w-full max-w-[850px] px-10 pt-3"
|
||||
>
|
||||
<div className="rounded-lg border border-border bg-background">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="flex flex-1 items-center gap-2 rounded-md px-2 py-1 text-sm font-semibold text-foreground hover:bg-accent"
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
Engine output
|
||||
</button>
|
||||
{open && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
// Resets the local view only; the server buffer is untouched.
|
||||
onClick={() => setLines([])}
|
||||
>
|
||||
Clear view
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{open && (
|
||||
<pre
|
||||
ref={consoleRef}
|
||||
onScroll={handleScroll}
|
||||
className="m-0 h-64 overflow-auto whitespace-pre-wrap break-words rounded-b-lg border-t border-border bg-zinc-950 p-3 font-mono text-xs leading-normal text-zinc-200"
|
||||
>
|
||||
{lines.length === 0 ? (
|
||||
<span className="italic text-zinc-500">
|
||||
Waiting for engine output…
|
||||
</span>
|
||||
) : (
|
||||
lines.join('\n')
|
||||
)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -20,14 +20,6 @@ vi.mock('@/lib/api', () => ({
|
||||
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',
|
||||
@@ -54,16 +46,6 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -119,10 +119,18 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectJob() {
|
||||
function handleSelectJob(e: React.MouseEvent | React.KeyboardEvent) {
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
setActiveJobId(isSelected ? null : job.id);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleSelectJob(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadVideo(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -140,57 +148,54 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleSelectJob}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
'mb-1.5 flex cursor-pointer flex-col gap-1 rounded-lg border bg-background px-3 py-1.5 transition-colors last:mb-0',
|
||||
'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 min-w-0 flex-col gap-1 rounded-md text-left"
|
||||
>
|
||||
<span className="flex w-full min-w-0 items-center gap-2">
|
||||
<span className="shrink-0 text-sm font-semibold text-foreground">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-[0.95rem] font-semibold text-foreground">
|
||||
{job.model_id}
|
||||
</span>
|
||||
<Badge variant={BADGE_VARIANTS[job.status] ?? 'secondary'}>
|
||||
{job.status}
|
||||
</Badge>
|
||||
<span className="ml-auto flex shrink-0 items-center gap-3 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}
|
||||
</div>
|
||||
<p className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-muted-foreground">
|
||||
{job.prompt}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
|
||||
{job.job_type === 'inference' ? (
|
||||
<>
|
||||
<span>{job.num_frames} frames</span>
|
||||
<span>
|
||||
{job.height}×{job.width}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="w-full whitespace-pre-wrap break-words text-xs text-muted-foreground">
|
||||
{job.prompt}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span>{job.workload_type?.replace(/_/g, ' ') ?? job.job_type}</span>
|
||||
)}
|
||||
{elapsedTime && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Timer className="size-3.5" aria-hidden />
|
||||
{elapsedTime}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{job.status === 'running' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleStop}
|
||||
disabled={isLoading}
|
||||
className="h-6 px-2 text-xs border-transparent bg-amber-500 text-black shadow-md hover:bg-amber-400"
|
||||
className="border-transparent bg-amber-500 text-black shadow-md hover:bg-amber-400"
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
@@ -199,7 +204,7 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
|
||||
size="sm"
|
||||
onClick={handleStart}
|
||||
disabled={isLoading}
|
||||
className="h-6 px-2 text-xs border-transparent bg-emerald-600 text-white shadow-md hover:bg-emerald-500"
|
||||
className="border-transparent bg-emerald-600 text-white shadow-md hover:bg-emerald-500"
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
@@ -208,7 +213,7 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
|
||||
size="sm"
|
||||
onClick={handleStart}
|
||||
disabled={isLoading}
|
||||
className="h-6 px-2 text-xs border-transparent bg-emerald-600 text-white shadow-md hover:bg-emerald-500"
|
||||
className="border-transparent bg-emerald-600 text-white shadow-md hover:bg-emerald-500"
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
@@ -222,7 +227,6 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
|
||||
onClick={handleDownloadVideo}
|
||||
disabled={isLoading}
|
||||
title="Download video"
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
Download Video
|
||||
</Button>
|
||||
@@ -232,11 +236,10 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isLoading}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { makeJob as makeBaseJob } from '@/test/factories';
|
||||
vi.mock('@/lib/api', () => ({
|
||||
getJobLogs: vi.fn(),
|
||||
downloadJobLog: vi.fn(),
|
||||
getJobVideoUrl: (id: string) => `http://test.local/api/jobs/${id}/video`,
|
||||
}));
|
||||
|
||||
const makeJob = (overrides: Partial<Job> = {}): Job =>
|
||||
@@ -20,69 +19,6 @@ const makeJob = (overrides: Partial<Job> = {}): Job =>
|
||||
});
|
||||
|
||||
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('plays completed inference output inline; running jobs get no player', async () => {
|
||||
vi.mocked(getJobLogs).mockResolvedValue({
|
||||
lines: [],
|
||||
total: 0,
|
||||
progress: 0,
|
||||
progress_msg: '',
|
||||
phase: '',
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<JobDetailsSidebar
|
||||
job={makeJob({
|
||||
status: 'completed',
|
||||
output_path: '/outputs/job-1.mp4',
|
||||
prompt: 'a cat surfing a wave',
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const video = screen.getByLabelText('Generated video: a cat surfing a wave');
|
||||
expect(video.tagName).toBe('VIDEO');
|
||||
expect(video).toHaveAttribute('controls');
|
||||
expect(video).toHaveAttribute(
|
||||
'src',
|
||||
'http://test.local/api/jobs/job-1/video',
|
||||
);
|
||||
|
||||
rerender(
|
||||
<JobDetailsSidebar
|
||||
job={makeJob({ status: 'running', output_path: null })}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByLabelText(/Generated video/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders log lines streamed from the job log poll', async () => {
|
||||
vi.mocked(getJobLogs).mockResolvedValue({
|
||||
lines: ['boot sequence started', 'loading model weights'],
|
||||
|
||||
@@ -4,9 +4,8 @@ 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, getJobVideoUrl } from '@/lib/api';
|
||||
import { downloadJobLog, getJobLogs } from '@/lib/api';
|
||||
import type { Job } from '@/lib/types';
|
||||
import { cn, downloadBlob } from '@/lib/utils';
|
||||
|
||||
@@ -16,16 +15,13 @@ 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);
|
||||
@@ -57,8 +53,8 @@ export default function JobDetailsSidebar({
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
onWidthChange?.(isMobile ? 0 : width);
|
||||
}, [isMobile, width, onWidthChange]);
|
||||
onWidthChange?.(width);
|
||||
}, [width, onWidthChange]);
|
||||
|
||||
// Auto-scroll the console to the bottom whenever new lines land. Runs after
|
||||
// commit so scrollHeight reflects the freshly-rendered output.
|
||||
@@ -141,16 +137,8 @@ export default function JobDetailsSidebar({
|
||||
|
||||
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,
|
||||
}}
|
||||
className="fixed bottom-0 right-0 top-[var(--header-height)] z-50 flex max-h-[calc(100vh-var(--header-height))] min-w-[280px] shrink-0 flex-col border-l border-border bg-card"
|
||||
style={{ width, maxWidth: SIDEBAR_MAX_WIDTH }}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<h2 className="m-0 text-base font-semibold text-foreground">
|
||||
@@ -180,41 +168,6 @@ export default function JobDetailsSidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{job.status === 'completed' &&
|
||||
job.output_path &&
|
||||
(job.job_type === 'inference' || !job.job_type) && (
|
||||
<div className="border-b border-border px-5 py-4">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Output
|
||||
</span>
|
||||
{job.output_path.toLowerCase().endsWith('.png') ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={getJobVideoUrl(job.id)}
|
||||
alt={
|
||||
job.prompt
|
||||
? `Generated image: ${job.prompt}`
|
||||
: 'Generated image'
|
||||
}
|
||||
className="block w-full rounded-lg border border-border bg-background object-contain"
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
src={getJobVideoUrl(job.id)}
|
||||
aria-label={
|
||||
job.prompt
|
||||
? `Generated video: ${job.prompt}`
|
||||
: 'Generated video'
|
||||
}
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className="block max-h-80 w-full rounded-lg border border-border bg-background object-contain"
|
||||
/>
|
||||
)}
|
||||
</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">
|
||||
@@ -242,14 +195,14 @@ export default function JobDetailsSidebar({
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{!isMobile && <div
|
||||
<div
|
||||
role="presentation"
|
||||
onMouseDown={onMouseDown}
|
||||
className={cn(
|
||||
'absolute bottom-0 left-0 top-0 z-[1] w-1.5 cursor-col-resize hover:bg-accent-blue/25',
|
||||
isDragging && 'bg-accent-blue/25',
|
||||
)}
|
||||
/>}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import JobQueue from '@/components/jobs/JobQueue';
|
||||
@@ -37,46 +37,6 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
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(
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
'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';
|
||||
@@ -34,8 +32,6 @@ function jobsShallowEqual(a: Job | null, b: Job | null): boolean {
|
||||
|
||||
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);
|
||||
|
||||
@@ -75,22 +71,11 @@ export default function JobQueue({ jobType, jobTypesForList }: JobQueueProps) {
|
||||
new Date(a.created_at ?? 0).getTime(),
|
||||
);
|
||||
}
|
||||
if (seq === fetchSeq.current) {
|
||||
setJobs(next);
|
||||
setError(null);
|
||||
}
|
||||
if (seq === fetchSeq.current) setJobs(next);
|
||||
} 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);
|
||||
}
|
||||
if (seq === fetchSeq.current) inFlight.current = false;
|
||||
}
|
||||
}, [typesKey]);
|
||||
|
||||
@@ -137,59 +122,20 @@ export default function JobQueue({ jobType, jobTypesForList }: JobQueueProps) {
|
||||
const multiType = typesToFetch.length > 1;
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[850px] flex-col gap-6 px-4 pb-12">
|
||||
<main 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 ? (
|
||||
<div>
|
||||
{jobs.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
No {multiType ? 'jobs' : `${jobType} jobs`} yet. Create one above.
|
||||
</p>
|
||||
) : (
|
||||
jobs.map((job) => (
|
||||
<JobCard key={job.id} job={job} onJobUpdated={fetchJobs} />
|
||||
))
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
jobs.map((job) => (
|
||||
<JobCard key={job.id} job={job} onJobUpdated={fetchJobs} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import WarmModelsPanel from './WarmModelsPanel';
|
||||
import {
|
||||
getModels,
|
||||
listGenerators,
|
||||
preloadGenerator,
|
||||
unloadGenerator,
|
||||
type GeneratorInfo,
|
||||
} from '@/lib/api';
|
||||
import { DEFAULT_OPTIONS } from '@/lib/defaultOptions';
|
||||
import { defaultOptionsStore } from '@/stores/defaultOptions';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
getModels: vi.fn(),
|
||||
listGenerators: vi.fn(),
|
||||
preloadGenerator: vi.fn(),
|
||||
unloadGenerator: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
updateSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { error: vi.fn() },
|
||||
}));
|
||||
|
||||
const makeGenerator = (
|
||||
overrides: Partial<GeneratorInfo> = {},
|
||||
): GeneratorInfo => ({
|
||||
state: 'ready',
|
||||
model_id: 'Wan-AI/Wan2.1-T2V-1.3B-Diffusers',
|
||||
workload_type: 't2v',
|
||||
num_gpus: 1,
|
||||
dit_cpu_offload: false,
|
||||
text_encoder_cpu_offload: false,
|
||||
vae_cpu_offload: false,
|
||||
image_encoder_cpu_offload: false,
|
||||
use_fsdp_inference: false,
|
||||
enable_torch_compile: false,
|
||||
vsa_sparsity: 0,
|
||||
tp_size: -1,
|
||||
sp_size: -1,
|
||||
error: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset the shared options store to a known baseline for test isolation.
|
||||
defaultOptionsStore.set({ options: DEFAULT_OPTIONS });
|
||||
vi.mocked(getModels).mockResolvedValue([
|
||||
{ id: 'Wan-AI/Wan2.1-T2V-1.3B-Diffusers', label: 'Wan2.1 T2V 1.3B' },
|
||||
]);
|
||||
vi.mocked(listGenerators).mockResolvedValue([]);
|
||||
vi.mocked(preloadGenerator).mockResolvedValue(
|
||||
makeGenerator({ state: 'loading' }),
|
||||
);
|
||||
vi.mocked(unloadGenerator).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('WarmModelsPanel', () => {
|
||||
it('shows the empty slot when no model is loaded', async () => {
|
||||
render(<WarmModelsPanel />);
|
||||
|
||||
expect(await screen.findByText('No model loaded')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Unload' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the resident slot with its state and config summary', async () => {
|
||||
vi.mocked(listGenerators).mockResolvedValue([
|
||||
makeGenerator({ num_gpus: 8, enable_torch_compile: true }),
|
||||
]);
|
||||
render(<WarmModelsPanel />);
|
||||
|
||||
// Model label: last path segment with dashes/underscores as spaces.
|
||||
expect(
|
||||
await screen.findByText('Wan2.1 T2V 1.3B Diffusers'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('ready')).toBeInTheDocument();
|
||||
expect(screen.getByText('8 GPU · compile')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Unload' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables loading a new model while a load is in flight', async () => {
|
||||
vi.mocked(listGenerators).mockResolvedValue([
|
||||
makeGenerator({ state: 'loading' }),
|
||||
]);
|
||||
render(<WarmModelsPanel />);
|
||||
|
||||
expect(await screen.findByText('loading')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Load model' })).toBeDisabled();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Unload' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the error on a failed slot and keeps retry enabled', async () => {
|
||||
vi.mocked(listGenerators).mockResolvedValue([
|
||||
makeGenerator({ state: 'failed', error: 'CUDA out of memory' }),
|
||||
]);
|
||||
render(<WarmModelsPanel />);
|
||||
|
||||
expect(await screen.findByText('failed')).toHaveAttribute(
|
||||
'title',
|
||||
'CUDA out of memory',
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'Load model' })).toBeEnabled(),
|
||||
);
|
||||
});
|
||||
|
||||
it('labels the load button as a swap when a different model is resident', async () => {
|
||||
vi.mocked(listGenerators).mockResolvedValue([
|
||||
makeGenerator({ model_id: 'FastVideo/FastHunyuan-diffusers' }),
|
||||
]);
|
||||
render(<WarmModelsPanel />);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: 'Load (replaces current)' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads the selected model with the persisted default options', async () => {
|
||||
defaultOptionsStore.set({
|
||||
options: { ...DEFAULT_OPTIONS, numGpus: 4, enableTorchCompile: true },
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<WarmModelsPanel />);
|
||||
|
||||
const button = await screen.findByRole('button', { name: 'Load model' });
|
||||
await waitFor(() => expect(button).toBeEnabled());
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(preloadGenerator).toHaveBeenCalledWith({
|
||||
model_id: 'Wan-AI/Wan2.1-T2V-1.3B-Diffusers',
|
||||
workload_type: 't2v',
|
||||
num_gpus: 4,
|
||||
dit_cpu_offload: false,
|
||||
text_encoder_cpu_offload: false,
|
||||
vae_cpu_offload: false,
|
||||
image_encoder_cpu_offload: false,
|
||||
use_fsdp_inference: false,
|
||||
enable_torch_compile: true,
|
||||
vsa_sparsity: 0,
|
||||
tp_size: -1,
|
||||
sp_size: -1,
|
||||
}),
|
||||
);
|
||||
// The panel refetches so the new "loading" slot appears promptly.
|
||||
await waitFor(() => expect(listGenerators).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('surfaces the backend detail when a load is rejected', async () => {
|
||||
vi.mocked(preloadGenerator).mockRejectedValue(
|
||||
new Error('a model load is already in progress'),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<WarmModelsPanel />);
|
||||
|
||||
const button = await screen.findByRole('button', { name: 'Load model' });
|
||||
await waitFor(() => expect(button).toBeEnabled());
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith('Model was not loaded', {
|
||||
description: 'a model load is already in progress',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('unloads the resident model with no payload', async () => {
|
||||
vi.mocked(listGenerators).mockResolvedValue([makeGenerator()]);
|
||||
const user = userEvent.setup();
|
||||
render(<WarmModelsPanel />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Unload' }));
|
||||
|
||||
await waitFor(() => expect(unloadGenerator).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(unloadGenerator).mock.calls[0]).toEqual([]);
|
||||
// The slot refreshes after the unload succeeds.
|
||||
await waitFor(() => expect(listGenerators).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
@@ -1,237 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Badge, type BadgeProps } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { NativeSelect } from '@/components/ui/native-select';
|
||||
import { useStore } from '@/hooks/useStore';
|
||||
import {
|
||||
getModels,
|
||||
listGenerators,
|
||||
preloadGenerator,
|
||||
unloadGenerator,
|
||||
type GeneratorInfo,
|
||||
type Model,
|
||||
} from '@/lib/api';
|
||||
import { getDefaultModelForWorkload } from '@/lib/defaultOptions';
|
||||
import { defaultOptionsStore } from '@/stores/defaultOptions';
|
||||
|
||||
// Mirrors the backend's model_label(): readable label from an HF-style path.
|
||||
function modelLabel(modelId: string): string {
|
||||
return (modelId.split('/').pop() ?? modelId).replace(/[-_]/g, ' ');
|
||||
}
|
||||
|
||||
// Compact summary of only the non-default engine bits, e.g. "8 GPU · compile".
|
||||
function configSummary(gen: GeneratorInfo): string {
|
||||
const parts: string[] = [];
|
||||
if (gen.num_gpus !== 1) parts.push(`${gen.num_gpus} GPU`);
|
||||
if (gen.sp_size !== -1) parts.push(`SP ${gen.sp_size}`);
|
||||
if (gen.tp_size !== -1) parts.push(`TP ${gen.tp_size}`);
|
||||
if (gen.dit_cpu_offload) parts.push('DiT offload');
|
||||
if (gen.text_encoder_cpu_offload) parts.push('TE offload');
|
||||
if (gen.vae_cpu_offload) parts.push('VAE offload');
|
||||
if (gen.image_encoder_cpu_offload) parts.push('image enc offload');
|
||||
if (gen.use_fsdp_inference) parts.push('FSDP');
|
||||
if (gen.enable_torch_compile) parts.push('compile');
|
||||
if (gen.vsa_sparsity > 0) parts.push(`VSA ${gen.vsa_sparsity.toFixed(2)}`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
const STATE_VARIANTS: Record<GeneratorInfo['state'], BadgeProps['variant']> = {
|
||||
ready: 'success',
|
||||
loading: 'warning',
|
||||
failed: 'destructive',
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility strip for the engine's single model slot: shows the resident model
|
||||
* (ready/loading/failed), loads the selected model using the persisted
|
||||
* default job options (replacing whatever is resident), and unloads it.
|
||||
*/
|
||||
export default function WarmModelsPanel() {
|
||||
const { options } = useStore(defaultOptionsStore);
|
||||
|
||||
const [slot, setSlot] = React.useState<GeneratorInfo | null>(null);
|
||||
const [models, setModels] = React.useState<Model[]>([]);
|
||||
const [modelId, setModelId] = React.useState('');
|
||||
const [isBusy, setIsBusy] = React.useState(false);
|
||||
|
||||
const fetchSlot = React.useCallback(async () => {
|
||||
try {
|
||||
const list = await listGenerators();
|
||||
setSlot(list[0] ?? null);
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch generators:', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchSlot();
|
||||
}, [fetchSlot]);
|
||||
|
||||
// Poll every 5s while a load is in flight; stop otherwise.
|
||||
const isLoading = slot?.state === 'loading';
|
||||
React.useEffect(() => {
|
||||
if (!isLoading) return;
|
||||
const interval = setInterval(fetchSlot, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isLoading, fetchSlot]);
|
||||
|
||||
// Same model catalogue (and default selection) as the create-job modal.
|
||||
React.useEffect(() => {
|
||||
getModels('t2v')
|
||||
.then((list) => {
|
||||
setModels(list);
|
||||
const defaultId = getDefaultModelForWorkload(
|
||||
defaultOptionsStore.get().options,
|
||||
't2v',
|
||||
);
|
||||
setModelId(
|
||||
list.some((m) => m.id === defaultId)
|
||||
? defaultId
|
||||
: (list[0]?.id ?? ''),
|
||||
);
|
||||
})
|
||||
.catch((e) => console.error('Failed to load models:', e));
|
||||
}, []);
|
||||
|
||||
async function handleLoad() {
|
||||
if (!modelId || isBusy || isLoading) return;
|
||||
setIsBusy(true);
|
||||
try {
|
||||
await preloadGenerator({
|
||||
model_id: modelId,
|
||||
workload_type: 't2v',
|
||||
num_gpus: options.numGpus,
|
||||
dit_cpu_offload: options.ditCpuOffload,
|
||||
text_encoder_cpu_offload: options.textEncoderCpuOffload,
|
||||
vae_cpu_offload: options.vaeCpuOffload,
|
||||
image_encoder_cpu_offload: options.imageEncoderCpuOffload,
|
||||
use_fsdp_inference: options.useFsdpInference,
|
||||
enable_torch_compile: options.enableTorchCompile,
|
||||
vsa_sparsity: options.vsaSparsity,
|
||||
tp_size: options.tpSize,
|
||||
sp_size: options.spSize,
|
||||
});
|
||||
await fetchSlot();
|
||||
} catch (err) {
|
||||
console.error('Failed to load model:', err);
|
||||
toast.error('Model was not loaded', {
|
||||
description:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Check the Studio API, then retry.',
|
||||
});
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnload() {
|
||||
if (isBusy) return;
|
||||
setIsBusy(true);
|
||||
try {
|
||||
await unloadGenerator();
|
||||
await fetchSlot();
|
||||
} catch (err) {
|
||||
console.error('Failed to unload model:', err);
|
||||
toast.error('Model was not unloaded', {
|
||||
description:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Check the Studio API, then retry.',
|
||||
});
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Loading a model always replaces the resident one — say so on the button.
|
||||
const replaces = slot !== null && !!modelId && slot.model_id !== modelId;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Warm models"
|
||||
className="mx-auto w-full max-w-[850px] px-10 pt-6"
|
||||
>
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-border bg-background p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="mr-auto text-sm font-semibold text-foreground">
|
||||
Warm Model
|
||||
</h2>
|
||||
<label htmlFor="warm-model-select" className="sr-only">
|
||||
Model to load
|
||||
</label>
|
||||
<NativeSelect
|
||||
id="warm-model-select"
|
||||
value={modelId}
|
||||
onChange={(e) => setModelId(e.target.value)}
|
||||
disabled={isBusy || models.length === 0}
|
||||
className="h-9 w-auto max-w-64 rounded-lg"
|
||||
>
|
||||
<option value="" disabled>
|
||||
{models.length === 0 ? 'Loading models…' : 'Select a model…'}
|
||||
</option>
|
||||
{models.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.label}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleLoad}
|
||||
disabled={isBusy || !modelId || isLoading}
|
||||
>
|
||||
{replaces ? 'Load (replaces current)' : 'Load model'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
One model at a time stays resident in GPU memory so jobs skip the
|
||||
load wait; loading a new one replaces it (uses your default job
|
||||
options).
|
||||
</p>
|
||||
<div className="flex min-h-8 flex-wrap items-center gap-2">
|
||||
{slot ? (
|
||||
<>
|
||||
<Badge
|
||||
variant={STATE_VARIANTS[slot.state]}
|
||||
className={
|
||||
slot.state === 'loading' ? 'animate-pulse' : undefined
|
||||
}
|
||||
title={
|
||||
slot.state === 'failed' ? (slot.error ?? undefined) : undefined
|
||||
}
|
||||
>
|
||||
{slot.state}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{modelLabel(slot.model_id)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{configSummary(slot)}
|
||||
</span>
|
||||
{slot.state === 'ready' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="ml-auto"
|
||||
onClick={handleUnload}
|
||||
disabled={isBusy}
|
||||
>
|
||||
Unload
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No model loaded
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ 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,
|
||||
@@ -24,80 +23,46 @@ 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 (e.key === 'Escape' && !document.querySelector('[data-modal]')) {
|
||||
if (activeJobStore.get().activeJob) setActiveJobId(null);
|
||||
if (activeDatasetStore.get().activeDataset) setActiveDatasetId(null);
|
||||
}
|
||||
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>
|
||||
<Header />
|
||||
<div
|
||||
className="flex overflow-hidden"
|
||||
style={{
|
||||
marginTop: 'var(--header-height)',
|
||||
height: 'calc(100dvh - var(--header-height))',
|
||||
height: 'calc(100vh - 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"
|
||||
/>
|
||||
)}
|
||||
<PrimarySidebar onWidthChange={setPrimaryWidth} />
|
||||
<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,
|
||||
marginLeft: primaryWidth,
|
||||
marginRight: secondaryOpen ? secondaryWidth : 0,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -105,7 +70,6 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
{jobSidebarOpen && activeJob && (
|
||||
<JobDetailsSidebar
|
||||
job={activeJob}
|
||||
isMobile={isMobile}
|
||||
onClose={() => setActiveJobId(null)}
|
||||
onWidthChange={setSecondaryWidth}
|
||||
/>
|
||||
@@ -113,7 +77,6 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
{datasetSidebarOpen && activeDataset && (
|
||||
<DatasetSidebar
|
||||
dataset={activeDataset}
|
||||
isMobile={isMobile}
|
||||
onClose={() => setActiveDatasetId(null)}
|
||||
onWidthChange={setSecondaryWidth}
|
||||
/>
|
||||
|
||||
@@ -1,63 +1,39 @@
|
||||
'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': 'Studio',
|
||||
'/finetuning': 'Studio',
|
||||
'/distillation': 'Studio',
|
||||
'/inference': 'Jobs',
|
||||
'/finetuning': 'Jobs',
|
||||
'/distillation': 'Jobs',
|
||||
'/datasets': 'Datasets',
|
||||
'/gallery': 'Gallery',
|
||||
'/gpus': 'GPUs',
|
||||
'/settings': 'Settings',
|
||||
};
|
||||
|
||||
export default function Header({
|
||||
navigationOpen,
|
||||
onNavigationToggle,
|
||||
}: {
|
||||
navigationOpen: boolean;
|
||||
onNavigationToggle: () => void;
|
||||
}) {
|
||||
export default function Header() {
|
||||
const pathname = usePathname();
|
||||
const { actions } = useHeaderActions();
|
||||
const title = TAB_TITLES[pathname] ?? 'FastVideo';
|
||||
|
||||
return (
|
||||
<header className="fixed inset-x-0 top-0 z-[100] flex h-[var(--header-height)] items-center gap-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>
|
||||
<header className="fixed inset-x-0 top-0 z-[100] flex h-[var(--header-height)] items-center gap-6 border-b border-border bg-background/80 px-6 backdrop-blur">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="FastVideo Logo"
|
||||
width={100}
|
||||
height={42}
|
||||
className="hidden h-[42px] w-[78px] shrink-0 object-contain min-[361px]:block md:w-[100px]"
|
||||
className="block h-[42px] w-[100px]"
|
||||
/>
|
||||
<h1 className="sr-only m-0 flex-1 text-xl font-semibold tracking-tight md:not-sr-only">
|
||||
<h1 className="m-0 flex-1 text-xl font-semibold tracking-tight">
|
||||
{title}
|
||||
</h1>
|
||||
<div className="ml-auto flex min-w-0 items-center gap-2 md:gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{actions}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
@@ -20,18 +19,12 @@ const JOB_ROUTES = [
|
||||
] 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';
|
||||
'block px-5 py-[0.65rem] text-left text-sm text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground';
|
||||
const TAB_ACTIVE = 'bg-accent-blue/10 font-medium text-accent-blue';
|
||||
|
||||
export default function PrimarySidebar({
|
||||
isMobile,
|
||||
mobileOpen,
|
||||
onMobileClose,
|
||||
onWidthChange,
|
||||
}: {
|
||||
isMobile: boolean;
|
||||
mobileOpen: boolean;
|
||||
onMobileClose: () => void;
|
||||
onWidthChange?: (w: number) => void;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
@@ -45,8 +38,8 @@ export default function PrimarySidebar({
|
||||
const isJobsActive = JOB_ROUTES.some((r) => pathname === r.href);
|
||||
|
||||
React.useEffect(() => {
|
||||
onWidthChange?.(isMobile ? 0 : layoutWidth);
|
||||
}, [isMobile, layoutWidth, onWidthChange]);
|
||||
onWidthChange?.(layoutWidth);
|
||||
}, [layoutWidth, onWidthChange]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (JOB_ROUTES.some((r) => pathname === r.href)) {
|
||||
@@ -65,37 +58,11 @@ export default function PrimarySidebar({
|
||||
|
||||
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,
|
||||
}}
|
||||
className="fixed bottom-0 left-0 top-[var(--header-height)] z-50 flex max-h-[calc(100vh-var(--header-height))] shrink-0 flex-col border-r border-border bg-card"
|
||||
style={{ width: effectiveWidth }}
|
||||
>
|
||||
{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"
|
||||
>
|
||||
<nav className="flex flex-col py-2">
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
@@ -108,7 +75,7 @@ export default function PrimarySidebar({
|
||||
isJobsActive && TAB_ACTIVE,
|
||||
)}
|
||||
>
|
||||
<span>Studio</span>
|
||||
<span>Jobs</span>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
@@ -128,8 +95,6 @@ export default function PrimarySidebar({
|
||||
<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]',
|
||||
@@ -144,32 +109,24 @@ export default function PrimarySidebar({
|
||||
</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
|
||||
@@ -177,7 +134,7 @@ export default function PrimarySidebar({
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{!isMobile && <div
|
||||
<div
|
||||
className={cn(
|
||||
'absolute bottom-0 p-2',
|
||||
isCollapsed ? '-right-[60px] top-0' : 'right-0',
|
||||
@@ -188,7 +145,8 @@ export default function PrimarySidebar({
|
||||
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',
|
||||
'flex items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground',
|
||||
isCollapsed ? 'p-3' : 'p-2',
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
@@ -201,9 +159,9 @@ export default function PrimarySidebar({
|
||||
<path d={isCollapsed ? 'M9 18l6-6-6-6' : 'M15 18l-6-6 6-6'} />
|
||||
</svg>
|
||||
</button>
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{!isMobile && !isCollapsed && (
|
||||
{!isCollapsed && (
|
||||
<div
|
||||
role="presentation"
|
||||
onMouseDown={onMouseDown}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import GpuGrid from './GpuGrid';
|
||||
@@ -73,30 +73,4 @@ describe('GpuGrid', () => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
'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';
|
||||
@@ -99,8 +97,7 @@ function GpuCard({ gpu }: { gpu: GpuInfo }) {
|
||||
|
||||
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);
|
||||
const [fetchError, setFetchError] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let mounted = true;
|
||||
@@ -113,14 +110,10 @@ export default function GpuGrid() {
|
||||
const next = await getGpus();
|
||||
if (mounted) {
|
||||
setSnapshot(next);
|
||||
setFetchError(null);
|
||||
setFetchError(false);
|
||||
}
|
||||
} catch {
|
||||
if (mounted) {
|
||||
setFetchError(
|
||||
'GPU status could not be refreshed. The values below may be stale.',
|
||||
);
|
||||
}
|
||||
if (mounted) setFetchError(true);
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
@@ -132,27 +125,14 @@ export default function GpuGrid() {
|
||||
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>
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
Could not reach the API server. GPU status needs the studio API server
|
||||
running.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (!snapshot) {
|
||||
@@ -170,22 +150,9 @@ export default function GpuGrid() {
|
||||
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>
|
||||
<p className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-600 dark:text-amber-400">
|
||||
Lost contact with the API server — showing the last known values.
|
||||
</p>
|
||||
)}
|
||||
<div className="grid gap-4 [grid-template-columns:repeat(auto-fill,minmax(280px,1fr))]">
|
||||
{snapshot.gpus.map((gpu) => (
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -29,12 +29,11 @@ const badgeVariants = cva(
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLSpanElement>,
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
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} />;
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
@@ -7,7 +7,7 @@ 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",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl border !text-sm !font-semibold transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/40 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -18,11 +18,11 @@ const buttonVariants = cva(
|
||||
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",
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-lg px-3 !text-xs",
|
||||
lg: "h-11 px-5 !text-sm",
|
||||
icon: "size-10",
|
||||
"icon-sm": "size-8",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -42,7 +42,7 @@ const DialogContent = React.forwardRef<
|
||||
{...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">
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-lg p-1 text-muted-foreground opacity-70 transition-opacity hover:bg-secondary hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-sky-400/40 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
|
||||
@@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
<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",
|
||||
"flex h-10 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-sky-400/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/25 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -11,7 +11,7 @@ const NativeSelect = React.forwardRef<
|
||||
<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',
|
||||
'flex h-10 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-sky-400/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/25 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<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',
|
||||
'flex h-10 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 outline-none transition-colors placeholder:text-muted-foreground focus:border-sky-400/70 focus:ring-2 focus:ring-sky-400/25 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -117,7 +117,7 @@ const SelectItem = React.forwardRef<
|
||||
<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',
|
||||
'relative flex 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}
|
||||
|
||||
@@ -8,39 +8,21 @@ import { cn } from '@/lib/utils';
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full touch-none select-none items-center',
|
||||
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>
|
||||
),
|
||||
);
|
||||
)}
|
||||
{...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 className="block h-4 w-4 rounded-full border-2 border-accent-blue bg-accent-blue shadow transition-colors hover:border-accent-blue/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/40 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider };
|
||||
|
||||
@@ -12,14 +12,14 @@ const Switch = React.forwardRef<
|
||||
<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',
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/40 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-accent-blue data-[state=checked]:bg-accent-blue data-[state=unchecked]:bg-background',
|
||||
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',
|
||||
'pointer-events-none 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 data-[state=unchecked]:translate-x-0.5',
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
|
||||
@@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
|
||||
<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',
|
||||
'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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/40 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -8,7 +8,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"tex
|
||||
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",
|
||||
"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-sky-400/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/25 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
'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;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
'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;
|
||||
}
|
||||
@@ -202,33 +202,6 @@ export async function getModels(workloadType?: string): Promise<Model[]> {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* A model's recommended sampling settings. Keys the backend has no value
|
||||
* for are absent; the UI leaves those form fields untouched.
|
||||
*/
|
||||
export interface ModelPresets {
|
||||
height?: number;
|
||||
width?: number;
|
||||
num_frames?: number;
|
||||
fps?: number;
|
||||
num_inference_steps?: number;
|
||||
guidance_scale?: number;
|
||||
guidance_rescale?: number;
|
||||
negative_prompt?: string;
|
||||
seed?: number;
|
||||
}
|
||||
|
||||
export async function getModelPresets(modelId: string): Promise<ModelPresets> {
|
||||
const baseApiUrl = getApiBaseUrl();
|
||||
const response = await fetch(
|
||||
`${baseApiUrl}/models/presets?model_id=${encodeURIComponent(modelId)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch model presets");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getGpus(): Promise<GpuSnapshot> {
|
||||
const baseApiUrl = getApiBaseUrl();
|
||||
const response = await fetch(`${baseApiUrl}/gpus`);
|
||||
@@ -346,109 +319,6 @@ export async function downloadJobVideo(id: string): Promise<Blob> {
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
// --- Generators (warm models) ---
|
||||
|
||||
/**
|
||||
* Engine subset of CreateJobRequest that identifies one resident generator.
|
||||
* Mirrors the backend's GeneratorRequest defaults.
|
||||
*/
|
||||
export interface GeneratorRequest {
|
||||
model_id: string;
|
||||
workload_type?: string;
|
||||
num_gpus?: number;
|
||||
dit_cpu_offload?: boolean;
|
||||
text_encoder_cpu_offload?: boolean;
|
||||
vae_cpu_offload?: boolean;
|
||||
image_encoder_cpu_offload?: boolean;
|
||||
use_fsdp_inference?: boolean;
|
||||
enable_torch_compile?: boolean;
|
||||
vsa_sparsity?: number;
|
||||
tp_size?: number;
|
||||
sp_size?: number;
|
||||
}
|
||||
|
||||
export interface GeneratorInfo {
|
||||
state: "ready" | "loading" | "failed";
|
||||
model_id: string;
|
||||
workload_type: string;
|
||||
num_gpus: number;
|
||||
dit_cpu_offload: boolean;
|
||||
text_encoder_cpu_offload: boolean;
|
||||
vae_cpu_offload: boolean;
|
||||
image_encoder_cpu_offload: boolean;
|
||||
use_fsdp_inference: boolean;
|
||||
enable_torch_compile: boolean;
|
||||
vsa_sparsity: number;
|
||||
tp_size: number;
|
||||
sp_size: number;
|
||||
error: string | null;
|
||||
started_at?: number;
|
||||
}
|
||||
|
||||
/** The single resident slot: [] when nothing is loaded, else one entry. */
|
||||
export async function listGenerators(): Promise<GeneratorInfo[]> {
|
||||
const baseApiUrl = getApiBaseUrl();
|
||||
const response = await fetch(`${baseApiUrl}/generators`);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch generators");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function preloadGenerator(
|
||||
req: GeneratorRequest,
|
||||
): Promise<GeneratorInfo> {
|
||||
const baseApiUrl = getApiBaseUrl();
|
||||
const response = await fetch(`${baseApiUrl}/generators/preload`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to preload model" }));
|
||||
throw new Error(error.detail || "Failed to preload model");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/** Unload the single resident generator (no body — there is only one slot). */
|
||||
export async function unloadGenerator(): Promise<void> {
|
||||
const baseApiUrl = getApiBaseUrl();
|
||||
const response = await fetch(`${baseApiUrl}/generators/unload`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to unload model" }));
|
||||
throw new Error(error.detail || "Failed to unload model");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Engine logs ---
|
||||
|
||||
export interface EngineLogs {
|
||||
lines: string[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental tail of the engine's stdout/stderr. Poll with
|
||||
* `after=<total from the previous response>`.
|
||||
*/
|
||||
export async function getEngineLogs(after: number = 0): Promise<EngineLogs> {
|
||||
const baseApiUrl = getApiBaseUrl();
|
||||
const response = await fetch(`${baseApiUrl}/engine/logs?after=${after}`);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch engine logs");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// --- Datasets ---
|
||||
|
||||
export interface Dataset {
|
||||
@@ -548,35 +418,3 @@ export function getDatasetMediaUrl(
|
||||
const baseApiUrl = getApiBaseUrl();
|
||||
return `${baseApiUrl}/datasets/${datasetId}/media/${encodeURIComponent(fileName)}`;
|
||||
}
|
||||
|
||||
// --- Cluster ---
|
||||
|
||||
/** Per-GPU telemetry within a cluster node (same shape as GpuInfo). */
|
||||
export type ClusterGpu = GpuInfo;
|
||||
|
||||
export interface ClusterNode {
|
||||
hostname: string;
|
||||
ip: string | null;
|
||||
is_this_host: boolean;
|
||||
cpus: number | null;
|
||||
ray_gpus: number | null;
|
||||
available: boolean;
|
||||
error: string | null;
|
||||
gpus: ClusterGpu[];
|
||||
}
|
||||
|
||||
export interface ClusterSnapshot {
|
||||
mode: "ray" | "local";
|
||||
nodes: ClusterNode[];
|
||||
resources: { gpus_total: number; gpus_available: number } | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export async function getClusterStatus(): Promise<ClusterSnapshot> {
|
||||
const baseApiUrl = getApiBaseUrl();
|
||||
const response = await fetch(`${baseApiUrl}/cluster`);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch cluster status");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Tests for the single-slot resident generator: preload, list, unload.
|
||||
|
||||
Exactly one VideoGenerator lives in memory. One load at a time; loading a new
|
||||
config always releases the old instance; unload deletes it. The generator is
|
||||
faked at the ``_create_generator_into_slot``/VideoGenerator boundary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from fastvideo_studio.job_runner import JobRunner, JobStatus
|
||||
|
||||
|
||||
class _FakeGenerator:
|
||||
def __init__(self) -> None:
|
||||
self.shutdown_calls = 0
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self.shutdown_calls += 1
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def runner():
|
||||
r = JobRunner.__new__(JobRunner) # skip __init__: no DB/Manager needed
|
||||
r._jobs = {}
|
||||
r._jobs_lock = threading.Lock()
|
||||
r._generator = None
|
||||
r._generator_config = None
|
||||
r._generator_state = "empty"
|
||||
r._generator_error = None
|
||||
r._generator_lock = threading.Lock()
|
||||
r._load_lock = threading.Lock()
|
||||
r._worker_log_queue = None # no Manager in unit tests
|
||||
import queue as _queue
|
||||
r._loader_queue = _queue.Queue()
|
||||
threading.Thread(target=r._loader_loop, daemon=True).start()
|
||||
return r
|
||||
|
||||
|
||||
def _install_fake_loader(runner, monkeypatch, made: list | None = None,
|
||||
gate: threading.Event | None = None,
|
||||
fail: str | None = None):
|
||||
"""Replace the VideoGenerator load inside _create_generator_into_slot."""
|
||||
import fastvideo_studio.job_runner as jr
|
||||
|
||||
class _FakeVG:
|
||||
@staticmethod
|
||||
def from_pretrained(model_path, **kwargs):
|
||||
if gate is not None:
|
||||
assert gate.wait(5), "test gate never opened"
|
||||
if fail is not None:
|
||||
raise RuntimeError(fail)
|
||||
gen = _FakeGenerator()
|
||||
if made is not None:
|
||||
made.append((model_path, gen))
|
||||
return gen
|
||||
|
||||
monkeypatch.setitem(__import__("sys").modules, "fastvideo",
|
||||
SimpleNamespace(VideoGenerator=_FakeVG))
|
||||
return jr
|
||||
|
||||
|
||||
def _wait_state(runner, state, timeout=5.0):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
with runner._generator_lock:
|
||||
if runner._generator_state == state:
|
||||
return runner._slot_entry()
|
||||
time.sleep(0.02)
|
||||
raise AssertionError(f"never reached state {state}: {runner._generator_state}")
|
||||
|
||||
|
||||
def test_preload_then_ready_then_idempotent(runner, monkeypatch):
|
||||
made = []
|
||||
_install_fake_loader(runner, monkeypatch, made=made)
|
||||
|
||||
entry = runner.preload_generator(model_id="org/a", workload_type="t2v", num_gpus=8)
|
||||
assert entry["state"] in ("loading", "ready")
|
||||
_wait_state(runner, "ready")
|
||||
|
||||
again = runner.preload_generator(model_id="org/a", workload_type="t2v", num_gpus=8)
|
||||
assert again["state"] == "ready"
|
||||
assert len(made) == 1 # same config never reloads
|
||||
|
||||
|
||||
def test_only_one_load_at_a_time(runner, monkeypatch):
|
||||
gate = threading.Event()
|
||||
_install_fake_loader(runner, monkeypatch, gate=gate)
|
||||
|
||||
runner.preload_generator(model_id="org/a", workload_type="t2v", num_gpus=8)
|
||||
with pytest.raises(RuntimeError, match="already in progress"):
|
||||
runner.preload_generator(model_id="org/b", workload_type="t2v", num_gpus=8)
|
||||
gate.set()
|
||||
_wait_state(runner, "ready")
|
||||
|
||||
|
||||
def test_new_config_always_releases_old_instance(runner, monkeypatch):
|
||||
made = []
|
||||
_install_fake_loader(runner, monkeypatch, made=made)
|
||||
|
||||
runner.preload_generator(model_id="org/a", workload_type="t2v", num_gpus=8)
|
||||
_wait_state(runner, "ready")
|
||||
first = made[0][1]
|
||||
|
||||
runner.preload_generator(model_id="org/b", workload_type="t2v", num_gpus=8)
|
||||
_wait_state(runner, "ready")
|
||||
assert first.shutdown_calls == 1 # old instance released, not stacked
|
||||
assert [m[0] for m in made] == ["org/a", "org/b"]
|
||||
assert len(runner.list_generators()) == 1
|
||||
assert runner.list_generators()[0]["model_id"] == "org/b"
|
||||
|
||||
|
||||
def test_failed_load_reports_error_and_allows_retry(runner, monkeypatch):
|
||||
_install_fake_loader(runner, monkeypatch, fail="no CUDA on this box")
|
||||
runner.preload_generator(model_id="org/broken", workload_type="t2v", num_gpus=1)
|
||||
failed = _wait_state(runner, "failed")
|
||||
assert "no CUDA" in failed["error"]
|
||||
|
||||
# retry after failure must be accepted (this was the reported bug)
|
||||
made = []
|
||||
_install_fake_loader(runner, monkeypatch, made=made)
|
||||
runner.preload_generator(model_id="org/broken", workload_type="t2v", num_gpus=1)
|
||||
_wait_state(runner, "ready")
|
||||
assert len(made) == 1
|
||||
|
||||
|
||||
def test_unload_deletes_instance(runner, monkeypatch):
|
||||
made = []
|
||||
_install_fake_loader(runner, monkeypatch, made=made)
|
||||
runner.preload_generator(model_id="org/a", workload_type="t2v", num_gpus=8)
|
||||
_wait_state(runner, "ready")
|
||||
|
||||
assert runner.unload_generator() is True
|
||||
assert made[0][1].shutdown_calls == 1
|
||||
assert runner.list_generators() == []
|
||||
assert runner._generator is None
|
||||
assert runner.unload_generator() is False # nothing resident
|
||||
|
||||
|
||||
def test_unload_refuses_while_inference_job_runs(runner, monkeypatch):
|
||||
made = []
|
||||
_install_fake_loader(runner, monkeypatch, made=made)
|
||||
runner.preload_generator(model_id="org/a", workload_type="t2v", num_gpus=8)
|
||||
_wait_state(runner, "ready")
|
||||
runner._jobs["j1"] = SimpleNamespace(id="j1", status=JobStatus.RUNNING, job_type="inference")
|
||||
|
||||
with pytest.raises(RuntimeError, match="j1"):
|
||||
runner.unload_generator()
|
||||
assert made[0][1].shutdown_calls == 0
|
||||
|
||||
runner._jobs["j1"].job_type = "finetune" # training doesn't block
|
||||
assert runner.unload_generator() is True
|
||||
|
||||
|
||||
def test_job_waits_for_matching_preload(runner, monkeypatch):
|
||||
gate = threading.Event()
|
||||
made = []
|
||||
_install_fake_loader(runner, monkeypatch, made=made, gate=gate)
|
||||
|
||||
runner.preload_generator(model_id="org/a", workload_type="t2v", num_gpus=8)
|
||||
got = []
|
||||
t = threading.Thread(
|
||||
target=lambda: got.append(
|
||||
runner._get_or_create_generator("org/a", "t2v", 8)),
|
||||
daemon=True)
|
||||
t.start()
|
||||
time.sleep(0.2)
|
||||
assert not got # job blocked on the in-flight load
|
||||
gate.set()
|
||||
t.join(5)
|
||||
assert got and got[0] is made[0][1]
|
||||
assert len(made) == 1 # the job reused the preloaded instance
|
||||
|
||||
|
||||
def test_job_with_different_config_replaces_slot(runner, monkeypatch):
|
||||
made = []
|
||||
_install_fake_loader(runner, monkeypatch, made=made)
|
||||
runner.preload_generator(model_id="org/a", workload_type="t2v", num_gpus=8)
|
||||
_wait_state(runner, "ready")
|
||||
|
||||
gen = runner._get_or_create_generator("org/b", "t2v", 4)
|
||||
assert gen is made[1][1]
|
||||
assert made[0][1].shutdown_calls == 1 # old released before new load
|
||||
assert runner.list_generators()[0]["model_id"] == "org/b"
|
||||
|
||||
|
||||
def test_job_during_replace_never_sees_half_replaced_slot(runner, monkeypatch):
|
||||
"""Regression: user restarted a stale 1-gpu job while an 8-gpu generator
|
||||
was resident; the replace nulled the generator with state still 'ready',
|
||||
and the next (correct) job grabbed None -> AttributeError on
|
||||
generate_video. All transitions now serialize under the load lock."""
|
||||
made = []
|
||||
gate = threading.Event()
|
||||
_install_fake_loader(runner, monkeypatch, made=made)
|
||||
runner.preload_generator(model_id="org/h3", workload_type="t2v", num_gpus=8)
|
||||
_wait_state(runner, "ready")
|
||||
|
||||
_install_fake_loader(runner, monkeypatch, made=made, gate=gate)
|
||||
results: dict[str, Any] = {}
|
||||
|
||||
def job_a(): # stale job: mismatching config triggers a slow replace
|
||||
results["a"] = runner._get_or_create_generator("org/h3", "t2v", 1)
|
||||
|
||||
def job_b(): # correct job arriving mid-replace
|
||||
time.sleep(0.3)
|
||||
results["b"] = runner._get_or_create_generator("org/h3", "t2v", 8)
|
||||
|
||||
ta = threading.Thread(target=job_a, daemon=True)
|
||||
tb = threading.Thread(target=job_b, daemon=True)
|
||||
ta.start(); tb.start()
|
||||
time.sleep(0.6)
|
||||
gate.set()
|
||||
ta.join(10); tb.join(10)
|
||||
|
||||
assert results["a"] is not None and hasattr(results["a"], "shutdown")
|
||||
assert results["b"] is not None and hasattr(results["b"], "shutdown")
|
||||
# b arrived second, so the slot ends at b's 8-gpu config
|
||||
assert runner.list_generators()[0]["num_gpus"] == 8
|
||||
|
||||
|
||||
def test_config_dict_matches_request_defaults(runner):
|
||||
"""GeneratorRequest defaults and CreateJobRequest defaults must resolve to
|
||||
the same slot config — else preloading never matches the job."""
|
||||
from fastvideo_studio.models import CreateJobRequest, GeneratorRequest
|
||||
|
||||
job = CreateJobRequest(model_id="m", prompt="p").model_dump()
|
||||
pre = GeneratorRequest(model_id="m").model_dump()
|
||||
assert runner._generator_config_dict(**pre) == runner._generator_config_dict(
|
||||
**{k: job[k] for k in pre})
|
||||
|
||||
|
||||
def test_engine_log_buffer_incremental_tail():
|
||||
from fastvideo_studio.server import _EngineLogBuffer
|
||||
|
||||
buf = _EngineLogBuffer(maxlen=3)
|
||||
buf.write("a\nb\n")
|
||||
buf.write("c") # partial line: not visible yet
|
||||
lines, total = buf.get_lines(0)
|
||||
assert lines == ["a", "b"] and total == 2
|
||||
buf.write("!\nd\ne\n") # completes "c!", then overflows the ring
|
||||
lines, total = buf.get_lines(total)
|
||||
assert lines == ["c!", "d", "e"] and total == 5
|
||||
# reader far behind: dropped lines are skipped, no crash
|
||||
lines, _ = buf.get_lines(0)
|
||||
assert lines == ["c!", "d", "e"]
|
||||
|
||||
|
||||
def test_engine_feed_drives_job_progress(runner):
|
||||
from fastvideo_studio.job_runner import Job
|
||||
|
||||
job = Job(id="j-prog", model_id="m", prompt="p")
|
||||
runner._active_inference_job = job
|
||||
# ray wraps relayed lines in ANSI color codes — the bridge must strip them
|
||||
runner.feed_engine_line("\x1b[36m(RayWorkerWrapper pid=123, ip=10.0.0.2)\x1b[0m denoising: 40%|████ | 20/50 [00:30<00:45, 1.5s/it]")
|
||||
assert job._log_buf.progress == 40.0
|
||||
assert job._log_buf.progress_msg == "20/50 steps"
|
||||
|
||||
# driver-side lines (no ray prefix) are NOT double-fed
|
||||
before = job._log_buf.get_lines()[1]
|
||||
runner.feed_engine_line("INFO 08-07 [video_generator.py] driver line")
|
||||
assert job._log_buf.get_lines()[1] == before
|
||||
|
||||
# no active job: no-op
|
||||
runner._active_inference_job = None
|
||||
runner.feed_engine_line("(RayWorkerWrapper pid=123) 90%|████| 45/50")
|
||||
assert job._log_buf.progress == 40.0
|
||||
+5
-30
@@ -186,40 +186,15 @@ RUN --mount=type=cache,target=/opt/uv/cache \
|
||||
|
||||
COPY . .
|
||||
|
||||
# Build immutable FastVideo kernel wheels for the published image. The requested
|
||||
# architecture remains installed for normal image users; amd64 images also carry
|
||||
# an SM89 artifact so the predominant L40S Modal lanes can reuse it exactly.
|
||||
ARG FASTVIDEO_KERNEL_PREBUILT_DIR=/opt/fastvideo-kernel-prebuilt
|
||||
# Install FastVideo Unified Kernel exactly once. build.sh initializes only its
|
||||
# CUTLASS/ThunderKittens submodules, then compiles for TORCH_CUDA_ARCH_LIST
|
||||
# (default Hopper sm_90a) without requiring a live GPU.
|
||||
RUN --mount=type=cache,target=/opt/uv/cache \
|
||||
source $HOME/.local/bin/env && \
|
||||
source /opt/venv/bin/activate && \
|
||||
default_arch="${TORCH_CUDA_ARCH_LIST}" && \
|
||||
default_wheel_dir="${FASTVIDEO_KERNEL_PREBUILT_DIR}/${default_arch}" && \
|
||||
export TORCH_CUDA_ARCH_LIST="${default_arch}" && \
|
||||
cd fastvideo-kernel && \
|
||||
CMAKE_ARGS= CMAKE_BUILD_PARALLEL_LEVEL=${CMAKE_BUILD_PARALLEL_LEVEL} \
|
||||
./build.sh --wheel-dir "${default_wheel_dir}" && \
|
||||
cd /FastVideo && \
|
||||
CMAKE_ARGS= python fastvideo/tests/modal/kernel_build_cache.py write-build-info \
|
||||
--wheel-dir "${default_wheel_dir}" \
|
||||
--output "${default_wheel_dir}/metadata.json" && \
|
||||
if [[ "${TARGETARCH:-amd64}" == "amd64" && "${default_arch}" != "8.9" ]]; then \
|
||||
export TORCH_CUDA_ARCH_LIST=8.9 && \
|
||||
l40s_wheel_dir="${FASTVIDEO_KERNEL_PREBUILT_DIR}/8.9" && \
|
||||
cd /FastVideo/fastvideo-kernel && \
|
||||
CMAKE_ARGS= CMAKE_BUILD_PARALLEL_LEVEL=${CMAKE_BUILD_PARALLEL_LEVEL} \
|
||||
./build.sh --wheel-dir "${l40s_wheel_dir}" && \
|
||||
cd /FastVideo && \
|
||||
CMAKE_ARGS= python fastvideo/tests/modal/kernel_build_cache.py write-build-info \
|
||||
--wheel-dir "${l40s_wheel_dir}" \
|
||||
--output "${l40s_wheel_dir}/metadata.json"; \
|
||||
fi && \
|
||||
export TORCH_CUDA_ARCH_LIST="${default_arch}" && \
|
||||
default_wheel="$(find "${default_wheel_dir}" -maxdepth 1 -type f \
|
||||
\( -name 'fastvideo_kernel-*.whl' -o -name 'fastvideo-kernel-*.whl' \) \
|
||||
| sort | tail -n 1)" && \
|
||||
uv pip install "${default_wheel}" \
|
||||
--reinstall-package fastvideo-kernel --no-deps
|
||||
CMAKE_BUILD_PARALLEL_LEVEL=${CMAKE_BUILD_PARALLEL_LEVEL} \
|
||||
TORCH_CUDA_ARCH_LIST=${TORCH_CUDA_ARCH_LIST} ./build.sh
|
||||
|
||||
# Install FastVideo itself (editable) now that the source is present, and set up
|
||||
# shell configuration. Dependencies and the local kernel are already installed,
|
||||
|
||||
@@ -8,15 +8,13 @@ FastVideo provides highly optimized custom attention kernels to accelerate video
|
||||
* **[Sliding Tile Attention (STA)](sta/index.md)**: STA kernel support is kept in
|
||||
`fastvideo-kernel`; full FastVideo STA pipeline workflow is archived in
|
||||
`sta_do_not_delete`.
|
||||
* **[Attn-QAT Training](../training/attn_qat.md)**: Runtime-JIT Triton forward
|
||||
and backward kernels for role-local quantization-aware training.
|
||||
* **Backend development guide**: See the developer guide at
|
||||
[Attention Backend Development](../contributing/attention_backend.md).
|
||||
|
||||
## General Build Instructions
|
||||
|
||||
These instructions apply to building the `fastvideo-kernel` package from
|
||||
source, which includes STA, VSA, and Attn-QAT kernels.
|
||||
source, which includes both STA and VSA kernels.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ status.
|
||||
| Transformer Tests | `transformer` | `fastvideo/models/dits/**`, `fastvideo/models/loader/**`, `fastvideo/tests/transformers/**`, `fastvideo/layers/**`, `fastvideo/attention/**`, `pyproject.toml`, `docker/Dockerfile` |
|
||||
| Kernel Tests | `kernel_tests` | `fastvideo-kernel/**`, `pyproject.toml`, `docker/Dockerfile` |
|
||||
| Unit Tests | `unit_test` | `fastvideo/**`, `.buildkite/**`, `.github/**`, `pyproject.toml`, `docker/Dockerfile` |
|
||||
| Preprocessing Tests | `preprocessing` | Preprocessing pipelines/workflows, Parquet schema/writer, the integration test, and its CI entrypoints |
|
||||
| DreamVerse App Tests | `dreamverse_app` | `apps/dreamverse/**`, `pyproject.toml` |
|
||||
|
||||
### Tier 3: Full Suite
|
||||
@@ -147,6 +148,7 @@ Valid direct test names:
|
||||
| `/test transformer` | `transformer` |
|
||||
| `/test kernel` | `kernel_tests` |
|
||||
| `/test unit` | `unit_test` |
|
||||
| `/test preprocessing` | `preprocessing` |
|
||||
| `/test dreamverse` | `dreamverse_app` |
|
||||
| `/test ssim` | `ssim` |
|
||||
| `/test training` | `training` |
|
||||
@@ -255,9 +257,10 @@ If you add a new CI test category:
|
||||
|
||||
### Documentation
|
||||
|
||||
`.github/workflows/infra-docs.yml` builds documentation for PRs that touch
|
||||
`docs/**`, `mkdocs.yml`, `requirements-mkdocs.txt`, or the workflow itself. On
|
||||
pushes to `main`, it also deploys the built site to GitHub Pages.
|
||||
`.github/workflows/infra-docs.yml` builds documentation for same-repository PRs
|
||||
that touch `docs/**`, `mkdocs.yml`, `requirements-mkdocs.txt`, or the workflow
|
||||
itself. Fork PRs skip this executable build instead of waiting for maintainer
|
||||
approval. On pushes to `main`, it also deploys the built site to GitHub Pages.
|
||||
|
||||
The docs job:
|
||||
|
||||
@@ -269,26 +272,16 @@ The docs job:
|
||||
### Docker Images
|
||||
|
||||
`.github/workflows/infra-build-image.yml` supports manual `workflow_dispatch`
|
||||
runs and automatically rebuilds the CUDA matrix when a repository-controlled
|
||||
image input changes on `main` in the canonical repository. Those inputs include
|
||||
the CUDA Dockerfile and reusable workflow, dependency metadata, Docker context
|
||||
policy, `fastvideo-kernel/**`, and the kernel artifact metadata/key helper.
|
||||
Manual runs let maintainers choose which image families to build. The
|
||||
`fastvideo-dev` matrix builds Python 3.12 images for CUDA 12.6 and CUDA 13 on
|
||||
native `amd64` and `arm64` runners, then publishes one multi-platform manifest
|
||||
per CUDA version. CUDA 12.6 owns the `py3.12-latest` and global `latest` tags, as
|
||||
well as the explicit `py3.12-cuda12.6.3-latest` alias. CUDA 13 is published under
|
||||
the explicit `py3.12-cuda13.0.0-latest` tag. This publication policy does not
|
||||
change the unparameterized `docker/Dockerfile` build defaults, which remain CUDA
|
||||
13 and `cu130`.
|
||||
|
||||
Published amd64 development images keep their configured Hopper kernel wheel
|
||||
installed and also carry an immutable SM89 wheel under
|
||||
`/opt/fastvideo-kernel-prebuilt`. Modal PR and SSIM jobs select the exact
|
||||
source, ABI, and GPU-architecture match from that directory, so L40S jobs reuse
|
||||
the trusted image artifact while kernel-changing PRs still build locally. Once
|
||||
a kernel or artifact-key change reaches `main`, the image workflow republishes
|
||||
the matching trusted artifact before later jobs consume the updated image tag.
|
||||
runs and automatically rebuilds the CUDA matrix when `docker/Dockerfile`
|
||||
changes on `main` in the canonical repository. Manual runs let maintainers
|
||||
choose which image families to build. The `fastvideo-dev` matrix builds Python
|
||||
3.12 images for CUDA 12.6 and CUDA 13 on native `amd64` and `arm64` runners,
|
||||
then publishes one multi-platform manifest per CUDA version. CUDA 12.6 owns the
|
||||
`py3.12-latest` and global `latest` tags, as well as the explicit
|
||||
`py3.12-cuda12.6.3-latest` alias. CUDA 13 is published under the explicit
|
||||
`py3.12-cuda13.0.0-latest` tag. This publication policy does not change the
|
||||
unparameterized `docker/Dockerfile` build defaults, which remain CUDA 13 and
|
||||
`cu130`.
|
||||
|
||||
The optional Dreamverse matrix builds backend and UI images for CUDA 12.6 and
|
||||
CUDA 13 on `amd64`. Dreamverse remains `amd64`-only because its FA4 dependency
|
||||
|
||||
@@ -76,7 +76,6 @@ surfaces:
|
||||
lora_target_modules: "Legacy LoRA configuration surface pending dedicated component API."
|
||||
output_type: "Legacy output formatting surface pending GenerationResult cleanup."
|
||||
VSA_sparsity: "Model-specific inference optimization not yet represented in the typed public schema."
|
||||
attention_backend: "Process-wide default attention-backend request applied per component at load time; kernel-selection knob not yet represented in the typed public schema."
|
||||
moba_config_path: "Model-specific MoBA optimization surface not yet represented in the typed public schema."
|
||||
master_port: "Executor/bootstrap compatibility field; not part of the canonical inference schema."
|
||||
refine_transformer_path: "Generic stage-2 refine transformer override; no typed equivalent yet."
|
||||
@@ -334,25 +333,15 @@ surfaces:
|
||||
use_distill:
|
||||
sources: [fastvideo.configs.pipelines.longcat.LongCatT2V480PConfig, fastvideo.configs.pipelines.longcat.LongCatT2V704PConfig]
|
||||
scheduler_arch:
|
||||
sources:
|
||||
- fastvideo.configs.pipelines.sd35.SD35Config
|
||||
- fastvideo.configs.pipelines.zimage.ZImagePipelineConfig
|
||||
sources: [fastvideo.configs.pipelines.sd35.SD35Config]
|
||||
text_encoder_archs:
|
||||
sources:
|
||||
- fastvideo.configs.pipelines.sd35.SD35Config
|
||||
- fastvideo.configs.pipelines.zimage.ZImagePipelineConfig
|
||||
sources: [fastvideo.configs.pipelines.sd35.SD35Config]
|
||||
tokenizer_archs:
|
||||
sources:
|
||||
- fastvideo.configs.pipelines.sd35.SD35Config
|
||||
- fastvideo.configs.pipelines.zimage.ZImagePipelineConfig
|
||||
sources: [fastvideo.configs.pipelines.sd35.SD35Config]
|
||||
transformer_arch:
|
||||
sources:
|
||||
- fastvideo.configs.pipelines.sd35.SD35Config
|
||||
- fastvideo.configs.pipelines.zimage.ZImagePipelineConfig
|
||||
sources: [fastvideo.configs.pipelines.sd35.SD35Config]
|
||||
vae_arch:
|
||||
sources:
|
||||
- fastvideo.configs.pipelines.sd35.SD35Config
|
||||
- fastvideo.configs.pipelines.zimage.ZImagePipelineConfig
|
||||
sources: [fastvideo.configs.pipelines.sd35.SD35Config]
|
||||
expand_timesteps:
|
||||
sources:
|
||||
- fastvideo.configs.pipelines.wan.FastWan2_2_TI2V_5B_Config
|
||||
@@ -432,8 +421,6 @@ surfaces:
|
||||
frame_receptive_field: "MagiHuman internal data-proxy receptive-field setting."
|
||||
image_conditioning: "MagiHuman preset variant marker for reference-image conditioning."
|
||||
ref_audio_offset: "MagiHuman internal data-proxy audio alignment offset."
|
||||
scheduler_sigma_min: "Z-Image scheduler parity invariant; not part of the public typed inference API."
|
||||
scheduler_use_reference_discrete_timesteps: "Z-Image scheduler parity invariant; not part of the public typed inference API."
|
||||
sr_local_attn_layers: "MagiHuman SR internal sparse-attention layer selection."
|
||||
text_offset: "MagiHuman internal data-proxy text alignment offset."
|
||||
vae_stride: "MagiHuman internal VAE/data-proxy stride setting."
|
||||
@@ -445,17 +432,12 @@ surfaces:
|
||||
moved:
|
||||
image_path: request.inputs.image_path
|
||||
pil_image: request.inputs.pil_image
|
||||
last_image: request.inputs.last_image
|
||||
references: request.inputs.references
|
||||
video_path: request.inputs.video_path
|
||||
latents: request.inputs.latents
|
||||
audio_latents: request.inputs.audio_latents
|
||||
mouse_cond: request.inputs.mouse_cond
|
||||
keyboard_cond: request.inputs.keyboard_cond
|
||||
grid_sizes: request.inputs.grid_sizes
|
||||
pose: request.inputs.pose
|
||||
c2ws_plucker_emb: request.inputs.c2ws_plucker_emb
|
||||
action_path: request.inputs.action_path
|
||||
refine_from: request.inputs.refine_from
|
||||
stage1_video: request.inputs.stage1_video
|
||||
prompt: request.prompt
|
||||
@@ -465,7 +447,6 @@ surfaces:
|
||||
output_video_name: request.output.output_video_name
|
||||
num_videos_per_prompt: request.sampling.num_videos_per_prompt
|
||||
seed: request.sampling.seed
|
||||
max_sequence_length: request.sampling.max_sequence_length
|
||||
num_frames: request.sampling.num_frames
|
||||
height: request.sampling.height
|
||||
width: request.sampling.width
|
||||
@@ -475,10 +456,7 @@ surfaces:
|
||||
num_inference_steps: request.sampling.num_inference_steps
|
||||
num_inference_steps_sr: request.sampling.num_inference_steps_sr
|
||||
guidance_scale: request.sampling.guidance_scale
|
||||
batch_cfg: request.sampling.batch_cfg
|
||||
guidance_scale_2: request.sampling.guidance_scale_2
|
||||
cfg_normalization: request.sampling.cfg_normalization
|
||||
cfg_truncation: request.sampling.cfg_truncation
|
||||
guidance_rescale: request.sampling.guidance_rescale
|
||||
use_embedded_guidance: request.sampling.use_embedded_guidance
|
||||
true_cfg_scale: request.sampling.true_cfg_scale
|
||||
@@ -529,6 +507,8 @@ surfaces:
|
||||
inpaint_mask: request.extensions.stable_audio.inpaint_mask
|
||||
internal_only:
|
||||
data_type: "Derived from the request shape and not a public input."
|
||||
latents: "Pre-generated diffusion latents supplied by parity/debug harnesses; not a public input."
|
||||
max_sequence_length: "Model-specific text-encoder sequence cap; not part of the public typed inference API."
|
||||
|
||||
sampling_param_extensions: {}
|
||||
|
||||
|
||||
@@ -2,12 +2,6 @@
|
||||
|
||||
We introduce a new finetuning strategy - **Sparse-distill**, which jointly integrates **[DMD](https://arxiv.org/abs/2405.14867)** and **[VSA](https://arxiv.org/abs/2505.13389)** in a single training process. This approach combines the benefits of both distillation to shorten diffusion steps and sparse attention to reduce attention computation, enabling much faster video generation.
|
||||
|
||||
!!! tip "Attn-QAT DMD2 workflow"
|
||||
The modular trainer also provides a Wan2.1 MixKit recipe that first
|
||||
fine-tunes with fake-quantized attention, then distills the student to
|
||||
timesteps `[1000, 757, 522]` while teacher and critic remain on Flash
|
||||
Attention. See [Attn-QAT Training](../training/attn_qat.md).
|
||||
|
||||
## 📊 Model Overview
|
||||
|
||||
We provide two distilled models:
|
||||
|
||||
@@ -5,7 +5,6 @@ FastVideo supports the following hardware platforms:
|
||||
|
||||
- [NVIDIA CUDA](installation/gpu.md)
|
||||
- [NVIDIA DGX Spark / GB10 (ARM64 + CUDA 13)](installation/spark.md)
|
||||
([performance & tuning](installation/spark_performance.md))
|
||||
- [Apple silicon](installation/mps.md)
|
||||
|
||||
## Quick Installation
|
||||
@@ -55,10 +54,8 @@ UV_TORCH_BACKEND=cu126 uv pip install -e .
|
||||
uv pip install flash-attn --no-build-isolation -v
|
||||
```
|
||||
|
||||
## Requirements
|
||||
## Hardware Requirements
|
||||
|
||||
- **Python**: 3.10-3.12 is the tested and recommended range (the commands
|
||||
above pin 3.12)
|
||||
- **NVIDIA GPUs**: CUDA 12.6+ with compute capability 7.0+
|
||||
- **Apple Silicon**: macOS 14.0+ with M1/M2/M3/M4 chips
|
||||
- **CPU**: x86_64 architecture (for CPU-only inference)
|
||||
|
||||
@@ -134,4 +134,4 @@ If you're planning to contribute to FastVideo please see the following page:
|
||||
|
||||
If you encounter any issues during installation, please open an issue on our [GitHub repository](https://github.com/hao-ai-lab/FastVideo).
|
||||
|
||||
You can also join our [Slack community](https://join.slack.com/t/fastvideo/shared_invite/zt-3f4lao1uq-u~Ipx6Lt4J27AlD2y~IdLQ) for additional support.
|
||||
You can also join our [Slack community](https://join.slack.com/t/fastvideo/shared_invite/zt-38u6p1jqe-yDI1QJOCEnbtkLoaI5bjZQ) for additional support.
|
||||
|
||||
@@ -100,4 +100,4 @@ If you're planning to contribute to FastVideo please see the following page:
|
||||
|
||||
If you encounter any issues during installation, please open an issue on our [GitHub repository](https://github.com/hao-ai-lab/FastVideo).
|
||||
|
||||
You can also join our [Slack community](https://join.slack.com/t/fastvideo/shared_invite/zt-3f4lao1uq-u~Ipx6Lt4J27AlD2y~IdLQ) for additional support.
|
||||
You can also join our [Slack community](https://join.slack.com/t/fastvideo/shared_invite/zt-38u6p1jqe-yDI1QJOCEnbtkLoaI5bjZQ) for additional support.
|
||||
|
||||
@@ -134,16 +134,9 @@ uv pip install "https://github.com/mjun0812/flash-attention-prebuild-wheels/rele
|
||||
|
||||
If you hit other issues, please open an issue on our
|
||||
[GitHub repository](https://github.com/hao-ai-lab/FastVideo). You can also join
|
||||
our [Slack community](https://join.slack.com/t/fastvideo/shared_invite/zt-3f4lao1uq-u~Ipx6Lt4J27AlD2y~IdLQ)
|
||||
our [Slack community](https://join.slack.com/t/fastvideo/shared_invite/zt-38u6p1jqe-yDI1QJOCEnbtkLoaI5bjZQ)
|
||||
for additional support.
|
||||
|
||||
## Next: performance & tuning
|
||||
|
||||
Installed and verified? See [DGX Spark: Performance & Tuning](spark_performance.md)
|
||||
for which models are practical on the GB10, what makes them faster, and what
|
||||
won't help on this hardware (and why) — so you don't spend a night tuning knobs
|
||||
that can't move here.
|
||||
|
||||
## Development Environment Setup
|
||||
|
||||
If you're planning to contribute to FastVideo please see the
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
# DGX Spark (GB10): Performance & Tuning
|
||||
|
||||
You have FastVideo [installed on a DGX Spark](spark.md) — this page is what to
|
||||
run next. It covers **which models are practical on the GB10, what actually
|
||||
makes them faster, and what won't help (and why)**, so you don't burn a night
|
||||
tuning knobs that can't move on this hardware.
|
||||
|
||||
!!! tip "TL;DR"
|
||||
- **Use distilled few-step models** (e.g. `FastVideo/FastWan2.1-T2V-1.3B-Diffusers`).
|
||||
They run in ~40 s/video. Full-step models are 12–47 min on the GB10.
|
||||
- On few-step models, **VAE decode is the bottleneck**, not attention — it's
|
||||
bandwidth-bound on the Spark's unified memory.
|
||||
- **bf16 VAE decode** is the real, lossless lever (FastVideo already turns it
|
||||
on for Wan). **FlashAttention, linear quantization, and `torch.compile` of
|
||||
the VAE give little or nothing here** — see the table below.
|
||||
- Heavy runs can make the box unreachable — run generations with VAE tiling on
|
||||
and `nice -n 19`. See [Running safely](#running-safely-dont-lock-the-box).
|
||||
|
||||
## The hardware reality (this explains everything below)
|
||||
|
||||
The GB10 pairs a Blackwell GPU (`sm_121`) with **128 GB of unified LPDDR5X memory
|
||||
(~270 GB/s) shared between CPU and GPU**. That bandwidth is roughly **10× below a
|
||||
datacenter GPU's HBM**. Two consequences drive every tuning decision:
|
||||
|
||||
1. **Memory-bandwidth-bound stages hurt disproportionately.** VAE decode moves a
|
||||
lot of data and becomes the dominant cost on short (few-step) generations.
|
||||
2. **Compute-bound stages scale with step count.** Full-step diffusion (50+
|
||||
steps) is denoise-bound and simply takes a long time here.
|
||||
|
||||
## Use distilled few-step models
|
||||
|
||||
The single biggest lever on the GB10 is **model choice**. A 3-step distilled
|
||||
model is ~18× faster than the full-step version of the same architecture:
|
||||
|
||||
| Model | Steps | Time / video | Bottleneck |
|
||||
|---|---|---|---|
|
||||
| FastWan2.1-T2V-1.3B (distilled) | 3 | **~40 s** | VAE decode |
|
||||
| Wan2.1-T2V-1.3B (full-step) | 50 | ~12 min | denoise |
|
||||
| Cosmos-Predict2.5-2B (full-step) | 51 | ~47 min | denoise |
|
||||
| LTX2.3-distilled (+audio) | 8 | ~6 min | mixed |
|
||||
|
||||
The bottleneck flips from decode to denoise at around **4 steps**. Below that,
|
||||
you're paying mostly for VAE decode; above it, mostly for the denoising loop.
|
||||
|
||||
!!! note "Few-step timings are noisy — measure in-process"
|
||||
On a 3-step run, one-time per-process startup (Triton autotune, allocator
|
||||
warmup) dominates and never amortizes, so single-run totals wobble ~±30%.
|
||||
Compare levers **back-to-back in one process or as medians**, never as two
|
||||
separate single runs. The [reproduction script](#reproduce-these-numbers)
|
||||
does this for you.
|
||||
|
||||
## bf16 VAE decode — the real lever (already on for Wan)
|
||||
|
||||
Because few-step generation is decode-bound, VAE decode precision is where the
|
||||
time is. Decoding in **bf16 instead of fp32 is essentially lossless** (MS-SSIM
|
||||
~0.9999 vs fp32 on the identical latent) and ~1.14× faster — worth roughly
|
||||
5–7% end-to-end on a decode-bound few-step model.
|
||||
|
||||
**FastVideo already defaults Wan's decode to bf16** (`vae_decode_precision="bf16"`,
|
||||
with encode kept at fp32), so for the recommended Wan/FastWan models there's
|
||||
nothing to set. If you run a model that still defaults to an fp32 decode, set the
|
||||
decode-only override yourself:
|
||||
|
||||
```python
|
||||
from fastvideo.configs.pipelines.base import PipelineConfig
|
||||
|
||||
pipeline_config = PipelineConfig.from_pretrained(model_id)
|
||||
pipeline_config.vae_decode_precision = "bf16" # decode-only; leaves encode precision alone
|
||||
```
|
||||
|
||||
Decode is output-only, so lowering its precision is safe. (Encode seeds the
|
||||
denoising trajectory for I2V/causal models, so that stays at the pipeline's
|
||||
default — don't lower `vae_precision` blindly for those.)
|
||||
|
||||
## Memory: one unified 128 GB pool
|
||||
|
||||
The GB10 has **no separate VRAM** — CPU and GPU share one 128 GB LPDDR5X pool
|
||||
(~118 GB usable). Two practical consequences:
|
||||
|
||||
- **`nvidia-smi` reports memory as `[N/A]`** on the GB10, and the system "used"
|
||||
figure conflates CPU + GPU + cache, so it's only a soft upper bound — treat the
|
||||
whole 128 GB as one shared budget. For a per-run figure, use FastVideo's own
|
||||
`peak_memory_mb` (reported on the generation result and by the performance
|
||||
benchmark), which is measured inside the worker that runs the model.
|
||||
- **The 128 GB is a *working-set* ceiling, not storage** — the model cache lives
|
||||
on the NVMe (3.7 TB, ample). What has to fit in 128 GB is the weights,
|
||||
activations, and KV cache — and, critically, the **VAE decode buffers**, which
|
||||
is why tiling matters
|
||||
(an untiled high-res decode can spike the pool into swap and lock the box).
|
||||
|
||||
The recommended few-step models are comfortable here: their weights are small
|
||||
(1.3–2 B) and few-step generation keeps activations modest — a Wan2.1-1.3B
|
||||
few-step generation peaks at **~8.4 GB** (measured), a small fraction of the pool.
|
||||
The pressure comes from **decode resolution/frames**, not the model — a
|
||||
1080p×121-frame untiled decode is what pushes the pool toward its ceiling, which
|
||||
is why VAE tiling stays
|
||||
on by default.
|
||||
|
||||
## What helps vs. what doesn't on the GB10
|
||||
|
||||
The honest summary — most "obvious" GPU optimizations don't move the needle on
|
||||
this hardware, for reasons specific to it:
|
||||
|
||||
| Lever | Effect on the GB10 | Use it? |
|
||||
|---|---|---|
|
||||
| Distilled few-step model | ~18× vs full-step | ✅ **the primary lever** |
|
||||
| bf16 VAE decode | ~1.14×, lossless; ~5–7% e2e on few-step | ✅ default for Wan |
|
||||
| VSA (video sparse attention) | works out of the box (Triton kernel auto-selects on `sm_121`) | ✅ automatic |
|
||||
| Building FlashAttention | **no speedup** — Torch SDPA already hits an efficient flash kernel on `sm_121`, and FA2 ties it | ❌ not worth building |
|
||||
| `torch.compile` of the VAE decode | recompile storm (per-frame varying shapes) → ~1.1× | ❌ dead end |
|
||||
| Linear (fp8 / nvfp4) quantization on long-sequence models (e.g. Cosmos) | ~nothing — see below | ❌ wrong lever here |
|
||||
| FP4 attention (`ATTN_QAT_INFER`) | works on `sm_121` (runtime allowlist landed in #1647; kernel build is #1598); helps, but needs a QAT-trained checkpoint | ⚠️ opt-in — see below |
|
||||
| FP4 linear on short-sequence models (LTX2) | up to −24% denoise at 1080p (#1594) | ⚠️ model/resolution-dependent |
|
||||
|
||||
### Why linear quantization is the wrong lever on long-sequence models
|
||||
|
||||
Quantizing the linear (GEMM) layers is a natural first instinct, but on a
|
||||
long-sequence video model it buys almost nothing on the GB10. A video-DiT denoise
|
||||
step is dominated by **O(N²) attention** at these sequence lengths (tens of
|
||||
thousands of tokens); the linear layers are a small single-digit fraction of the
|
||||
work. Quantizing them faster leaves the attention-bound total essentially
|
||||
unchanged — measured at ~1% on Cosmos-2.5, i.e. noise, and full-step CFG models
|
||||
also lose quality to per-step quantization error.
|
||||
|
||||
The same mechanism **does** help on **short-sequence** models: LTX2's aggressive
|
||||
VAE compression gives it short attention sequences, so FP4 linear reaches −24%
|
||||
there (#1594). The rule: **on the GB10, the lever that matters is attention
|
||||
(sparse or FP4), not the linear layers** — unless the model has short sequences.
|
||||
|
||||
### FP4 on the GB10 (opt-in)
|
||||
|
||||
Block-scaled FP4 works on `sm_121` under CUDA 13:
|
||||
|
||||
- **FP4 attention** (`FASTVIDEO_ATTENTION_BACKEND=ATTN_QAT_INFER`, #1598) is
|
||||
numerically correct on the GB10 and ~6% faster end-to-end generation, but it only preserves
|
||||
quality on a **quantization-aware-distilled checkpoint** (e.g.
|
||||
`FastVideo/FastWan-QAD-1.3B`) — stock weights aren't trained to tolerate it.
|
||||
- **FP4 linear** helps only where sequences are short (LTX2, above).
|
||||
|
||||
The [`qad_fp4_ab.py`](#reproduce-these-numbers) harness reproduces the FP4
|
||||
attention A/B on the QAD checkpoint.
|
||||
|
||||
## Running safely (don't lock the box)
|
||||
|
||||
The GB10 is easy to make **unreachable** — a heavy build or an untiled high-res
|
||||
decode starves the ~20 ARM cores and unified memory, `sshd` can't get cycles, and
|
||||
you're locked out at *"Connection timed out during banner exchange"* until the box
|
||||
is power-cycled. To avoid it:
|
||||
|
||||
- **Inference:** keep **VAE tiling on** (the default), use sane resolution/frames,
|
||||
and run under `nice -n 19`:
|
||||
|
||||
```bash
|
||||
nice -n 19 nohup python your_script.py > run.log 2>&1 &
|
||||
```
|
||||
|
||||
- **Builds** (flash-attn, kernel): `nice -n 19`, `MAX_JOBS=2`, `nohup`. Never a
|
||||
bare foreground high-parallelism build.
|
||||
- Leave `*_cpu_offload` at the example defaults — "CPU" offload is the *same*
|
||||
unified RAM on the GB10, so the win is tiling + sane resolution, not offloading.
|
||||
|
||||
## Gotchas specific to the GB10
|
||||
|
||||
A few things that surprise people on this box (beyond the memory notes above):
|
||||
|
||||
- **Don't force `TORCH_SDPA` on a VSA checkpoint** (FastWan, LTX2.3-distilled).
|
||||
The SDPA path builds a model without the gate weights the checkpoint carries and
|
||||
fails to load. Run the model natively — VSA auto-routes to its Triton kernel on
|
||||
`sm_121`.
|
||||
- **Few-step timings are noisy run-to-run** (~±30%) — one-time startup dominates a
|
||||
3-step run. Compare in-process / as medians, never two separate single runs (the
|
||||
benchmark script does this).
|
||||
- **`nvidia-smi` shows `[N/A]` for memory** — see [Memory](#memory-one-unified-128-gb-pool).
|
||||
- **Cosmos-2.5** uses a Qwen2.5-VL text encoder; make sure you're on a FastVideo
|
||||
build recent enough to include its `transformers`-compatibility handling before
|
||||
running it.
|
||||
|
||||
## Reproduce these numbers
|
||||
|
||||
Two scripts under `examples/inference/optimizations/` reproduce the claims on
|
||||
your own GB10:
|
||||
|
||||
```bash
|
||||
# Headline: few-step generation timing (median) + the bf16-vs-fp32 decode A/B.
|
||||
# FASTVIDEO_STAGE_LOGGING=1 also prints the denoise / decode / text split.
|
||||
FASTVIDEO_STAGE_LOGGING=1 nice -n 19 \
|
||||
python examples/inference/optimizations/spark_benchmark.py
|
||||
|
||||
# FP4 attention quality/speed A/B on the QAD checkpoint (one arm per run).
|
||||
QAD_LINEAR=0 FASTVIDEO_ATTENTION_BACKEND=ATTN_QAT_INFER nice -n 19 \
|
||||
python examples/inference/optimizations/qad_fp4_ab.py
|
||||
```
|
||||
|
||||
See also the [Optimizations](../../inference/optimizations.md) reference for the
|
||||
full list of attention backends and quantization options.
|
||||
+8
-17
@@ -5,7 +5,7 @@
|
||||
</div>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<strong>FastVideo is a unified post-training and real-time inference framework for accelerated video generation.</strong>
|
||||
<strong>FastVideo is a unified inference and post-training framework for accelerated video generation.</strong>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center;">
|
||||
@@ -25,23 +25,14 @@ FastVideo is an inference and post-training framework for diffusion models. It f
|
||||
|
||||
FastVideo has the following features:
|
||||
|
||||
- End-to-end post-training support for bidirectional and autoregressive models
|
||||
- Full finetuning and LoRA [finetuning](training/finetune.md) for state-of-the-art open video DiTs
|
||||
- [Data preprocessing pipeline](training/data_preprocess.md) for video, image, and text data
|
||||
- [Distribution Matching Distillation (DMD2)](distillation/dmd.md) stepwise distillation
|
||||
- Sparse attention with [Video Sparse Attention](attention/vsa/index.md)
|
||||
- [Sparse distillation](https://hao-ai-lab.github.io/blogs/fastvideo_post_training/) to achieve >50x denoising speedup
|
||||
- [Attn-QAT training](training/attn_qat.md) for quantization-aware post-training
|
||||
- Causal distillation through Self-Forcing
|
||||
- Scalable training with FSDP2, sequence parallelism, and selective activation checkpointing
|
||||
- See the [training overview](training/overview.md) for the full training workflow
|
||||
- State-of-the-art performance optimizations for inference
|
||||
- Sequence parallelism for distributed inference
|
||||
- Multiple state-of-the-art [attention backends](attention/index.md)
|
||||
- User-friendly [CLI](inference/cli.md) and Python API
|
||||
- See the [support matrix](inference/support_matrix.md) for supported models and [optimizations](inference/optimizations.md) for the full list
|
||||
- Realtime video generation and editing
|
||||
- [Dreamverse](https://github.com/hao-ai-lab/FastVideo/tree/main/apps/dreamverse): stream and "vibe direct" video in realtime ([live demo](https://dreamverse.fastvideo.org/))
|
||||
- [Sliding Tile Attention](attention/sta/index.md)
|
||||
- [Sage Attention](https://arxiv.org/abs/2410.02367)
|
||||
- E2E post-training support
|
||||
- Data preprocessing pipeline for video data
|
||||
- [Sparse distillation](https://hao-ai-lab.github.io/blogs/fastvideo_post_training/) for Wan2.1 and Wan2.2 using [Video Sparse Attention](https://arxiv.org/pdf/2505.13389) and [Distribution Matching Distillation](https://tianweiy.github.io/dmd2/)
|
||||
- Support full finetuning and LoRA finetuning for state-of-the-art open video DiTs.
|
||||
- Scalable training with FSDP2, sequence parallelism, and selective activation checkpointing, with near linear scaling to 64 GPUs.
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ This guide explains how to implement a custom diffusion pipeline in FastVideo, l
|
||||
4. **Register Your Pipeline** - Make it discoverable by the framework
|
||||
5. **Configure Your Pipeline** - (Coming soon)
|
||||
|
||||
Need help? Join our [Slack community](https://join.slack.com/t/fastvideo/shared_invite/zt-3f4lao1uq-u~Ipx6Lt4J27AlD2y~IdLQ).
|
||||
Need help? Join our [Slack community](https://join.slack.com/t/fastvideo/shared_invite/zt-38u6p1jqe-yDI1QJOCEnbtkLoaI5bjZQ).
|
||||
|
||||
## Step 1: Pipeline Modules
|
||||
|
||||
|
||||
@@ -4,11 +4,10 @@ This page contains step-by-step instructions to get you quickly started with vid
|
||||
|
||||
## Requirements
|
||||
|
||||
- **OS**: Linux (tested on Ubuntu 22.04+), or macOS on Apple silicon via the
|
||||
[MPS installation guide](../getting_started/installation/mps.md)
|
||||
- **OS**: Linux (Tested on Ubuntu 22.04+)
|
||||
- **Python**: 3.10-3.12
|
||||
- **CUDA**: 12.6 or 13.0 (NVIDIA GPUs)
|
||||
- **GPU**: At least one NVIDIA GPU, or an Apple silicon chip with MPS
|
||||
- **CUDA**: 12.6 or 13.0
|
||||
- **GPU**: At least one NVIDIA GPU
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -135,4 +134,5 @@ If the generated video doesn't match your prompt:
|
||||
- Learn about [Advanced Inference Configurations](configuration.md)
|
||||
- Learn about using [Optimizations](optimizations.md)
|
||||
- See [Examples](examples/examples_inference_index.md) for more usage scenarios
|
||||
- Join our [Community Slack](https://join.slack.com/t/fastvideo/shared_invite/zt-3f4lao1uq-u~Ipx6Lt4J27AlD2y~IdLQ).
|
||||
- Join our [Community Discord](https://discord.gg/JA7cksDz86).
|
||||
- Join our [Community Slack](https://join.slack.com/t/fastvideo/shared_invite/zt-38u6p1jqe-yDI1QJOCEnbtkLoaI5bjZQ).
|
||||
|
||||
@@ -3,12 +3,6 @@
|
||||
|
||||
This page describes the various options for speeding up generation times in FastVideo.
|
||||
|
||||
!!! note "On a DGX Spark (GB10)?"
|
||||
Several options on this page behave differently on the GB10's unified-memory
|
||||
hardware — some give little or nothing there. See
|
||||
[DGX Spark: Performance & Tuning](../getting_started/installation/spark_performance.md)
|
||||
for what actually helps on that platform and why.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- Optimized Attention Backends
|
||||
@@ -125,22 +119,6 @@ pip install "nvidia-cutlass-dsl>=4.5.2" apache-tvm-ffi flashinfer-python
|
||||
The `--no-deps` flag prevents upgrading torch/torchvision. Use the supported
|
||||
PyTorch 2.12.0 and CUDA 13 environment for this kernel.
|
||||
|
||||
Branch-to-`nvidia-cutlass-dsl` compatibility (the fork tracks the CuTe DSL API
|
||||
surface closely):
|
||||
|
||||
| fork branch | cutlass-dsl | notes |
|
||||
|---|---|---|
|
||||
| `fp4` | `==4.4.2` (+ `nvidia-cutlass-dsl-libs-base==4.4.2`) | validated set on GB200: `quack-kernels==0.4.1`, `flashinfer-python==0.6.8`, `CUTE_DSL_ENABLE_TVM_FFI=1`, `FASTVIDEO_FA4=1` |
|
||||
| `fix/cutlass-dsl-4.5` | `>=4.5.2` | carries the `cute.core.ThrMma` -> `cute.ThrMma` fix |
|
||||
| any | 4.6-era | unsupported: `cute.make_fragment` was removed at module level; fails at CuTe JIT trace |
|
||||
|
||||
`FASTVIDEO_FA4=1` is required alongside the fork: it ships no compiled
|
||||
FlashAttention-2, so dense attention paths raise ImportError without the FA4
|
||||
opt-in. The same kernel also serves `ATTN_QAT_INFER` on sm_100a/sm_103a
|
||||
(datacenter Blackwell) — the selection log's receipt line
|
||||
(`ATTN_QAT_INFER resolved: ...`) records the arch, kernel, and quantization
|
||||
mode that actually bound.
|
||||
|
||||
#### Usage
|
||||
|
||||
Enable FP4 attention via the `--nvfp4_fa4` flag:
|
||||
|
||||
@@ -13,97 +13,6 @@ For the canonical, code-level list of model IDs recognized by
|
||||
We do this because we believe VSA is strictly better than STA for the
|
||||
actively maintained `main` inference path.
|
||||
|
||||
## Registered Model IDs
|
||||
|
||||
Every Hugging Face model ID registered in `fastvideo/registry.py` on `main`
|
||||
(commit `8d89f30d`), grouped by family. Any ID below can
|
||||
be passed to `VideoGenerator.from_pretrained(...)`; FastVideo resolves the
|
||||
matching pipeline and sampling defaults. The **Family** column is a
|
||||
documentation grouping: it follows each registration's declared `model_family`,
|
||||
except `black-forest-labs/FLUX.1-dev`, which declares none and is listed under
|
||||
`flux` for readability. The **Workloads** column shows each
|
||||
registration's declared `workload_types`; `—` means the entry is registered
|
||||
without a UI workload option but is still loadable by ID. The **Example**
|
||||
column links a runnable script in `examples/inference/basic/` where one exists.
|
||||
|
||||
| Family | HuggingFace Model ID | Workloads | Example |
|
||||
|--------|----------------------|-----------|---------|
|
||||
| cosmos | `nvidia/Cosmos-Predict2-2B-Video2World` | T2V | — |
|
||||
| cosmos25 | `KyleShao/Cosmos-Predict2.5-2B-Diffusers` | T2V | [basic_cosmos2_5_t2w.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_cosmos2_5_t2w.py) |
|
||||
| cosmos25 | `nvidia/Cosmos-Predict2.5-14B` | T2V | [basic_cosmos2_5_t2w.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_cosmos2_5_t2w.py) |
|
||||
| dreamx_world | `FastVideo/DreamX-World-5B-Cam-Diffusers` | I2V | [basic_dreamx_world.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_dreamx_world.py) |
|
||||
| dreamx_world | `FastVideo/DreamX-World-5B-Diffusers` | I2V | [basic_dreamx_world.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_dreamx_world.py) |
|
||||
| flux | `black-forest-labs/FLUX.1-dev` | T2I | [basic_flux_dev.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_flux_dev.py) |
|
||||
| flux2 | `black-forest-labs/FLUX.2-klein-4B`<br>`black-forest-labs/FLUX.2-klein-9B` | T2I | [basic_flux2_klein.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_flux2_klein.py) |
|
||||
| flux2 | `black-forest-labs/FLUX.2-dev` | T2I | [basic_flux2.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_flux2.py) |
|
||||
| gamecraft | `FastVideo/HunyuanGameCraft-Diffusers` | I2V | [basic_gamecraft.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_gamecraft.py) |
|
||||
| gen3c | `FastVideo/GEN3C-Cosmos-7B-Diffusers` | T2V | [basic_gen3c.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_gen3c.py) |
|
||||
| glm_image | `zai-org/GLM-Image` | T2I | [basic_glm_image.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_glm_image.py) |
|
||||
| hunyuan | `hunyuanvideo-community/HunyuanVideo` | T2V | — |
|
||||
| hunyuan | `FastVideo/FastHunyuan-diffusers` | T2V | — |
|
||||
| hunyuan15 | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v` | T2V | [basic_hy15.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_hy15.py) |
|
||||
| hunyuan15 | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_i2v_step_distilled` | I2V | [basic_hy15.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_hy15.py) |
|
||||
| hunyuan15 | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_t2v` | T2V | [basic_hy15.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_hy15.py) |
|
||||
| hunyuan15 | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_i2v_distilled` | I2V | [basic_hy15.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_hy15.py) |
|
||||
| hunyuan15 | `weizhou03/HunyuanVideo-1.5-Diffusers-1080p`<br>`weizhou03/HunyuanVideo-1.5-Diffusers-1080p-2SR` | — | [basic_hy15_1080p.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_hy15_1080p.py) |
|
||||
| hyworld | `FastVideo/HY-WorldPlay-Bidirectional-Diffusers` | — | [basic_hyworld.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_hyworld.py) |
|
||||
| kandinsky5 | `kandinskylab/Kandinsky-5.0-T2V-Lite-sft-5s-Diffusers` | T2V | [basic_kandinsky5_t2v.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_kandinsky5_t2v.py) |
|
||||
| kandinsky5 | `kandinskylab/Kandinsky-5.0-T2V-Pro-sft-5s-Diffusers` | T2V | [basic_kandinsky5_t2v.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_kandinsky5_t2v.py) |
|
||||
| kandinsky5 | `kandinskylab/Kandinsky-5.0-T2V-Lite-distilled16steps-5s-Diffusers` | T2V | [basic_kandinsky5_t2v.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_kandinsky5_t2v.py) |
|
||||
| kandinsky5 | `kandinskylab/Kandinsky-5.0-T2V-Pro-distilled-5s-Diffusers` | T2V | [basic_kandinsky5_t2v.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_kandinsky5_t2v.py) |
|
||||
| kandinsky5 | `kandinskylab/Kandinsky-5.0-I2V-Lite-5s-Diffusers` | I2V | [basic_kandinsky5_i2v.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_kandinsky5_i2v.py) |
|
||||
| kandinsky5 | `kandinskylab/Kandinsky-5.0-I2V-Pro-sft-5s-Diffusers` | I2V | [basic_kandinsky5_i2v.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_kandinsky5_i2v.py) |
|
||||
| kandinsky5 | `kandinskylab/Kandinsky-5.0-I2V-Pro-distilled-5s-Diffusers` | I2V | [basic_kandinsky5_i2v.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_kandinsky5_i2v.py) |
|
||||
| lingbot_video | `FastVideo/LingBot-Video-MoE-30B-A3B-Diffusers` | T2V | [basic_lingbot_video.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_lingbot_video.py) |
|
||||
| lingbot_video | `FastVideo/LingBot-Video-Dense-1.3B-Diffusers` | T2V | [basic_lingbot_video.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_lingbot_video.py) |
|
||||
| lingbotworld | `FastVideo/LingBot-World-Base-Cam-Diffusers` | I2V | [basic_lingbotworld_base_cam.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_lingbotworld_base_cam.py) |
|
||||
| lingbotworld2 | `robbyant/lingbot-world-v2-14b-causal-fast` | I2V | [basic_lingbotworld2_causal_fast.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_lingbotworld2_causal_fast.py) |
|
||||
| longcat | `FastVideo/LongCat-Video-T2V-Diffusers` | T2V | [basic_longcat_t2v.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_longcat_t2v.py) |
|
||||
| longcat | `FastVideo/LongCat-Video-I2V-Diffusers` | I2V | [basic_longcat_i2v.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_longcat_i2v.py) |
|
||||
| longcat | `FastVideo/LongCat-Video-VC-Diffusers` | — | [basic_longcat_vc.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_longcat_vc.py) |
|
||||
| ltx2 | `FastVideo/LTX2-Distilled-Diffusers`<br>`FastVideo/LTX2.3-Distilled-Diffusers`<br>`FastVideo/LTX-2.3-Distilled-Diffusers` | T2V | [basic_ltx2_distilled.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_ltx2_distilled.py) |
|
||||
| ltx2 | `Lightricks/LTX-2.3`<br>`FastVideo/LTX2.3-base`<br>`FastVideo/LTX2.3-Diffusers` | T2V | [basic_ltx2.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_ltx2.py) |
|
||||
| ltx2 | `Lightricks/LTX-2`<br>`FastVideo/LTX2-base`<br>`FastVideo/LTX2-Diffusers` | T2V | [basic_ltx2.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_ltx2.py) |
|
||||
| matrixgame | `FastVideo/Matrix-Game-2.0-Base-Distilled-Diffusers`<br>`FastVideo/Matrix-Game-2.0-GTA-Distilled-Diffusers`<br>`FastVideo/Matrix-Game-2.0-TempleRun-Distilled-Diffusers`<br>`FastVideo/Matrix-Game-2.0-Base-Diffusers`<br>`FastVideo/Matrix-Game-2.0-GTA-Diffusers`<br>`FastVideo/Matrix-Game-2.0-TempleRun-Diffusers`<br>`mignonjia/mg_longtuning_distilled_zelda`<br>`mignonjia/mg_sf_distilled_zelda_1k_steps`<br>`mignonjia/mg_sf_distilled_zelda`<br>`mignonjia/mg_causal_zelda`<br>`mignonjia/mg_bidirectional_zelda` | I2V | [basic_matrixgame2.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_matrixgame2.py) |
|
||||
| matrixgame | `FastVideo/Matrix-Game-3.0-Base-Distilled-Diffusers` | I2V | [basic_matrixgame3.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_matrixgame3.py) |
|
||||
| minimax_h3 | `MiniMaxAI/MiniMax-H3` | T2V, I2V | [T2VA](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_minimax_h3_t2v.py)<br>[FL2VA](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_minimax_h3_fl2va.py)<br>[Ref2VA](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_minimax_h3_ref2va.py) |
|
||||
| sd35 | `stabilityai/stable-diffusion-3.5-medium` | T2I | [basic_sd35_t2i.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_sd35_t2i.py) |
|
||||
| stable_audio | `FastVideo/stable-audio-open-1.0-Diffusers` | T2V | [basic_stable_audio.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_stable_audio.py) |
|
||||
| stable_audio | `FastVideo/stable-audio-open-small-Diffusers` | T2V | [basic_stable_audio_small.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_stable_audio_small.py) |
|
||||
| turbodiffusion | `loayrashid/TurboWan2.1-T2V-1.3B-Diffusers` | T2V | [basic_turbodiffusion.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_turbodiffusion.py) |
|
||||
| turbodiffusion | `loayrashid/TurboWan2.1-T2V-14B-Diffusers` | T2V | [basic_turbodiffusion_14b.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_turbodiffusion_14b.py) |
|
||||
| turbodiffusion | `loayrashid/TurboWan2.2-I2V-A14B-Diffusers` | I2V | [basic_turbodiffusion_i2v.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_turbodiffusion_i2v.py) |
|
||||
| wan | `Wan-AI/Wan2.1-T2V-1.3B-Diffusers` | T2V | [basic.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic.py) |
|
||||
| wan | `Wan-AI/Wan2.1-T2V-14B-Diffusers`<br>`FastVideo/Wan2.1-VSA-T2V-14B-720P-Diffusers` | T2V | — |
|
||||
| wan | `Wan-AI/Wan2.1-I2V-14B-480P-Diffusers` | I2V | — |
|
||||
| wan | `Wan-AI/Wan2.1-I2V-14B-720P-Diffusers` | I2V | — |
|
||||
| wan | `weizhou03/Wan2.1-Fun-1.3B-InP-Diffusers` | I2V | — |
|
||||
| wan | `IRMChen/Wan2.1-Fun-1.3B-Control-Diffusers` | — | [basic_wan2_2_Fun.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_wan2_2_Fun.py) |
|
||||
| wan | `FastVideo/FastWan2.1-T2V-1.3B-Diffusers`<br>`FastVideo/FastWan2.1-T2V-14B-480P-Diffusers` | T2V | [basic_dmd.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_dmd.py) |
|
||||
| wan | `Wan-AI/Wan2.2-TI2V-5B-Diffusers` | T2V, I2V | [basic_wan2_2_ti2v.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_wan2_2_ti2v.py) |
|
||||
| wan | `FastVideo/FastWan2.2-TI2V-5B-FullAttn-Diffusers`<br>`FastVideo/FastWan2.2-TI2V-5B-Diffusers` | T2V, I2V | [basic_dmd.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_dmd.py) |
|
||||
| wan | `decart-ai/Lucy-Edit-Dev`<br>`decart-ai/Lucy-Edit-1.1-Dev` | — | [basic_lucy_edit.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_lucy_edit.py) |
|
||||
| wan | `Wan-AI/Wan2.2-T2V-A14B-Diffusers` | T2V | [basic_wan2_2.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_wan2_2.py) |
|
||||
| wan | `Wan-AI/Wan2.2-I2V-A14B-Diffusers` | I2V | [basic_wan2_2_i2v.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_wan2_2_i2v.py) |
|
||||
| wan | `wlsaidhi/SFWan2.1-T2V-1.3B-Diffusers` | T2V | [basic_self_forcing_causal.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_self_forcing_causal.py) |
|
||||
| wan | `rand0nmr/SFWan2.2-T2V-A14B-Diffusers` | T2V | [basic_self_forcing_causal_wan2_2_t2v.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_self_forcing_causal_wan2_2_t2v.py) |
|
||||
| wan | `FastVideo/SFWan2.2-I2V-A14B-Preview-Diffusers` | I2V | [basic_self_forcing_causal_wan2_2_i2v.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_self_forcing_causal_wan2_2_i2v.py) |
|
||||
| zimage | `Tongyi-MAI/Z-Image-Turbo` | T2I | [basic_zimage.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_zimage.py) |
|
||||
|
||||
**Note (stable_audio)**: the Stable Audio Open pipelines generate audio
|
||||
(`StableAudioT2AConfig` / `StableAudioOpenSmallConfig`); they are registered
|
||||
under the generic T2V workload option in the registry.
|
||||
|
||||
**Note (MiniMax H3)**: T2VA, FL2VA, and Ref2VA all generate video with stereo
|
||||
audio. Use the Ref2VA example when passing ordered image, video, or audio
|
||||
references.
|
||||
|
||||
**Note (Wan-VACE)**: not currently supported — no VACE pipeline or registered
|
||||
model ID exists on `main`
|
||||
([#1435](https://github.com/hao-ai-lab/FastVideo/issues/1435)). The closest
|
||||
supported path is the Wan2.1-Fun control pipeline
|
||||
(`IRMChen/Wan2.1-Fun-1.3B-Control-Diffusers`).
|
||||
|
||||
The symbols used have the following meanings:
|
||||
|
||||
- ✅ = Full compatibility
|
||||
@@ -116,9 +25,6 @@ The `HuggingFace Model ID` can be passed directly to
|
||||
`from_pretrained()`. FastVideo then uses model-specific default settings for
|
||||
pipeline initialization and sampling.
|
||||
|
||||
Registered models absent from this table have not been validated against these
|
||||
optimizations: absence means **untested**, not incompatible.
|
||||
|
||||
<style>
|
||||
/* Target tables in this section */
|
||||
#models-x-optimization + p + table {
|
||||
@@ -150,7 +56,7 @@ optimizations: absence means **untested**, not incompatible.
|
||||
| Model Name | HuggingFace Model ID | Resolutions | TeaCache | Sliding Tile Attn (Legacy Branch) | Sage Attn | VSA | BSA |
|
||||
|------------|---------------------|-------------|----------|-------------------|-----------|-----|-----|
|
||||
| FastWan2.1 T2V 1.3B | `FastVideo/FastWan2.1-T2V-1.3B-Diffusers` | 480P | ⭕ | ⭕ | ⭕ | ✅ | ⭕ |
|
||||
| FastWan2.2 TI2V 5B Full Attn | `FastVideo/FastWan2.2-TI2V-5B-FullAttn-Diffusers` | 720P | ⭕ | ⭕ | ⭕ | ✅ | ⭕ |
|
||||
| FastWan2.2 TI2V 5B Full Attn* | `FastVideo/FastWan2.2-TI2V-5B-FullAttn-Diffusers` | 720P | ⭕ | ⭕ | ⭕ | ✅ | ⭕ |
|
||||
| Wan2.2 TI2V 5B | `Wan-AI/Wan2.2-TI2V-5B-Diffusers` | 720P | ⭕ | ⭕ | ✅ | ⭕ | ⭕ |
|
||||
| DreamX-World 5B Cam | `FastVideo/DreamX-World-5B-Cam-Diffusers` | 480P | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ |
|
||||
| DreamX-World 5B AR | `FastVideo/DreamX-World-5B-Diffusers` | 704px1280p | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ |
|
||||
@@ -159,14 +65,14 @@ optimizations: absence means **untested**, not incompatible.
|
||||
| Wan2.2 I2V A14B | `Wan-AI/Wan2.2-I2V-A14B-Diffusers` | 480P<br>720P | ❌ | ❌ | ✅ | ⭕ | ⭕ |
|
||||
| HunyuanVideo | `hunyuanvideo-community/HunyuanVideo` | 720px1280p<br>544px960p | ❌ | ✅ | ✅ | ⭕ | ⭕ |
|
||||
| FastHunyuan | `FastVideo/FastHunyuan-diffusers` | 720px1280p<br>544px960p | ❌ | ✅ | ✅ | ⭕ | ⭕ |
|
||||
| Wan2.1 T2V 1.3B | `Wan-AI/Wan2.1-T2V-1.3B-Diffusers` | 480P | ✅ | ✅ | ✅ | ⭕ | ⭕ |
|
||||
| Wan2.1 T2V 14B | `Wan-AI/Wan2.1-T2V-14B-Diffusers` | 480P, 720P | ✅ | ✅ | ✅ | ⭕ | ⭕ |
|
||||
| Wan2.1 I2V 480P | `Wan-AI/Wan2.1-I2V-14B-480P-Diffusers` | 480P | ✅ | ✅ | ✅ | ⭕ | ⭕ |
|
||||
| Wan2.1 T2V 1.3B | `Wan-AI/Wan2.1-T2V-1.3B-Diffusers` | 480P | ✅ | ✅* | ✅ | ⭕ | ⭕ |
|
||||
| Wan2.1 T2V 14B | `Wan-AI/Wan2.1-T2V-14B-Diffusers` | 480P, 720P | ✅ | ✅* | ✅ | ⭕ | ⭕ |
|
||||
| Wan2.1 I2V 480P | `Wan-AI/Wan2.1-I2V-14B-480P-Diffusers` | 480P | ✅ | ✅* | ✅ | ⭕ | ⭕ |
|
||||
| Wan2.1 I2V 720P | `Wan-AI/Wan2.1-I2V-14B-720P-Diffusers` | 720P | ✅ | ✅ | ✅ | ⭕ | ⭕ |
|
||||
| TurboWan2.1 T2V 1.3B | `loayrashid/TurboWan2.1-T2V-1.3B-Diffusers` | 480P | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ |
|
||||
| TurboWan2.1 T2V 14B | `loayrashid/TurboWan2.1-T2V-14B-Diffusers` | 480P, 720P | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ |
|
||||
| TurboWan2.2 I2V A14B | `loayrashid/TurboWan2.2-I2V-A14B-Diffusers` | 480P<br>720P | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ |
|
||||
| LongCat T2V 13.6B | `FastVideo/LongCat-Video-T2V-Diffusers` | 480P<br>720P | ❌ | ❌ | ❌ | ⭕ | ✅ |
|
||||
| LongCat T2V 13.6B | See note** | 480P<br>720P | ❌ | ❌ | ❌ | ⭕ | ✅ |
|
||||
| Matrix Game 2.0 Base Distilled | `FastVideo/Matrix-Game-2.0-Base-Distilled-Diffusers` | 352x640 | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ |
|
||||
| Matrix Game 2.0 GTA Distilled | `FastVideo/Matrix-Game-2.0-GTA-Distilled-Diffusers` | 352x640 | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ |
|
||||
| Matrix Game 2.0 TempleRun Distilled | `FastVideo/Matrix-Game-2.0-TempleRun-Distilled-Diffusers` | 352x640 | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ |
|
||||
@@ -192,21 +98,6 @@ resolve default pipeline and sampling configuration for it.
|
||||
`FastVideo/GEN3C-Cosmos-7B-Diffusers`) or convert locally with
|
||||
`scripts/checkpoint_conversion/convert_gen3c_to_fastvideo.py`.
|
||||
|
||||
## Hardware and OS
|
||||
|
||||
Per the installation guides:
|
||||
|
||||
- **NVIDIA GPU (x86_64)** — CUDA 12.6 or 13.0; see the
|
||||
[GPU install guide](../getting_started/installation/gpu.md).
|
||||
- **NVIDIA DGX Spark (GB10, aarch64)** — CUDA 13, from-source kernel build; see
|
||||
the [DGX Spark install guide](../getting_started/installation/spark.md).
|
||||
- **Apple silicon (MPS)** — macOS 14 or newer; see the
|
||||
[MPS install guide](../getting_started/installation/mps.md) and
|
||||
[`basic_mps.py`](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_mps.py).
|
||||
|
||||
Optimization-specific hardware constraints (e.g. STA requiring Hopper) are
|
||||
listed under [Special requirements](#special-requirements).
|
||||
|
||||
## Special requirements
|
||||
|
||||
### Sliding Tile Attention
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
# Attn-QAT Training
|
||||
|
||||
Attn-QAT simulates low-bit attention during training while keeping the rest of
|
||||
the training method unchanged. In the modular `fastvideo/train` framework it is
|
||||
a per-role model option, not a separate training method: supervised fine-tuning
|
||||
and DMD2 still own their losses and optimizer cadence.
|
||||
|
||||
This guide covers the QAD Wan2.1-T2V-1.3B MixKit workflow:
|
||||
|
||||
1. run a 4,000-step supervised Attn-QAT fine-tune;
|
||||
2. export the stage-1 DCP checkpoint to Diffusers format; and
|
||||
3. distill the student to three denoising steps with DMD2.
|
||||
|
||||
The ready-to-run configs and wrappers are in
|
||||
`examples/train/scenario/qad_wan2_1_mixkit/`.
|
||||
|
||||
## Role-local attention backends
|
||||
|
||||
A DMD2 run owns three independent model roles. Configure the attention backend
|
||||
on each role so fake quantization is applied only to the student:
|
||||
|
||||
```yaml
|
||||
models:
|
||||
student:
|
||||
attention_backend: ATTN_QAT_TRAIN
|
||||
teacher:
|
||||
attention_backend: FLASH_ATTN
|
||||
critic:
|
||||
attention_backend: FLASH_ATTN
|
||||
```
|
||||
|
||||
The override is active only while that role's transformer is constructed, then
|
||||
the previous process-wide backend is restored. This lets student, teacher, and
|
||||
critic use different implementations in one process. Invalid role-level names
|
||||
fail during configuration instead of silently selecting another backend.
|
||||
|
||||
See [Training Infrastructure](train_infra.md) for the complete model-role
|
||||
configuration reference.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Install FastVideo and make the `fastvideo-kernel` Python package importable.
|
||||
`ATTN_QAT_TRAIN` intentionally fails instead of falling back to dense
|
||||
attention when its kernel cannot be loaded.
|
||||
- Prepare the precomputed MixKit VAE latents and text embeddings.
|
||||
- Run the commands below from the repository root. The supplied recipe expects
|
||||
four GPUs by default; set `NUM_GPUS` to override it.
|
||||
|
||||
Download the published preprocessed dataset:
|
||||
|
||||
```bash
|
||||
bash examples/datasets/mixkit/download_dataset.sh
|
||||
```
|
||||
|
||||
## Stage 1: supervised Attn-QAT fine-tuning
|
||||
|
||||
The stage-1 config uses `ATTN_QAT_TRAIN` on the student, sequence parallelism
|
||||
across four GPUs, FP32 master weights, and 4,000 optimizer steps:
|
||||
|
||||
```bash
|
||||
NUM_GPUS=4 \
|
||||
bash examples/train/scenario/qad_wan2_1_mixkit/run_stage1.sh
|
||||
```
|
||||
|
||||
Pass a dataset directory as the first positional argument when it differs from
|
||||
the default:
|
||||
|
||||
```bash
|
||||
NUM_GPUS=4 \
|
||||
bash examples/train/scenario/qad_wan2_1_mixkit/run_stage1.sh \
|
||||
/path/to/combined_parquet_dataset
|
||||
```
|
||||
|
||||
The wrapper calls `examples/train/run.sh`; the YAML file remains the source of
|
||||
truth for optimizer, validation, checkpointing, and distributed settings.
|
||||
|
||||
## Export the stage-1 checkpoint
|
||||
|
||||
Modular training checkpoints use Distributed Checkpoint (DCP) format. Export
|
||||
the student before using it to initialize stage 2:
|
||||
|
||||
```bash
|
||||
bash examples/train/scenario/qad_wan2_1_mixkit/export_stage1.sh \
|
||||
checkpoints/wan_t2v_qat_finetune/checkpoint-4000 \
|
||||
checkpoints/wan_t2v_qat_finetune/diffusers
|
||||
```
|
||||
|
||||
Both arguments are optional; the command above shows their defaults.
|
||||
|
||||
## Stage 2: three-step DMD2 distillation
|
||||
|
||||
Stage 2 loads the exported student weights, keeps Attn-QAT on the student, and
|
||||
uses Flash Attention for the teacher and critic:
|
||||
|
||||
```bash
|
||||
NUM_GPUS=4 \
|
||||
bash examples/train/scenario/qad_wan2_1_mixkit/run_stage2.sh \
|
||||
data/HD-Mixkit-Finetune-Wan/combined_parquet_dataset \
|
||||
checkpoints/wan_t2v_qat_finetune/diffusers/transformer/model.safetensors
|
||||
```
|
||||
|
||||
The migrated recipe preserves these behaviors:
|
||||
|
||||
| Behavior | Modular configuration |
|
||||
|---|---|
|
||||
| Student fake-quantized attention | `models.student.attention_backend: ATTN_QAT_TRAIN` |
|
||||
| Teacher and critic full-precision attention | Role-local `FLASH_ATTN` |
|
||||
| Generator update every five critic steps | `method.generator_update_interval: 5` |
|
||||
| Three-step rollout | `method.dmd_denoising_steps: [1000, 757, 522]` |
|
||||
| Score timestep range | `method.min_timestep_ratio: 0.02`, `max_timestep_ratio: 0.98` |
|
||||
| Legacy guidance `cond + 2(cond - uncond)` | Standard CFG scale `3.0` |
|
||||
| Stage handoff | DCP checkpoint to Diffusers export to student override weights |
|
||||
|
||||
The timestep ratios apply to randomly sampled teacher and critic score
|
||||
timesteps; `dmd_denoising_steps` separately controls the student rollout. See
|
||||
[DMD Distillation](../distillation/dmd.md) for general DMD concepts.
|
||||
|
||||
## Architecture-specific Triton routing
|
||||
|
||||
The training kernel is runtime-JIT-compiled Triton code and selects its route on
|
||||
every call. It supports different query and key/value sequence lengths for
|
||||
cross-attention; key and value must have the same sequence length.
|
||||
|
||||
| Hardware/configuration | Route |
|
||||
|---|---|
|
||||
| SM100, validated non-causal BF16 QAT configuration with head dimension 128 | Large-tile forward and split 64x64 backward; optimized backward requires a 16-aligned KV length |
|
||||
| SM120, including RTX 5090 | Previous forward tiling with joined quantized/STE P@V operations and a shallower backward pipeline for long sequences |
|
||||
| Unsupported configurations | Previous Triton implementation |
|
||||
|
||||
Warp specialization is disabled automatically on SM100 and SM120 because the
|
||||
Triton 3.7 NVWS compiler pass aborts for this kernel on Blackwell. No user
|
||||
setting is required.
|
||||
|
||||
The available tuning and comparison controls are:
|
||||
|
||||
| Environment variable | Default | Effect |
|
||||
|---|---|---|
|
||||
| `FASTVIDEO_ATTN_QAT_FWD_MODE` | `fast` | Selects `fast`, `balanced`, or `reference` forward tiling on the SM100 optimized route |
|
||||
| `FASTVIDEO_ATTN_QAT_FWD_EXACT_M` | `0` | Set to `1` to recompute reference-order softmax statistics and keep `dV` bitwise-compatible on the SM100 optimized route |
|
||||
| `FASTVIDEO_ATTN_QAT_SM100_OPTIMIZED` | `1` | Set to `0` to force the previous SM100 forward and backward for comparison |
|
||||
| `FASTVIDEO_ATTN_QAT_SM120_JOIN_QAT_PV` | `1` | Set to `0` to compare SM120 against the split P@V path |
|
||||
|
||||
The first invocation JIT-compiles the selected configuration; later calls reuse
|
||||
the Triton cache. To measure the production shape, run
|
||||
`python benchmarks/benchmark_attn_qat_train.py` from `fastvideo-kernel/`.
|
||||
|
||||
For import and backend-selection failures, see [Debugging](../utilities/debugging.md).
|
||||
@@ -62,18 +62,6 @@ bash examples/training/finetune/wan_t2v_1.3B/crush_smol/finetune_t2v.sh
|
||||
- Gradient checkpointing: `--enable_gradient_checkpointing_type "full"`
|
||||
- Memory scaling: Increase `--sp_size` or reduce `--num_latent_t` to fit in memory
|
||||
|
||||
## Attention Quantization-Aware Training
|
||||
|
||||
Attn-QAT fine-tunes a model while simulating low-bit attention in the forward
|
||||
and backward passes. The modular trainer can select the backend per model role,
|
||||
so a later DMD2 stage can keep fake quantization on the student while the
|
||||
teacher and critic use Flash Attention.
|
||||
|
||||
The ready-to-run Wan2.1 MixKit workflow includes supervised fine-tuning,
|
||||
checkpoint export, and three-step DMD2 distillation:
|
||||
|
||||
**→ [Follow the Attn-QAT training guide](attn_qat.md)**
|
||||
|
||||
## LoRA Finetuning
|
||||
|
||||
LoRA (Low-Rank Adaptation) trains lightweight adapters while keeping the base model frozen. This significantly reduces memory usage and training time.
|
||||
@@ -178,11 +166,10 @@ Ready-to-run training scripts are available for multiple models:
|
||||
| Wan2.1 I2V 14B | I2V | `examples/training/finetune/wan_i2v_14B_480p/crush_smol/` |
|
||||
| Wan2.1-Fun 1.3B InP | I2V | `examples/training/finetune/Wan2.1-Fun-1.3B-InP/crush_smol/` |
|
||||
| Wan2.1 VSA | T2V/I2V | `examples/training/finetune/Wan2.1-VSA/Wan-Syn-Data/` |
|
||||
| Wan2.1 T2V 1.3B Attn-QAT | QAT SFT + DMD2 | `examples/train/scenario/qad_wan2_1_mixkit/` |
|
||||
|
||||
Each example includes:
|
||||
|
||||
- a README pointing at the matching download script under `examples/datasets/`
|
||||
- `download_dataset.sh` — download sample data
|
||||
- `preprocess_*.sh` — run preprocessing
|
||||
- `finetune_*.sh` — full finetune launcher
|
||||
- `finetune_*_lora.sh` — LoRA finetune launcher
|
||||
|
||||
@@ -43,12 +43,9 @@ Ready-to-run examples with preprocessing scripts, training launchers, and valida
|
||||
|
||||
**→ [Browse all training examples](examples/examples_training_index.md)**
|
||||
|
||||
For the complete two-stage Wan2.1 MixKit quantization-aware workflow, see
|
||||
**[Attn-QAT Training](attn_qat.md)**.
|
||||
|
||||
Each example includes:
|
||||
|
||||
- a README pointing at the matching download script under `examples/datasets/`
|
||||
- `download_dataset.sh` — download sample data
|
||||
- `preprocess_*.sh` — run preprocessing
|
||||
- `finetune_*.sh` — launch training (full finetune or LoRA)
|
||||
- `validation.json` — validation prompts for checkpoints
|
||||
@@ -62,11 +59,9 @@ FastVideo supports several training approaches:
|
||||
| **Full finetune** | Adapt entire model to a new domain or style |
|
||||
| **LoRA finetune** | Lightweight adaptation with frozen base weights |
|
||||
| **VSA finetune** | Finetune with Variable Sparse Attention for efficiency |
|
||||
| **Attn-QAT** | Train with fake-quantized attention, optionally followed by DMD2 distillation |
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Get started**: Pick an example from the [training examples index](examples/examples_training_index.md)
|
||||
2. **Prepare data**: Follow [data preprocessing](data_preprocess.md) for your own dataset
|
||||
3. **Train with quantized attention**: Follow the [Attn-QAT two-stage recipe](attn_qat.md)
|
||||
4. **Run inference**: After training, see [inference examples](../inference/examples/examples_inference_index.md)
|
||||
3. **Run inference**: After training, see [inference examples](../inference/examples/examples_inference_index.md)
|
||||
|
||||
@@ -81,7 +81,6 @@ Common model parameters:
|
||||
| `disable_custom_init_weights` | `false` | Skip custom weight initialization (use for teacher/critic) |
|
||||
| `flow_shift` | `3.0` | Timestep shifting factor |
|
||||
| `enable_gradient_checkpointing_type` | `null` | Gradient checkpointing (`"full"` or `null`) |
|
||||
| `attention_backend` | `null` | Optional role-local backend for Wan models (for example `ATTN_QAT_TRAIN`); overrides the process default only while this role's transformer is built |
|
||||
|
||||
Which roles are needed depends on the training method:
|
||||
|
||||
@@ -212,29 +211,6 @@ pipeline:
|
||||
flow_shift: 8
|
||||
```
|
||||
|
||||
Registered transformer linear-quantization configs can also be selected by
|
||||
name. For example, the LTX-2 NVFP4-QAT recipe applies real FP4 forward GEMMs
|
||||
with a straight-through-estimator backward to its deployment-targeted
|
||||
attention/FFN projections:
|
||||
|
||||
```yaml
|
||||
pipeline:
|
||||
dit_config:
|
||||
quant_config: nvfp4_qat_train
|
||||
```
|
||||
|
||||
The LTX-2 recipe in
|
||||
`examples/train/configs/overfit_ltx2_t2v_nvfp4_qat.yaml` combines that linear
|
||||
configuration with `models.student.attention_backend: ATTN_QAT_TRAIN` for
|
||||
video-attention forward/backward. On sm120, its validation callback temporarily
|
||||
switches those layers to `ATTN_QAT_INFER`.
|
||||
On GB200, set `callbacks.validation.attn_qat_infer: false` to keep validation on
|
||||
the train-time QAT backend; the inference kernel is sm120-only.
|
||||
|
||||
User-adaptable LTX-2 fine-tuning recipes (full, LoRA, and NVFP4 QAT) live in
|
||||
`examples/train/configs/fine_tuning/ltx2/`, alongside the other model
|
||||
families under `examples/train/configs/fine_tuning/`.
|
||||
|
||||
---
|
||||
|
||||
## Training Methods
|
||||
@@ -322,8 +298,6 @@ method:
|
||||
| `dmd_denoising_steps` | *(required)* | Timestep schedule for student rollout |
|
||||
| `generator_update_interval` | `1` | Update student every N critic steps |
|
||||
| `real_score_guidance_scale` | `1.0` | CFG scale for teacher predictions |
|
||||
| `min_timestep_ratio` | `0.0` | Lower bound for randomly sampled teacher/critic score timesteps |
|
||||
| `max_timestep_ratio` | `1.0` | Upper bound for randomly sampled teacher/critic score timesteps |
|
||||
| `fake_score_learning_rate` | *(required)* | Critic optimizer learning rate |
|
||||
| `fake_score_betas` | *(required)* | Critic optimizer Adam betas |
|
||||
| `fake_score_lr_scheduler` | *(required)* | Critic LR scheduler type |
|
||||
|
||||
@@ -77,13 +77,8 @@ If forcing a backend fails, verify optional dependencies are installed:
|
||||
- `SAGE_ATTN`: SageAttention package
|
||||
- `SAGE_ATTN_THREE`: upstream `sageattn3` package
|
||||
- `ATTN_QAT_INFER`: `fastvideo-kernel` checkout/source install that exposes
|
||||
`attn_qat_infer`, AND a consumer-Blackwell (sm_120/sm_121) GPU -- on any
|
||||
other device the backend reports unavailable (even if a CUDA 13 wheel
|
||||
bundles the extension) and selection falls back to FlashAttention
|
||||
- `ATTN_QAT_TRAIN`: `fastvideo-kernel`; its runtime-JIT Triton implementation
|
||||
selects an optimized route on SM100, joins the quantized and STE P@V paths on
|
||||
SM120, and retains the previous route for unsupported configurations. See
|
||||
[Attn-QAT Training](../training/attn_qat.md) for architecture controls.
|
||||
`attn_qat_infer`
|
||||
- `ATTN_QAT_TRAIN`: `fastvideo-kernel` install exposing `fastvideo_kernel`
|
||||
|
||||
As a fallback, use:
|
||||
|
||||
|
||||
Executable → Regular
@@ -1,17 +0,0 @@
|
||||
# LingBot World 2 Example Dataset
|
||||
|
||||
These files were copied unchanged from the LingBot World 2 repository for the
|
||||
FastVideo causal-fast inference example.
|
||||
|
||||
- Repository: `https://github.com/Robbyant/lingbot-world-v2.git`
|
||||
- Source commit: `94f43115de8d4a4f9f282126528c300a0b232c5f`
|
||||
- Source directory: `examples/03`
|
||||
|
||||
## Files
|
||||
|
||||
- `image.jpg`: source image for image-to-video generation. SHA-256:
|
||||
`6ee3dacfef32cfef504dd698adb8a660cf15f686535c52fed4903fef27c0edd0`
|
||||
- `poses.npy`: camera-to-world trajectory matrices. SHA-256:
|
||||
`bd0a23a696e184b0b43e7767eb432bfe644690560fe327fa96961affc941c404`
|
||||
- `intrinsics.npy`: camera intrinsic parameters. SHA-256:
|
||||
`821fca6cf957ae8fbb1181307f02479efb1705e04c9e05734cd02fb43462e082`
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
Binary file not shown.
@@ -1,3 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Wan-Syn 720P dataset (77x768x1280, 250k clips).
|
||||
python scripts/huggingface/download_hf.py --repo_id "FastVideo/Wan-Syn_77x768x1280_250k" --local_dir "data/Wan-Syn_77x768x1280_250k" --repo_type "dataset"
|
||||
@@ -5,7 +5,7 @@ These scripts demonstrate self-forcing distillation (SFwan) for the causal Wan2.
|
||||
## Run the recipe
|
||||
1. Download the preprocessed text-video dataset:
|
||||
```bash
|
||||
bash examples/datasets/crush-smol/download_dataset.sh
|
||||
bash examples/distill/SFWan2.1-T2V/download_dataset.sh
|
||||
```
|
||||
2. (Optional) Regenerate parquet shards locally:
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
python scripts/huggingface/download_hf.py --repo_id "wlsaidhi/crush-smol-merged" --local_dir "data/crush-smol" --repo_type "dataset"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user