Compare commits

..
Author SHA1 Message Date
SolitaryThinker 757e024dc7 [misc]: address review — stale STG docstring, typos, rename np-less freq-grid helper, add compile-conditions contract test
- ltx2.py: the keep-mask closure docstring still claimed only
  stg_block_idx is perturbed; that gate moved to the eager caller, so
  document the caller contract instead. Note that
  LTX2_PIPELINE_DEBUG_LOG re-specializes compiled graphs per block.
- rename generate_ltx_freq_grid_np -> generate_ltx_freq_grid_float64
  (it no longer uses numpy); update the gemma.py consumer.
- flash_attn_cute.py: fix 'nstead' typo; flash_attn.py: wrap comment
  to 120 cols; launch script: grammar fix.
- new fastvideo/tests/contract/test_ltx2_compile_conditions.py pins
  the class-level _compile_conditions wiring and the is_ltx2_blocks
  matcher so the whole-model fullgraph fallback can't silently return
  (CPU-only, no model instantiation).
2026-07-20 13:14:08 -07:00
Adriel Fung d3ff912ac0 [perf]: enable per-block torch.compile for LTX2 with persistent cache
Cache LTX2 graphs and allow all 48 blocks to share one compiled graph. Also added compile to the dreamverse backend launch.
2026-07-20 13:14:08 -07:00
148 changed files with 552 additions and 10118 deletions
+2 -2
View File
@@ -404,7 +404,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
@@ -415,7 +415,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
+7 -14
View File
@@ -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' }}
-1
View File
@@ -75,7 +75,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
@@ -39,16 +39,15 @@ 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
# Persistent torch.compile cache so warmup pays the full autotune/codegen cost only on the first launch.
# Later launches on the same box reload the Inductor FX-graph + AOTAutograd + Triton artifacts from disk.
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} (first launch builds it, later launches reload it)"
cd "${DREAMVERSE_ROOT}"
+4 -3
View File
@@ -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();
+7 -10
View File
@@ -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();
});
});
-68
View File
@@ -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,
});
});
});
-681
View File
@@ -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",
-1
View File
@@ -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",
@@ -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();
});
});
+22 -73
View File
@@ -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 }),
+19 -50
View File
@@ -1,6 +1,6 @@
'use client';
import { ImageOff, Loader2 } from 'lucide-react';
import { Loader2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Card } from '@/components/ui/card';
@@ -11,54 +11,6 @@ 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);
@@ -125,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"
+2 -19
View File
@@ -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;');
});
});
+2 -2
View File
@@ -4,8 +4,8 @@ import GpuGrid from '@/components/system/GpuGrid';
export default function GpusPage() {
return (
<div className="mx-auto flex w-full max-w-[1100px] flex-col gap-6 px-4 pb-12 pt-6">
<main className="mx-auto flex w-full max-w-[1100px] flex-col gap-6 px-4 pb-12 pt-6">
<GpuGrid />
</div>
</main>
);
}
@@ -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' }));
@@ -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>
);
}
@@ -1,50 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { downloadBlob } from '@/lib/utils';
import DownloadCaptions from './DownloadCaptions';
vi.mock('@/lib/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/utils')>();
return { ...actual, downloadBlob: vi.fn() };
});
const props = {
fileNames: ['b.mp4', 'a.mp4'],
captions: { 'a.mp4': 'cap a', 'b.mp4': 'cap b' },
};
describe('DownloadCaptions', () => {
it('opens the format menu on click and downloads the selection', async () => {
const user = userEvent.setup();
render(<DownloadCaptions {...props} />);
await user.click(
screen.getByRole('button', { name: 'Download Captions' }),
);
await user.click(screen.getByRole('menuitem', { name: 'JSON' }));
expect(vi.mocked(downloadBlob)).toHaveBeenCalledWith(
expect.any(Blob),
'videos2caption.json',
);
});
it('operates entirely from the keyboard', async () => {
const user = userEvent.setup();
render(<DownloadCaptions {...props} />);
screen.getByRole('button', { name: 'Download Captions' }).focus();
await user.keyboard('{Enter}');
const first = await screen.findByRole('menuitem', { name: 'JSON' });
expect(first).toHaveFocus();
await user.keyboard('{ArrowDown}{Enter}');
expect(vi.mocked(downloadBlob)).toHaveBeenCalledWith(
expect.any(Blob),
'videos.txt',
);
});
});
@@ -1,13 +1,12 @@
'use client';
import { ChevronDown } from 'lucide-react';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { Button } from '@/components/ui/button';
import { downloadBlob } from '@/lib/utils';
const MENU_ITEM =
'block min-h-11 w-full cursor-pointer select-none px-4 py-2 text-left text-sm font-medium text-foreground outline-none transition-colors data-[highlighted]:bg-muted';
'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,
@@ -63,40 +62,52 @@ export default function DownloadCaptions({
downloadBlob(new Blob([csv], { type: 'text/csv' }), 'captions.csv');
}
// Same click/keyboard-accessible menu idiom as CreateJobButton — hover-only
// menus exclude touch and keyboard users.
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="gap-1.5"
<div className="group relative inline-block">
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
aria-haspopup="menu"
className="gap-1.5"
>
Download Captions
<ChevronDown className="h-3.5 w-3.5 opacity-85" />
</Button>
{!disabled && (
<div
role="menu"
className="invisible absolute right-0 top-full z-[200] min-w-full -translate-y-1 pt-1 opacity-0 transition-all group-focus-within:visible group-focus-within:translate-y-0 group-focus-within:opacity-100 group-hover:visible group-hover:translate-y-0 group-hover:opacity-100"
>
Download Captions
<ChevronDown className="h-3.5 w-3.5 opacity-85" aria-hidden />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
align="end"
sideOffset={4}
collisionPadding={8}
className="z-[200] min-w-40 overflow-hidden rounded-lg border border-border bg-popover py-1 text-popover-foreground shadow-lg"
>
<DropdownMenu.Item className={MENU_ITEM} onSelect={handleDownloadJson}>
JSON
</DropdownMenu.Item>
<DropdownMenu.Item className={MENU_ITEM} onSelect={handleDownloadTxt}>
TXT
</DropdownMenu.Item>
<DropdownMenu.Item className={MENU_ITEM} onSelect={handleDownloadCsv}>
CSV
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<div className="overflow-hidden rounded-lg border border-border bg-popover py-1 shadow-lg">
<button
type="button"
role="menuitem"
className={MENU_ITEM}
onClick={handleDownloadJson}
>
JSON
</button>
<button
type="button"
role="menuitem"
className={MENU_ITEM}
onClick={handleDownloadTxt}
>
TXT
</button>
<button
type="button"
role="menuitem"
className={MENU_ITEM}
onClick={handleDownloadCsv}
>
CSV
</button>
</div>
</div>
)}
</div>
);
}
@@ -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)}
@@ -50,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();
@@ -103,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
@@ -155,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);
@@ -177,7 +162,6 @@ export default function CreateJobModal({
// can't overwrite the current workload's model list/selection.
let stale = false;
setIsLoadingModels(true);
setModelLoadError(null);
getModels(inferenceWorkload)
.then((list) => {
if (stale) return;
@@ -196,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);
@@ -215,22 +193,11 @@ export default function CreateJobModal({
// 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]);
@@ -239,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);
}
@@ -265,7 +224,6 @@ export default function CreateJobModal({
function clearImage() {
setImagePath('');
setImageFileName('');
setImageUploadError(null);
if (imageInputRef.current) imageInputRef.current.value = '';
}
@@ -281,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,
@@ -339,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);
}
@@ -391,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
@@ -410,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' && (
@@ -430,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"
/>
@@ -450,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>
)}
@@ -506,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}>
@@ -529,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"
@@ -550,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) => (
@@ -906,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>
@@ -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,7 +148,11 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
}
return (
<article
<div
role="button"
tabIndex={0}
onClick={handleSelectJob}
onKeyDown={handleKeyDown}
className={cn(
'mb-3 flex cursor-pointer flex-col gap-2.5 rounded-lg border bg-background p-4 transition-colors last:mb-0',
isSelected
@@ -148,42 +160,35 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
: 'border-border hover:border-muted-foreground/40',
)}
>
<button
type="button"
aria-pressed={isSelected}
onClick={handleSelectJob}
className="flex w-full flex-col gap-2.5 rounded-md text-left"
>
<span className="flex flex-wrap items-center justify-between gap-2">
<span className="text-[0.95rem] font-semibold text-foreground">
{job.model_id}
</span>
<Badge variant={BADGE_VARIANTS[job.status] ?? 'secondary'}>
{job.status}
</Badge>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-[0.95rem] font-semibold text-foreground">
{job.model_id}
</span>
<span className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-muted-foreground">
{job.prompt}
</span>
<span className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
{job.job_type === 'inference' ? (
<>
<span>{job.num_frames} frames</span>
<span>
{job.height}×{job.width}
</span>
</>
) : (
<span>{job.workload_type?.replace(/_/g, ' ') ?? job.job_type}</span>
)}
{elapsedTime && (
<span className="inline-flex items-center gap-1">
<Timer className="size-3.5" aria-hidden />
{elapsedTime}
<Badge variant={BADGE_VARIANTS[job.status] ?? 'secondary'}>
{job.status}
</Badge>
</div>
<p className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-muted-foreground">
{job.prompt}
</p>
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
{job.job_type === 'inference' ? (
<>
<span>{job.num_frames} frames</span>
<span>
{job.height}×{job.width}
</span>
)}
</span>
</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
@@ -235,6 +240,6 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
Delete
</Button>
</div>
</article>
</div>
);
}
@@ -19,32 +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('renders log lines streamed from the job log poll', async () => {
vi.mocked(getJobLogs).mockResolvedValue({
lines: ['boot sequence started', 'loading model weights'],
@@ -4,7 +4,6 @@ import * as React from 'react';
import { X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useDrawerFocus } from '@/hooks/useDrawerFocus';
import { useResizable } from '@/hooks/useResizable';
import { downloadJobLog, getJobLogs } from '@/lib/api';
import type { Job } from '@/lib/types';
@@ -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">
@@ -207,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>
);
}
@@ -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,10 +1,8 @@
'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> = {
@@ -17,47 +15,25 @@ const TAB_TITLES: Record<string, string> = {
'/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"
@@ -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;
}
+5 -30
View File
@@ -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,
+10 -20
View File
@@ -269,26 +269,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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -134,7 +134,7 @@ 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.
## Development Environment Setup
+1 -1
View File
@@ -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
+2 -1
View File
@@ -134,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).
-16
View File
@@ -119,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:
+5 -109
View File
@@ -13,92 +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) |
| 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 (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
@@ -111,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 {
@@ -145,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 | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ |
@@ -154,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 | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ |
@@ -187,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
-23
View File
@@ -212,29 +212,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
+1 -3
View File
@@ -77,9 +77,7 @@ 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_infer`
- `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
@@ -191,22 +191,6 @@ def main() -> None:
print(f"Using refine upsampler: {refine_upsampler_path}")
pipeline_config = PipelineConfig.from_pretrained(model_root)
# LTX-2 NVFP4 deploy contract (train==deploy surface):
# * Linears: NVFP4 block-scaled GEMMs (per-16 E2M1 + E4M3 SFs) on every
# arch, via flashinfer.
# * ATTN_QAT_INFER attention differs per arch: sm_120a/sm_121a use the
# fastvideo-kernel CUTLASS (SageAttention3-FP4) scheme that
# ATTN_QAT_TRAIN simulates; sm_100a (GB200) / sm_103a (GB300) use the
# FP4 FA4 kernel (flash-attention-fp4) with per-16 block-scaled NVFP4
# Q/K and BF16 P/V -- a train-sim mismatch that is gated by MS-SSIM
# measurement, not assumed equal. The selection receipt is logged at
# backend resolution ("ATTN_QAT_INFER resolved: ...").
# Original-weight retention: the default purges the always-FP4 layers'
# bf16 originals after conversion. Refine-only layers (the cross-modal
# AV projections) always keep theirs: the base stage profile runs them
# dense by deployment contract -- in the two-stage fast profile AND the
# distilled single-stage deploy. retain_original_weights=True keeps
# everything (debugging).
pipeline_config.dit_config.quant_config = NVFP4Config()
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
torch_compile_kwargs = {
@@ -6,6 +6,10 @@ original single-file version to make each concern independently reviewable.
The folder name matches the sibling `gradio_local_demo*.py` demos in this
directory so both flat and packaged demos read consistently.
> **Status: draft.** This package is structurally in place but will not run
> against the current upstream `fastvideo` package. See *Blocking prereqs*
> below.
## Layout
| File | Purpose |
@@ -34,21 +38,27 @@ python -m gradio_local_demo_ltx2_3 --port 7860
GPU requirement: a single FP4-capable GPU (B200 or comparable) for the
"real-time 1080p" speed claim. Lower tiers will still run but slower.
## Upstream surfaces the demo relies on
## Blocking prereqs (why this draft PR cannot be merged yet)
Everything the demo needs ships in the upstream `fastvideo` package:
The upstream `fastvideo` package is missing three pieces that the demo
currently depends on verbatim. Each needs its own upstreaming PR before this
demo can actually boot:
- **`fastvideo.layers.quantization.nvfp4_config.NVFP4Config`** — the demo sets
`pipeline_config.dit_config.quant_config = NVFP4Config()` in `app.py` (same
pattern as `examples/inference/basic/basic_ltx2_distilled_fast_profile.py`).
- **LTX-2.3 refine kwargs on `VideoGenerator`** — `ltx2_refine_enabled`,
`ltx2_refine_upsampler_path`, `ltx2_refine_lora_path`,
`ltx2_refine_num_inference_steps`, `ltx2_refine_guidance_scale`,
`ltx2_refine_add_noise`, and `ltx2_vae_tiling` are wired in
`fastvideo/fastvideo_args.py`, backed by
`fastvideo/pipelines/basic/ltx2/stages/ltx2_refine.py`.
- **`SamplingParam`** — imported from the public `fastvideo` package root
(`fastvideo.api.sampling_param` under the hood).
1. **`fastvideo.layers.quantization.fp4_config.FP4Config`** — the demo sets
`pipeline_config.dit_config.quant_config = FP4Config()` in `app.py`.
Upstream only ships `absmax_fp8.py` and `base_config.py` under
`fastvideo/layers/quantization/`.
2. **LTX-2.3 refine / image-conditioning kwargs on `VideoGenerator`** —
`ltx2_refine_enabled`, `ltx2_refine_upsampler_path`, `ltx2_refine_lora_path`,
`ltx2_refine_num_inference_steps`, `ltx2_refine_guidance_scale`,
`ltx2_refine_add_noise`, `ltx2_images`, `ltx2_image_crf`. Upstream
`fastvideo/fastvideo_args.py` currently wires only `ltx2_vae_tiling`.
The backing stages (`ltx2_refine.py`, `ltx2_i2v_conditioning.py`) are
also missing from `fastvideo/pipelines/stages/`.
3. **`fastvideo.configs.sample.base.SamplingParam`** — the import path used
by this demo. Upstream moved sampling params to
`fastvideo.api.sampling_param`. A re-export shim at the old path, or an
import update here once the other two prereqs land, will resolve it.
## Environment variables
@@ -4,10 +4,10 @@ from pathlib import Path
import gradio as gr
from fastvideo import SamplingParam
from fastvideo.configs.pipelines.base import PipelineConfig
from fastvideo.configs.sample.base import SamplingParam
from fastvideo.entrypoints.video_generator import VideoGenerator
from fastvideo.layers.quantization.nvfp4_config import NVFP4Config
from fastvideo.layers.quantization.fp4_config import FP4Config
from fastvideo.utils import maybe_download_model
from .config import (
@@ -44,7 +44,7 @@ def main():
resolved_model_path = Path(model_root)
pipeline_config = PipelineConfig.from_pretrained(str(resolved_model_path))
pipeline_config.dit_config.quant_config = NVFP4Config()
pipeline_config.dit_config.quant_config = FP4Config()
refine_upsampler_path = resolve_refine_upsampler_path(resolved_model_path)
print(f"Using refine upsampler: {refine_upsampler_path}")
@@ -4,7 +4,7 @@ from pathlib import Path
import torch
import torch._inductor.config
from fastvideo import SamplingParam
from fastvideo.configs.sample.base import SamplingParam
LOCAL_DEMO_DIR = Path(__file__).resolve().parent
CLASSIFIER_DIR = Path(
@@ -5,7 +5,7 @@ from copy import deepcopy
import gradio as gr
from fastvideo import SamplingParam
from fastvideo.configs.sample.base import SamplingParam
from fastvideo.entrypoints.video_generator import VideoGenerator
from .config import (
@@ -1,176 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""LTX-2.3 distilled text-to-video with the optimized NVFP4 inference stack.
Runs `FastVideo/LTX-2.3-Distilled-Diffusers` on a single GPU with the full
validated optimization stack:
* NVFP4 block-scaled linear layers (per-16 E2M1 weights + E4M3 scale factors),
* ATTN_QAT_INFER FP4 attention (arch-resolved kernel, receipt logged),
* torch.compile (fullgraph) over the DiT, text encoder, and VAE,
* single-stage 8-step distilled sampling at guidance 1.0.
Quick start
-----------
# On GB200-class ARM hosts, unset LD_LIBRARY_PATH (see Hardware notes):
env -u LD_LIBRARY_PATH python examples/inference/ltx2_3/optimized_nvfp4_t2v.py
# Optional overrides:
# export LTX23_MODEL_PATH=/path/to/local/snapshot
# export LTX23_T2V_PROMPT="a red fox running through fresh snow"
# export LTX23_OUTPUT_DIR=outputs_video/ltx2_3_nvfp4_t2v
Hardware notes
--------------
- On GB200 / Blackwell, run with `env -u LD_LIBRARY_PATH ...` to avoid a
system-cuBLAS / torch-cuBLAS mismatch that fails every GEMM (some ARM
container images ship an LD_LIBRARY_PATH that breaks torch.compile's
toolchain discovery). The `_inductor.shape_padding = False` line below
also avoids a pad_mm landmine on the same generation of cards.
"""
from __future__ import annotations
import os
import time
from pathlib import Path
import torch._inductor.config as _inductor
from fastvideo import VideoGenerator
from fastvideo.configs.pipelines.base import PipelineConfig
from fastvideo.layers.quantization.nvfp4_config import NVFP4Config
from fastvideo.utils import maybe_download_model
# ATTN_QAT_INFER is the FP4 attention half of the NVFP4 deploy contract. It
# resolves per arch (CUTLASS SageAttention3-FP4 on sm_120a/sm_121a, FP4 FA4
# on sm_100a/sm_103a) and logs a one-line receipt of what actually bound.
os.environ.setdefault("FASTVIDEO_ATTENTION_BACKEND", "ATTN_QAT_INFER")
os.environ.setdefault("FASTVIDEO_STAGE_LOGGING", "1")
# Inductor knobs. The first one (shape_padding=False) is mandatory on
# Blackwell to avoid a cuBLAS INVALID_VALUE crash inside pad_mm. The rest
# are the same matmul-friendliness flags the sibling LTX-2 examples use.
_inductor.shape_padding = False
_inductor.conv_1x1_as_mm = True # treat 1x1 convolutions as matrix muls
_inductor.coordinate_descent_tuning = True
_inductor.coordinate_descent_check_all_directions = True
_inductor.epilogue_fusion = False # do not fuse pointwise ops into matmuls
MODEL_ID = os.path.expandvars(
os.path.expanduser(
os.getenv("LTX23_MODEL_PATH", "FastVideo/LTX-2.3-Distilled-Diffusers")
)
)
OUTPUT_DIR = Path(os.getenv("LTX23_OUTPUT_DIR", "outputs_video/ltx2_3_nvfp4_t2v"))
DEFAULT_PROMPT = (
"A fashion model takes a slow step forward and shifts her weight, "
"the soft fabric of her clothing swaying and rippling with the "
"motion, her hair shifting gently, soft even studio lighting on a "
"clean light background, elegant slow-motion runway feel."
)
PROMPT = os.getenv("LTX23_T2V_PROMPT", DEFAULT_PROMPT)
def main() -> None:
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
model_root = maybe_download_model(MODEL_ID)
print(f"Model: {model_root}")
print(f"Output dir: {OUTPUT_DIR.resolve()}")
# Loading the pipeline config *with model_path* binds model-specific
# tuning (notably VAE precision/decoder defaults) into the config.
pipeline_config = PipelineConfig.from_pretrained(model_root)
# NVFP4 linear layers on the DiT. The default purges the original BF16
# weights of the always-FP4 linears right after conversion — a large
# peak-memory reduction. Refine-only layers (the cross-modal AV
# projections) always keep theirs: the base stage profile runs them
# dense by deployment contract. retain_original_weights=True keeps
# everything (debugging).
pipeline_config.dit_config.quant_config = NVFP4Config()
# fullgraph=True is supported on the NVFP4 path: the FP4 quantize step
# is a registered custom op (fastvideo::nvfp4_quantize_fa4), so dynamo
# traces through it without graph breaks. mode="default" — the two
# CUDAGraph modes ("reduce-overhead" / "max-autotune") are a separate
# opt-in, not part of this validated preset.
torch_compile_kwargs = {
"backend": "inductor",
"fullgraph": True,
"mode": "default",
"dynamic": False,
}
generator = VideoGenerator.from_pretrained(
model_root,
num_gpus=1,
pipeline_config=pipeline_config,
# Compile the DiT, text encoder, and VAE — all three stages benefit,
# and the VAE's codec submodules compile cleanly under fullgraph.
enable_torch_compile=True,
enable_torch_compile_text_encoder=True,
enable_torch_compile_vae=True,
torch_compile_kwargs=torch_compile_kwargs,
torch_compile_kwargs_vae=torch_compile_kwargs,
# Keep everything resident — no CPU offload for serving-style runs.
dit_cpu_offload=False,
text_encoder_cpu_offload=False,
vae_cpu_offload=False,
ltx2_vae_tiling=False,
)
common_kwargs = dict(
prompt=PROMPT,
negative_prompt="", # distilled is CFG-free; no negative needed
guidance_scale=1.0, # CFG=1 for distilled
height=1280, width=832, # portrait runway aspect
num_frames=121, fps=24, # ~5s clip
# Single-stage 8-step distilled sampling — the validated preset for
# this checkpoint (no two-stage refine; the NVFP4 deploy contract
# runs the distilled single-stage recipe).
num_inference_steps=8,
save_video=True,
)
try:
# Warmup: pays cold compile + first-shape guard work, untimed.
print("\n[warmup] compiling + generating…")
generator.generate_video(
output_path=str(OUTPUT_DIR / "_warmup.mp4"),
seed=7,
**common_kwargs,
)
(OUTPUT_DIR / "_warmup.mp4").unlink(missing_ok=True)
# Measured run.
out_path = OUTPUT_DIR / "output_ltx2_3_nvfp4_t2v.mp4"
print(f"\n[measured] generating: {out_path}")
t0 = time.perf_counter()
result = generator.generate_video(
output_path=str(out_path),
seed=2002,
**common_kwargs,
)
wall = time.perf_counter() - t0
e2e = (result.get("e2e_latency") if isinstance(result, dict) else None) or wall
print(f"[measured] e2e={e2e:.2f}s wall={wall:.2f}s")
finally:
generator.shutdown()
# Expected receipts — verify these two lines in your own run's log:
#
# 1. ATTN_QAT_INFER routing receipt (logged at backend resolution; on a
# GB200-class part it reads):
#
# ATTN_QAT_INFER resolved: arch=sm_100 kernel=flash-attention-fp4 \
# qk_mode=nvfp4(per-16-e4m3-sf) pv_mode=bf16 train_sim_mismatch=measured
#
# 2. NVFP4 weight purge receipt (logged after model conversion; N/M/X
# depend on the checkpoint):
#
# NVFP4 weight purge receipt: purged N original bf16 weight tensors \
# (X.XX GiB freed); retained M (refine-only dense fallback or \
# retain_original_weights).
if __name__ == "__main__":
main()
@@ -1,70 +0,0 @@
#!/bin/bash
# Stage-2 QAT-aware DMD2 distillation for Kandinsky5 T2V (480p).
#
# Usage:
# bash distill_dmd_qat.sh <stage1-checkpoint> [--dotted.key value ...]
#
# <stage1-checkpoint> is either:
# - a raw stage-1 DCP checkpoint written by
# examples/train/configs/fine_tuning/kandinsky5/finetune_qat.sh
# (a checkpoint-<N>/ dir with a dcp/ subdir, or the stage-1 output_dir
# itself -- dcp_to_diffusers auto-picks the latest checkpoint). It is
# converted to a diffusers export (<checkpoint>-diffusers) with
# `dcp_to_diffusers --role student --verify` before launching --
# models.*.init_from need a diffusers model directory (model_index.json
# + component subfolders), not the raw DCP layout; or
# - an already-exported diffusers model dir (model_index.json present),
# used as-is with no conversion.
#
# The export is passed to all three models.*.init_from overrides: student,
# teacher, and critic all load the same weights. Teacher/critic are
# automatically masked back to dense attention and full-precision weights
# by the _loading_teacher_critic_model gate in
# fastvideo/models/loader/component_loader.py (family-agnostic, no
# Kandinsky5-specific handling needed) -- NOT by pointing them at a
# different export.
#
# FASTVIDEO_ATTENTION_BACKEND=ATTN_QAT_TRAIN keeps the student's dense/local
# attention fake-quantized during distillation.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../../.." && pwd)"
CHECKPOINT="${1:?Usage: $0 <stage1-checkpoint (DCP dir or diffusers export)> [extra --dotted.key overrides...]}"
shift
if [[ "${CHECKPOINT}" != /* ]]; then
CHECKPOINT="$(pwd)/${CHECKPOINT}"
fi
CHECKPOINT="${CHECKPOINT%/}"
if [[ -f "${CHECKPOINT}/model_index.json" ]]; then
# Already a diffusers export -- use as-is.
INIT_FROM="${CHECKPOINT}"
else
# Convert the raw DCP checkpoint. Runs BEFORE ATTN_QAT_TRAIN is
# exported below: backend selection for that env var refuses to start
# (ImportError) when the training kernel isn't built, and the
# conversion itself must not depend on it. --overwrite keeps relaunches
# from silently reusing a stale export for a newer checkpoint;
# --verify strictly reloads the exported transformer so a key-mapping
# bug fails here, not deep inside the training launch below.
INIT_FROM="${CHECKPOINT}-diffusers"
echo "Converting stage-1 DCP checkpoint ${CHECKPOINT} -> ${INIT_FROM}"
python -m fastvideo.train.entrypoint.dcp_to_diffusers \
--checkpoint "${CHECKPOINT}" \
--output-dir "${INIT_FROM}" \
--role student \
--overwrite \
--verify
fi
export FASTVIDEO_ATTENTION_BACKEND=ATTN_QAT_TRAIN
bash "${REPO_ROOT}/examples/train/run.sh" \
"${SCRIPT_DIR}/dmd2_t2v_480p_qat.yaml" \
--models.student.init_from "${INIT_FROM}" \
--models.teacher.init_from "${INIT_FROM}" \
--models.critic.init_from "${INIT_FROM}" \
"$@"
@@ -1,113 +0,0 @@
# DMD2 QAT-aware distillation: Kandinsky5 T2V Lite, 480p (teacher many-step
# -> student 4-step), continuing from a stage-1 Attn-QAT finetune checkpoint.
#
# init_from below must be a diffusers model directory (model_index.json +
# component subfolders), not the raw checkpoint-N/ stage 1 writes (dcp/ +
# metadata/RNG state only). Launching via distill_dmd_qat.sh handles this:
# it runs fastvideo.train.entrypoint.dcp_to_diffusers (--verify) on the
# stage-1 checkpoint you pass it and overrides all three init_from values
# with the export -- the placeholder paths below only apply when launching
# the YAML directly.
#
# - Teacher: frozen, full-precision Kandinsky5 (loaded from the stage-1 QAT
# checkpoint's base weights, or the original pretrained checkpoint --
# the _loading_teacher_critic_model gate in component_loader.py masks
# quant_config and FASTVIDEO_ATTENTION_BACKEND for teacher/critic
# regardless, so they always run full precision / dense attention).
# - Student: trainable, initialized from the stage-1 QAT finetune checkpoint.
# - Critic: trainable, initialized from the same checkpoint as the student.
# - Validation: 4-step sampling via Kandinsky5DMDPipeline.
#
# Launch with FASTVIDEO_ATTENTION_BACKEND=ATTN_QAT_TRAIN set (student only;
# teacher/critic are masked to dense automatically) so the student continues
# learning to absorb fake-quantized attention error during distillation.
#
# Scope: T2V, 480p only, dense/local attention only.
models:
student:
_target_: fastvideo.train.models.kandinsky5.Kandinsky5Model
init_from: outputs/kandinsky5_t2v_qat_finetune/checkpoint-1000-diffusers
trainable: true
teacher:
_target_: fastvideo.train.models.kandinsky5.Kandinsky5Model
init_from: outputs/kandinsky5_t2v_qat_finetune/checkpoint-1000-diffusers
trainable: false
disable_custom_init_weights: true
critic:
_target_: fastvideo.train.models.kandinsky5.Kandinsky5Model
init_from: outputs/kandinsky5_t2v_qat_finetune/checkpoint-1000-diffusers
trainable: true
disable_custom_init_weights: true
method:
_target_: fastvideo.train.methods.distribution_matching.dmd2.DMD2Method
rollout_mode: simulate
generator_update_interval: 5
real_score_guidance_scale: 4.5
dmd_denoising_steps: [1000, 750, 500, 250]
# Critic optimizer (required -- no fallback to training.optimizer)
fake_score_learning_rate: 8.0e-6
fake_score_betas: [0.0, 0.999]
fake_score_lr_scheduler: constant
training:
distributed:
num_gpus: 8
sp_size: 1
tp_size: 1
hsdp_replicate_dim: 1
hsdp_shard_dim: 8
data:
data_path: data/kandinsky5_overfit_preprocessed
dataloader_num_workers: 4
train_batch_size: 1
training_cfg_rate: 0.0
seed: 1000
num_latent_t: 31
num_height: 512
num_width: 768
num_frames: 121
optimizer:
learning_rate: 2.0e-6
betas: [0.0, 0.999]
weight_decay: 0.01
lr_scheduler: constant
lr_warmup_steps: 0
loop:
max_train_steps: 4000
gradient_accumulation_steps: 1
checkpoint:
output_dir: outputs/kandinsky5_t2v_dmd2_4steps_qat
training_state_checkpointing_steps: 20
checkpoints_total_limit: 3
tracker:
project_name: distillation_kandinsky5
run_name: kandinsky5_t2v_dmd2_4steps_qat
model:
enable_gradient_checkpointing_type: full
callbacks:
grad_clip:
max_grad_norm: 1.0
validation:
pipeline_target: fastvideo.pipelines.basic.kandinsky5.kandinsky5_dmd_pipeline.Kandinsky5DMDPipeline
dataset_file: data/kandinsky5_overfit_preprocessed/validation_prompts.json
every_steps: 50
sampling_steps: [4]
sampling_timesteps: [1000, 750, 500, 250]
guidance_scale: 6.0
ema:
_target_: fastvideo.train.callbacks.ema.EMACallback
decay: 0.98
start_iter: 0
pipeline:
flow_shift: 5
-4
View File
@@ -195,10 +195,6 @@ callbacks:
# ------------------------------------------------------------------------------
pipeline:
flow_shift: 3 # default: null (model-specific)
# LTX-2 linear QAT (SM100+ with FlashInfer); distinct from the role-local
# attention_backend option above, which selects attention math for Wan.
# dit_config:
# quant_config: nvfp4_qat_train
# flow_shift_sr: null # default: null (super-resolution shift)
# embedded_cfg_scale: 6.0 # default: 6.0
# is_causal: false # default: false
@@ -1,198 +0,0 @@
# Kandinsky5 QAD recipe (T2V, 480p)
Quantization-aware distillation for Kandinsky-5.0 Lite T2V at
**512×768, 121 frames, 24 fps** (the checkpoint's native `KANDINSKY5_T2V_LITE_5S`
preset -- 121/24 ~= 5.04s, matching the "5s" in the checkpoint name),
mirroring the Wan2.1 QAD recipe
(`examples/training/finetune/wan_t2v_1.3B/mixkit/`) on the new
`fastvideo/train/` stack. Scope is deliberately narrow: T2V only, 480p only,
dense/local attention only. Kandinsky5's NABLA sparse attention path is
never engaged at this resolution and is unsupported by this recipe --
attempting a larger resolution will raise loudly from `Kandinsky5Model`
rather than silently mis-scale the visual RoPE.
## Data
Kandinsky5 uses two text encoders (Qwen/Reason1 + CLIP), unlike Wan's single
encoder. The CLIP pooled projection is zero-padded into a row and prepended
to the Qwen sequence embeddings, then stored as a single `[seq+1, dim]`
tensor in the existing `text_embedding` Parquet field -- the same trick
`preprocess_hunyuan_overfit.py` uses for LLaMA+CLIP. No new Parquet schema
is needed.
Build a small Parquet dataset from raw videos + captions
(`videos2caption.json` + `videos/*.mp4`, matching the Hunyuan overfit script's
layout) with:
```bash
# from the repo root; reads data/kandinsky5_overfit and writes
# data/kandinsky5_overfit_preprocessed by default -- override with
# KANDINSKY5_OVERFIT_DATA_DIR / KANDINSKY5_OVERFIT_OUTPUT_DIR env vars
python -m fastvideo.pipelines.preprocess.preprocess_kandinsky5_overfit
```
This loads Kandinsky5's VAE (shared with HunyuanVideo) and both text
encoders via FastVideo's own loaders (`VAELoader`/`TextEncoderLoader`), and
writes `data_00000.parquet` + `validation_prompts.json` into the configured
output directory. Point `training.data.data_path` at that output directory
in the YAML configs below.
There is currently no scalable/production preprocessing pipeline (the kind
built on `BasePreprocessPipeline` that Wan/LTX2 use) for Kandinsky5 -- like
Hunyuan, its dual text encoders don't fit that base class's single-encoder
assumption, and `BasePreprocessPipeline.preprocess_video_and_text` isn't
cleanly overridable in pieces. If you need to scale past a handful of
overfit samples, extend `preprocess_kandinsky5_overfit.py`'s loop rather
than forking the production pipeline base class.
## Train stage 1 (Attn-QAT finetune)
```bash
bash examples/train/configs/fine_tuning/kandinsky5/finetune_qat.sh \
--training.data.data_path data/kandinsky5_overfit_preprocessed
```
`FASTVIDEO_ATTENTION_BACKEND=ATTN_QAT_TRAIN` (set by the script) routes
Kandinsky5's dense/local attention through the fake-quantized
straight-through-estimator Triton kernel, so the DiT learns to absorb
quantization error instead of fighting it. This is purely env-var driven,
model-agnostic, and does not touch weights during training -- weight-level
FP4/FP8 quantization is applied post-hoc at inference time (see below), not
during either training stage.
## Train stage 2 (QAT-aware DMD distillation)
Stage 1 writes a raw DCP checkpoint (`checkpoint-<N>/dcp` + metadata/RNG
state) under `training.checkpoint.output_dir`
(`outputs/kandinsky5_t2v_qat_finetune/` by default) -- `models.*.init_from`
needs a diffusers model directory instead (`model_index.json` + component
subfolders). Pass the stage-1 checkpoint to the launcher, which performs
the conversion and injects the export into all three `init_from` overrides:
```bash
bash examples/train/configs/distribution_matching/kandinsky5/distill_dmd_qat.sh \
outputs/kandinsky5_t2v_qat_finetune/checkpoint-<N>
```
Under the hood it runs exactly:
```bash
python -m fastvideo.train.entrypoint.dcp_to_diffusers \
--checkpoint outputs/kandinsky5_t2v_qat_finetune/checkpoint-<N> \
--output-dir outputs/kandinsky5_t2v_qat_finetune/checkpoint-<N>-diffusers \
--role student --overwrite --verify
```
then launches `dmd2_t2v_480p_qat.yaml` with
`--models.student/teacher/critic.init_from` all pointing at the export.
(Passing an already-exported diffusers directory to the launcher skips the
conversion and uses it directly.)
`--role student` exports the trained transformer weights (the only role
that matters here: student/teacher/critic in stage 2 all load from the same
checkpoint, and `_loading_teacher_critic_model` handles the full-precision
masking for teacher/critic at load time, not at export time). `--verify`
strictly reloads the exported transformer immediately, so a key-mapping bug
fails here instead of deep inside the stage-2 launch.
Only the student is quantized (Attn-QAT); the teacher and critic stay full
precision / dense attention. This is enforced in the loader
(`fastvideo/models/loader/component_loader.py`, via the
`_loading_teacher_critic_model` flag), which masks `quant_config` and clears
`FASTVIDEO_ATTENTION_BACKEND` for teacher/critic -- the same global env var
reaches only the student, with no per-model flags or Kandinsky5-specific
handling. Validation runs the distilled student through
`Kandinsky5DMDPipeline` at the step counts configured in
`callbacks.validation.sampling_steps`.
## Export the stage-2 student for inference
Stage 2 writes raw DCP checkpoints too, so the distilled student must be
exported the same way stage 1 was before anything can load it -- the
`path/to/kandinsky5_dmd_checkpoint` used below is exactly this export:
```bash
python -m fastvideo.train.entrypoint.dcp_to_diffusers \
--checkpoint outputs/kandinsky5_t2v_dmd2_4steps_qat/checkpoint-<N> \
--output-dir outputs/kandinsky5_t2v_dmd2_4steps_qat/checkpoint-<N>-diffusers \
--role student --verify
```
## Inference (NVFP4 / FP8 weight quantization)
Kandinsky5's attention out-projection (`self_attention.out_layer` /
`cross_attention.out_layer`) and FFN (`feed_forward.mlp.fc_in` /
`feed_forward.mlp.fc_out`) names differ from Wan's (`to_out`, `ffn.fc_in` /
`ffn.fc_out`); `to_query`/`to_key`/`to_value` already substring-match the
`to_q`/`to_k`/`to_v` entries the quant configs use for Wan. Both
`nvfp4_qat` and `FP8` now include Kandinsky5's out-projection/FFN names in
their default layer lists (`fastvideo/layers/quantization/nvfp4_qat_config.py`,
`fastvideo/layers/quantization/fp8_config.py`), so no `target_layers`
override is needed:
`dcp_to_diffusers` copies the base T2V checkpoint's `model_index.json`
unchanged into every export (see "Train stage 2" above), so `_class_name`
still says the base T2V pipeline and the registry
(`fastvideo/registry.py`) has no way to auto-detect that a given directory
is actually a DMD (four-step re-noise sampler) export -- it will resolve to
`Kandinsky5T2VPipeline` and run the full-length sampler on DMD-distilled
weights. Pass `override_pipeline_cls_name` and a `Kandinsky5DMDConfig`
explicitly to select the right pipeline/sampler and get
`dmd_denoising_steps` set (`Kandinsky5T2VConfig`'s default of `None` makes
`Kandinsky5DmdDenoisingStage` raise immediately):
```python
from fastvideo import VideoGenerator
from fastvideo.configs.pipelines.kandinsky5 import Kandinsky5DMDConfig
from fastvideo.layers.quantization import get_quantization_config
gen = VideoGenerator.from_pretrained(
"path/to/kandinsky5_dmd_checkpoint", num_gpus=1,
override_pipeline_cls_name="Kandinsky5DMDPipeline",
pipeline_config=Kandinsky5DMDConfig(),
transformer_quant=get_quantization_config("nvfp4_qat")(),
use_fsdp_inference=False,
)
# Kandinsky5DmdDenoisingStage ignores request.sampling.num_inference_steps
# and guidance_scale -- it's a fixed 4-step, no-CFG sampler driven entirely
# by pipeline_config.dmd_denoising_steps (set above). Adjust
# Kandinsky5DMDConfig(dmd_denoising_steps=[...]) instead if the checkpoint
# was distilled/validated with a different schedule than the default
# [1000, 750, 500, 250].
gen.generate(request={"prompt": "...", "output": {"save_video": True}})
```
As with Wan, combine with `FASTVIDEO_ATTENTION_BACKEND=ATTN_QAT_INFER` on an
sm_120 GPU for the full 4-bit path; on other GPUs the attention falls back
to a supported dense backend while the FP4 linear layers still run.
`flashinfer` is required for the FP4 path.
## Tests
- `fastvideo/tests/train/models/test_load_kandinsky5.py` -- loads the real
checkpoint and runs one transformer forward pass.
- `fastvideo/tests/train/models/test_kandinsky5_qat_attention_engages.py` --
confirms `ATTN_QAT_TRAIN` actually engages (not a silent SDPA fallback)
and that gradients flow through a forward+backward pass.
- `fastvideo/tests/api/test_kandinsky5_dmd_pipeline_resolution.py` --
confirms an unmodified export resolves to `Kandinsky5T2VPipeline` (the bug)
and that `override_pipeline_cls_name="Kandinsky5DMDPipeline"` fixes it (the
documented workaround above), plus `Kandinsky5DMDConfig`'s
`dmd_denoising_steps` default.
- `fastvideo/tests/nightly/test_e2e_kandinsky5_dmd_t2v_overfit.py` -- a few
steps of both training stages on a single synthetic sample, exercising the
whole recipe end to end: the `dcp_to_diffusers --verify` conversion
between the stages AND of the final stage-2 student, then reloading that
export through the documented `Kandinsky5DMDPipeline` /
`Kandinsky5DMDConfig` override, generating deterministically (fixed
seed), and comparing MS-SSIM against a committed reference video. The
test fails (does not skip) if the reference is missing; record it once on
a sanctioned GPU box with `KANDINSKY5_E2E_WRITE_REFERENCE=1`, review the
written video, and commit it (see the test's module docstring). Nightly
tests are not collected per-PR (`fastvideo/tests/contract/
test_ci_test_collection.py` allowlists the directory), so run it
explicitly:
```bash
pytest fastvideo/tests/nightly/test_e2e_kandinsky5_dmd_t2v_overfit.py -vs
```
@@ -1,33 +0,0 @@
#!/bin/bash
# QAD recipe -- quantization-aware finetune of Kandinsky5 T2V (480p) with
# fake-quant (Attn-QAT) attention.
#
# The fake-quantized attention path is selected purely by env var
# (config-driven, no monkey-patching): FASTVIDEO_ATTENTION_BACKEND=ATTN_QAT_TRAIN
# routes Kandinsky5's dense/local attention through the fake-quantized
# straight-through-estimator kernel, so the DiT learns to absorb the
# quantization error instead of fighting it. No weight quantization happens
# during this stage; that's applied post-hoc at inference time (see the
# stage-2 config and README).
#
# Scope: T2V, 480p only, dense/local attention only -- NABLA sparse attention
# is never engaged by Kandinsky5Model at this resolution.
#
# Data: preprocess with fastvideo/pipelines/preprocess/preprocess_kandinsky5_overfit.py
# (or an equivalent parquet dataset matching pyarrow_schema_t2v) first.
#
# Next: this writes a raw DCP checkpoint (checkpoint-N/dcp + metadata/RNG
# state), not something stage 2 can load directly. Pass that checkpoint to
# ../../distribution_matching/kandinsky5/distill_dmd_qat.sh, which converts
# it with fastvideo.train.entrypoint.dcp_to_diffusers (--verify) and feeds
# the export to all three models.*.init_from overrides automatically.
set -euo pipefail
export FASTVIDEO_ATTENTION_BACKEND=ATTN_QAT_TRAIN # <-- enables Attn-QAT training
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../../.." && pwd)"
bash "${REPO_ROOT}/examples/train/run.sh" \
"${SCRIPT_DIR}/t2v_480p_qat.yaml" \
"$@"
@@ -1,85 +0,0 @@
# Kandinsky5 T2V 480p stage-1 QAT finetune config.
#
# QAT here means attention-only fake-quant during training: launch with
# FASTVIDEO_ATTENTION_BACKEND=ATTN_QAT_TRAIN set (see finetune_qat.sh), which
# routes Kandinsky5's dense/local attention through the fake-quantized
# straight-through-estimator kernel. No weight quantization happens during
# training; that's applied post-hoc at inference time via the nvfp4_qat/FP8
# quant configs (see the stage-2 config and README for that step).
#
# Scope: T2V, 480p only, dense/local attention only -- NABLA sparse attention
# is never engaged by Kandinsky5Model at this resolution.
#
# Data must be preprocessed with Kandinsky5's VAE (shared with HunyuanVideo)
# + dual text encoders (Qwen/Reason1 + CLIP) into parquet format before
# training. See fastvideo/pipelines/preprocess/preprocess_kandinsky5_overfit.py.
models:
student:
_target_: fastvideo.train.models.kandinsky5.Kandinsky5Model
init_from: kandinskylab/Kandinsky-5.0-T2V-Lite-sft-5s-Diffusers
trainable: true
method:
_target_: fastvideo.train.methods.fine_tuning.finetune.FineTuneMethod
training:
distributed:
num_gpus: 8
sp_size: 1
tp_size: 1
hsdp_replicate_dim: 1
hsdp_shard_dim: 8
data:
data_path: data/kandinsky5_overfit_preprocessed
dataloader_num_workers: 4
train_batch_size: 1
training_cfg_rate: 0.1
seed: 1000
num_latent_t: 31
num_height: 512
num_width: 768
num_frames: 121
optimizer:
learning_rate: 5.0e-5
betas: [0.9, 0.999]
weight_decay: 1.0e-4
lr_scheduler: constant
lr_warmup_steps: 0
loop:
max_train_steps: 2000
gradient_accumulation_steps: 1
checkpoint:
output_dir: outputs/kandinsky5_t2v_qat_finetune
training_state_checkpointing_steps: 500
checkpoints_total_limit: 3
resume_from_checkpoint: latest
tracker:
project_name: fastvideo_kandinsky5
run_name: kandinsky5_t2v_qat_finetune
model:
enable_gradient_checkpointing_type: full
callbacks:
grad_clip:
_target_: fastvideo.train.callbacks.grad_clip.GradNormClipCallback
max_grad_norm: 1.0
validation:
_target_: fastvideo.train.callbacks.validation.ValidationCallback
pipeline_target: fastvideo.pipelines.basic.kandinsky5.kandinsky5_pipeline.Kandinsky5T2VPipeline
dataset_file: data/kandinsky5_overfit_preprocessed/validation_prompts.json
every_steps: 250
# Cut way down from 50 while shaking out validation-path bugs -- at
# 512x768x121 each step is ~30s, so 50 steps/sample x 6 samples/rank
# was ~2.5h per validation round. Bump back to 50 for real runs.
sampling_steps: [25]
guidance_scale: 6.0
pipeline:
flow_shift: 5
@@ -1,140 +0,0 @@
# LTX-2 T2V NVFP4 quantization-aware fine-tune (QAT).
#
# Same recipe as fine_tuning/ltx2/t2v.yaml, with the deployment-matched
# NVFP4 quantization from the validated
# examples/train/configs/overfit_ltx2_t2v_nvfp4_qat.yaml run: the curated
# attention/FFN linear set used by LTX-2 NVFP4 deployment runs its
# forward through real NVFP4 GEMMs (flashinfer cutlass) with a
# straight-through-estimator backward into the dense fp32 master weights.
# No extra parameters or buffers are added, so FSDP sharding,
# checkpointing, and export are identical to dense training. Video
# attention uses ATTN_QAT_TRAIN for its quantized forward and STE
# backward, then ATTN_QAT_INFER during validation. Head-dim-64 audio
# attention and masked text attention remain dense.
#
# Validation-time ATTN_QAT_INFER is arch-aware:
# * sm_120 / sm_121: the bundled fastvideo-kernel CUTLASS extension.
# * sm_100 (GB200-class) / sm_103 (GB300-class): the FP4 FA4 kernel —
# requires the flash-attention-fp4 fork and its dependency pins; see
# docs/inference/optimizations.md.
# * any other GPU: falls back to flash attention with a logged receipt
# (validation still runs, just not with the quantized kernel).
#
# Prerequisites: preprocess your dataset into the trainer's latent format
# (see docs/training/data_preprocess.md;
# fastvideo/pipelines/preprocess/preprocess_ltx2_overfit.py is a minimal
# LTX-2 reference to adapt). Data paths, step counts, and learning rate
# below are placeholders you must adapt to your workload.
#
# Run (multi-GPU; see the validation note above for kernel availability):
# NUM_GPUS=4 bash examples/train/run.sh \
# examples/train/configs/fine_tuning/ltx2/nvfp4_qat_t2v.yaml
#
# `attn_qat_infer: true` below is the deployment-representative choice on
# every arch with a quantized kernel available (see the validation note
# above). Flip it off only when your GPU has no ATTN_QAT_INFER kernel and
# you prefer validating with the train-time backend instead of the flash
# fallback:
# NUM_GPUS=4 bash examples/train/run.sh \
# examples/train/configs/fine_tuning/ltx2/nvfp4_qat_t2v.yaml \
# --callbacks.validation.attn_qat_infer false
models:
student:
_target_: fastvideo.train.models.ltx2.LTX2Model
# Validation settings below (8 steps, guidance 1.0) assume this
# distilled checkpoint.
init_from: FastVideo/LTX2-Distilled-Diffusers
trainable: true
enable_gradient_checkpointing_type: full
attention_backend: ATTN_QAT_TRAIN
method:
_target_: fastvideo.train.methods.fine_tuning.finetune.FineTuneMethod
training:
distributed:
# The 13B video branch needs sharding; 4-way HSDP shard matches the
# validated LTX-2 runs. Scale num_gpus/hsdp_shard_dim together.
num_gpus: 4
sp_size: 1
tp_size: 1
hsdp_replicate_dim: 1
hsdp_shard_dim: 4
data:
# REQUIRED: path to your preprocessed dataset.
data_path: data/your_dataset_preprocessed
dataloader_num_workers: 4
train_batch_size: 1
# LTX2Model requires 0.0: CFG dropout would zero post-connector
# embeddings, which is not the model's unconditional input.
training_cfg_rate: 0.0
seed: 42
# Must match your preprocessed resolution/length.
# num_latent_t = (num_frames - 1) / 8 + 1.
num_latent_t: 11
num_height: 480
num_width: 832
num_frames: 81
optimizer:
# Carried from the validated LTX-2 QAT overfit run as a starting
# point; tune for your dataset size and batch configuration.
learning_rate: 5.0e-5
betas: [0.9, 0.999]
weight_decay: 0.0
lr_scheduler: constant
lr_warmup_steps: 0
loop:
# Workload-dependent: set from your dataset size and target epochs.
max_train_steps: 2000
gradient_accumulation_steps: 1
checkpoint:
output_dir: outputs/ltx2_t2v_nvfp4_qat_finetune
# A full training-state checkpoint is ~150GB for the 13B trainable
# video branch — size the interval and total limit to your storage.
training_state_checkpointing_steps: 500
checkpoints_total_limit: 2
resume_from_checkpoint: latest
tracker:
trackers: [wandb]
project_name: fastvideo_ltx2
run_name: ltx2_t2v_nvfp4_qat_finetune
model:
# LTX2Model.predict_noise converts the DiT's x0 output to velocity,
# so the default noise-minus-clean target reproduces the official
# unweighted masked-MSE (mask is all-ones for plain T2V).
precondition_outputs: false
enable_gradient_checkpointing_type: full
callbacks:
grad_clip:
_target_: fastvideo.train.callbacks.grad_clip.GradNormClipCallback
max_grad_norm: 1.0
validation:
_target_: fastvideo.train.callbacks.validation.ValidationCallback
pipeline_target: fastvideo.pipelines.basic.ltx2.ltx2_pipeline.LTX2Pipeline
# REQUIRED: prompts to sample during training.
dataset_file: data/your_dataset_preprocessed/validation_prompts.json
every_steps: 250
# 8-step single-pass sampling matches the distilled checkpoint
# (validated in the LTX-2 overfit runs).
sampling_steps: [8]
guidance_scale: 1.0
num_frames: 81
# Validation reuses the live transformer and temporarily replaces its
# ATTN_QAT_TRAIN implementations with the sm120 inference kernel.
# Set false on GB200 (see header).
attn_qat_infer: true
# quant_config selects NVFP4 QAT for the same curated linear prefixes as
# LTX-2 NVFP4 deployment. They fake-quantize through real FP4 GEMMs with
# an STE backward. The string resolves to NVFP4QATTrainConfig at parse.
pipeline:
dit_config:
quant_config: nvfp4_qat_train
@@ -1,110 +0,0 @@
# LTX-2 T2V full fine-tune.
#
# Starting point for fine-tuning LTX-2 on your own dataset with the
# modular trainer. Model wiring (checkpoint, CFG rate, output
# preconditioning, sharding) follows the validated
# examples/train/configs/overfit_ltx2_t2v.yaml run; data paths, step
# counts, and learning rate are placeholders you must adapt — the right
# values depend on your dataset and are marked below.
#
# Prerequisites: preprocess your dataset into the trainer's latent format
# (see docs/training/data_preprocess.md;
# fastvideo/pipelines/preprocess/preprocess_ltx2_overfit.py is a minimal
# LTX-2 reference to adapt).
#
# Run:
# NUM_GPUS=4 bash examples/train/run.sh \
# examples/train/configs/fine_tuning/ltx2/t2v.yaml
models:
student:
_target_: fastvideo.train.models.ltx2.LTX2Model
# Validation settings below (8 steps, guidance 1.0) assume this
# distilled checkpoint.
init_from: FastVideo/LTX2-Distilled-Diffusers
trainable: true
enable_gradient_checkpointing_type: full
method:
_target_: fastvideo.train.methods.fine_tuning.finetune.FineTuneMethod
training:
distributed:
# The 13B video branch needs sharding; 4-way HSDP shard matches the
# validated LTX-2 runs. Scale num_gpus/hsdp_shard_dim together.
num_gpus: 4
sp_size: 1
tp_size: 1
hsdp_replicate_dim: 1
hsdp_shard_dim: 4
data:
# REQUIRED: path to your preprocessed dataset.
data_path: data/your_dataset_preprocessed
dataloader_num_workers: 4
train_batch_size: 1
# LTX2Model requires 0.0: CFG dropout would zero post-connector
# embeddings, which is not the model's unconditional input.
training_cfg_rate: 0.0
seed: 42
# Must match your preprocessed resolution/length.
# num_latent_t = (num_frames - 1) / 8 + 1.
num_latent_t: 11
num_height: 480
num_width: 832
num_frames: 81
optimizer:
# Carried from the validated LTX-2 overfit run as a starting point;
# tune for your dataset size and batch configuration.
learning_rate: 5.0e-5
betas: [0.9, 0.999]
weight_decay: 0.0
lr_scheduler: constant
lr_warmup_steps: 0
loop:
# Workload-dependent: set from your dataset size and target epochs.
max_train_steps: 2000
gradient_accumulation_steps: 1
checkpoint:
output_dir: outputs/ltx2_t2v_finetune
# A full training-state checkpoint is ~150GB for the 13B trainable
# video branch — size the interval and total limit to your storage.
training_state_checkpointing_steps: 500
checkpoints_total_limit: 2
resume_from_checkpoint: latest
tracker:
trackers: [wandb]
project_name: fastvideo_ltx2
run_name: ltx2_t2v_finetune
model:
# LTX2Model.predict_noise converts the DiT's x0 output to velocity,
# so the default noise-minus-clean target reproduces the official
# unweighted masked-MSE (mask is all-ones for plain T2V).
precondition_outputs: false
enable_gradient_checkpointing_type: full
callbacks:
grad_clip:
_target_: fastvideo.train.callbacks.grad_clip.GradNormClipCallback
max_grad_norm: 1.0
validation:
_target_: fastvideo.train.callbacks.validation.ValidationCallback
pipeline_target: fastvideo.pipelines.basic.ltx2.ltx2_pipeline.LTX2Pipeline
# REQUIRED: prompts to sample during training.
dataset_file: data/your_dataset_preprocessed/validation_prompts.json
every_steps: 250
# 8-step single-pass sampling matches the distilled checkpoint
# (validated in the LTX-2 overfit runs).
sampling_steps: [8]
guidance_scale: 1.0
num_frames: 81
# Required so the LTX2T2VConfig pipeline config is resolved from
# init_from (without a `pipeline:` key the loader falls back to a
# generic PipelineConfig and the LTX-2 DiT cannot be constructed).
pipeline: {}
@@ -1,114 +0,0 @@
# LTX-2 T2V LoRA fine-tune.
#
# Same recipe as fine_tuning/ltx2/t2v.yaml, but only LoRA adapters on the
# attention projections train — the base weights stay frozen, so
# training-state checkpoints are adapter-sized instead of ~150GB.
# See that file's header for prerequisites and placeholder notes.
#
# Run:
# NUM_GPUS=4 bash examples/train/run.sh \
# examples/train/configs/fine_tuning/ltx2/t2v_lora.yaml
models:
student:
_target_: fastvideo.train.models.ltx2.LTX2Model
# Validation settings below (8 steps, guidance 1.0) assume this
# distilled checkpoint.
init_from: FastVideo/LTX2-Distilled-Diffusers
trainable: true
enable_gradient_checkpointing_type: full
lora:
enable: true
# rank/alpha and the attention-projection target set follow the
# Wan/Hunyuan LoRA recipes; targets match by substring against
# module names (LTX-2 blocks name theirs to_q/to_k/to_v/to_out).
rank: 16
alpha: 32
target_modules:
- to_q
- to_k
- to_v
- to_out
method:
_target_: fastvideo.train.methods.fine_tuning.finetune.FineTuneMethod
training:
distributed:
# The 13B video branch needs sharding even with frozen base weights;
# 4-way HSDP shard matches the validated LTX-2 runs.
num_gpus: 4
sp_size: 1
tp_size: 1
hsdp_replicate_dim: 1
hsdp_shard_dim: 4
data:
# REQUIRED: path to your preprocessed dataset.
data_path: data/your_dataset_preprocessed
dataloader_num_workers: 4
train_batch_size: 1
# LTX2Model requires 0.0: CFG dropout would zero post-connector
# embeddings, which is not the model's unconditional input.
training_cfg_rate: 0.0
seed: 42
# Must match your preprocessed resolution/length.
# num_latent_t = (num_frames - 1) / 8 + 1.
num_latent_t: 11
num_height: 480
num_width: 832
num_frames: 81
optimizer:
# LoRA-only training usually tolerates a higher LR than full
# fine-tuning (the Wan/Hunyuan LoRA recipes use 1.0e-4); tune for
# your dataset.
learning_rate: 1.0e-4
betas: [0.9, 0.999]
weight_decay: 0.0
lr_scheduler: constant
lr_warmup_steps: 0
loop:
# Workload-dependent: set from your dataset size and target epochs.
max_train_steps: 2000
gradient_accumulation_steps: 1
checkpoint:
output_dir: outputs/ltx2_t2v_lora_finetune
training_state_checkpointing_steps: 500
checkpoints_total_limit: 2
resume_from_checkpoint: latest
tracker:
trackers: [wandb]
project_name: fastvideo_ltx2
run_name: ltx2_t2v_lora_finetune
model:
# LTX2Model.predict_noise converts the DiT's x0 output to velocity,
# so the default noise-minus-clean target reproduces the official
# unweighted masked-MSE (mask is all-ones for plain T2V).
precondition_outputs: false
enable_gradient_checkpointing_type: full
callbacks:
grad_clip:
_target_: fastvideo.train.callbacks.grad_clip.GradNormClipCallback
max_grad_norm: 1.0
validation:
_target_: fastvideo.train.callbacks.validation.ValidationCallback
pipeline_target: fastvideo.pipelines.basic.ltx2.ltx2_pipeline.LTX2Pipeline
# REQUIRED: prompts to sample during training.
dataset_file: data/your_dataset_preprocessed/validation_prompts.json
every_steps: 250
# 8-step single-pass sampling matches the distilled checkpoint
# (validated in the LTX-2 overfit runs).
sampling_steps: [8]
guidance_scale: 1.0
num_frames: 81
# Required so the LTX2T2VConfig pipeline config is resolved from
# init_from (without a `pipeline:` key the loader falls back to a
# generic PipelineConfig and the LTX-2 DiT cannot be constructed).
pipeline: {}
@@ -1,151 +0,0 @@
# LTX-2.3 T2V NVFP4 quantization-aware fine-tune (QAT).
#
# Same recipe as fine_tuning/ltx2_3/t2v.yaml, with the deployment-matched
# NVFP4 quantization wiring carried from the validated LTX-2.0 QAT run
# (examples/train/configs/overfit_ltx2_t2v_nvfp4_qat.yaml): the curated
# attention/FFN linear set runs its forward through real NVFP4 GEMMs
# (flashinfer cutlass) with a straight-through-estimator backward into
# the dense fp32 master weights. No extra parameters or buffers are
# added, so FSDP sharding, checkpointing, and export are identical to
# dense training. Video attention uses ATTN_QAT_TRAIN for its quantized
# forward and STE backward, then ATTN_QAT_INFER during validation.
# Head-dim-64 audio attention and masked text attention remain dense.
#
# LTX-2.3 applicability: the curated linear prefix set
# (fastvideo/layers/quantization/nvfp4_config.py) targets
# ltx2.blocks.{0..47}.* — LTX-2.3 uses the same LTX-2 transformer class,
# module naming, and 48-block depth, so the same 576 linears attach.
# CAVEAT: the set was curated against LTX-2.0 NVFP4 deployment, and
# LTX-2.3 QAT training has no validated overfit run yet. The 2.3
# gated-attention to_gate_logits projections are not in the set and
# remain dense.
#
# Validation-time ATTN_QAT_INFER is arch-aware:
# * sm_120 / sm_121: the bundled fastvideo-kernel CUTLASS extension.
# * sm_100 (GB200-class) / sm_103 (GB300-class): the FP4 FA4 kernel —
# requires the flash-attention-fp4 fork and its dependency pins; see
# docs/inference/optimizations.md.
# * any other GPU: falls back to flash attention with a logged receipt
# (validation still runs, just not with the quantized kernel).
#
# Prerequisites: preprocess your dataset into the trainer's latent format
# (see docs/training/data_preprocess.md;
# fastvideo/pipelines/preprocess/preprocess_ltx2_overfit.py is a minimal
# LTX-2 reference to adapt — set
# LTX2_OVERFIT_MODEL=FastVideo/LTX-2.3-Distilled-Diffusers). Data paths,
# step counts, and learning rate below are placeholders you must adapt
# to your workload.
#
# Run (multi-GPU; see the validation note above for kernel availability):
# NUM_GPUS=4 bash examples/train/run.sh \
# examples/train/configs/fine_tuning/ltx2_3/nvfp4_qat_t2v.yaml
#
# `attn_qat_infer: true` below is the deployment-representative choice on
# every arch with a quantized kernel available (see the validation note
# above). Flip it off only when your GPU has no ATTN_QAT_INFER kernel and
# you prefer validating with the train-time backend instead of the flash
# fallback:
# NUM_GPUS=4 bash examples/train/run.sh \
# examples/train/configs/fine_tuning/ltx2_3/nvfp4_qat_t2v.yaml \
# --callbacks.validation.attn_qat_infer false
models:
student:
_target_: fastvideo.train.models.ltx2.LTX2Model
# Validation settings below (8 steps, guidance 1.0) assume this
# distilled checkpoint.
init_from: FastVideo/LTX-2.3-Distilled-Diffusers
trainable: true
enable_gradient_checkpointing_type: full
attention_backend: ATTN_QAT_TRAIN
method:
_target_: fastvideo.train.methods.fine_tuning.finetune.FineTuneMethod
training:
distributed:
# The 13B video branch needs sharding; 4-way HSDP shard matches the
# validated LTX-2.3 runs. Scale num_gpus/hsdp_shard_dim together.
num_gpus: 4
sp_size: 1
tp_size: 1
hsdp_replicate_dim: 1
hsdp_shard_dim: 4
data:
# REQUIRED: path to your preprocessed dataset.
data_path: data/your_dataset_preprocessed
dataloader_num_workers: 4
train_batch_size: 1
# LTX2Model requires 0.0: CFG dropout would zero post-connector
# embeddings, which is not the model's unconditional input.
training_cfg_rate: 0.0
seed: 42
# Must match your preprocessed resolution/length.
# num_latent_t = (num_frames - 1) / 8 + 1.
num_latent_t: 11
num_height: 480
num_width: 832
num_frames: 81
optimizer:
# Carried from the validated LTX-2.0 QAT overfit run as a starting
# point; tune for your dataset size and batch configuration.
learning_rate: 5.0e-5
betas: [0.9, 0.999]
weight_decay: 0.0
lr_scheduler: constant
lr_warmup_steps: 0
loop:
# Workload-dependent: set from your dataset size and target epochs.
max_train_steps: 2000
gradient_accumulation_steps: 1
checkpoint:
output_dir: outputs/ltx2_3_t2v_nvfp4_qat_finetune
# A full training-state checkpoint is ~150GB for the 13B trainable
# video branch — size the interval and total limit to your storage.
training_state_checkpointing_steps: 500
checkpoints_total_limit: 2
resume_from_checkpoint: latest
tracker:
trackers: [wandb]
project_name: fastvideo_ltx2
run_name: ltx2_3_t2v_nvfp4_qat_finetune
model:
# LTX2Model.predict_noise converts the DiT's x0 output to velocity,
# so the default noise-minus-clean target reproduces the official
# unweighted masked-MSE (mask is all-ones for plain T2V).
precondition_outputs: false
enable_gradient_checkpointing_type: full
callbacks:
grad_clip:
_target_: fastvideo.train.callbacks.grad_clip.GradNormClipCallback
max_grad_norm: 1.0
validation:
_target_: fastvideo.train.callbacks.validation.ValidationCallback
pipeline_target: fastvideo.pipelines.basic.ltx2.ltx2_pipeline.LTX2Pipeline
# REQUIRED: prompts to sample during training.
dataset_file: data/your_dataset_preprocessed/validation_prompts.json
every_steps: 250
# 8-step single-pass sampling matches the distilled checkpoint
# (validated in the LTX-2.3 overfit runs).
sampling_steps: [8]
guidance_scale: 1.0
num_frames: 81
# Validation reuses the live transformer and temporarily replaces its
# ATTN_QAT_TRAIN implementations with the sm120 inference kernel.
# Set false on GB200 (see header).
attn_qat_infer: true
# quant_config selects NVFP4 QAT for the same curated linear prefixes as
# LTX-2 NVFP4 deployment (see the LTX-2.3 applicability note in the
# header). They fake-quantize through real FP4 GEMMs with an STE
# backward. The string resolves to NVFP4QATTrainConfig at parse.
pipeline:
dit_config:
quant_config: nvfp4_qat_train
@@ -1,112 +0,0 @@
# LTX-2.3 T2V full fine-tune.
#
# Starting point for fine-tuning LTX-2.3 (gated attention, cross-attn
# AdaLN, 4096-d post-connector text embeddings, no in-DiT caption
# projection) on your own dataset with the modular trainer. Model wiring
# (checkpoint, CFG rate, output preconditioning, sharding) follows the
# validated examples/train/configs/overfit_ltx2_3_t2v.yaml run; data
# paths, step counts, and learning rate are placeholders you must
# adapt — the right values depend on your dataset and are marked below.
#
# Prerequisites: preprocess your dataset into the trainer's latent format
# (see docs/training/data_preprocess.md;
# fastvideo/pipelines/preprocess/preprocess_ltx2_overfit.py is a minimal
# LTX-2 reference to adapt — set
# LTX2_OVERFIT_MODEL=FastVideo/LTX-2.3-Distilled-Diffusers).
#
# Run:
# NUM_GPUS=4 bash examples/train/run.sh \
# examples/train/configs/fine_tuning/ltx2_3/t2v.yaml
models:
student:
_target_: fastvideo.train.models.ltx2.LTX2Model
# Validation settings below (8 steps, guidance 1.0) assume this
# distilled checkpoint.
init_from: FastVideo/LTX-2.3-Distilled-Diffusers
trainable: true
enable_gradient_checkpointing_type: full
method:
_target_: fastvideo.train.methods.fine_tuning.finetune.FineTuneMethod
training:
distributed:
# The 13B video branch needs sharding; 4-way HSDP shard matches the
# validated LTX-2.3 runs. Scale num_gpus/hsdp_shard_dim together.
num_gpus: 4
sp_size: 1
tp_size: 1
hsdp_replicate_dim: 1
hsdp_shard_dim: 4
data:
# REQUIRED: path to your preprocessed dataset.
data_path: data/your_dataset_preprocessed
dataloader_num_workers: 4
train_batch_size: 1
# LTX2Model requires 0.0: CFG dropout would zero post-connector
# embeddings, which is not the model's unconditional input.
training_cfg_rate: 0.0
seed: 42
# Must match your preprocessed resolution/length.
# num_latent_t = (num_frames - 1) / 8 + 1.
num_latent_t: 11
num_height: 480
num_width: 832
num_frames: 81
optimizer:
# Carried from the validated LTX-2.3 overfit run as a starting point;
# tune for your dataset size and batch configuration.
learning_rate: 5.0e-5
betas: [0.9, 0.999]
weight_decay: 0.0
lr_scheduler: constant
lr_warmup_steps: 0
loop:
# Workload-dependent: set from your dataset size and target epochs.
max_train_steps: 2000
gradient_accumulation_steps: 1
checkpoint:
output_dir: outputs/ltx2_3_t2v_finetune
# A full training-state checkpoint is ~150GB for the 13B trainable
# video branch — size the interval and total limit to your storage.
training_state_checkpointing_steps: 500
checkpoints_total_limit: 2
resume_from_checkpoint: latest
tracker:
trackers: [wandb]
project_name: fastvideo_ltx2
run_name: ltx2_3_t2v_finetune
model:
# LTX2Model.predict_noise converts the DiT's x0 output to velocity,
# so the default noise-minus-clean target reproduces the official
# unweighted masked-MSE (mask is all-ones for plain T2V).
precondition_outputs: false
enable_gradient_checkpointing_type: full
callbacks:
grad_clip:
_target_: fastvideo.train.callbacks.grad_clip.GradNormClipCallback
max_grad_norm: 1.0
validation:
_target_: fastvideo.train.callbacks.validation.ValidationCallback
pipeline_target: fastvideo.pipelines.basic.ltx2.ltx2_pipeline.LTX2Pipeline
# REQUIRED: prompts to sample during training.
dataset_file: data/your_dataset_preprocessed/validation_prompts.json
every_steps: 250
# 8-step single-pass sampling matches the distilled checkpoint
# (validated in the LTX-2.3 overfit runs).
sampling_steps: [8]
guidance_scale: 1.0
num_frames: 81
# Required so the LTX2T2VConfig pipeline config is resolved from
# init_from (without a `pipeline:` key the loader falls back to a
# generic PipelineConfig and the LTX-2 DiT cannot be constructed).
pipeline: {}
@@ -1,116 +0,0 @@
# LTX-2.3 T2V LoRA fine-tune.
#
# Same recipe as fine_tuning/ltx2_3/t2v.yaml, but only LoRA adapters on
# the attention projections train — the base weights stay frozen, so
# training-state checkpoints are adapter-sized instead of ~150GB.
# See that file's header for prerequisites and placeholder notes.
#
# Run:
# NUM_GPUS=4 bash examples/train/run.sh \
# examples/train/configs/fine_tuning/ltx2_3/t2v_lora.yaml
models:
student:
_target_: fastvideo.train.models.ltx2.LTX2Model
# Validation settings below (8 steps, guidance 1.0) assume this
# distilled checkpoint.
init_from: FastVideo/LTX-2.3-Distilled-Diffusers
trainable: true
enable_gradient_checkpointing_type: full
lora:
enable: true
# rank/alpha and the attention-projection target set follow the
# Wan/Hunyuan LoRA recipes; targets match by substring against
# module names (LTX-2.3 blocks name theirs to_q/to_k/to_v/to_out;
# the 2.3 gated-attention to_gate_logits projection is not
# targeted).
rank: 16
alpha: 32
target_modules:
- to_q
- to_k
- to_v
- to_out
method:
_target_: fastvideo.train.methods.fine_tuning.finetune.FineTuneMethod
training:
distributed:
# The 13B video branch needs sharding even with frozen base weights;
# 4-way HSDP shard matches the validated LTX-2.3 runs.
num_gpus: 4
sp_size: 1
tp_size: 1
hsdp_replicate_dim: 1
hsdp_shard_dim: 4
data:
# REQUIRED: path to your preprocessed dataset.
data_path: data/your_dataset_preprocessed
dataloader_num_workers: 4
train_batch_size: 1
# LTX2Model requires 0.0: CFG dropout would zero post-connector
# embeddings, which is not the model's unconditional input.
training_cfg_rate: 0.0
seed: 42
# Must match your preprocessed resolution/length.
# num_latent_t = (num_frames - 1) / 8 + 1.
num_latent_t: 11
num_height: 480
num_width: 832
num_frames: 81
optimizer:
# LoRA-only training usually tolerates a higher LR than full
# fine-tuning (the Wan/Hunyuan LoRA recipes use 1.0e-4); tune for
# your dataset.
learning_rate: 1.0e-4
betas: [0.9, 0.999]
weight_decay: 0.0
lr_scheduler: constant
lr_warmup_steps: 0
loop:
# Workload-dependent: set from your dataset size and target epochs.
max_train_steps: 2000
gradient_accumulation_steps: 1
checkpoint:
output_dir: outputs/ltx2_3_t2v_lora_finetune
training_state_checkpointing_steps: 500
checkpoints_total_limit: 2
resume_from_checkpoint: latest
tracker:
trackers: [wandb]
project_name: fastvideo_ltx2
run_name: ltx2_3_t2v_lora_finetune
model:
# LTX2Model.predict_noise converts the DiT's x0 output to velocity,
# so the default noise-minus-clean target reproduces the official
# unweighted masked-MSE (mask is all-ones for plain T2V).
precondition_outputs: false
enable_gradient_checkpointing_type: full
callbacks:
grad_clip:
_target_: fastvideo.train.callbacks.grad_clip.GradNormClipCallback
max_grad_norm: 1.0
validation:
_target_: fastvideo.train.callbacks.validation.ValidationCallback
pipeline_target: fastvideo.pipelines.basic.ltx2.ltx2_pipeline.LTX2Pipeline
# REQUIRED: prompts to sample during training.
dataset_file: data/your_dataset_preprocessed/validation_prompts.json
every_steps: 250
# 8-step single-pass sampling matches the distilled checkpoint
# (validated in the LTX-2.3 overfit runs).
sampling_steps: [8]
guidance_scale: 1.0
num_frames: 81
# Required so the LTX2T2VConfig pipeline config is resolved from
# init_from (without a `pipeline:` key the loader falls back to a
# generic PipelineConfig and the LTX-2 DiT cannot be constructed).
pipeline: {}
@@ -1,123 +0,0 @@
# LTX-2 T2V overfitting test config with NVFP4 quantization-aware
# training (QAT).
#
# Same recipe as overfit_ltx2_t2v.yaml, but the curated attention/FFN
# linear set used by LTX-2 NVFP4 deployment runs its forward through
# real NVFP4 GEMMs (flashinfer cutlass) with a straight-through-estimator
# backward into the dense fp32 master weights. No extra parameters or
# buffers are added, so FSDP sharding, checkpointing, and export are
# identical to dense training. Video attention uses ATTN_QAT_TRAIN for its
# quantized forward and STE backward, then ATTN_QAT_INFER during validation.
# Head-dim-64 audio attention and masked text attention remain dense.
#
# Validation-time ATTN_QAT_INFER is arch-aware:
# * sm_120a/sm_121a: fastvideo-kernel CUTLASS extension -- the exact
# quantization scheme ATTN_QAT_TRAIN simulates.
# * sm_100a (GB200) / sm_103a (GB300): FP4 FA4 kernel
# (github.com/hao-ai-lab/flash-attention-fp4, branch fp4; per-16
# block-scaled NVFP4 Q/K, BF16 P/V; validated install set:
# nvidia-cutlass-dsl==4.4.2, quack-kernels==0.4.1,
# flashinfer-python==0.6.8, FASTVIDEO_FA4=1 — see
# docs/inference/optimizations.md). This scheme DIFFERS from the
# CUTLASS one the training simulation matches, so sm_100/sm_103
# validation and deployment carry a train-sim mismatch -- gate quality
# by MS-SSIM measurement rather than assuming parity. The resolution
# receipt ("ATTN_QAT_INFER resolved: ...") records arch + scheme.
#
# Preprocess data first (same data as the bf16 overfit):
# CUDA_VISIBLE_DEVICES=0 python fastvideo/pipelines/preprocess/preprocess_ltx2_overfit.py
#
# Run on multi-GPU sm_120 hardware:
# NUM_GPUS=4 \
# bash examples/train/run.sh examples/train/configs/overfit_ltx2_t2v_nvfp4_qat.yaml
#
# On GB200 without flash-attention-fp4 installed (or any other arch with no
# ATTN_QAT_INFER kernel), disable only the validation-time swap:
# NUM_GPUS=4 \
# bash examples/train/run.sh examples/train/configs/overfit_ltx2_t2v_nvfp4_qat.yaml \
# --callbacks.validation.attn_qat_infer false
models:
student:
_target_: fastvideo.train.models.ltx2.LTX2Model
init_from: FastVideo/LTX2-Distilled-Diffusers
trainable: true
enable_gradient_checkpointing_type: full
attention_backend: ATTN_QAT_TRAIN
method:
_target_: fastvideo.train.methods.fine_tuning.finetune.FineTuneMethod
training:
distributed:
num_gpus: 4
sp_size: 1
tp_size: 1
hsdp_replicate_dim: 1
hsdp_shard_dim: 4
data:
data_path: data/ltx2_overfit_preprocessed
dataloader_num_workers: 0
train_batch_size: 1
# LTX2Model requires 0.0: CFG dropout would zero post-connector
# embeddings, which is not the model's unconditional input.
training_cfg_rate: 0.0
seed: 42
num_latent_t: 11 # (81 - 1) / 8 + 1
num_height: 480
num_width: 832
num_frames: 81
optimizer:
learning_rate: 5.0e-5
betas: [0.9, 0.999]
weight_decay: 0.0
lr_scheduler: constant
lr_warmup_steps: 0
loop:
max_train_steps: 300
gradient_accumulation_steps: 1
checkpoint:
output_dir: outputs/ltx2_overfit_nvfp4_attn_qat
# A full training-state checkpoint is ~150GB for the 13B trainable
# video branch; disable saves for the overfit smoke run.
training_state_checkpointing_steps: 0
checkpoints_total_limit: 1
tracker:
trackers: [wandb]
project_name: fastvideo_ltx2
run_name: ltx2_overfit_nvfp4_attn_qat
model:
# LTX2Model.predict_noise converts the DiT's x0 output to velocity,
# so the default noise-minus-clean target reproduces the official
# unweighted masked-MSE (mask is all-ones for plain T2V).
precondition_outputs: false
enable_gradient_checkpointing_type: full
callbacks:
grad_clip:
_target_: fastvideo.train.callbacks.grad_clip.GradNormClipCallback
max_grad_norm: 1.0
validation:
_target_: fastvideo.train.callbacks.validation.ValidationCallback
pipeline_target: fastvideo.pipelines.basic.ltx2.ltx2_pipeline.LTX2Pipeline
dataset_file: data/ltx2_overfit_preprocessed/validation_prompts.json
every_steps: 50
sampling_steps: [8]
guidance_scale: 1.0
num_frames: 81
# Validation reuses the live transformer and temporarily replaces its
# ATTN_QAT_TRAIN implementations with the sm120 inference kernel.
attn_qat_infer: true
# quant_config selects NVFP4 QAT for the same curated linear prefixes as
# LTX-2 NVFP4 deployment. They fake-quantize through real FP4 GEMMs with
# an STE backward. The string resolves to NVFP4QATTrainConfig at parse.
pipeline:
dit_config:
quant_config: nvfp4_qat_train
+4 -29
View File
@@ -4,7 +4,6 @@ set -ex
# Simple build script wrapping uv/pip
# Usage:
# ./build.sh # local build (torch-based arch detection, TK only on SM90)
# ./build.sh --wheel-dir /tmp/wheels # build + install a reusable wheel
# Environment overrides (if set, they win over auto-detection):
# TORCH_CUDA_ARCH_LIST
# CMAKE_ARGS (for FASTVIDEO_KERNEL_BUILD_TK / CMAKE_CUDA_ARCHITECTURES / GPU_BACKEND)
@@ -68,25 +67,12 @@ fi
# Install build dependencies
uv pip install scikit-build-core cmake ninja
RELEASE=0
GPU_BACKEND=CUDA
WHEEL_DIR=""
while [[ $# -gt 0 ]]; do
case "$1" in
for arg in "$@"; do
case "$arg" in
--rocm)
GPU_BACKEND=ROCM
shift
;;
--wheel-dir)
if [[ $# -lt 2 ]]; then
echo "ERROR: --wheel-dir requires a directory path" >&2
exit 2
fi
WHEEL_DIR="$2"
shift 2
;;
*)
echo "ERROR: unknown argument: $1" >&2
exit 2
;;
esac
done
@@ -179,15 +165,4 @@ echo "CMAKE_ARGS: ${CMAKE_ARGS:-<unset>}"
echo "GPU_BACKEND: ${GPU_BACKEND:-<unset>}"
# Build and install
# Use -v for verbose output
if [ -n "${WHEEL_DIR}" ]; then
mkdir -p "${WHEEL_DIR}"
uv build --wheel -v --no-build-isolation --out-dir "${WHEEL_DIR}" .
wheel_path="$(find "${WHEEL_DIR}" -maxdepth 1 -type f \( -name 'fastvideo_kernel-*.whl' -o -name 'fastvideo-kernel-*.whl' \) | sort | tail -n 1)"
if [ -z "${wheel_path}" ]; then
echo "ERROR: no fastvideo-kernel wheel was built in ${WHEEL_DIR}" >&2
exit 1
fi
uv pip install "${wheel_path}" --reinstall-package fastvideo-kernel --no-deps
else
uv pip install . -v --no-build-isolation
fi
uv pip install . -v --no-build-isolation
+4 -189
View File
@@ -50,143 +50,8 @@ def _get_attn_qat_infer() -> Callable[..., torch.Tensor] | None:
return _attn_qat_infer
# Consumer-Blackwell compute capabilities the modified SageAttention3 FP4
# kernel is compiled for (sm_120a / sm_121a -- see fastvideo-kernel/README.md).
_SUPPORTED_DEVICE_CAPABILITIES = frozenset({(12, 0), (12, 1)})
# Datacenter-Blackwell capabilities served by the FP4 FA4 kernel
# (flash-attention-fp4 @ fp4, sm_100a/sm_103a) through #1221's plumbing:
# per-16 block-scaled NVFP4 Q/K (E4M3 scale factors), BF16 P/V. This is a
# DIFFERENT quantization scheme from the sm_12x CUTLASS extension above --
# ATTN_QAT_TRAIN simulates the CUTLASS scheme, so sm_100/sm_103 deployment
# carries a train-sim mismatch that is measured (MS-SSIM gate), not assumed.
_FA4_FP4_CAPABILITIES = frozenset({(10, 0), (10, 3)})
# The fork is written against the cutlass-dsl 4.4 API surface; the validated
# install set (GB200-proven) is nvidia-cutlass-dsl==4.4.2 +
# nvidia-cutlass-dsl-libs-base==4.4.2 + quack-kernels==0.4.1 +
# flashinfer-python==0.6.8, with the fork on PYTHONPATH,
# CUTE_DSL_ENABLE_TVM_FFI=1, and FASTVIDEO_FA4=1 (the fork ships no compiled
# FA2, so dense attention paths need the FA4 opt-in). dsl 4.6-era installs
# fail at CuTe JIT trace (cute.make_fragment was removed at module level).
_FA4_INSTALL_HINT = ("install flash-attention-fp4 (branch fp4) from "
"https://github.com/hao-ai-lab/flash-attention-fp4 with "
"nvidia-cutlass-dsl==4.4.2, quack-kernels==0.4.1, "
"flashinfer-python==0.6.8 and FASTVIDEO_FA4=1; "
"see docs/inference/optimizations.md")
_fa4_fp4_import_ok: bool | None = None
def _fa4_fp4_available() -> bool:
"""flash_attn.cute (FA4) import probe, cached. Reuses #1221's guarded
import chain in fastvideo.attention.utils.flash_attn_cute (which maps
cutlass-dsl version skew to ImportError with a loud warning)."""
global _fa4_fp4_import_ok
if _fa4_fp4_import_ok is None:
try:
from fastvideo.attention.utils.flash_attn_cute import ( # noqa: F401
flash_attn_fp4_func, )
_fa4_fp4_import_ok = True
except ImportError:
_fa4_fp4_import_ok = False
return _fa4_fp4_import_ok
def _active_capability() -> tuple[int, int] | None:
if not torch.cuda.is_available():
return None
try:
return tuple(torch.cuda.get_device_capability())
except Exception: # pragma: no cover - defensive: never break backend selection
return None
def _resolved_kernel() -> str | None:
"""Which ATTN_QAT_INFER kernel serves the active device, or None.
Per-arch resolution (single source of truth -- extend the capability
sets above, do not add equality checks elsewhere):
* sm_12x consumer Blackwell -> fastvideo-kernel CUTLASS extension
(modified SageAttention3 FP4).
* sm_100a/sm_103a datacenter Blackwell -> FP4 FA4 (flash-attention-fp4)
via the merged #1221 plumbing.
"""
cap = _active_capability()
if cap in _SUPPORTED_DEVICE_CAPABILITIES and _get_attn_qat_infer() is not None:
return "cutlass_sm12x"
if cap in _FA4_FP4_CAPABILITIES and _fa4_fp4_available():
return "fa4_fp4"
return None
def attn_qat_infer_receipt() -> str:
"""One-line receipt of the resolution decision (arch + kernel + quant
knobs), for the selection log and for tooling. The FA4 knobs are the
repo's tuned defaults passed through verbatim: qk_mode=nvfp4
(per-16 E4M3 SFs), pv_mode=bf16 -- see flash_attn/cute/README.md in
the kernel repo."""
cap = _active_capability()
arch = f"sm_{cap[0]}{cap[1]}" if cap is not None else "no-cuda"
kernel = _resolved_kernel()
if kernel == "cutlass_sm12x":
return f"arch={arch} kernel=fastvideo-kernel-cutlass scheme=sage3-fp4-sm120"
if kernel == "fa4_fp4":
return (f"arch={arch} kernel=flash-attention-fp4 qk_mode=nvfp4(per-16-e4m3-sf) "
f"pv_mode=bf16 train_sim_mismatch=measured")
supported = "sm_120a/sm_121a via fastvideo-kernel build.sh; sm_100a/sm_103a via flash-attention-fp4"
if cap is not None and cap in _FA4_FP4_CAPABILITIES:
return f"arch={arch} kernel=none (flash_attn.cute not importable -- {_FA4_INSTALL_HINT})"
return f"arch={arch} kernel=none (supported: {supported})"
_FA4_ROUTE_OPS: tuple | None = None
def _import_fa4_route_ops() -> tuple:
"""Slow path (own function so tests pin it runs once per process):
resolves the FA4 quantize helper and kernel entry point."""
from fastvideo.attention.backends.flash_attn import (
_nvfp4_quantize_for_fa4, )
from fastvideo.attention.utils.flash_attn_cute import (
flash_attn_fp4_func, )
return (_nvfp4_quantize_for_fa4, flash_attn_fp4_func)
def _resolve_fa4_route_ops() -> tuple:
# Lazy but memoized: per-forward resolution graph-breaks dynamo every
# step and blocks fullgraph compilation of the NVFP4 path.
global _FA4_ROUTE_OPS
if _FA4_ROUTE_OPS is None:
_FA4_ROUTE_OPS = _import_fa4_route_ops()
return _FA4_ROUTE_OPS
_receipt_logged = False
def _log_receipt_once() -> None:
# One line per process, not per layer (the validation swap constructs
# one impl per attention layer).
global _receipt_logged
if not _receipt_logged:
_receipt_logged = True
logger.info("ATTN_QAT_INFER resolved: %s", attn_qat_infer_receipt())
def is_attn_qat_infer_available() -> bool:
"""True only when the active device has a built ATTN_QAT_INFER kernel.
The import check alone is not sufficient: CUDA 13 wheel builds can
carry the sm_12x extension on any host (e.g. H100, GB200), where the
import succeeds, backend selection picks this backend, and the first
kernel call then fails with an unsupported-capability error instead of
ever reaching the documented FlashAttention fallback in
fastvideo.platforms.cuda. Gating on the active device's capability
keeps that fallback working on every unsupported GPU, while
sm_100a/sm_103a now resolve to the FP4 FA4 kernel (#1221).
"""
return _resolved_kernel() is not None
return _get_attn_qat_infer() is not None
class AttnQatInferBackend(AttentionBackend):
@@ -232,12 +97,6 @@ class AttnQatInferImpl(AttentionImpl[AttentionMetadata]):
if dropout_p > 0:
raise NotImplementedError(f"attn_qat_infer does not support dropout (got dropout_p={dropout_p}). "
"The QAT inference kernel applies no stochastic dropout.")
# Kernel resolution is per-forward, not per-construction: callers
# (the validation swap, backend selection) gate on
# is_attn_qat_infer_available() first, and constructing an impl on a
# host without the kernel must stay legal (pre-existing contract the
# validation-swap test pins).
_log_receipt_once()
def forward(
self,
@@ -246,18 +105,10 @@ class AttnQatInferImpl(AttentionImpl[AttentionMetadata]):
value: torch.Tensor,
attn_metadata: AttentionMetadata,
) -> torch.Tensor:
# Dispatch on the single per-arch resolution: importability of the
# bundled sm_12x extension is NOT sufficient (CUDA 13 wheels carry it
# on unsupported hosts, where calling it is the wrong binary).
kernel = _resolved_kernel()
if kernel == "fa4_fp4":
return self._forward_fa4_fp4(query, key, value)
if kernel is None:
raise ImportError(f"attn_qat_infer is not available ({attn_qat_infer_receipt()}). "
"Please ensure an ATTN_QAT_INFER kernel is installed for this device.")
attn_qat_infer = _get_attn_qat_infer()
assert attn_qat_infer is not None # kernel == "cutlass_sm12x" implies the import succeeded
if attn_qat_infer is None:
raise ImportError("attn_qat_infer is not available. Please ensure the "
"attn_qat_infer kernel package is installed.")
query = query.transpose(1, 2).contiguous()
key = key.transpose(1, 2).contiguous()
@@ -272,39 +123,3 @@ class AttnQatInferImpl(AttentionImpl[AttentionMetadata]):
sm_scale=self.softmax_scale,
)
return output.transpose(1, 2).contiguous()
def _forward_fa4_fp4(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
) -> torch.Tensor:
"""sm_100a/sm_103a path: FP4 FA4 with the repo's tuned defaults
(NVFP4 per-16 block-scaled Q/K, BF16 V) -- mirrors
FlashAttentionImpl._forward_nvfp4 (#1221). Inputs/outputs are
(batch, seqlen, nheads, headdim); no transpose."""
_nvfp4_quantize_for_fa4, flash_attn_fp4_func = _resolve_fa4_route_ops()
orig_seqlen_q = query.shape[1]
orig_seqlen_k = key.shape[1]
q_fp4, q_sf = _nvfp4_quantize_for_fa4(query)
k_fp4, k_sf = _nvfp4_quantize_for_fa4(key)
# FP4/SF buffers are padded to a 128 multiple; FA4 masks to the
# original lengths so padding never biases the softmax.
q_fp4 = q_fp4[:, :orig_seqlen_q]
k_fp4 = k_fp4[:, :orig_seqlen_k]
output = flash_attn_fp4_func(
q_fp4,
k_fp4,
value,
q_sf,
k_sf,
softmax_scale=self.softmax_scale,
causal=self.causal,
)
if isinstance(output, tuple):
output = output[0]
return output
+2 -61
View File
@@ -32,30 +32,8 @@ except ImportError:
flash_attn_fp4_func = None
_FA4_FP4_AVAILABLE = False
_FA4_QUANT_OPS: tuple | None = None
def _import_fa4_quant_ops() -> tuple:
"""The slow path: resolves flashinfer's FP4 quantization entry points
(imports + JIT-module lookup, which probes the CUDA toolchain via a
subprocess on first use). Kept as its own function so tests can pin
that it runs at most once per process."""
from flashinfer.quantization import SfLayout, nvfp4_quantize
return (nvfp4_quantize, SfLayout)
def _resolve_fa4_quant_ops() -> tuple:
# Lazy (construct-anywhere/fail-at-forward stays intact) but memoized:
# re-resolving per forward graph-breaks dynamo every step (making
# fullgraph compilation impossible for the NVFP4 path) and keeps eager
# dispatch overhead on the hot path.
global _FA4_QUANT_OPS
if _FA4_QUANT_OPS is None:
_FA4_QUANT_OPS = _import_fa4_quant_ops()
return _FA4_QUANT_OPS
def _nvfp4_quantize_for_fa4_impl(tensor_4d: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
def _nvfp4_quantize_for_fa4(tensor_4d: torch.Tensor, ) -> tuple[torch.Tensor, torch.Tensor]:
"""Quantize a (batch, seqlen, nheads, headdim) BF16 tensor to FP4.
Returns:
@@ -64,7 +42,7 @@ def _nvfp4_quantize_for_fa4_impl(tensor_4d: torch.Tensor) -> tuple[torch.Tensor,
Caller should slice [:, :orig_seqlen] before passing to FA4.
sf_tensor: torch.uint8, shape (32, 4, rest_m, 4, rest_k, nheads, batch) with stride[3]=1
"""
nvfp4_quantize, SfLayout = _resolve_fa4_quant_ops()
from flashinfer.quantization import nvfp4_quantize, SfLayout
batch, seqlen, nheads, headdim = tensor_4d.shape
sf_vec_size = 16
@@ -104,43 +82,6 @@ def _nvfp4_quantize_for_fa4_impl(tensor_4d: torch.Tensor) -> tuple[torch.Tensor,
return fp4_tensor, sf_mma
# Dynamo boundary for the FP4 quantize path (same pattern as the masked
# flash-attention entry points in flash_attn_no_pad.py): tracing a python
# body that resolves flashinfer's JIT module descends into its toolchain
# probe (a subprocess) regardless of any runtime memoization -- a cache hit
# is invisible at trace time. Registering the whole quantize step as a
# custom op makes it one opaque graph node, unlocking
# torch.compile(fullgraph=True) for the NVFP4 attention path.
@torch.library.custom_op(
"fastvideo::nvfp4_quantize_fa4",
mutates_args=(),
device_types="cuda",
)
def _nvfp4_quantize_fa4_op(tensor_4d: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
return _nvfp4_quantize_for_fa4_impl(tensor_4d)
@torch.library.register_fake("fastvideo::nvfp4_quantize_fa4")
def _nvfp4_quantize_fa4_fake(tensor_4d: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
batch, seqlen, nheads, headdim = tensor_4d.shape
seqlen_padded = (seqlen + 127) // 128 * 128
fp4 = tensor_4d.new_empty((batch, seqlen_padded, nheads, headdim // 2), dtype=torch.float4_e2m1fn_x2)
rest_m = seqlen_padded // 128
rest_k = (headdim // 16) // 4
# Must reproduce the impl's output STRIDES, not just its shape: the real
# sf is a permuted view of a contiguous (batch, nheads, rest_m, rest_k,
# 32, 4, 4) buffer, and torch.compile bakes the fake's strides into the
# generated code (a contiguous fake here asserts at runtime).
sf = tensor_4d.new_empty((batch, nheads, rest_m, rest_k, 32, 4, 4), dtype=torch.uint8).permute(4, 5, 2, 6, 3, 1, 0)
return fp4, sf
def _nvfp4_quantize_for_fa4(tensor_4d: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
"""Quantize (batch, seqlen, nheads, headdim) BF16 to FP4 via the custom
op boundary; see _nvfp4_quantize_for_fa4_impl for the layout contract."""
return torch.ops.fastvideo.nvfp4_quantize_fa4(tensor_4d)
class FlashAttentionBackend(AttentionBackend):
accept_output_buffer: bool = True
+3 -4
View File
@@ -150,15 +150,14 @@ def _cached_get_attn_backend(
from fastvideo.platforms import current_platform
if (selected_backend is not None and selected_backend not in supported_attention_backends):
fallback_backend = (default_backend if default_backend in supported_attention_backends else None)
logger.warning(
"Requested attention backend %s is not supported by this "
"layer; supported backends are %s. Falling back to %s.",
"layer; supported backends are %s. Falling back to automatic "
"selection.",
selected_backend.name,
[b.name for b in supported_attention_backends],
fallback_backend.name if fallback_backend is not None else "automatic selection",
)
selected_backend = fallback_backend
selected_backend = None
attention_cls = current_platform.get_attn_backend_cls(selected_backend, head_size, dtype)
if not attention_cls:
raise ValueError(f"Invalid attention backend for {current_platform.device_name}")
+5 -10
View File
@@ -42,8 +42,7 @@ except ImportError:
_flash_attn_2_func = None
_flash_attn_2_varlen_func = None
# Dynamo unwraps functools caches, so keep this cache inside the opaque helper.
_SM90_OR_NEWER_BY_DEVICE: dict[int, bool] = {}
_IS_SM90_OR_NEWER = current_platform.has_device_capability(90)
def _check_dropout(dropout_p: float) -> None:
@@ -51,17 +50,13 @@ def _check_dropout(dropout_p: float) -> None:
raise NotImplementedError(f"flash_attn.cute does not support dropout (got dropout_p={dropout_p})")
@torch.compiler.assume_constant_result
def _sm90_or_newer(device_id: int) -> bool:
if device_id not in _SM90_OR_NEWER_BY_DEVICE:
_SM90_OR_NEWER_BY_DEVICE[device_id] = (current_platform.has_device_capability(90, device_id))
return _SM90_OR_NEWER_BY_DEVICE[device_id]
def _sm90_or_newer() -> bool:
# Compiled callers only read a boolean instead of tracing through a memoized platform query.
return _IS_SM90_OR_NEWER
def _use_fa2(q: torch.Tensor, k: torch.Tensor, v: torch.Tensor) -> bool:
device_id = q.device.index
assert device_id is not None
if _sm90_or_newer(device_id):
if _sm90_or_newer():
return False
# Pre-sm90 FA4 cute limitations, both served by FA2 (deterministic
# capability gate, not a runtime fallback):
+2 -5
View File
@@ -13,15 +13,12 @@ def _is_kandinsky5_transformer_block(n: str, m) -> bool:
class Kandinsky5ArchConfig(DiTArchConfig):
_fsdp_shard_conditions: list = field(default_factory=lambda: [_is_kandinsky5_transformer_block])
# NABLA block-sparse attention for attention_type="nabla" checkpoints, the
# dense backends every DiT supports, and the Attn-QAT backends needed for
# quantization-aware finetuning/distillation (fastvideo/train/models/kandinsky5/).
# NABLA block-sparse attention for attention_type="nabla" checkpoints, plus
# the dense backends every DiT supports.
_supported_attention_backends: tuple[AttentionBackendEnum, ...] = (
AttentionBackendEnum.NABLA_ATTN,
AttentionBackendEnum.FLASH_ATTN,
AttentionBackendEnum.TORCH_SDPA,
AttentionBackendEnum.ATTN_QAT_INFER,
AttentionBackendEnum.ATTN_QAT_TRAIN,
)
# Native FastVideo implementation uses the same parameter names as diffusers
@@ -7,18 +7,6 @@ from typing import Any
from fastvideo.configs.models.encoders.base import TextEncoderArchConfig, TextEncoderConfig
def _is_transformer_layer(n: str, m) -> bool:
return "layers" in n and n.split(".")[-1].isdigit()
def _is_embeddings(n: str, m) -> bool:
return n.endswith("embed_tokens")
def _is_final_norm(n: str, m) -> bool:
return n.endswith("norm")
@dataclass
class Reason1ArchConfig(TextEncoderArchConfig):
"""Architecture settings (defaults match Qwen2.5-VL-7B-Instruct)."""
@@ -73,8 +61,6 @@ class Reason1ArchConfig(TextEncoderArchConfig):
torch_dtype: str = "bfloat16"
_attn_implementation: str = "flash_attention_2"
_fsdp_shard_conditions: list = field(
default_factory=lambda: [_is_transformer_layer, _is_embeddings, _is_final_norm])
@dataclass
+3 -3
View File
@@ -6,7 +6,7 @@ from fastvideo.configs.pipelines.hunyuan import FastHunyuanConfig, HunyuanConfig
from fastvideo.configs.pipelines.hunyuan15 import Hunyuan15T2V480PConfig, Hunyuan15T2V720PConfig
from fastvideo.configs.pipelines.hunyuangamecraft import HunyuanGameCraftPipelineConfig
from fastvideo.configs.pipelines.hyworld import HYWorldConfig
from fastvideo.configs.pipelines.kandinsky5 import Kandinsky5DMDConfig, Kandinsky5I2VConfig, Kandinsky5T2VConfig
from fastvideo.configs.pipelines.kandinsky5 import Kandinsky5I2VConfig, Kandinsky5T2VConfig
from fastvideo.configs.pipelines.lingbotworld2 import LingBotWorld2CausalFastI2V480PConfig
from fastvideo.configs.pipelines.lingbot_video import LingBotVideoT2VConfig
from fastvideo.configs.pipelines.matrixgame2 import MatrixGame2I2V480PConfig
@@ -21,6 +21,6 @@ __all__ = [
"Hunyuan15T2V720PConfig", "WanT2V480PConfig", "WanI2V480PConfig", "WanT2V720PConfig", "WanI2V720PConfig",
"SelfForcingWanT2V480PConfig", "LucyEditDevConfig", "CosmosConfig", "Cosmos25Config", "LTX2T2VConfig",
"DreamXWorld5BCamPipelineConfig", "DreamXWorld5BARPipelineConfig", "HYWorldConfig", "Kandinsky5T2VConfig",
"Kandinsky5I2VConfig", "Kandinsky5DMDConfig", "LingBotWorld2CausalFastI2V480PConfig", "LingBotVideoT2VConfig",
"MatrixGame2I2V480PConfig", "MatrixGame3I2V720PConfig", "get_pipeline_config_cls_from_name"
"Kandinsky5I2VConfig", "LingBotWorld2CausalFastI2V480PConfig", "LingBotVideoT2VConfig", "MatrixGame2I2V480PConfig",
"MatrixGame3I2V720PConfig", "get_pipeline_config_cls_from_name"
]
-18
View File
@@ -120,21 +120,3 @@ class Kandinsky5I2VConfig(Kandinsky5T2VConfig):
super().__post_init__()
# I2V needs the VAE encoder to encode the conditioning image.
self.vae_config.load_encoder = True
@dataclass
class Kandinsky5DMDConfig(Kandinsky5T2VConfig):
"""Kandinsky-5.0 DMD (few-step distilled) text-to-video pipeline configuration.
Checkpoints exported by ``fastvideo.train.entrypoint.dcp_to_diffusers``
copy their base T2V checkpoint's ``model_index.json`` unchanged, so
``_class_name`` still says the base T2V pipeline and the registry
(``fastvideo/registry.py``) cannot auto-detect a DMD export -- pass this
config together with ``override_pipeline_cls_name="Kandinsky5DMDPipeline"``
explicitly to ``VideoGenerator.from_pretrained``/``from_config`` (see
``examples/train/configs/fine_tuning/kandinsky5/README.md``). Without it,
``Kandinsky5T2VConfig``'s ``dmd_denoising_steps=None`` makes
``Kandinsky5DmdDenoisingStage`` raise immediately.
"""
dmd_denoising_steps: list[int] | None = field(default_factory=lambda: [1000, 750, 500, 250])
+1 -2
View File
@@ -8,7 +8,6 @@ from fastvideo.entrypoints.cli.router_serve import (
from fastvideo.entrypoints.cli.serve import cmd_init as serve_cmd_init
from fastvideo.entrypoints.cli.bench import cmd_init as bench_cmd_init
from fastvideo.entrypoints.cli.eval import cmd_init as eval_cmd_init
from fastvideo.version import __version__
def cmd_init() -> list[CLISubcommand]:
@@ -24,7 +23,7 @@ def cmd_init() -> list[CLISubcommand]:
def main() -> None:
parser = FlexibleArgumentParser(description="FastVideo CLI")
parser.add_argument('-v', '--version', action='version', version=__version__)
parser.add_argument('-v', '--version', action='version', version='0.1.0')
subparsers = parser.add_subparsers(required=False, dest="subparser")
@@ -26,9 +26,6 @@ FP8_DTYPE = torch.float8_e4m3fn
FP8_MAX = float(torch.finfo(FP8_DTYPE).max) # 448.0
FP8_MIN_SCALE = 1.0 / (FP8_MAX * 512.0)
# Wan's "to_q"/"to_k"/"to_v" also substring-match Kandinsky5's "to_query"/
# "to_key"/"to_value", so only Kandinsky5's out-projection and FFN names need
# to be listed explicitly below.
_FP8_SUFFIXES = (
"ffn.fc_in",
"ffn.fc_out",
@@ -36,11 +33,6 @@ _FP8_SUFFIXES = (
"to_k",
"to_v",
"to_out",
# Kandinsky5
"self_attention.out_layer",
"cross_attention.out_layer",
"feed_forward.mlp.fc_in",
"feed_forward.mlp.fc_out",
)
+24 -91
View File
@@ -58,29 +58,6 @@ _LTX2_REFINE_ONLY_SUFFIXES = (
".video_to_audio_attn.to_v",
)
_LTX2_NVFP4_BLOCK_LINEAR_SUFFIXES = (
"attn1.to_q",
"attn1.to_k",
"attn1.to_v",
"attn1.to_out",
"attn2.to_q",
"attn2.to_out",
"audio_to_video_attn.to_q",
"audio_to_video_attn.to_out",
"video_to_audio_attn.to_k",
"video_to_audio_attn.to_v",
"ffn.fc_in",
"ffn.fc_out",
)
_LTX2_NVFP4_LINEAR_PREFIXES = frozenset(f"ltx2.blocks.{block_idx}.{suffix}" for block_idx in range(48)
for suffix in _LTX2_NVFP4_BLOCK_LINEAR_SUFFIXES) | frozenset(
("ltx2.adaln_single.linear", ))
def is_ltx2_nvfp4_linear_prefix(prefix: str) -> bool:
"""Return whether *prefix* belongs to the LTX-2 NVFP4 deployment set."""
return prefix in _LTX2_NVFP4_LINEAR_PREFIXES
def _is_ltx2_refine_only_prefix(prefix: str) -> bool:
return any(prefix.endswith(suffix) for suffix in _LTX2_REFINE_ONLY_SUFFIXES)
@@ -309,11 +286,6 @@ class NVFP4QuantizeMethod(QuantizeMethodBase):
self.x_global_sf = torch.tensor(1.0, device="cuda", dtype=torch.float32)
self.layer_prefix = layer_prefix
self._is_refine_only_layer = _is_ltx2_refine_only_prefix(layer_prefix)
# Set from NVFP4Config.retain_original_weights in get_quant_method:
# True = retain every original bf16 weight; None/False (default) =
# purge the purgeable set. Refine-only layers are always retained --
# the base stage profile runs them dense by deployment contract.
self._retain_original_weights: bool | None = None
def create_weights(self, layer: torch.nn.Module, input_size_per_partition: int, output_partition_sizes: list[int],
input_size: int, output_size: int, params_dtype: torch.dtype, **extra_weight_attrs):
@@ -354,11 +326,7 @@ class NVFP4QuantizeMethod(QuantizeMethodBase):
| None = None,
) -> torch.Tensor:
SfLayout, _, _ = _require_flashinfer()
# The original bf16 weight may have been purged after FP4 conversion
# (see convert_model_to_nvfp4); the packed FP4 weight keeps the
# output dim as its first dimension (only K is packed 2-per-byte).
weight = getattr(layer, "weight", None)
out_dim = weight.shape[0] if weight is not None else layer._nvfp4_weight.shape[0]
out_dim = layer.weight.shape[0]
original_shape = x.shape
# Stage-aware profile: keep refine-only FP4 layers in dense mode
@@ -366,13 +334,8 @@ class NVFP4QuantizeMethod(QuantizeMethodBase):
# quantize/dequantize tax for layers it never touches.
stage_profile = _get_ltx2_fp4_stage_profile(default="refine")
if self._is_refine_only_layer and stage_profile == "base":
if weight is None:
raise RuntimeError(f"NVFP4 layer {self.layer_prefix!r} hit the stage-profile dense path, "
"but its original weights were purged "
"(NVFP4Config(retain_original_weights=False)). Streaming/two-stage "
"deploys must load with retain_original_weights left unset (auto) or True.")
out = (F.linear(x, weight, bias) if torch.cuda.is_available() or bias is None else F.linear(
x, weight, bias.to(x.dtype)))
out = (F.linear(x, layer.weight, bias) if torch.cuda.is_available() or bias is None else F.linear(
x, layer.weight, bias.to(x.dtype)))
return out.view(*original_shape[:-1], out_dim)
if pre_quantized is not None:
x_fp4, x_scale, x_global_sf = pre_quantized
@@ -430,19 +393,11 @@ class NVFP4Config(QuantizationConfig):
instead of hardcoding it here.
"""
def __init__(self, layer_profile: str = "refine", retain_original_weights: bool | None = None):
def __init__(self, layer_profile: str = "refine"):
super().__init__()
# ``base``: stage-1 set (no attn2.to_out, no cross-modal AV
# projections). ``refine``: full stage-2 set.
self.layer_profile = layer_profile
# Original bf16 ``layer.weight`` retention after FP4 conversion.
# Default (None/False): purge the purgeable originals -- every
# always-FP4 layer. Refine-only layers (the cross-modal AV
# projections) are ALWAYS retained: the ``base`` stage profile runs
# them dense by deployment contract, including the distilled
# single-stage deploy. True: retain everything (debugging /
# pre-purge behavior).
self.retain_original_weights = retain_original_weights
def get_name(self):
return "nvfp4"
@@ -460,20 +415,32 @@ class NVFP4Config(QuantizationConfig):
@classmethod
def from_config(cls, config: dict[str, Any]) -> NVFP4Config:
return cls(
layer_profile=config.get("layer_profile", "refine"),
retain_original_weights=config.get("retain_original_weights"),
)
return cls(layer_profile=config.get("layer_profile", "refine"))
def get_quant_method(self, layer: torch.nn.Module, prefix: str):
from fastvideo.layers.linear import LinearBase
# Use the superset at build/load time, then switch active subset
# dynamically in NVFP4QuantizeMethod.apply based on stage profile.
if isinstance(layer, LinearBase) and is_ltx2_nvfp4_linear_prefix(prefix):
method = NVFP4QuantizeMethod(layer_prefix=prefix)
method._retain_original_weights = self.retain_original_weights
return method
fp4_layers = [[
f"ltx2.blocks.{i}.attn1.to_q",
f"ltx2.blocks.{i}.attn1.to_k",
f"ltx2.blocks.{i}.attn1.to_v",
f"ltx2.blocks.{i}.attn1.to_out",
f"ltx2.blocks.{i}.attn2.to_q",
f"ltx2.blocks.{i}.attn2.to_out",
f"ltx2.blocks.{i}.audio_to_video_attn.to_q",
f"ltx2.blocks.{i}.audio_to_video_attn.to_out",
f"ltx2.blocks.{i}.video_to_audio_attn.to_k",
f"ltx2.blocks.{i}.video_to_audio_attn.to_v",
f"ltx2.blocks.{i}.ffn.fc_in",
f"ltx2.blocks.{i}.ffn.fc_out",
] for i in range(48)]
fp4_layers.append([
"ltx2.adaln_single.linear",
])
if isinstance(layer, LinearBase) and any(prefix in layer_names for layer_names in fp4_layers):
return NVFP4QuantizeMethod(layer_prefix=prefix)
return None
@@ -481,9 +448,6 @@ def convert_model_to_nvfp4(model: torch.nn.Module) -> None:
SfLayout, _, _ = _require_flashinfer()
from torch.distributed.tensor import DTensor # type: ignore
purged = 0
retained = 0
purged_bytes = 0
for mod in model.modules():
qm = getattr(mod, "quant_method", None)
if isinstance(qm, NVFP4QuantizeMethod):
@@ -516,40 +480,9 @@ def convert_model_to_nvfp4(model: torch.nn.Module) -> None:
persistent=False,
)
retain_flag = getattr(qm, "_retain_original_weights", None)
# Refine-only layers are NEVER purgeable: the "base" stage profile
# runs them dense by deployment contract (the distilled
# single-stage deploy included — its forward context is the base
# profile, so e.g. audio_to_video_attn routes dense every step).
# retain_original_weights therefore only widens retention
# (True = keep everything); it cannot narrow it below the
# dense-capable set.
retain = qm._is_refine_only_layer or retain_flag is True
if retain:
retained += 1
elif isinstance(weight, DTensor):
# ponytail: purging FSDP-sharded originals needs per-shard
# resharding bookkeeping; skip until a sharded deploy needs it.
retained += 1
else:
purged_bytes += weight.numel() * weight.element_size()
purged += 1
mod.register_parameter("weight", None)
if purged or retained:
logger.info(
"NVFP4 weight purge receipt: purged %d original bf16 weight tensors "
"(%.2f GiB freed); retained %d (refine-only dense fallback or "
"retain_original_weights).",
purged,
purged_bytes / (1 << 30),
retained,
)
__all__ = [
"NVFP4Config",
"NVFP4QuantizeMethod",
"convert_model_to_nvfp4",
"is_ltx2_nvfp4_linear_prefix",
]
@@ -46,11 +46,7 @@ from fastvideo.models.utils import set_weight_attrs
logger = logging.getLogger(__name__)
# Wan-style attention + FFN projection layers. Matched as substrings of the
# layer prefix (e.g. "blocks.0.attn1.to_q" contains "to_q"). Wan's "to_q"/
# "to_k"/"to_v" also substring-match Kandinsky5's "to_query"/"to_key"/
# "to_value", so only Kandinsky5's out-projection and FFN names (which don't
# share a substring with Wan's "to_out"/"ffn.fc_in"/"ffn.fc_out") need to be
# listed explicitly below.
# layer prefix (e.g. "blocks.0.attn1.to_q" contains "to_q").
DEFAULT_FP4_LAYERS = (
"ffn.fc_in",
"ffn.fc_out",
@@ -58,11 +54,6 @@ DEFAULT_FP4_LAYERS = (
"to_k",
"to_v",
"to_out",
# Kandinsky5
"self_attention.out_layer",
"cross_attention.out_layer",
"feed_forward.mlp.fc_in",
"feed_forward.mlp.fc_out",
)
@@ -9,11 +9,10 @@ straight-through estimator), so the model learns to absorb FP4 linear error.
That STE already exists in ``fastvideo.layers.fp4linear._LinearFWD4BWD16Fn``
(FP4 forward, full-precision backward) but is otherwise unwired. This method
bridges it into the standard ``quant_config`` path. Wan-style prefixes retain
the broad attention/FFN projection profile used by ``nvfp4_qat``; LTX-2
prefixes use the exact curated target set from its ``NVFP4`` deployment config.
No ``convert_model_to_fp4`` is needed: the weight is kept in full precision and
quantized on the fly each step.
bridges it into the standard ``quant_config`` path, so it activates via
``transformer_quant="nvfp4_qat_train"`` on the same Wan-2.1 layers as nvfp4_qat
(to_q/k/v/out + ffn). No ``convert_model_to_fp4`` is needed: the weight is kept
in full precision and quantized on the fly each step.
"""
import logging
from typing import Any
@@ -22,8 +21,6 @@ import torch
from torch.nn.parameter import Parameter
from fastvideo.layers.quantization.base_config import QuantizationConfig, QuantizeMethodBase
from fastvideo.layers.quantization.nvfp4_config import is_ltx2_nvfp4_linear_prefix
from fastvideo.layers.quantization.nvfp4_qat_config import DEFAULT_FP4_LAYERS
from fastvideo.models.utils import set_weight_attrs
logger = logging.getLogger(__name__)
@@ -75,10 +72,7 @@ class NVFP4QATTrainConfig(QuantizationConfig):
def get_quant_method(self, layer: torch.nn.Module, prefix: str):
from fastvideo.layers.linear import LinearBase
if not isinstance(layer, LinearBase):
return None
matches = (is_ltx2_nvfp4_linear_prefix(prefix) if prefix.startswith("ltx2.") else any(
layer_name in prefix for layer_name in DEFAULT_FP4_LAYERS))
if matches:
fp4_layers = ["ffn.fc_in", "ffn.fc_out", "to_q", "to_k", "to_v", "to_out"]
if isinstance(layer, LinearBase) and any(layer_name in prefix for layer_name in fp4_layers):
return NVFP4QATTrainQuantizeMethod()
return None
+11 -32
View File
@@ -15,7 +15,6 @@ from fastvideo.configs.models.dits import Kandinsky5VideoConfig
from fastvideo.layers.layernorm import LayerNormScaleShift
from fastvideo.layers.linear import ReplicatedLinear
from fastvideo.layers.mlp import MLP
from fastvideo.layers.quantization import QuantizationConfig
from fastvideo.logger import init_logger
from fastvideo.models.dits.base import BaseDiT
from fastvideo.platforms import AttentionBackendEnum
@@ -286,7 +285,6 @@ class Kandinsky5Attention(nn.Module):
supported_attention_backends: tuple[AttentionBackendEnum, ...] | None,
prefix: str = "",
use_nabla: bool = False,
quant_config: QuantizationConfig | None = None,
):
super().__init__()
assert num_channels % head_dim == 0
@@ -295,24 +293,20 @@ class Kandinsky5Attention(nn.Module):
self.to_query = ReplicatedLinear(num_channels,
num_channels,
bias=True,
quant_config=quant_config,
prefix=f"{prefix}.to_query")
self.to_key = ReplicatedLinear(num_channels,
num_channels,
bias=True,
quant_config=quant_config,
prefix=f"{prefix}.to_key")
self.to_value = ReplicatedLinear(num_channels,
num_channels,
bias=True,
quant_config=quant_config,
prefix=f"{prefix}.to_value")
self.query_norm = nn.RMSNorm(head_dim)
self.key_norm = nn.RMSNorm(head_dim)
self.out_layer = ReplicatedLinear(num_channels,
num_channels,
bias=True,
quant_config=quant_config,
prefix=f"{prefix}.out_layer")
self.local_attention = LocalAttention(
num_heads=self.num_heads,
@@ -419,11 +413,9 @@ class Kandinsky5Attention(nn.Module):
class Kandinsky5FeedForward(nn.Module):
def __init__(self, dim: int, ff_dim: int, prefix: str = "",
quant_config: QuantizationConfig | None = None):
def __init__(self, dim: int, ff_dim: int):
super().__init__()
self.mlp = MLP(dim, ff_dim, bias=False, act_type="gelu",
quant_config=quant_config, prefix=f"{prefix}.mlp")
self.mlp = MLP(dim, ff_dim, bias=False, act_type="gelu")
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.mlp(x)
@@ -475,8 +467,7 @@ class Kandinsky5TransformerEncoderBlock(nn.Module):
head_dim: int,
supported_attention_backends: tuple[AttentionBackendEnum, ...]
| None = None,
prefix: str = "",
quant_config: QuantizationConfig | None = None):
prefix: str = ""):
super().__init__()
self.text_modulation = Kandinsky5Modulation(time_dim, model_dim, 6)
@@ -491,8 +482,7 @@ class Kandinsky5TransformerEncoderBlock(nn.Module):
model_dim,
head_dim,
supported_attention_backends=supported_attention_backends,
prefix=f"{prefix}.self_attention",
quant_config=quant_config)
prefix=f"{prefix}.self_attention")
self.feed_forward_norm = LayerNormScaleShift(
model_dim,
@@ -501,9 +491,7 @@ class Kandinsky5TransformerEncoderBlock(nn.Module):
elementwise_affine=False,
dtype=torch.float32,
compute_dtype=torch.float32)
self.feed_forward = Kandinsky5FeedForward(
model_dim, ff_dim, prefix=f"{prefix}.feed_forward",
quant_config=quant_config)
self.feed_forward = Kandinsky5FeedForward(model_dim, ff_dim)
def forward(self, x: torch.Tensor, time_embed: torch.Tensor,
rope: torch.Tensor) -> torch.Tensor:
@@ -535,8 +523,7 @@ class Kandinsky5TransformerDecoderBlock(nn.Module):
supported_attention_backends: tuple[AttentionBackendEnum, ...]
| None = None,
prefix: str = "",
use_nabla: bool = False,
quant_config: QuantizationConfig | None = None):
use_nabla: bool = False):
super().__init__()
self.visual_modulation = Kandinsky5Modulation(time_dim, model_dim, 9)
@@ -552,8 +539,7 @@ class Kandinsky5TransformerDecoderBlock(nn.Module):
head_dim,
supported_attention_backends=supported_attention_backends,
prefix=f"{prefix}.self_attention",
use_nabla=use_nabla,
quant_config=quant_config)
use_nabla=use_nabla)
self.cross_attention_norm = LayerNormScaleShift(
model_dim,
@@ -566,8 +552,7 @@ class Kandinsky5TransformerDecoderBlock(nn.Module):
model_dim,
head_dim,
supported_attention_backends=supported_attention_backends,
prefix=f"{prefix}.cross_attention",
quant_config=quant_config)
prefix=f"{prefix}.cross_attention")
self.feed_forward_norm = LayerNormScaleShift(
model_dim,
@@ -576,9 +561,7 @@ class Kandinsky5TransformerDecoderBlock(nn.Module):
elementwise_affine=False,
dtype=torch.float32,
compute_dtype=torch.float32)
self.feed_forward = Kandinsky5FeedForward(
model_dim, ff_dim, prefix=f"{prefix}.feed_forward",
quant_config=quant_config)
self.feed_forward = Kandinsky5FeedForward(model_dim, ff_dim)
def forward(self, visual_embed: torch.Tensor, text_embed: torch.Tensor,
time_embed: torch.Tensor, rope: torch.Tensor,
@@ -653,8 +636,6 @@ class Kandinsky5Transformer3DModel(BaseDiT):
hf_config: dict[str, Any]) -> None:
super().__init__(config=config, hf_config=hf_config)
arch = config.arch_config
quant_config = config.quant_config
self.quant_config = quant_config
head_dim = sum(arch.axes_dims)
self.in_visual_dim = arch.in_visual_dim
@@ -683,8 +664,7 @@ class Kandinsky5Transformer3DModel(BaseDiT):
arch.ff_dim,
head_dim,
self._supported_attention_backends,
prefix=f"{config.prefix}.text_transformer_blocks.{i}",
quant_config=quant_config)
prefix=f"{config.prefix}.text_transformer_blocks.{i}")
for i in range(arch.num_text_blocks)
])
self.visual_transformer_blocks = nn.ModuleList([
@@ -693,8 +673,7 @@ class Kandinsky5Transformer3DModel(BaseDiT):
head_dim,
self._supported_attention_backends,
prefix=f"{config.prefix}.visual_transformer_blocks.{i}",
use_nabla=arch.attention_type == "nabla",
quant_config=quant_config)
use_nabla=arch.attention_type == "nabla")
for i in range(arch.num_visual_blocks)
])
+19 -75
View File
@@ -5,7 +5,6 @@ LTX-2 transformer implementation
from dataclasses import dataclass, replace
from enum import Enum
import functools
import math
import os
from pathlib import Path
@@ -62,34 +61,6 @@ def _is_ltx2_refine_stage() -> bool:
return forward_batch.extra.get("ltx2_fp4_stage_profile") == "refine"
def _functional_rms_norm(
x: torch.Tensor,
eps: float,
weight: torch.Tensor | None = None,
) -> torch.Tensor:
"""Out-of-place RMSNorm equivalent to ``torch.nn.functional.rms_norm``.
``F.rms_norm`` / ``nn.RMSNorm`` decompose under AOTAutograd into an
in-place ``variance.add_(eps)`` (an ``aten.copy_`` into the computed
``mean``). AOTAutograd's ``assert_functional_graph`` requires every
``copy_`` destination to be a graph input, so this trips when the DiT is
compiled through the ``torch.compile(backend="torch_tensorrt")`` path
(Dynamo -> AOTAutograd functionalization -> torch-tensorrt), breaking
TensorRT + sequence-parallel (Ulysses) export. Computing ``variance + eps``
out-of-place keeps the graph functional. Numerically matches
``F.rms_norm`` (fp32 accumulation, dtype restored to the input)."""
input_dtype = x.dtype
hidden = x.to(torch.float32)
variance = hidden.pow(2).mean(-1, keepdim=True)
hidden = hidden * torch.rsqrt(variance + eps)
out = hidden.to(input_dtype)
if weight is not None:
# A weight dtype differing from x (e.g. fp32 params under autocast)
# would promote the output; keep the input-dtype guarantee.
out = out * weight
return out.to(input_dtype)
def _rms_norm_dispatch(
x: torch.Tensor,
eps: float,
@@ -103,7 +74,8 @@ def _rms_norm_dispatch(
# rms_norm to autocast's float32 cast policy, which under our
# autocast(bfloat16) forward returns float32 instead. Restore the input
# dtype to match the official numerics (and torch <= 2.11 behavior).
return _functional_rms_norm(x, eps, weight)
return torch.nn.functional.rms_norm(
x, (x.shape[-1], ), weight=weight, eps=eps).to(x.dtype)
class StageAwareRMSNorm(nn.RMSNorm):
@@ -128,7 +100,7 @@ class StageAwareRMSNorm(nn.RMSNorm):
# norm now returns float32 where torch <= 2.11 returned bfloat16.
# Restore the input dtype so autocast-blind consumers downstream
# (e.g. the flash-attention custom op) don't receive upcast q/k.
return _functional_rms_norm(x, self.eps, self.weight)
return super().forward(x).to(x.dtype)
def _supports_prequantized_input(linear: ReplicatedLinear) -> bool:
@@ -795,9 +767,14 @@ def _apply_ltx_split_rotary_emb(
return output
def _generate_ltx_freq_grid_float64(
def generate_ltx_freq_grid_float64(
positional_embedding_theta: float, positional_embedding_max_pos_count: int, inner_dim: int
) -> torch.Tensor:
"""Generate LTX-2 rotary frequencies with float64 precision.
Implemented in pure torch and free of Python memoization so it is safe
for callers that may be captured and reused by torch.compile.
"""
theta = positional_embedding_theta
start = 1
end = theta
@@ -814,24 +791,10 @@ def _generate_ltx_freq_grid_float64(
return (pow_indices * math.pi / 2).to(dtype=torch.float32)
_cached_generate_ltx_freq_grid_float64 = functools.lru_cache(maxsize=5)(
_generate_ltx_freq_grid_float64)
def generate_ltx_freq_grid_float64(
positional_embedding_theta: float, positional_embedding_max_pos_count: int, inner_dim: int
) -> torch.Tensor:
"""Generate an eager-cached, compile-safe float64 LTX-2 rotary grid."""
if torch.compiler.is_compiling():
return _generate_ltx_freq_grid_float64(
positional_embedding_theta, positional_embedding_max_pos_count, inner_dim)
return _cached_generate_ltx_freq_grid_float64(
positional_embedding_theta, positional_embedding_max_pos_count, inner_dim)
def _generate_ltx_freq_grid_pytorch(
def generate_ltx_freq_grid_pytorch(
positional_embedding_theta: float, positional_embedding_max_pos_count: int, inner_dim: int
) -> torch.Tensor:
"""Generate LTX-2 rotary frequencies in torch for speed."""
theta = positional_embedding_theta
start = 1
end = theta
@@ -848,21 +811,6 @@ def _generate_ltx_freq_grid_pytorch(
return indices * math.pi / 2
_cached_generate_ltx_freq_grid_pytorch = functools.lru_cache(maxsize=5)(
_generate_ltx_freq_grid_pytorch)
def generate_ltx_freq_grid_pytorch(
positional_embedding_theta: float, positional_embedding_max_pos_count: int, inner_dim: int
) -> torch.Tensor:
"""Generate an eager-cached, compile-safe float32 LTX-2 rotary grid."""
if torch.compiler.is_compiling():
return _generate_ltx_freq_grid_pytorch(
positional_embedding_theta, positional_embedding_max_pos_count, inner_dim)
return _cached_generate_ltx_freq_grid_pytorch(
positional_embedding_theta, positional_embedding_max_pos_count, inner_dim)
def _ltx_get_fractional_positions(indices_grid: torch.Tensor, max_pos: list[int]) -> torch.Tensor:
n_pos_dims = indices_grid.shape[1]
if n_pos_dims != len(max_pos):
@@ -1571,7 +1519,6 @@ class LTXSelfAttention(nn.Module):
softmax_scale=None,
causal=False,
supported_attention_backends=(AttentionBackendEnum.TORCH_SDPA,),
default_backend=AttentionBackendEnum.TORCH_SDPA,
)
def forward(
@@ -1854,15 +1801,12 @@ class BasicAVTransformerBlock(torch.nn.Module):
# Text cross-attention uses LocalAttention (text embeddings are replicated)
SelfAttnCls = LTXDistributedSelfAttention if use_distributed_attention else LTXSelfAttention
CrossAttnCls = LTXSelfAttention # Text cross-attention is always local
video_attn_backends = (
video_self_attn_backends = (
AttentionBackendEnum.FLASH_ATTN,
AttentionBackendEnum.TORCH_SDPA,
AttentionBackendEnum.ATTN_QAT_INFER,
AttentionBackendEnum.ATTN_QAT_TRAIN,
AttentionBackendEnum.VIDEO_SPARSE_ATTN,
)
video_self_attn_backends = video_attn_backends + (
AttentionBackendEnum.VIDEO_SPARSE_ATTN, )
audio_attn_backends = (
dense_attn_backends = (
AttentionBackendEnum.FLASH_ATTN,
AttentionBackendEnum.TORCH_SDPA,
)
@@ -1889,7 +1833,7 @@ class BasicAVTransformerBlock(torch.nn.Module):
dim_head=video.d_head,
norm_eps=norm_eps,
rope_type=rope_type,
supported_attention_backends=video_attn_backends,
supported_attention_backends=dense_attn_backends,
apply_gated_attention=video.apply_gated_attention,
quant_config=quant_config,
prefix=f"{prefix}.blocks.{idx}.attn2",
@@ -1915,7 +1859,7 @@ class BasicAVTransformerBlock(torch.nn.Module):
dim_head=audio.d_head,
norm_eps=norm_eps,
rope_type=rope_type,
supported_attention_backends=audio_attn_backends,
supported_attention_backends=dense_attn_backends,
apply_gated_attention=audio.apply_gated_attention,
quant_config=quant_config,
prefix=f"{prefix}.blocks.{idx}.audio_attn1",
@@ -1928,7 +1872,7 @@ class BasicAVTransformerBlock(torch.nn.Module):
dim_head=audio.d_head,
norm_eps=norm_eps,
rope_type=rope_type,
supported_attention_backends=audio_attn_backends,
supported_attention_backends=dense_attn_backends,
apply_gated_attention=audio.apply_gated_attention,
quant_config=quant_config,
prefix=f"{prefix}.blocks.{idx}.audio_attn2",
@@ -1953,7 +1897,7 @@ class BasicAVTransformerBlock(torch.nn.Module):
dim_head=audio.d_head,
norm_eps=norm_eps,
rope_type=rope_type,
supported_attention_backends=audio_attn_backends,
supported_attention_backends=dense_attn_backends,
apply_gated_attention=video.apply_gated_attention,
quant_config=quant_config,
prefix=f"{prefix}.blocks.{idx}.audio_to_video_attn",
@@ -1967,7 +1911,7 @@ class BasicAVTransformerBlock(torch.nn.Module):
dim_head=audio.d_head,
norm_eps=norm_eps,
rope_type=rope_type,
supported_attention_backends=audio_attn_backends,
supported_attention_backends=dense_attn_backends,
apply_gated_attention=audio.apply_gated_attention,
quant_config=quant_config,
prefix=f"{prefix}.blocks.{idx}.video_to_audio_attn",
-20
View File
@@ -38,14 +38,8 @@ def _maybe_quantize_model(model: nn.Module) -> None:
The walk returns on the first quantized layer found so unquantized callers
pay only an ``isinstance`` check per module. Both imports are deferred so
this is a no-op on hosts without the relevant backends.
QAT-*train* linears (``nvfp4_qat_train``) need no weight conversion — the
master weight stays full precision — but their attachment is otherwise
silent, so the same walk emits a one-line receipt with the count of
linears that actually carry the QAT method.
"""
# Defer imports: these modules pull in heavy symbols at module-load time.
from fastvideo.layers.linear import LinearBase
from fastvideo.layers.quantization.nvfp4_config import (
NVFP4QuantizeMethod,
convert_model_to_nvfp4,
@@ -54,15 +48,11 @@ def _maybe_quantize_model(model: nn.Module) -> None:
NVFP4QATQuantizeMethod,
convert_model_to_fp4,
)
from fastvideo.layers.quantization.nvfp4_qat_train_config import (
NVFP4QATTrainQuantizeMethod, )
from fastvideo.layers.quantization.fp8_config import (
FP8QuantizeMethod,
convert_model_to_fp8,
)
qat_train_attached = 0
qat_train_skipped = 0
for mod in model.modules():
qm = getattr(mod, "quant_method", None)
if isinstance(qm, NVFP4QuantizeMethod):
@@ -77,16 +67,6 @@ def _maybe_quantize_model(model: nn.Module) -> None:
logger.info("Converting loaded model weights for FP8 linear layers")
convert_model_to_fp8(model)
return
# QAT-train configs are mutually exclusive with the inference schemes
# above (one quant_config per model), so when they're active the loop
# always runs to completion and the counts below are model-wide.
if isinstance(qm, NVFP4QATTrainQuantizeMethod):
qat_train_attached += 1
elif isinstance(mod, LinearBase):
qat_train_skipped += 1
if qat_train_attached:
logger.info("NVFP4 QAT: attached %d linears (%d skipped by prefix filter)", qat_train_attached,
qat_train_skipped)
# TODO(PY): move this to utils elsewhere
@@ -1,82 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""Kandinsky5 DMD (few-step distilled) validation pipeline.
Composes the same stages as ``Kandinsky5T2VPipeline`` (text encoding, latent
prep, decoding) but swaps in ``Kandinsky5DmdDenoisingStage`` for the
denoising loop, mirroring ``wan_dmd_pipeline.py``'s split from the base T2V
pipeline. This exists solely to drive the stage-2 DMD training config's
validation callback.
"""
from __future__ import annotations
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.pipelines.composed_pipeline_base import ComposedPipelineBase
from fastvideo.pipelines.stages.input_validation import InputValidationStage
from fastvideo.pipelines.stages.kandinsky5 import (
Kandinsky5DecodingStage,
Kandinsky5DmdDenoisingStage,
Kandinsky5LatentPreparationStage,
)
from fastvideo.pipelines.stages.text_encoding import TextEncodingStage
from fastvideo.pipelines.stages.timestep_preparation import TimestepPreparationStage
class Kandinsky5DMDPipeline(ComposedPipelineBase):
"""Kandinsky-5.0 Lite DMD (few-step) text-to-video pipeline."""
_required_config_modules = [
"scheduler",
"text_encoder",
"text_encoder_2",
"tokenizer",
"tokenizer_2",
"transformer",
"vae",
]
def create_pipeline_stages(self, fastvideo_args: FastVideoArgs) -> None:
self.add_stage(stage_name="input_validation_stage", stage=InputValidationStage())
self.add_stage(
stage_name="text_encoding_stage",
stage=TextEncodingStage(
text_encoders=[
self.get_module("text_encoder"),
self.get_module("text_encoder_2"),
],
tokenizers=[
self.get_module("tokenizer"),
self.get_module("tokenizer_2"),
],
),
)
self.add_stage(
stage_name="timestep_preparation_stage",
stage=TimestepPreparationStage(scheduler=self.get_module("scheduler")),
)
self.add_stage(
stage_name="latent_preparation_stage",
stage=Kandinsky5LatentPreparationStage(
scheduler=self.get_module("scheduler"),
transformer=self.get_module("transformer"),
),
)
self.add_stage(
stage_name="denoising_stage",
stage=Kandinsky5DmdDenoisingStage(
transformer=self.get_module("transformer"),
scheduler=self.get_module("scheduler"),
),
)
self.add_stage(
stage_name="decoding_stage",
stage=Kandinsky5DecodingStage(vae=self.get_module("vae"), pipeline=self),
)
EntryClass = Kandinsky5DMDPipeline
@@ -1,309 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""Preprocess Kandinsky5 overfit data into parquet format.
Encodes videos with Kandinsky5's VAE (shared with HunyuanVideo) and
captions with dual text encoders (Qwen/Reason1 + CLIP) into the t2v
parquet schema expected by the training framework.
Unlike Hunyuan's version of this script, text encoders are loaded via
FastVideo's own ``TextEncoderLoader``/``VAELoader`` (matching
``Kandinsky5Model.ensure_negative_conditioning``) rather than raw
``transformers`` classes -- Kandinsky5's Qwen/Reason1 encoder is a
native FastVideo wrapper around Qwen2.5-VL's language model, not a
plain ``AutoModel.from_pretrained`` load.
The CLIP pooled projection (``[768]``) is zero-padded into a row and
prepended to the Qwen sequence embeddings, then stored as a single
``[seq+1, dim]`` tensor in the existing ``text_embedding`` parquet
field -- the same trick ``preprocess_hunyuan_overfit.py`` uses for
LLaMA+CLIP. No schema change and no separate attention-mask field are
needed: the standard collator derives ``text_attention_mask`` from
padding against the stored (unpadded) row count, so the prepended
pooled row is automatically counted as valid.
Usage:
CUDA_VISIBLE_DEVICES=0 python -m fastvideo.pipelines.preprocess.preprocess_kandinsky5_overfit
Input/output roots default to ``data/kandinsky5_overfit`` /
``data/kandinsky5_overfit_preprocessed`` and can be overridden with the
``KANDINSKY5_OVERFIT_DATA_DIR`` / ``KANDINSKY5_OVERFIT_OUTPUT_DIR`` env
vars (used by the nightly e2e test to keep its disposable roots separate
from a user's real dataset at the defaults).
"""
import json
import os
os.environ.setdefault("MASTER_ADDR", "localhost")
os.environ.setdefault("MASTER_PORT", "29520")
import cv2
import numpy as np
import pyarrow as pa
import pyarrow.parquet as pq
import torch
from transformers import AutoTokenizer
from fastvideo.configs.pipelines.base import preprocess_text
from fastvideo.configs.pipelines.kandinsky5 import (
Kandinsky5T2VConfig,
kandinsky5_clip_postprocess_text,
kandinsky5_qwen_postprocess_text,
kandinsky5_qwen_preprocess_text,
)
from fastvideo.dataset.dataloader.schema import pyarrow_schema_t2v
from fastvideo.distributed import maybe_init_distributed_environment_and_model_parallel
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.forward_context import set_forward_context
from fastvideo.models.loader.component_loader import TextEncoderLoader, VAELoader
from fastvideo.utils import maybe_download_model
# --- Config ---
# Matches KANDINSKY5_T2V_LITE_5S's native preset (fastvideo/pipelines/basic/
# kandinsky5/presets.py): 121 frames @ 24fps ~= 5.04s, matching the "5s" in
# the checkpoint name. 512/768 fall in the 480p RoPE scale_factor band
# Kandinsky5Model asserts on, and satisfy the real divisor requirement
# (spatial_compression_ratio(8) * patch_size[1](2) = 16 -- not just 8).
NUM_FRAMES = 121 # 4k+1 for temporal compression ratio 4
MAX_HEIGHT = 512
MAX_WIDTH = 768
TRAIN_FPS = 24.0
MODEL_PATH = "kandinskylab/Kandinsky-5.0-T2V-Lite-sft-5s-Diffusers"
# Overridable so automation (e.g. the nightly e2e test) can point at its own
# test-owned directories instead of clobbering the documented default paths
# a user may have populated with their real dataset.
DATA_DIR = os.environ.get("KANDINSKY5_OVERFIT_DATA_DIR", "data/kandinsky5_overfit")
OUTPUT_DIR = os.environ.get("KANDINSKY5_OVERFIT_OUTPUT_DIR", "data/kandinsky5_overfit_preprocessed")
def load_video(path: str, num_frames: int, height: int, width: int) -> torch.Tensor:
"""Load video as [1, C, T, H, W] in [-1, 1], resized to (height, width).
Source clips are frequently at native resolution (e.g. 1920x1080);
encoding that directly through the VAE (instead of at the target
training resolution) uses far more memory than intended and can OOM.
"""
cap = cv2.VideoCapture(path)
frames: list[np.ndarray] = []
while len(frames) < num_frames:
ret, frame = cap.read()
if not ret:
break
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
if frame.shape[0] != height or frame.shape[1] != width:
frame = cv2.resize(frame, (width, height), interpolation=cv2.INTER_AREA)
frames.append(frame)
cap.release()
if not frames:
# Without this, the repeat-last-frame fill below raises an opaque
# IndexError on frames[-1]; cv2.VideoCapture doesn't raise on a
# missing/corrupt file, it just decodes nothing.
raise ValueError(f"Could not decode any frames from video {path!r} -- "
"the file is missing, empty, or not readable by OpenCV.")
if len(frames) < num_frames:
# Repeat last frame to fill
while len(frames) < num_frames:
frames.append(frames[-1])
frames = frames[:num_frames]
video = np.stack(frames, axis=0)
video = torch.from_numpy(video).float()
video = video / 127.5 - 1.0 # [0,255] -> [-1,1]
video = video.permute(3, 0, 1, 2).unsqueeze(0) # [1,C,T,H,W]
return video
def get_caption(item: dict) -> str:
"""Extract a caption from one ``videos2caption.json`` entry.
The documented schema (``docs/training/data_preprocess.md``) stores
``cap`` as a plain string; some producers (e.g. this repo's own e2e
test fixtures, mirroring ``preprocess_hunyuan_overfit.py``) instead
store a non-empty list of caption variants and use the first one.
Indexing unconditionally with ``item["cap"][0]`` silently takes the
first *character* of a string caption instead of erroring, so accept
and validate both forms explicitly here.
"""
cap = item.get("cap")
if isinstance(cap, str):
if not cap:
raise ValueError(f"Empty 'cap' string for entry: {item}")
return cap
if isinstance(cap, list):
if not cap or not isinstance(cap[0], str) or not cap[0]:
raise ValueError(f"'cap' list must be non-empty with a non-empty first string, got: {item}")
return cap[0]
raise ValueError(f"'cap' must be a string or a list of strings, got {type(cap).__name__}: {item}")
def main() -> None:
# FastVideo's native text-encoder layers (e.g. CLIPAttention's
# QKVParallelLinear) are tensor-parallel-aware and assert the TP process
# group is initialized, even for a single-process/single-GPU run.
maybe_init_distributed_environment_and_model_parallel(1, 1)
device = torch.device("cuda:0")
model_path = maybe_download_model(MODEL_PATH)
os.makedirs(OUTPUT_DIR, exist_ok=True)
# Load captions. Validate up front (before spending minutes loading the
# VAE + two text encoders): an empty/malformed manifest would otherwise
# only surface as an IndexError on records[0] at parquet-write time.
manifest_path = os.path.join(DATA_DIR, "videos2caption.json")
with open(manifest_path) as f:
caption_data = json.load(f)
if not isinstance(caption_data, list) or not caption_data:
raise ValueError(f"Manifest {manifest_path} must be a non-empty JSON list of "
f"{{'path', 'cap'}} entries, got: {type(caption_data).__name__} "
f"with {len(caption_data) if isinstance(caption_data, list) else 'n/a'} entries")
pipeline_config = Kandinsky5T2VConfig()
# Kandinsky5T2VConfig.__post_init__ sets load_encoder=False by default --
# T2V inference only ever decodes generated latents, never encodes real
# video. Preprocessing needs the encoder to turn real clips into latents.
pipeline_config.vae_config.load_encoder = True
fastvideo_args = FastVideoArgs(
model_path=model_path,
dit_cpu_offload=False,
dit_layerwise_offload=False,
use_fsdp_inference=False,
text_encoder_cpu_offload=False,
vae_cpu_offload=False,
pipeline_config=pipeline_config,
)
fastvideo_args.device = device
# --- Load VAE (shared with HunyuanVideo) ---
print("Loading Kandinsky5 VAE...")
vae = VAELoader().load(os.path.join(model_path, "vae"), fastvideo_args)
vae = vae.to(device=device, dtype=torch.float16).eval()
print(f"VAE loaded ({sum(p.numel() for p in vae.parameters()) / 1e6:.0f}M)")
# --- Load text encoders ---
print("Loading Qwen/Reason1 text encoder...")
qwen_enc = TextEncoderLoader().load(
os.path.join(model_path, "text_encoder"),
fastvideo_args,
).to(device).eval()
qwen_tok = AutoTokenizer.from_pretrained(os.path.join(model_path, "tokenizer"))
qwen_tok_kwargs = dict(pipeline_config.text_encoder_configs[0].tokenizer_kwargs)
# TextEncodingStage overrides max_length from pipeline_config.text_encoder_max_lengths
# at runtime (fastvideo/pipelines/stages/text_encoding.py) -- Reason1Config's static
# tokenizer_kwargs default (text_len=512) doesn't account for the 129-token Kandinsky
# system template ENCODE_START_IDX strips off afterwards, so mirror the runtime value
# here or training conditions on fewer caption tokens than inference does.
qwen_tok_kwargs["max_length"] = pipeline_config.text_encoder_max_lengths[0]
print("Loading CLIP text encoder...")
clip_enc = TextEncoderLoader().load(
os.path.join(model_path, "text_encoder_2"),
fastvideo_args,
).to(device).eval()
clip_tok = AutoTokenizer.from_pretrained(os.path.join(model_path, "tokenizer_2"))
clip_tok_kwargs = dict(pipeline_config.text_encoder_configs[1].tokenizer_kwargs)
clip_tok_kwargs["max_length"] = pipeline_config.text_encoder_max_lengths[1]
# --- Process each video ---
records = []
for item in caption_data:
video_name = item["path"]
caption = get_caption(item)
video_path = os.path.join(DATA_DIR, "videos", video_name)
print(f"\nProcessing: {video_name}")
print(f" Caption: {caption[:80]}...")
# Encode video. Kandinsky5's VAE encode returns channel-first
# [B, C, T, H, W], matching the storage convention Kandinsky5Model
# expects (it permutes to channel-last only right before calling
# the transformer).
video = load_video(video_path, NUM_FRAMES, MAX_HEIGHT, MAX_WIDTH).to(device=device, dtype=torch.float16)
print(f" Video shape: {video.shape}")
with torch.no_grad():
latent_dist = vae.encode(video)
latent = latent_dist.mean.squeeze(0).float().cpu() # [C, T, H, W]
print(f" Latent shape: {latent.shape}")
# Encode text with Qwen/Reason1.
qwen_text = kandinsky5_qwen_preprocess_text(caption)
with torch.no_grad(), set_forward_context(current_timestep=0, attn_metadata=None):
qwen_inputs = qwen_tok(qwen_text, **qwen_tok_kwargs).to(device)
qwen_out = qwen_enc(
input_ids=qwen_inputs.input_ids,
attention_mask=qwen_inputs.attention_mask,
output_hidden_states=True,
)
qwen_embeds, _qwen_mask = kandinsky5_qwen_postprocess_text(qwen_out, qwen_inputs.attention_mask)
qwen_embeds = qwen_embeds.squeeze(0) # [seq, dim]
# Encode text with CLIP.
clip_text = preprocess_text(caption)
with torch.no_grad(), set_forward_context(current_timestep=0, attn_metadata=None):
clip_inputs = clip_tok(clip_text, **clip_tok_kwargs).to(device)
clip_out = clip_enc(
input_ids=clip_inputs.input_ids,
attention_mask=clip_inputs.attention_mask,
)
clip_pooled = kandinsky5_clip_postprocess_text(clip_out).squeeze(0) # [768]
# Combine: [pooled_clip_row, qwen_embeds]
qwen_dim = qwen_embeds.shape[-1]
pooled_row = torch.zeros(qwen_dim, device=device, dtype=torch.float16)
pooled_row[:clip_pooled.shape[-1]] = clip_pooled
text_embedding = torch.cat(
[pooled_row.unsqueeze(0), qwen_embeds],
dim=0,
).float().cpu() # [seq+1, dim]
print(f" Text embedding shape: {text_embedding.shape}")
record = {
"id": video_name,
"vae_latent_bytes": latent.numpy().tobytes(),
"vae_latent_shape": list(latent.shape),
"vae_latent_dtype": str(latent.dtype).replace("torch.", ""),
"text_embedding_bytes": text_embedding.numpy().tobytes(),
"text_embedding_shape": list(text_embedding.shape),
"text_embedding_dtype": str(text_embedding.dtype).replace("torch.", ""),
"file_name": video_name,
"caption": caption,
"media_type": "video",
"width": MAX_WIDTH,
"height": MAX_HEIGHT,
"num_frames": NUM_FRAMES,
"duration_sec": NUM_FRAMES / TRAIN_FPS,
"fps": TRAIN_FPS,
}
records.append(record)
# Clean up encoders
del qwen_enc, qwen_tok, clip_enc, clip_tok, vae
# Write parquet
table = pa.table(
{k: [r[k] for r in records]
for k in records[0]},
schema=pyarrow_schema_t2v,
)
output_path = os.path.join(OUTPUT_DIR, "data_00000.parquet")
pq.write_table(table, output_path)
print(f"\nWrote {len(records)} records to {output_path}")
# Write validation prompts for callback.
# Wrap in "data" key -- ValidationDataset expects field="data".
# Use "caption" field -- ValidationDataset aliases it to "prompt".
val_prompts = {"data": [{"caption": get_caption(item)} for item in caption_data]}
val_path = os.path.join(OUTPUT_DIR, "validation_prompts.json")
with open(val_path, "w") as f:
json.dump(val_prompts, f, indent=2)
print(f"Wrote validation prompts to {val_path}")
print("\nDone! Use data_path: " + OUTPUT_DIR + " in training config.")
if __name__ == "__main__":
main()

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