Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f8e84d22e | ||
|
|
698173402f | ||
|
|
627ca97fda | ||
|
|
117302d153 | ||
|
|
ec36f7dfd6 | ||
|
|
61f213403d | ||
|
|
e6481b691c | ||
|
|
5d7b8690e1 | ||
|
|
e43336d31c | ||
|
|
a587f1d3e4 | ||
|
|
14ad388ee3 | ||
|
|
f962352c90 | ||
|
|
8c1170ca06 | ||
|
|
562b1d2361 | ||
|
|
a7c8d8bb79 | ||
|
|
e85c566f2f | ||
|
|
b446a6d9cf | ||
|
|
1d4505509a | ||
|
|
ec93f73983 | ||
|
|
6ac2174602 | ||
|
|
dff6f7757f | ||
|
|
0cd4a64aa3 | ||
|
|
d65406c48f | ||
|
|
842f24d974 | ||
|
|
1b2b2a0161 | ||
|
|
98d65835b5 | ||
|
|
422585d08f | ||
|
|
e59a1ce16a | ||
|
|
d71acc0eb5 | ||
|
|
af2934dd6b | ||
|
|
1801512818 | ||
|
|
5ae05b032e | ||
|
|
7a592ff09a | ||
|
|
8b23984c79 | ||
|
|
bf18371afe | ||
|
|
69349dd2aa | ||
|
|
8d89f30d3f | ||
|
|
10546353da |
@@ -404,7 +404,7 @@ steps:
|
||||
- "pyproject.toml"
|
||||
- "docker/Dockerfile"
|
||||
config:
|
||||
command: "timeout 15m .buildkite/scripts/pr_test.sh"
|
||||
command: "timeout 25m .buildkite/scripts/pr_test.sh"
|
||||
label: ":test_tube: Training Tests"
|
||||
env:
|
||||
- TEST_TYPE=training
|
||||
@@ -415,7 +415,7 @@ steps:
|
||||
- "pyproject.toml"
|
||||
- "docker/Dockerfile"
|
||||
config:
|
||||
command: "timeout 15m .buildkite/scripts/pr_test.sh"
|
||||
command: "timeout 25m .buildkite/scripts/pr_test.sh"
|
||||
label: ":test_tube: Distillation DMD Tests"
|
||||
env:
|
||||
- TEST_TYPE=distillation_dmd
|
||||
|
||||
@@ -13,16 +13,23 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
# Auto-rebuild the CUDA images when their Dockerfile changes on main. The CUDA
|
||||
# matrix is the only lane that builds from docker/Dockerfile, so a path-scoped
|
||||
# push trigger is a sufficient change detector on its own -- no separate
|
||||
# detect-changes/paths-filter job is needed now that there is a single
|
||||
# in-scope Dockerfile. Dreamverse (apps/dreamverse/docker/Dockerfile) and the
|
||||
# rocm Dockerfile stay manual-dispatch only.
|
||||
# Auto-rebuild the CUDA images when a repository-controlled image input
|
||||
# changes on main. This includes the trusted SM89 kernel artifact's source,
|
||||
# metadata/key helper, ABI dependency metadata, and build orchestration.
|
||||
# Dreamverse (apps/dreamverse/docker/Dockerfile) and the ROCm Dockerfile stay
|
||||
# manual-dispatch only.
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '.dockerignore'
|
||||
- '.github/workflows/_template-build-image.yml'
|
||||
- '.github/workflows/infra-build-image.yml'
|
||||
- '.gitmodules'
|
||||
- 'docker/Dockerfile'
|
||||
- 'docker/uv-excludes'
|
||||
- 'fastvideo-kernel/**'
|
||||
- 'fastvideo/tests/modal/kernel_build_cache.py'
|
||||
- 'pyproject.toml'
|
||||
|
||||
|
||||
permissions:
|
||||
@@ -50,7 +57,7 @@ jobs:
|
||||
# 2.8.3 comes from the architecture-specific prebuilt releases.
|
||||
build-cuda-images:
|
||||
# Runs on a manual dispatch when build_cuda_matrix is set, or automatically
|
||||
# on a push that changed docker/Dockerfile (inputs are null on push). The
|
||||
# on an in-scope main push (inputs are null on push). The
|
||||
# repository guard keeps fork syncs from auto-building; manual dispatch
|
||||
# still works in forks.
|
||||
if: ${{ (github.event_name == 'push' && github.repository == 'hao-ai-lab/FastVideo') || github.event.inputs.build_cuda_matrix == 'true' }}
|
||||
|
||||
@@ -75,6 +75,7 @@ docs/distillation/examples/
|
||||
*.pkl
|
||||
|
||||
# Reference videos (negations must come after the catch-all on line below)
|
||||
!fastvideo/tests/nightly/reference_video_*.mp4
|
||||
|
||||
# Static images
|
||||
!docs/assets/images/**/*.png
|
||||
|
||||
@@ -13,10 +13,9 @@ test.describe('create inference job', () => {
|
||||
test('creates a T2V job and shows it in the queue', async ({ page }) => {
|
||||
await page.goto('/inference');
|
||||
|
||||
// The "Create Job" button reveals a workload menu on hover; wait for the
|
||||
// T2V item to become visible before clicking so the CSS hover transition
|
||||
// can't race the click.
|
||||
await page.getByRole('button', { name: /create job/i }).hover();
|
||||
// The trigger opens a real menu on click, so this path works for touch,
|
||||
// mouse, and keyboard users.
|
||||
await page.getByRole('button', { name: /create job/i }).click();
|
||||
const t2vItem = page.getByRole('menuitem', { name: /T2V/i });
|
||||
await expect(t2vItem).toBeVisible();
|
||||
await t2vItem.click();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { API_BASE, skipWithoutMock } from './helpers';
|
||||
|
||||
/**
|
||||
* Gallery page: the seeded completed inference job surfaces as a media tile
|
||||
* (an <article> wrapping a <video>) captioned with its prompt.
|
||||
* with playback controls or an explicit media-error fallback.
|
||||
*/
|
||||
test.describe('gallery', () => {
|
||||
skipWithoutMock();
|
||||
@@ -30,12 +30,15 @@ test.describe('gallery', () => {
|
||||
page.getByRole('heading', { level: 1, name: 'Gallery' }),
|
||||
).toBeVisible();
|
||||
|
||||
// The completed job renders as an <article> containing a <video> tile.
|
||||
const tile = page
|
||||
.locator('article')
|
||||
.filter({ has: page.locator('video') });
|
||||
await expect(tile.first()).toBeVisible();
|
||||
const tile = page.locator('article').filter({ hasText: completed!.prompt });
|
||||
await expect(tile).toBeVisible();
|
||||
await expect(
|
||||
tile.locator('video').or(tile.getByText('Preview unavailable')),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByText(completed!.prompt)).toBeVisible();
|
||||
const video = tile.locator('video');
|
||||
if (await video.isVisible()) {
|
||||
await expect(video).toHaveAttribute('controls', '');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,74 @@ 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+681
@@ -9,6 +9,7 @@
|
||||
"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",
|
||||
@@ -1936,6 +1937,183 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -2017,6 +2195,494 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -2410,6 +3076,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-is-hydrated": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz",
|
||||
"integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { HeaderActionsProvider } from '@/components/shell/HeaderActionsContext';
|
||||
import { getDatasets, type Dataset } from '@/lib/api';
|
||||
|
||||
import DatasetsPage from './page';
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
getDatasets: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/datasets/AddDatasetButton', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/datasets/CreateDatasetModal', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/datasets/DatasetCard', () => ({
|
||||
default: ({ dataset }: { dataset: Dataset }) => <div>{dataset.name}</div>,
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<HeaderActionsProvider>
|
||||
<DatasetsPage />
|
||||
</HeaderActionsProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('DatasetsPage', () => {
|
||||
it('shows loading content before the initial request settles', async () => {
|
||||
let resolveDatasets: (datasets: Dataset[]) => void = () => {};
|
||||
vi.mocked(getDatasets).mockReturnValue(
|
||||
new Promise<Dataset[]>((resolve) => {
|
||||
resolveDatasets = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
renderPage();
|
||||
expect(screen.getByLabelText('Loading datasets')).toBeInTheDocument();
|
||||
expect(screen.queryByText('No datasets yet.')).not.toBeInTheDocument();
|
||||
|
||||
act(() => resolveDatasets([]));
|
||||
expect(await screen.findByText('No datasets yet.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows API failures separately from an empty list and retries', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.mocked(getDatasets).mockRejectedValueOnce(new Error('network down'));
|
||||
|
||||
renderPage();
|
||||
expect(
|
||||
await screen.findByText(/Could not load datasets from the Studio API/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('No datasets yet.')).not.toBeInTheDocument();
|
||||
|
||||
vi.mocked(getDatasets).mockResolvedValueOnce([]);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Try Again' }));
|
||||
expect(await screen.findByText('No datasets yet.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,14 @@
|
||||
'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';
|
||||
@@ -21,18 +23,28 @@ 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 {
|
||||
setDatasets(await getDatasets());
|
||||
setError(null);
|
||||
const next = await getDatasets();
|
||||
if (sequence === fetchSequence.current) {
|
||||
setDatasets(next);
|
||||
setError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch datasets:', err);
|
||||
// Distinguish an API outage from a genuinely empty list, so the user
|
||||
// isn't told they have no datasets when the server is unreachable.
|
||||
setError(err instanceof Error ? err.message : 'Failed to load datasets');
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -50,28 +62,67 @@ export default function DatasetsPage() {
|
||||
<HeaderActions>
|
||||
<AddDatasetButton />
|
||||
</HeaderActions>
|
||||
<main className="mx-auto flex w-full max-w-[850px] flex-col gap-6 px-4 pb-12">
|
||||
<div className="mx-auto flex w-full max-w-[850px] flex-col gap-6 px-4 pb-12">
|
||||
<Card className="p-6">
|
||||
<div>
|
||||
{error ? (
|
||||
<p className="py-8 text-center text-destructive">{error}</p>
|
||||
) : datasets.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
No datasets yet.
|
||||
</p>
|
||||
) : (
|
||||
datasets.map((ds) => (
|
||||
<DatasetCard
|
||||
key={ds.id}
|
||||
dataset={ds}
|
||||
onUpdated={fetchDatasets}
|
||||
onSelect={() => handleSelectDataset(ds)}
|
||||
<div aria-busy={isInitialLoading}>
|
||||
{isInitialLoading ? (
|
||||
<div
|
||||
aria-label="Loading datasets"
|
||||
className="flex flex-col gap-3 py-2"
|
||||
>
|
||||
{[0, 1, 2].map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="h-24 animate-pulse rounded-lg border border-border bg-muted/50"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : error && datasets.length === 0 ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col items-center gap-3 py-8 text-center"
|
||||
>
|
||||
<AlertTriangle
|
||||
className="size-6 text-destructive"
|
||||
aria-hidden
|
||||
/>
|
||||
))
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
{error}
|
||||
</p>
|
||||
<Button type="button" variant="outline" onClick={fetchDatasets}>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{error && (
|
||||
<p
|
||||
role="status"
|
||||
className="mb-3 rounded-lg border border-amber-500/50 bg-amber-500/10 px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
Dataset updates are temporarily unavailable. Showing the
|
||||
most recent results.
|
||||
</p>
|
||||
)}
|
||||
{datasets.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
No datasets yet.
|
||||
</p>
|
||||
) : (
|
||||
datasets.map((ds) => (
|
||||
<DatasetCard
|
||||
key={ds.id}
|
||||
dataset={ds}
|
||||
onUpdated={fetchDatasets}
|
||||
onSelect={() => handleSelectDataset(ds)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
<CreateDatasetModal
|
||||
isOpen={open}
|
||||
onClose={() => setCreateDatasetModalOpen(false)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import GalleryPage from './page';
|
||||
@@ -43,6 +43,22 @@ describe('GalleryPage', () => {
|
||||
expect(getJobsList).toHaveBeenCalledWith('inference');
|
||||
});
|
||||
|
||||
it('provides video controls and a visible fallback when media fails', async () => {
|
||||
vi.mocked(getJobsList).mockResolvedValue([makeJob()]);
|
||||
renderGallery();
|
||||
|
||||
const video = await screen.findByLabelText(
|
||||
'Generated video: a cat surfing a wave',
|
||||
);
|
||||
expect(video).toHaveAttribute('controls');
|
||||
|
||||
fireEvent.error(video);
|
||||
expect(screen.getByText('Preview unavailable')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('The generated file could not be loaded.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the empty state when no completed videos exist', async () => {
|
||||
vi.mocked(getJobsList).mockResolvedValue([
|
||||
makeJob({ status: 'running', output_path: null }),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { ImageOff, Loader2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Card } from '@/components/ui/card';
|
||||
@@ -11,6 +11,54 @@ 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);
|
||||
@@ -77,24 +125,7 @@ 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">
|
||||
{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"
|
||||
/>
|
||||
)}
|
||||
<GalleryMedia job={job} />
|
||||
</div>
|
||||
<p
|
||||
className="line-clamp-3 border-t border-border px-4 py-3 text-sm text-muted-foreground"
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
--border: #e2e8f0;
|
||||
--input: #cbd5e1;
|
||||
--ring: #94a3b8;
|
||||
--ring: #1d4ed8;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
--border: #334155;
|
||||
--input: #334155;
|
||||
--ring: #cbd5e1;
|
||||
--ring: #7dd3fc;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -125,6 +125,7 @@
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
html {
|
||||
@@ -163,6 +164,22 @@ 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const css = readFileSync(join(process.cwd(), 'src/app/globals.css'), 'utf8');
|
||||
|
||||
function token(block: string, name: string): string {
|
||||
const match = block.match(new RegExp(`--${name}:\\s*(#[0-9a-fA-F]{6})`));
|
||||
if (!match) throw new Error(`Missing --${name} token`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function luminance(hex: string): number {
|
||||
const channels = hex
|
||||
.slice(1)
|
||||
.match(/.{2}/g)!
|
||||
.map((channel) => parseInt(channel, 16) / 255)
|
||||
.map((channel) =>
|
||||
channel <= 0.04045
|
||||
? channel / 12.92
|
||||
: ((channel + 0.055) / 1.055) ** 2.4,
|
||||
);
|
||||
return (
|
||||
0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]
|
||||
);
|
||||
}
|
||||
|
||||
function contrast(first: string, second: string): number {
|
||||
const firstLuminance = luminance(first);
|
||||
const secondLuminance = luminance(second);
|
||||
return (
|
||||
(Math.max(firstLuminance, secondLuminance) + 0.05) /
|
||||
(Math.min(firstLuminance, secondLuminance) + 0.05)
|
||||
);
|
||||
}
|
||||
|
||||
describe('global focus styles', () => {
|
||||
it('keeps focus tokens above 3:1 against both page themes', () => {
|
||||
const light = css.match(/:root\s*{([\s\S]*?)\n}/)?.[1] ?? '';
|
||||
const dark = css.match(/\.dark\s*{([\s\S]*?)\n}/)?.[1] ?? '';
|
||||
|
||||
expect(contrast(token(light, 'ring'), token(light, 'background'))).toBeGreaterThanOrEqual(
|
||||
3,
|
||||
);
|
||||
expect(contrast(token(dark, 'ring'), token(dark, 'background'))).toBeGreaterThanOrEqual(
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
it('applies a non-animated three-pixel outline to focus-visible controls', () => {
|
||||
expect(css).toContain('):focus-visible {');
|
||||
expect(css).toContain('outline: 3px solid var(--ring) !important;');
|
||||
expect(css).toContain('outline-offset: 2px !important;');
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,8 @@ import GpuGrid from '@/components/system/GpuGrid';
|
||||
|
||||
export default function GpusPage() {
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-[1100px] flex-col gap-6 px-4 pb-12 pt-6">
|
||||
<div className="mx-auto flex w-full max-w-[1100px] flex-col gap-6 px-4 pb-12 pt-6">
|
||||
<GpuGrid />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,20 @@ 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,6 +25,16 @@ 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();
|
||||
@@ -71,7 +81,7 @@ describe('DatasetCard', () => {
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('selects on keyboard activation of the card body but not of the Delete button', () => {
|
||||
it('keeps the selection and delete actions separate', () => {
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<DatasetCard dataset={dataset} onUpdated={() => {}} onSelect={onSelect} />,
|
||||
@@ -83,8 +93,12 @@ describe('DatasetCard', () => {
|
||||
});
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
|
||||
// Activating the card body itself does select.
|
||||
fireEvent.keyDown(screen.getByText('My Dataset'), { key: 'Enter' });
|
||||
// Activating the dedicated selection button selects the dataset.
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: /My Dataset.*3 files.*2.0 KB/,
|
||||
}),
|
||||
);
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -55,43 +55,33 @@ 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 (
|
||||
<div
|
||||
<article
|
||||
className={cn(
|
||||
'mb-3 flex cursor-pointer flex-col gap-[0.6rem] rounded-lg border border-border bg-background px-[1.15rem] py-4',
|
||||
'mb-3 flex items-start gap-3 rounded-lg border border-border bg-background px-[1.15rem] py-4',
|
||||
isSelected && 'border-accent-blue bg-accent-blue/5',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
onSelect();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={isSelected}
|
||||
onClick={onSelect}
|
||||
className="flex min-w-0 flex-1 cursor-pointer flex-col gap-[0.6rem] rounded-md text-left"
|
||||
>
|
||||
<span className="text-[0.95rem] font-semibold">{dataset.name}</span>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ 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);
|
||||
|
||||
@@ -27,6 +30,27 @@ 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={() => {}} />);
|
||||
|
||||
@@ -43,6 +67,16 @@ 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');
|
||||
@@ -99,6 +133,39 @@ 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,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { ImageOff, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import DownloadCaptions from '@/components/datasets/DownloadCaptions';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -13,12 +14,14 @@ 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).
|
||||
@@ -27,54 +30,101 @@ 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 && (
|
||||
{!thumbLoaded && !mediaFailed && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-background/70">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground/40 border-t-accent-blue" />
|
||||
</div>
|
||||
)}
|
||||
{/* 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)}
|
||||
/>
|
||||
{mediaFailed ? (
|
||||
<div
|
||||
role="status"
|
||||
className="flex aspect-video w-full flex-col items-center justify-center gap-1 bg-muted px-2 text-center text-muted-foreground"
|
||||
>
|
||||
<ImageOff className="size-5" aria-hidden />
|
||||
<span className="text-xs">Preview unavailable</span>
|
||||
</div>
|
||||
) : (
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<video
|
||||
src={mediaUrl}
|
||||
aria-label={`Preview of ${fileName}`}
|
||||
className="aspect-video w-full bg-border object-cover"
|
||||
muted
|
||||
autoPlay
|
||||
loop
|
||||
playsInline
|
||||
onLoadedData={() => onThumbLoaded(fileName)}
|
||||
onError={() => {
|
||||
setMediaFailed(true);
|
||||
onThumbLoaded(fileName);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Textarea
|
||||
aria-label={`Caption for ${fileName}`}
|
||||
value={caption}
|
||||
onChange={(e) => onCaptionChange(fileName, e.target.value)}
|
||||
placeholder="Caption"
|
||||
rows={2}
|
||||
className="min-h-[2.5rem] resize-y rounded-none border-0 bg-transparent p-1.5 text-xs shadow-none focus-visible:border-transparent focus-visible:ring-0"
|
||||
/>
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="flex min-h-6 items-center px-1.5 pb-1 text-[0.7rem] text-muted-foreground"
|
||||
>
|
||||
{saveState === 'saving' && <span>Saving…</span>}
|
||||
{saveState === 'saved' && <span>Saved</span>}
|
||||
{saveState === 'error' && (
|
||||
<span role="alert" className="text-destructive">
|
||||
Not saved.{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCaptionRetry(fileName, caption)}
|
||||
className="inline-flex min-h-11 items-center font-medium underline underline-offset-2"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default function DatasetSidebar({
|
||||
dataset,
|
||||
isMobile = false,
|
||||
onClose,
|
||||
onWidthChange,
|
||||
}: {
|
||||
dataset: Dataset;
|
||||
isMobile?: boolean;
|
||||
onClose: () => void;
|
||||
onWidthChange?: (w: number) => void;
|
||||
}) {
|
||||
const drawerRef = useDrawerFocus<HTMLElement>(isMobile);
|
||||
const [width, setWidth] = React.useState(400);
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
const [fileNames, setFileNames] = React.useState<string[]>([]);
|
||||
@@ -84,17 +134,21 @@ 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?.(width);
|
||||
}, [width, onWidthChange]);
|
||||
onWidthChange?.(isMobile ? 0 : width);
|
||||
}, [isMobile, width, onWidthChange]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -106,6 +160,8 @@ 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(() => {
|
||||
@@ -138,23 +194,51 @@ 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 save = () => {
|
||||
updateDatasetCaption(datasetId, fileName, value).catch((err) =>
|
||||
console.error('Failed to save caption:', err),
|
||||
);
|
||||
};
|
||||
const version = (captionVersions.current.get(fileName) ?? 0) + 1;
|
||||
captionVersions.current.set(fileName, version);
|
||||
const save = () => persistCaption(fileName, value, version);
|
||||
const timer = setTimeout(() => {
|
||||
pendingSaves.current.delete(fileName);
|
||||
save();
|
||||
}, 500);
|
||||
pendingSaves.current.set(fileName, { timer, save });
|
||||
},
|
||||
[datasetId],
|
||||
[persistCaption],
|
||||
);
|
||||
|
||||
const handleCaptionRetry = React.useCallback(
|
||||
(fileName: string, value: string) => {
|
||||
const version = (captionVersions.current.get(fileName) ?? 0) + 1;
|
||||
captionVersions.current.set(fileName, version);
|
||||
persistCaption(fileName, value, version);
|
||||
},
|
||||
[persistCaption],
|
||||
);
|
||||
|
||||
function handleScroll() {
|
||||
@@ -189,8 +273,16 @@ export default function DatasetSidebar({
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="fixed bottom-0 right-0 top-[var(--header-height)] z-50 flex max-h-[calc(100vh-var(--header-height))] min-w-[320px] shrink-0 flex-col border-l border-border bg-card"
|
||||
style={{ width, maxWidth: SIDEBAR_MAX_WIDTH }}
|
||||
ref={drawerRef}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-label={`${dataset.name} dataset details`}
|
||||
aria-modal={isMobile || undefined}
|
||||
className="fixed bottom-0 right-0 top-[var(--header-height)] z-50 flex max-h-[calc(100dvh-var(--header-height))] min-w-0 shrink-0 flex-col border-l border-border bg-card md:min-w-[320px]"
|
||||
style={{
|
||||
width: isMobile ? '100%' : width,
|
||||
maxWidth: isMobile ? 'none' : SIDEBAR_MAX_WIDTH,
|
||||
}}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border px-5 py-4">
|
||||
<h2 className="m-0 min-w-0 truncate text-base font-semibold text-foreground">
|
||||
@@ -203,7 +295,7 @@ export default function DatasetSidebar({
|
||||
onClick={onClose}
|
||||
title="Close"
|
||||
aria-label="Close"
|
||||
className="flex items-center justify-center rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
className="flex size-11 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<X className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
@@ -231,7 +323,9 @@ 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}
|
||||
/>
|
||||
))}
|
||||
@@ -240,14 +334,14 @@ export default function DatasetSidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
{!isMobile && <div
|
||||
role="presentation"
|
||||
onMouseDown={onMouseDown}
|
||||
className={cn(
|
||||
'absolute bottom-0 left-0 top-0 z-[1] w-1.5 cursor-col-resize hover:bg-accent-blue/25',
|
||||
isDragging && 'bg-accent-blue/25',
|
||||
)}
|
||||
/>
|
||||
/>}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { downloadBlob } from '@/lib/utils';
|
||||
|
||||
import DownloadCaptions from './DownloadCaptions';
|
||||
|
||||
vi.mock('@/lib/utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/utils')>();
|
||||
return { ...actual, downloadBlob: vi.fn() };
|
||||
});
|
||||
|
||||
const props = {
|
||||
fileNames: ['b.mp4', 'a.mp4'],
|
||||
captions: { 'a.mp4': 'cap a', 'b.mp4': 'cap b' },
|
||||
};
|
||||
|
||||
describe('DownloadCaptions', () => {
|
||||
it('opens the format menu on click and downloads the selection', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DownloadCaptions {...props} />);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Download Captions' }),
|
||||
);
|
||||
await user.click(screen.getByRole('menuitem', { name: 'JSON' }));
|
||||
|
||||
expect(vi.mocked(downloadBlob)).toHaveBeenCalledWith(
|
||||
expect.any(Blob),
|
||||
'videos2caption.json',
|
||||
);
|
||||
});
|
||||
|
||||
it('operates entirely from the keyboard', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DownloadCaptions {...props} />);
|
||||
|
||||
screen.getByRole('button', { name: 'Download Captions' }).focus();
|
||||
await user.keyboard('{Enter}');
|
||||
const first = await screen.findByRole('menuitem', { name: 'JSON' });
|
||||
expect(first).toHaveFocus();
|
||||
await user.keyboard('{ArrowDown}{Enter}');
|
||||
|
||||
expect(vi.mocked(downloadBlob)).toHaveBeenCalledWith(
|
||||
expect.any(Blob),
|
||||
'videos.txt',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
'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 w-full cursor-pointer px-4 py-2 text-left text-sm font-medium text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50';
|
||||
'block min-h-11 w-full cursor-pointer select-none px-4 py-2 text-left text-sm font-medium text-foreground outline-none transition-colors data-[highlighted]:bg-muted';
|
||||
|
||||
export default function DownloadCaptions({
|
||||
fileNames,
|
||||
@@ -62,52 +63,40 @@ 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 (
|
||||
<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"
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<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>
|
||||
Download Captions
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-85" aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
collisionPadding={8}
|
||||
className="z-[200] min-w-40 overflow-hidden rounded-lg border border-border bg-popover py-1 text-popover-foreground shadow-lg"
|
||||
>
|
||||
<DropdownMenu.Item className={MENU_ITEM} onSelect={handleDownloadJson}>
|
||||
JSON
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item className={MENU_ITEM} onSelect={handleDownloadTxt}>
|
||||
TXT
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item className={MENU_ITEM} onSelect={handleDownloadCsv}>
|
||||
CSV
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import CreateJobButton from './CreateJobButton';
|
||||
|
||||
vi.mock('./CreateJobModal', () => ({
|
||||
default: ({
|
||||
isOpen,
|
||||
workloadType,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
workloadType: string;
|
||||
}) =>
|
||||
isOpen ? (
|
||||
<div role="dialog" data-workload-type={workloadType}>
|
||||
Create job form
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
describe('CreateJobButton', () => {
|
||||
it('opens the workload menu on click and selects an item', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CreateJobButton jobType="inference" />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create Job' }));
|
||||
await user.click(screen.getByRole('menuitem', { name: /I2V/i }));
|
||||
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute(
|
||||
'data-workload-type',
|
||||
'i2v',
|
||||
);
|
||||
});
|
||||
|
||||
it('opens and operates the workload menu from the keyboard', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CreateJobButton jobType="inference" />);
|
||||
|
||||
const trigger = screen.getByRole('button', { name: 'Create Job' });
|
||||
trigger.focus();
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
const firstItem = await screen.findByRole('menuitem', { name: /T2V/i });
|
||||
expect(firstItem).toHaveFocus();
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute(
|
||||
'data-workload-type',
|
||||
't2v',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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';
|
||||
@@ -33,31 +34,35 @@ export default function CreateJobButton({ jobType }: CreateJobButtonProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="group relative inline-block">
|
||||
<Button type="button" className="gap-1.5">
|
||||
Create Job
|
||||
<ChevronDown className="size-3.5 opacity-85" aria-hidden />
|
||||
</Button>
|
||||
<div
|
||||
role="menu"
|
||||
className="invisible absolute right-0 top-full z-[200] mt-1 min-w-full -translate-y-1 rounded-lg border border-border bg-popover py-1 opacity-0 shadow-lg transition-all duration-150 group-hover:visible group-hover:translate-y-0 group-hover:opacity-100"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.type}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => openModal(opt.type)}
|
||||
className="block w-full whitespace-nowrap px-4 py-2 text-left text-sm font-medium text-popover-foreground transition-colors hover:bg-secondary"
|
||||
>
|
||||
{opt.label}
|
||||
<span className="mt-0.5 block text-xs font-normal text-muted-foreground">
|
||||
{opt.desc}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<Button type="button" className="gap-1.5">
|
||||
Create Job
|
||||
<ChevronDown className="size-3.5 opacity-85" aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
collisionPadding={8}
|
||||
className="z-[200] min-w-48 overflow-hidden rounded-lg border border-border bg-popover py-1 text-popover-foreground shadow-lg"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<DropdownMenu.Item
|
||||
key={opt.type}
|
||||
onSelect={() => openModal(opt.type)}
|
||||
className="flex min-h-11 cursor-pointer select-none flex-col justify-center px-4 py-2 text-left text-sm font-medium outline-none data-[highlighted]:bg-secondary"
|
||||
>
|
||||
{opt.label}
|
||||
<span className="mt-0.5 block text-xs font-normal text-muted-foreground">
|
||||
{opt.desc}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
<CreateJobModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
|
||||
@@ -50,6 +50,57 @@ 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,6 +103,17 @@ 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
|
||||
@@ -144,6 +155,10 @@ export default function CreateJobModal({
|
||||
setImageFileName('');
|
||||
setSelectedDatasetId('');
|
||||
setSelectedValidationDatasetId('');
|
||||
setModelLoadError(null);
|
||||
setDatasetLoadError(null);
|
||||
setImageUploadError(null);
|
||||
setSubmitError(null);
|
||||
if (workloadType === 'dmd_t2v') {
|
||||
setDmdUseVsa(false);
|
||||
setDmdVsaSparsity(0.8);
|
||||
@@ -162,6 +177,7 @@ 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;
|
||||
@@ -180,7 +196,13 @@ export default function CreateJobModal({
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!stale) console.error('Failed to load models:', e);
|
||||
if (stale) return;
|
||||
console.error('Failed to load models:', e);
|
||||
setModels([]);
|
||||
setModelId('');
|
||||
setModelLoadError(
|
||||
'Models could not be loaded. Check the Studio API and reopen this form to try again.',
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!stale) setIsLoadingModels(false);
|
||||
@@ -193,11 +215,22 @@ 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(() => setReadyDatasets([]));
|
||||
.catch((error) => {
|
||||
console.error('Failed to load datasets:', error);
|
||||
setReadyDatasets([]);
|
||||
setDatasetLoadError(
|
||||
'Datasets could not be loaded. Check the Studio API and reopen this form to try again.',
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoadingDatasets(false));
|
||||
} else {
|
||||
setReadyDatasets([]);
|
||||
setIsLoadingDatasets(false);
|
||||
setDatasetLoadError(null);
|
||||
}
|
||||
}, [isOpen, isInference]);
|
||||
|
||||
@@ -206,16 +239,24 @@ 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 {
|
||||
} catch (error) {
|
||||
console.error('Failed to upload image:', error);
|
||||
setImagePath('');
|
||||
setImageFileName('');
|
||||
setImageUploadError(
|
||||
error instanceof Error
|
||||
? `${error.message}. Choose the image again to retry.`
|
||||
: 'The image could not be uploaded. Choose it again to retry.',
|
||||
);
|
||||
} finally {
|
||||
setIsUploadingImage(false);
|
||||
}
|
||||
@@ -224,6 +265,7 @@ export default function CreateJobModal({
|
||||
function clearImage() {
|
||||
setImagePath('');
|
||||
setImageFileName('');
|
||||
setImageUploadError(null);
|
||||
if (imageInputRef.current) imageInputRef.current.value = '';
|
||||
}
|
||||
|
||||
@@ -239,6 +281,7 @@ export default function CreateJobModal({
|
||||
workloadType === 'lora_t2v' ? 'lora' : jobType
|
||||
) as JobType;
|
||||
setIsSubmitting(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const payload: CreateJobRequest = {
|
||||
model_id: modelId,
|
||||
@@ -296,6 +339,11 @@ 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);
|
||||
}
|
||||
@@ -343,7 +391,11 @@ export default function CreateJobModal({
|
||||
value={modelId}
|
||||
onChange={(e) => setModelId(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting || isLoadingModels}
|
||||
aria-describedby={
|
||||
modelLoadError ? 'modal-model-error' : undefined
|
||||
}
|
||||
aria-invalid={modelLoadError ? true : undefined}
|
||||
disabled={isSubmitting || isLoadingModels || !!modelLoadError}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{isLoadingModels
|
||||
@@ -358,6 +410,15 @@ 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' && (
|
||||
@@ -369,6 +430,10 @@ 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"
|
||||
/>
|
||||
@@ -385,6 +450,15 @@ export default function CreateJobModal({
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{imageUploadError && (
|
||||
<p
|
||||
id="modal-image-error"
|
||||
role="alert"
|
||||
className="text-sm text-destructive"
|
||||
>
|
||||
{imageUploadError}
|
||||
</p>
|
||||
)}
|
||||
</FieldRow>
|
||||
)}
|
||||
|
||||
@@ -432,12 +506,22 @@ export default function CreateJobModal({
|
||||
id="modal-dataset"
|
||||
value={selectedDatasetId}
|
||||
onChange={(e) => setSelectedDatasetId(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
aria-describedby={
|
||||
datasetLoadError ? 'modal-dataset-error' : undefined
|
||||
}
|
||||
aria-invalid={datasetLoadError ? true : undefined}
|
||||
disabled={
|
||||
isSubmitting || isLoadingDatasets || !!datasetLoadError
|
||||
}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{readyDatasets.length === 0
|
||||
? 'No datasets (add in Datasets tab)'
|
||||
: 'Select a dataset…'}
|
||||
{isLoadingDatasets
|
||||
? 'Loading datasets…'
|
||||
: datasetLoadError
|
||||
? 'Datasets unavailable'
|
||||
: readyDatasets.length === 0
|
||||
? 'No datasets (add in Datasets tab)'
|
||||
: 'Select a dataset…'}
|
||||
</option>
|
||||
{readyDatasets.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
@@ -445,6 +529,15 @@ 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"
|
||||
@@ -457,7 +550,9 @@ export default function CreateJobModal({
|
||||
onChange={(e) =>
|
||||
setSelectedValidationDatasetId(e.target.value)
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
disabled={
|
||||
isSubmitting || isLoadingDatasets || !!datasetLoadError
|
||||
}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{readyDatasets.map((d) => (
|
||||
@@ -811,9 +906,22 @@ export default function CreateJobModal({
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Creating...' : 'Create Job'}
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
{submitError && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{submitError}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
isUploadingImage ||
|
||||
!!modelLoadError ||
|
||||
!!datasetLoadError
|
||||
}
|
||||
>
|
||||
{isSubmitting ? 'Creating…' : 'Create Job'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -20,6 +20,14 @@ 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',
|
||||
@@ -46,6 +54,16 @@ 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,18 +119,10 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectJob(e: React.MouseEvent | React.KeyboardEvent) {
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
function handleSelectJob() {
|
||||
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();
|
||||
@@ -148,11 +140,7 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleSelectJob}
|
||||
onKeyDown={handleKeyDown}
|
||||
<article
|
||||
className={cn(
|
||||
'mb-3 flex cursor-pointer flex-col gap-2.5 rounded-lg border bg-background p-4 transition-colors last:mb-0',
|
||||
isSelected
|
||||
@@ -160,35 +148,42 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
|
||||
: 'border-border hover:border-muted-foreground/40',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-[0.95rem] font-semibold text-foreground">
|
||||
{job.model_id}
|
||||
</span>
|
||||
<Badge variant={BADGE_VARIANTS[job.status] ?? 'secondary'}>
|
||||
{job.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-muted-foreground">
|
||||
{job.prompt}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
|
||||
{job.job_type === 'inference' ? (
|
||||
<>
|
||||
<span>{job.num_frames} frames</span>
|
||||
<span>
|
||||
{job.height}×{job.width}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{job.workload_type?.replace(/_/g, ' ') ?? job.job_type}</span>
|
||||
)}
|
||||
{elapsedTime && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Timer className="size-3.5" aria-hidden />
|
||||
{elapsedTime}
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant={BADGE_VARIANTS[job.status] ?? 'secondary'}>
|
||||
{job.status}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-muted-foreground">
|
||||
{job.prompt}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
|
||||
{job.job_type === 'inference' ? (
|
||||
<>
|
||||
<span>{job.num_frames} frames</span>
|
||||
<span>
|
||||
{job.height}×{job.width}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{job.workload_type?.replace(/_/g, ' ') ?? job.job_type}</span>
|
||||
)}
|
||||
{elapsedTime && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Timer className="size-3.5" aria-hidden />
|
||||
{elapsedTime}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{job.status === 'running' ? (
|
||||
<Button
|
||||
@@ -240,6 +235,6 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,32 @@ 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,6 +4,7 @@ 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';
|
||||
@@ -15,13 +16,16 @@ 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);
|
||||
@@ -53,8 +57,8 @@ export default function JobDetailsSidebar({
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
onWidthChange?.(width);
|
||||
}, [width, onWidthChange]);
|
||||
onWidthChange?.(isMobile ? 0 : width);
|
||||
}, [isMobile, width, onWidthChange]);
|
||||
|
||||
// Auto-scroll the console to the bottom whenever new lines land. Runs after
|
||||
// commit so scrollHeight reflects the freshly-rendered output.
|
||||
@@ -137,8 +141,16 @@ export default function JobDetailsSidebar({
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="fixed bottom-0 right-0 top-[var(--header-height)] z-50 flex max-h-[calc(100vh-var(--header-height))] min-w-[280px] shrink-0 flex-col border-l border-border bg-card"
|
||||
style={{ width, maxWidth: SIDEBAR_MAX_WIDTH }}
|
||||
ref={drawerRef}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-label="Job details"
|
||||
aria-modal={isMobile || undefined}
|
||||
className="fixed bottom-0 right-0 top-[var(--header-height)] z-50 flex max-h-[calc(100dvh-var(--header-height))] min-w-0 shrink-0 flex-col border-l border-border bg-card md:min-w-[280px]"
|
||||
style={{
|
||||
width: isMobile ? '100%' : width,
|
||||
maxWidth: isMobile ? 'none' : SIDEBAR_MAX_WIDTH,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<h2 className="m-0 text-base font-semibold text-foreground">
|
||||
@@ -195,14 +207,14 @@ export default function JobDetailsSidebar({
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div
|
||||
{!isMobile && <div
|
||||
role="presentation"
|
||||
onMouseDown={onMouseDown}
|
||||
className={cn(
|
||||
'absolute bottom-0 left-0 top-0 z-[1] w-1.5 cursor-col-resize hover:bg-accent-blue/25',
|
||||
isDragging && 'bg-accent-blue/25',
|
||||
)}
|
||||
/>
|
||||
/>}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import JobQueue from '@/components/jobs/JobQueue';
|
||||
@@ -37,6 +37,46 @@ 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,8 +1,10 @@
|
||||
'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';
|
||||
@@ -32,6 +34,8 @@ 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);
|
||||
|
||||
@@ -71,11 +75,22 @@ export default function JobQueue({ jobType, jobTypesForList }: JobQueueProps) {
|
||||
new Date(a.created_at ?? 0).getTime(),
|
||||
);
|
||||
}
|
||||
if (seq === fetchSeq.current) setJobs(next);
|
||||
if (seq === fetchSeq.current) {
|
||||
setJobs(next);
|
||||
setError(null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch jobs:', e);
|
||||
if (seq === fetchSeq.current) {
|
||||
setError(
|
||||
'Could not load jobs from the Studio API. Check the server and try again.',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (seq === fetchSeq.current) inFlight.current = false;
|
||||
if (seq === fetchSeq.current) {
|
||||
inFlight.current = false;
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
}
|
||||
}, [typesKey]);
|
||||
|
||||
@@ -122,20 +137,59 @@ export default function JobQueue({ jobType, jobTypesForList }: JobQueueProps) {
|
||||
const multiType = typesToFetch.length > 1;
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-[850px] flex-col gap-6 px-4 pb-12">
|
||||
<div className="mx-auto flex w-full max-w-[850px] flex-col gap-6 px-4 pb-12">
|
||||
<section className="p-6">
|
||||
<div>
|
||||
{jobs.length === 0 ? (
|
||||
<div aria-busy={isInitialLoading}>
|
||||
{isInitialLoading ? (
|
||||
<div
|
||||
aria-label="Loading jobs"
|
||||
className="flex flex-col gap-3 py-2"
|
||||
>
|
||||
{[0, 1, 2].map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="h-32 animate-pulse rounded-lg border border-border bg-muted/50"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : error && jobs.length === 0 ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col items-center gap-3 py-8 text-center"
|
||||
>
|
||||
<AlertTriangle
|
||||
className="size-6 text-destructive"
|
||||
aria-hidden
|
||||
/>
|
||||
<p className="max-w-md text-sm text-muted-foreground">{error}</p>
|
||||
<Button type="button" variant="outline" onClick={fetchJobs}>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{error && (
|
||||
<p
|
||||
role="status"
|
||||
className="mb-3 rounded-lg border border-amber-500/50 bg-amber-500/10 px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
Job updates are temporarily unavailable. Showing the most
|
||||
recent results.
|
||||
</p>
|
||||
)}
|
||||
{jobs.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
No {multiType ? 'jobs' : `${jobType} jobs`} yet. Create one above.
|
||||
</p>
|
||||
) : (
|
||||
jobs.map((job) => (
|
||||
<JobCard key={job.id} job={job} onJobUpdated={fetchJobs} />
|
||||
))
|
||||
) : (
|
||||
jobs.map((job) => (
|
||||
<JobCard key={job.id} job={job} onJobUpdated={fetchJobs} />
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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,
|
||||
@@ -23,46 +24,80 @@ 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]')) {
|
||||
if (activeJobStore.get().activeJob) setActiveJobId(null);
|
||||
if (activeDatasetStore.get().activeDataset) setActiveDatasetId(null);
|
||||
if (e.key !== 'Escape' || document.querySelector('[data-modal]')) return;
|
||||
if (primaryOpen) {
|
||||
setPrimaryOpen(false);
|
||||
return;
|
||||
}
|
||||
if (activeJobStore.get().activeJob) setActiveJobId(null);
|
||||
if (activeDatasetStore.get().activeDataset) setActiveDatasetId(null);
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
}, [primaryOpen]);
|
||||
|
||||
return (
|
||||
<HeaderActionsProvider>
|
||||
<Header />
|
||||
<div
|
||||
style={{ display: 'contents' }}
|
||||
inert={drawerModal ? true : undefined}
|
||||
>
|
||||
<Header
|
||||
navigationOpen={primaryOpen}
|
||||
onNavigationToggle={() => setPrimaryOpen((open) => !open)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex overflow-hidden"
|
||||
style={{
|
||||
marginTop: 'var(--header-height)',
|
||||
height: 'calc(100vh - var(--header-height))',
|
||||
height: 'calc(100dvh - var(--header-height))',
|
||||
}}
|
||||
>
|
||||
<PrimarySidebar onWidthChange={setPrimaryWidth} />
|
||||
<PrimarySidebar
|
||||
isMobile={isMobile}
|
||||
mobileOpen={primaryOpen}
|
||||
onMobileClose={() => setPrimaryOpen(false)}
|
||||
onWidthChange={setPrimaryWidth}
|
||||
/>
|
||||
{primaryOpen && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close navigation"
|
||||
onClick={() => setPrimaryOpen(false)}
|
||||
className="fixed inset-x-0 bottom-0 top-[var(--header-height)] z-40 bg-black/55 md:hidden"
|
||||
/>
|
||||
)}
|
||||
<main
|
||||
className="flex min-w-0 flex-1 flex-col overflow-auto"
|
||||
inert={drawerModal ? true : undefined}
|
||||
style={{
|
||||
marginLeft: primaryWidth,
|
||||
marginRight: secondaryOpen ? secondaryWidth : 0,
|
||||
marginLeft: isMobile ? 0 : primaryWidth,
|
||||
marginRight: isMobile || !secondaryOpen ? 0 : secondaryWidth,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -70,6 +105,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
{jobSidebarOpen && activeJob && (
|
||||
<JobDetailsSidebar
|
||||
job={activeJob}
|
||||
isMobile={isMobile}
|
||||
onClose={() => setActiveJobId(null)}
|
||||
onWidthChange={setSecondaryWidth}
|
||||
/>
|
||||
@@ -77,6 +113,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
{datasetSidebarOpen && activeDataset && (
|
||||
<DatasetSidebar
|
||||
dataset={activeDataset}
|
||||
isMobile={isMobile}
|
||||
onClose={() => setActiveDatasetId(null)}
|
||||
onWidthChange={setSecondaryWidth}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'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> = {
|
||||
@@ -15,25 +17,47 @@ const TAB_TITLES: Record<string, string> = {
|
||||
'/settings': 'Settings',
|
||||
};
|
||||
|
||||
export default function Header() {
|
||||
export default function Header({
|
||||
navigationOpen,
|
||||
onNavigationToggle,
|
||||
}: {
|
||||
navigationOpen: boolean;
|
||||
onNavigationToggle: () => void;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const { actions } = useHeaderActions();
|
||||
const title = TAB_TITLES[pathname] ?? 'FastVideo';
|
||||
|
||||
return (
|
||||
<header className="fixed inset-x-0 top-0 z-[100] flex h-[var(--header-height)] items-center gap-6 border-b border-border bg-background/80 px-6 backdrop-blur">
|
||||
<header className="fixed inset-x-0 top-0 z-[100] flex h-[var(--header-height)] items-center gap-2 border-b border-border bg-background/80 px-2 backdrop-blur sm:px-4 md:gap-6 md:px-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={navigationOpen ? 'Close navigation' : 'Open navigation'}
|
||||
aria-controls="primary-navigation"
|
||||
aria-expanded={navigationOpen}
|
||||
onClick={onNavigationToggle}
|
||||
className="shrink-0 md:hidden"
|
||||
>
|
||||
{navigationOpen ? (
|
||||
<X className="size-5" aria-hidden />
|
||||
) : (
|
||||
<Menu className="size-5" aria-hidden />
|
||||
)}
|
||||
</Button>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="FastVideo Logo"
|
||||
width={100}
|
||||
height={42}
|
||||
className="block h-[42px] w-[100px]"
|
||||
className="hidden h-[42px] w-[78px] shrink-0 object-contain min-[361px]:block md:w-[100px]"
|
||||
/>
|
||||
<h1 className="m-0 flex-1 text-xl font-semibold tracking-tight">
|
||||
<h1 className="sr-only m-0 flex-1 text-xl font-semibold tracking-tight md:not-sr-only">
|
||||
{title}
|
||||
</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="ml-auto flex min-w-0 items-center gap-2 md:gap-3">
|
||||
{actions}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
@@ -19,12 +20,18 @@ const JOB_ROUTES = [
|
||||
] as const;
|
||||
|
||||
const TAB_BASE =
|
||||
'block px-5 py-[0.65rem] text-left text-sm text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground';
|
||||
'block min-h-11 px-5 py-[0.65rem] text-left text-sm text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground';
|
||||
const TAB_ACTIVE = 'bg-accent-blue/10 font-medium text-accent-blue';
|
||||
|
||||
export default function PrimarySidebar({
|
||||
isMobile,
|
||||
mobileOpen,
|
||||
onMobileClose,
|
||||
onWidthChange,
|
||||
}: {
|
||||
isMobile: boolean;
|
||||
mobileOpen: boolean;
|
||||
onMobileClose: () => void;
|
||||
onWidthChange?: (w: number) => void;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
@@ -38,8 +45,8 @@ export default function PrimarySidebar({
|
||||
const isJobsActive = JOB_ROUTES.some((r) => pathname === r.href);
|
||||
|
||||
React.useEffect(() => {
|
||||
onWidthChange?.(layoutWidth);
|
||||
}, [layoutWidth, onWidthChange]);
|
||||
onWidthChange?.(isMobile ? 0 : layoutWidth);
|
||||
}, [isMobile, layoutWidth, onWidthChange]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (JOB_ROUTES.some((r) => pathname === r.href)) {
|
||||
@@ -58,11 +65,37 @@ export default function PrimarySidebar({
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="fixed bottom-0 left-0 top-[var(--header-height)] z-50 flex max-h-[calc(100vh-var(--header-height))] shrink-0 flex-col border-r border-border bg-card"
|
||||
style={{ width: effectiveWidth }}
|
||||
id="primary-navigation"
|
||||
aria-hidden={isMobile && !mobileOpen}
|
||||
inert={isMobile && !mobileOpen ? true : undefined}
|
||||
className={cn(
|
||||
'fixed bottom-0 left-0 top-[var(--header-height)] z-50 flex max-h-[calc(100dvh-var(--header-height))] shrink-0 flex-col border-r border-border bg-card transition-transform duration-200 md:translate-x-0',
|
||||
mobileOpen ? 'translate-x-0' : '-translate-x-full',
|
||||
)}
|
||||
style={{
|
||||
width: isMobile
|
||||
? 'min(18rem, calc(100vw - 3rem))'
|
||||
: effectiveWidth,
|
||||
}}
|
||||
>
|
||||
{isMobile && (
|
||||
<div className="flex h-14 items-center justify-between border-b border-border px-4">
|
||||
<span className="text-sm font-semibold">Navigation</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMobileClose}
|
||||
aria-label="Close navigation"
|
||||
className="flex size-11 items-center justify-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<X className="size-5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<nav className="flex flex-col py-2">
|
||||
<nav
|
||||
aria-label="Primary navigation"
|
||||
className="flex flex-col overflow-y-auto py-2"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
@@ -95,6 +128,8 @@ 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]',
|
||||
@@ -109,24 +144,32 @@ 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
|
||||
@@ -134,7 +177,7 @@ export default function PrimarySidebar({
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<div
|
||||
{!isMobile && <div
|
||||
className={cn(
|
||||
'absolute bottom-0 p-2',
|
||||
isCollapsed ? '-right-[60px] top-0' : 'right-0',
|
||||
@@ -145,8 +188,7 @@ export default function PrimarySidebar({
|
||||
onClick={() => setIsCollapsed((v) => !v)}
|
||||
title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
className={cn(
|
||||
'flex items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground',
|
||||
isCollapsed ? 'p-3' : 'p-2',
|
||||
'flex size-11 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
@@ -159,9 +201,9 @@ export default function PrimarySidebar({
|
||||
<path d={isCollapsed ? 'M9 18l6-6-6-6' : 'M15 18l-6-6 6-6'} />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{!isCollapsed && (
|
||||
{!isMobile && !isCollapsed && (
|
||||
<div
|
||||
role="presentation"
|
||||
onMouseDown={onMouseDown}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import GpuGrid from './GpuGrid';
|
||||
@@ -73,4 +73,30 @@ 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,7 +1,9 @@
|
||||
'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';
|
||||
@@ -97,7 +99,8 @@ function GpuCard({ gpu }: { gpu: GpuInfo }) {
|
||||
|
||||
export default function GpuGrid() {
|
||||
const [snapshot, setSnapshot] = React.useState<GpuSnapshot | null>(null);
|
||||
const [fetchError, setFetchError] = React.useState(false);
|
||||
const [fetchError, setFetchError] = React.useState<string | null>(null);
|
||||
const [retryToken, setRetryToken] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
let mounted = true;
|
||||
@@ -110,10 +113,14 @@ export default function GpuGrid() {
|
||||
const next = await getGpus();
|
||||
if (mounted) {
|
||||
setSnapshot(next);
|
||||
setFetchError(false);
|
||||
setFetchError(null);
|
||||
}
|
||||
} catch {
|
||||
if (mounted) setFetchError(true);
|
||||
if (mounted) {
|
||||
setFetchError(
|
||||
'GPU status could not be refreshed. The values below may be stale.',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
@@ -125,14 +132,27 @@ export default function GpuGrid() {
|
||||
mounted = false;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
}, [retryToken]);
|
||||
|
||||
if (fetchError && !snapshot) {
|
||||
return (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
Could not reach the API server. GPU status needs the studio API server
|
||||
running.
|
||||
</p>
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col items-center gap-3 py-8 text-center"
|
||||
>
|
||||
<AlertTriangle className="size-6 text-destructive" aria-hidden />
|
||||
<p className="text-muted-foreground">
|
||||
Could not reach the API server. GPU status needs the Studio API server
|
||||
running.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setRetryToken((token) => token + 1)}
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!snapshot) {
|
||||
@@ -150,9 +170,22 @@ export default function GpuGrid() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{fetchError && (
|
||||
<p className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-600 dark:text-amber-400">
|
||||
Lost contact with the API server — showing the last known values.
|
||||
</p>
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="flex flex-wrap items-center gap-3 rounded-lg border border-amber-500/50 bg-amber-500/10 px-3 py-2 text-sm"
|
||||
>
|
||||
<AlertTriangle className="size-4 text-amber-600" aria-hidden />
|
||||
<span className="min-w-0 flex-1">{fetchError}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setRetryToken((token) => token + 1)}
|
||||
>
|
||||
Refresh Now
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-4 [grid-template-columns:repeat(auto-fill,minmax(280px,1fr))]">
|
||||
{snapshot.gpus.map((gpu) => (
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { Button } from './button';
|
||||
import { Input } from './input';
|
||||
import { NativeSelect } from './native-select';
|
||||
import { Slider } from './slider';
|
||||
import { Switch } from './switch';
|
||||
|
||||
describe('shared control accessibility', () => {
|
||||
it('keeps button, input, and select targets at least 44px tall', () => {
|
||||
render(
|
||||
<>
|
||||
<Button size="sm">Small action</Button>
|
||||
<Input aria-label="Text value" />
|
||||
<NativeSelect aria-label="Choice" defaultValue="one">
|
||||
<option value="one">One</option>
|
||||
</NativeSelect>
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Small action' })).toHaveClass(
|
||||
'h-11',
|
||||
);
|
||||
expect(screen.getByRole('textbox', { name: 'Text value' })).toHaveClass(
|
||||
'h-11',
|
||||
);
|
||||
expect(screen.getByRole('combobox', { name: 'Choice' })).toHaveClass(
|
||||
'h-11',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses 44px switch and slider interaction surfaces', () => {
|
||||
render(
|
||||
<>
|
||||
<Switch aria-label="Enabled" />
|
||||
<Slider aria-label="Amount" defaultValue={[50]} />
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('switch', { name: 'Enabled' })).toHaveClass(
|
||||
'size-11',
|
||||
);
|
||||
expect(screen.getByRole('slider', { name: 'Amount' })).toHaveClass(
|
||||
'size-11',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -29,11 +29,12 @@ const badgeVariants = cva(
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
extends React.HTMLAttributes<HTMLSpanElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
// A span (phrasing content), so badges stay valid inside buttons and links.
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
return <span 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 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",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl border !text-sm !font-semibold transition-colors duration-150 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -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-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",
|
||||
default: "h-11 px-4 py-2",
|
||||
sm: "h-11 rounded-lg px-3 !text-xs",
|
||||
lg: "h-12 px-5 !text-sm",
|
||||
icon: "size-11",
|
||||
"icon-sm": "size-11",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -42,7 +42,7 @@ const DialogContent = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<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">
|
||||
<DialogPrimitive.Close className="absolute right-2 top-2 flex size-11 items-center justify-center rounded-lg text-muted-foreground opacity-70 transition-opacity hover:bg-secondary hover:opacity-100 disabled:pointer-events-none sm:right-4 sm:top-4">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
|
||||
@@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"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",
|
||||
"flex h-11 w-full rounded-xl border border-input bg-card/60 px-3 py-2 text-sm text-foreground shadow-sm backdrop-blur-md transition-colors placeholder:text-muted-foreground focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -11,7 +11,7 @@ const NativeSelect = React.forwardRef<
|
||||
<select
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'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',
|
||||
'flex h-11 w-full appearance-none rounded-xl border border-input bg-card px-3 py-2 text-sm text-foreground shadow-sm transition-colors focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'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',
|
||||
'flex h-11 w-full items-center justify-between gap-2 rounded-xl border border-input bg-card px-3 py-2 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -117,7 +117,7 @@ const SelectItem = React.forwardRef<
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'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',
|
||||
'relative flex min-h-11 w-full cursor-default select-none items-center rounded-xl py-2 pl-8 pr-3 text-sm text-foreground outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -8,21 +8,39 @@ 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,
|
||||
)}
|
||||
{...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>
|
||||
));
|
||||
id,
|
||||
'aria-label': ariaLabel,
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
'aria-describedby': ariaDescribedBy,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex h-11 w-full touch-none select-none items-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-border">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-accent-blue" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb
|
||||
id={id}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
className="relative block size-11 rounded-full bg-transparent after:absolute after:left-1/2 after:top-1/2 after:size-4 after:-translate-x-1/2 after:-translate-y-1/2 after:rounded-full after:border-2 after:border-accent-blue after:bg-accent-blue after:shadow after:content-[''] hover:after:border-accent-blue/80 disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
</SliderPrimitive.Root>
|
||||
),
|
||||
);
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider };
|
||||
|
||||
@@ -12,14 +12,14 @@ const Switch = React.forwardRef<
|
||||
<SwitchPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'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',
|
||||
'peer relative inline-flex size-11 shrink-0 cursor-pointer items-center justify-center rounded-xl bg-transparent transition-colors before:absolute before:h-5 before:w-9 before:rounded-full before:border before:border-border before:bg-background data-[state=checked]:before:border-accent-blue data-[state=checked]:before:bg-accent-blue disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none 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',
|
||||
'pointer-events-none absolute left-1.5 top-[15px] z-[1] block h-3.5 w-3.5 rounded-full bg-muted-foreground shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=checked]:bg-white',
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
|
||||
@@ -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 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',
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -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-sky-400/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/25 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-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
/**
|
||||
* Move focus into a drawer when it opens as a modal (mobile), and hand it
|
||||
* back to the previously focused element on close. Pairs with `inert` on
|
||||
* the background content — together they make `aria-modal` truthful.
|
||||
*/
|
||||
export function useDrawerFocus<T extends HTMLElement>(active: boolean) {
|
||||
const ref = React.useRef<T | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) return;
|
||||
const previous = document.activeElement;
|
||||
ref.current?.focus();
|
||||
return () => {
|
||||
if (previous instanceof HTMLElement) previous.focus();
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mediaQuery = window.matchMedia(query);
|
||||
const updateMatch = () => setMatches(mediaQuery.matches);
|
||||
|
||||
updateMatch();
|
||||
mediaQuery.addEventListener('change', updateMatch);
|
||||
return () => mediaQuery.removeEventListener('change', updateMatch);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
+30
-5
@@ -186,15 +186,40 @@ RUN --mount=type=cache,target=/opt/uv/cache \
|
||||
|
||||
COPY . .
|
||||
|
||||
# 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.
|
||||
# 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
|
||||
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_BUILD_PARALLEL_LEVEL=${CMAKE_BUILD_PARALLEL_LEVEL} \
|
||||
TORCH_CUDA_ARCH_LIST=${TORCH_CUDA_ARCH_LIST} ./build.sh
|
||||
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
|
||||
|
||||
# Install FastVideo itself (editable) now that the source is present, and set up
|
||||
# shell configuration. Dependencies and the local kernel are already installed,
|
||||
|
||||
@@ -269,16 +269,26 @@ The docs job:
|
||||
### Docker Images
|
||||
|
||||
`.github/workflows/infra-build-image.yml` supports manual `workflow_dispatch`
|
||||
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`.
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
@@ -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-38u6p1jqe-yDI1QJOCEnbtkLoaI5bjZQ) for additional support.
|
||||
You can also join our [Slack community](https://join.slack.com/t/fastvideo/shared_invite/zt-3f4lao1uq-u~Ipx6Lt4J27AlD2y~IdLQ) for additional support.
|
||||
|
||||
@@ -100,4 +100,4 @@ If you're planning to contribute to FastVideo please see the following page:
|
||||
|
||||
If you encounter any issues during installation, please open an issue on our [GitHub repository](https://github.com/hao-ai-lab/FastVideo).
|
||||
|
||||
You can also join our [Slack community](https://join.slack.com/t/fastvideo/shared_invite/zt-38u6p1jqe-yDI1QJOCEnbtkLoaI5bjZQ) for additional support.
|
||||
You can also join our [Slack community](https://join.slack.com/t/fastvideo/shared_invite/zt-3f4lao1uq-u~Ipx6Lt4J27AlD2y~IdLQ) for additional support.
|
||||
|
||||
@@ -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-38u6p1jqe-yDI1QJOCEnbtkLoaI5bjZQ)
|
||||
our [Slack community](https://join.slack.com/t/fastvideo/shared_invite/zt-3f4lao1uq-u~Ipx6Lt4J27AlD2y~IdLQ)
|
||||
for additional support.
|
||||
|
||||
## Development Environment Setup
|
||||
|
||||
@@ -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-38u6p1jqe-yDI1QJOCEnbtkLoaI5bjZQ).
|
||||
Need help? Join our [Slack community](https://join.slack.com/t/fastvideo/shared_invite/zt-3f4lao1uq-u~Ipx6Lt4J27AlD2y~IdLQ).
|
||||
|
||||
## Step 1: Pipeline Modules
|
||||
|
||||
|
||||
@@ -134,5 +134,4 @@ 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 Discord](https://discord.gg/JA7cksDz86).
|
||||
- Join our [Community Slack](https://join.slack.com/t/fastvideo/shared_invite/zt-38u6p1jqe-yDI1QJOCEnbtkLoaI5bjZQ).
|
||||
- Join our [Community Slack](https://join.slack.com/t/fastvideo/shared_invite/zt-3f4lao1uq-u~Ipx6Lt4J27AlD2y~IdLQ).
|
||||
|
||||
@@ -119,6 +119,22 @@ pip install "nvidia-cutlass-dsl>=4.5.2" apache-tvm-ffi flashinfer-python
|
||||
The `--no-deps` flag prevents upgrading torch/torchvision. Use the supported
|
||||
PyTorch 2.12.0 and CUDA 13 environment for this kernel.
|
||||
|
||||
Branch-to-`nvidia-cutlass-dsl` compatibility (the fork tracks the CuTe DSL API
|
||||
surface closely):
|
||||
|
||||
| fork branch | cutlass-dsl | notes |
|
||||
|---|---|---|
|
||||
| `fp4` | `==4.4.2` (+ `nvidia-cutlass-dsl-libs-base==4.4.2`) | validated set on GB200: `quack-kernels==0.4.1`, `flashinfer-python==0.6.8`, `CUTE_DSL_ENABLE_TVM_FFI=1`, `FASTVIDEO_FA4=1` |
|
||||
| `fix/cutlass-dsl-4.5` | `>=4.5.2` | carries the `cute.core.ThrMma` -> `cute.ThrMma` fix |
|
||||
| any | 4.6-era | unsupported: `cute.make_fragment` was removed at module level; fails at CuTe JIT trace |
|
||||
|
||||
`FASTVIDEO_FA4=1` is required alongside the fork: it ships no compiled
|
||||
FlashAttention-2, so dense attention paths raise ImportError without the FA4
|
||||
opt-in. The same kernel also serves `ATTN_QAT_INFER` on sm_100a/sm_103a
|
||||
(datacenter Blackwell) — the selection log's receipt line
|
||||
(`ATTN_QAT_INFER resolved: ...`) records the arch, kernel, and quantization
|
||||
mode that actually bound.
|
||||
|
||||
#### Usage
|
||||
|
||||
Enable FP4 attention via the `--nvfp4_fa4` flag:
|
||||
|
||||
@@ -13,6 +13,92 @@ 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
|
||||
@@ -25,6 +111,9 @@ 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 {
|
||||
@@ -56,7 +145,7 @@ pipeline initialization and sampling.
|
||||
| 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 | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ |
|
||||
@@ -65,14 +154,14 @@ pipeline initialization and sampling.
|
||||
| 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 | See note** | 480P<br>720P | ❌ | ❌ | ❌ | ⭕ | ✅ |
|
||||
| LongCat T2V 13.6B | `FastVideo/LongCat-Video-T2V-Diffusers` | 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 | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ |
|
||||
@@ -98,6 +187,21 @@ 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
|
||||
|
||||
@@ -212,6 +212,29 @@ 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
|
||||
|
||||
@@ -77,7 +77,9 @@ 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`
|
||||
`attn_qat_infer`, AND a consumer-Blackwell (sm_120/sm_121) GPU -- on any
|
||||
other device the backend reports unavailable (even if a CUDA 13 wheel
|
||||
bundles the extension) and selection falls back to FlashAttention
|
||||
- `ATTN_QAT_TRAIN`: `fastvideo-kernel`; its runtime-JIT Triton implementation
|
||||
selects an optimized route on SM100, joins the quantized and STE P@V paths on
|
||||
SM120, and retains the previous route for unsupported configurations. See
|
||||
|
||||
@@ -191,6 +191,22 @@ 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,10 +6,6 @@ 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 |
|
||||
@@ -38,27 +34,21 @@ 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.
|
||||
|
||||
## Blocking prereqs (why this draft PR cannot be merged yet)
|
||||
## Upstream surfaces the demo relies on
|
||||
|
||||
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:
|
||||
Everything the demo needs ships in the upstream `fastvideo` package:
|
||||
|
||||
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.
|
||||
- **`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).
|
||||
|
||||
## 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.fp4_config import FP4Config
|
||||
from fastvideo.layers.quantization.nvfp4_config import NVFP4Config
|
||||
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 = FP4Config()
|
||||
pipeline_config.dit_config.quant_config = NVFP4Config()
|
||||
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.configs.sample.base import SamplingParam
|
||||
from fastvideo 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.configs.sample.base import SamplingParam
|
||||
from fastvideo import SamplingParam
|
||||
from fastvideo.entrypoints.video_generator import VideoGenerator
|
||||
|
||||
from .config import (
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
# 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()
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/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}" \
|
||||
"$@"
|
||||
@@ -0,0 +1,113 @@
|
||||
# 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
|
||||
@@ -195,6 +195,10 @@ 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
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
# 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
|
||||
```
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/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" \
|
||||
"$@"
|
||||
@@ -0,0 +1,85 @@
|
||||
# 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
|
||||
@@ -0,0 +1,140 @@
|
||||
# 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
|
||||
@@ -0,0 +1,110 @@
|
||||
# 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: {}
|
||||
@@ -0,0 +1,114 @@
|
||||
# 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: {}
|
||||
@@ -0,0 +1,151 @@
|
||||
# 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
|
||||
@@ -0,0 +1,112 @@
|
||||
# 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: {}
|
||||
@@ -0,0 +1,116 @@
|
||||
# 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: {}
|
||||
@@ -0,0 +1,123 @@
|
||||
# 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,6 +4,7 @@ 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)
|
||||
@@ -67,12 +68,25 @@ fi
|
||||
# Install build dependencies
|
||||
uv pip install scikit-build-core cmake ninja
|
||||
|
||||
RELEASE=0
|
||||
GPU_BACKEND=CUDA
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
WHEEL_DIR=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" 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
|
||||
@@ -165,4 +179,15 @@ echo "CMAKE_ARGS: ${CMAKE_ARGS:-<unset>}"
|
||||
echo "GPU_BACKEND: ${GPU_BACKEND:-<unset>}"
|
||||
# Build and install
|
||||
# Use -v for verbose output
|
||||
uv pip install . -v --no-build-isolation
|
||||
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
|
||||
|
||||
@@ -50,8 +50,143 @@ 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:
|
||||
return _get_attn_qat_infer() is not None
|
||||
"""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
|
||||
|
||||
|
||||
class AttnQatInferBackend(AttentionBackend):
|
||||
@@ -97,6 +232,12 @@ 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,
|
||||
@@ -105,10 +246,18 @@ 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()
|
||||
if attn_qat_infer is None:
|
||||
raise ImportError("attn_qat_infer is not available. Please ensure the "
|
||||
"attn_qat_infer kernel package is installed.")
|
||||
assert attn_qat_infer is not None # kernel == "cutlass_sm12x" implies the import succeeded
|
||||
|
||||
query = query.transpose(1, 2).contiguous()
|
||||
key = key.transpose(1, 2).contiguous()
|
||||
@@ -123,3 +272,39 @@ 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
|
||||
|
||||
@@ -32,8 +32,30 @@ except ImportError:
|
||||
flash_attn_fp4_func = None
|
||||
_FA4_FP4_AVAILABLE = False
|
||||
|
||||
_FA4_QUANT_OPS: tuple | None = None
|
||||
|
||||
def _nvfp4_quantize_for_fa4(tensor_4d: torch.Tensor, ) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
|
||||
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]:
|
||||
"""Quantize a (batch, seqlen, nheads, headdim) BF16 tensor to FP4.
|
||||
|
||||
Returns:
|
||||
@@ -42,7 +64,7 @@ def _nvfp4_quantize_for_fa4(tensor_4d: torch.Tensor, ) -> tuple[torch.Tensor, to
|
||||
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
|
||||
"""
|
||||
from flashinfer.quantization import nvfp4_quantize, SfLayout
|
||||
nvfp4_quantize, SfLayout = _resolve_fa4_quant_ops()
|
||||
|
||||
batch, seqlen, nheads, headdim = tensor_4d.shape
|
||||
sf_vec_size = 16
|
||||
@@ -82,6 +104,43 @@ def _nvfp4_quantize_for_fa4(tensor_4d: torch.Tensor, ) -> tuple[torch.Tensor, to
|
||||
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
|
||||
|
||||
|
||||
@@ -150,14 +150,15 @@ 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 automatic "
|
||||
"selection.",
|
||||
"layer; supported backends are %s. Falling back to %s.",
|
||||
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 = None
|
||||
selected_backend = fallback_backend
|
||||
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}")
|
||||
|
||||
@@ -13,12 +13,15 @@ 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, plus
|
||||
# the dense backends every DiT supports.
|
||||
# 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/).
|
||||
_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,6 +7,18 @@ 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)."""
|
||||
@@ -61,6 +73,8 @@ 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
|
||||
|
||||
@@ -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 Kandinsky5I2VConfig, Kandinsky5T2VConfig
|
||||
from fastvideo.configs.pipelines.kandinsky5 import Kandinsky5DMDConfig, 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", "LingBotWorld2CausalFastI2V480PConfig", "LingBotVideoT2VConfig", "MatrixGame2I2V480PConfig",
|
||||
"MatrixGame3I2V720PConfig", "get_pipeline_config_cls_from_name"
|
||||
"Kandinsky5I2VConfig", "Kandinsky5DMDConfig", "LingBotWorld2CausalFastI2V480PConfig", "LingBotVideoT2VConfig",
|
||||
"MatrixGame2I2V480PConfig", "MatrixGame3I2V720PConfig", "get_pipeline_config_cls_from_name"
|
||||
]
|
||||
|
||||
@@ -120,3 +120,21 @@ 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])
|
||||
|
||||
@@ -8,6 +8,7 @@ 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]:
|
||||
@@ -23,7 +24,7 @@ def cmd_init() -> list[CLISubcommand]:
|
||||
|
||||
def main() -> None:
|
||||
parser = FlexibleArgumentParser(description="FastVideo CLI")
|
||||
parser.add_argument('-v', '--version', action='version', version='0.1.0')
|
||||
parser.add_argument('-v', '--version', action='version', version=__version__)
|
||||
|
||||
subparsers = parser.add_subparsers(required=False, dest="subparser")
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ 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",
|
||||
@@ -33,6 +36,11 @@ _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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -58,6 +58,29 @@ _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)
|
||||
@@ -286,6 +309,11 @@ 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):
|
||||
@@ -326,7 +354,11 @@ class NVFP4QuantizeMethod(QuantizeMethodBase):
|
||||
| None = None,
|
||||
) -> torch.Tensor:
|
||||
SfLayout, _, _ = _require_flashinfer()
|
||||
out_dim = layer.weight.shape[0]
|
||||
# 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]
|
||||
original_shape = x.shape
|
||||
|
||||
# Stage-aware profile: keep refine-only FP4 layers in dense mode
|
||||
@@ -334,8 +366,13 @@ 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":
|
||||
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)))
|
||||
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)))
|
||||
return out.view(*original_shape[:-1], out_dim)
|
||||
if pre_quantized is not None:
|
||||
x_fp4, x_scale, x_global_sf = pre_quantized
|
||||
@@ -393,11 +430,19 @@ class NVFP4Config(QuantizationConfig):
|
||||
instead of hardcoding it here.
|
||||
"""
|
||||
|
||||
def __init__(self, layer_profile: str = "refine"):
|
||||
def __init__(self, layer_profile: str = "refine", retain_original_weights: bool | None = None):
|
||||
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"
|
||||
@@ -415,32 +460,20 @@ class NVFP4Config(QuantizationConfig):
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config: dict[str, Any]) -> NVFP4Config:
|
||||
return cls(layer_profile=config.get("layer_profile", "refine"))
|
||||
return cls(
|
||||
layer_profile=config.get("layer_profile", "refine"),
|
||||
retain_original_weights=config.get("retain_original_weights"),
|
||||
)
|
||||
|
||||
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.
|
||||
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)
|
||||
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
|
||||
return None
|
||||
|
||||
|
||||
@@ -448,6 +481,9 @@ 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):
|
||||
@@ -480,9 +516,40 @@ 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,7 +46,11 @@ 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").
|
||||
# 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.
|
||||
DEFAULT_FP4_LAYERS = (
|
||||
"ffn.fc_in",
|
||||
"ffn.fc_out",
|
||||
@@ -54,6 +58,11 @@ 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,10 +9,11 @@ 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, 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.
|
||||
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.
|
||||
"""
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -21,6 +22,8 @@ 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__)
|
||||
@@ -72,7 +75,10 @@ class NVFP4QATTrainConfig(QuantizationConfig):
|
||||
|
||||
def get_quant_method(self, layer: torch.nn.Module, prefix: str):
|
||||
from fastvideo.layers.linear import LinearBase
|
||||
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):
|
||||
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:
|
||||
return NVFP4QATTrainQuantizeMethod()
|
||||
return None
|
||||
|
||||
@@ -15,6 +15,7 @@ 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
|
||||
@@ -285,6 +286,7 @@ 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
|
||||
@@ -293,20 +295,24 @@ 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,
|
||||
@@ -413,9 +419,11 @@ class Kandinsky5Attention(nn.Module):
|
||||
|
||||
class Kandinsky5FeedForward(nn.Module):
|
||||
|
||||
def __init__(self, dim: int, ff_dim: int):
|
||||
def __init__(self, dim: int, ff_dim: int, prefix: str = "",
|
||||
quant_config: QuantizationConfig | None = None):
|
||||
super().__init__()
|
||||
self.mlp = MLP(dim, ff_dim, bias=False, act_type="gelu")
|
||||
self.mlp = MLP(dim, ff_dim, bias=False, act_type="gelu",
|
||||
quant_config=quant_config, prefix=f"{prefix}.mlp")
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
return self.mlp(x)
|
||||
@@ -467,7 +475,8 @@ class Kandinsky5TransformerEncoderBlock(nn.Module):
|
||||
head_dim: int,
|
||||
supported_attention_backends: tuple[AttentionBackendEnum, ...]
|
||||
| None = None,
|
||||
prefix: str = ""):
|
||||
prefix: str = "",
|
||||
quant_config: QuantizationConfig | None = None):
|
||||
super().__init__()
|
||||
self.text_modulation = Kandinsky5Modulation(time_dim, model_dim, 6)
|
||||
|
||||
@@ -482,7 +491,8 @@ class Kandinsky5TransformerEncoderBlock(nn.Module):
|
||||
model_dim,
|
||||
head_dim,
|
||||
supported_attention_backends=supported_attention_backends,
|
||||
prefix=f"{prefix}.self_attention")
|
||||
prefix=f"{prefix}.self_attention",
|
||||
quant_config=quant_config)
|
||||
|
||||
self.feed_forward_norm = LayerNormScaleShift(
|
||||
model_dim,
|
||||
@@ -491,7 +501,9 @@ class Kandinsky5TransformerEncoderBlock(nn.Module):
|
||||
elementwise_affine=False,
|
||||
dtype=torch.float32,
|
||||
compute_dtype=torch.float32)
|
||||
self.feed_forward = Kandinsky5FeedForward(model_dim, ff_dim)
|
||||
self.feed_forward = Kandinsky5FeedForward(
|
||||
model_dim, ff_dim, prefix=f"{prefix}.feed_forward",
|
||||
quant_config=quant_config)
|
||||
|
||||
def forward(self, x: torch.Tensor, time_embed: torch.Tensor,
|
||||
rope: torch.Tensor) -> torch.Tensor:
|
||||
@@ -523,7 +535,8 @@ class Kandinsky5TransformerDecoderBlock(nn.Module):
|
||||
supported_attention_backends: tuple[AttentionBackendEnum, ...]
|
||||
| None = None,
|
||||
prefix: str = "",
|
||||
use_nabla: bool = False):
|
||||
use_nabla: bool = False,
|
||||
quant_config: QuantizationConfig | None = None):
|
||||
super().__init__()
|
||||
self.visual_modulation = Kandinsky5Modulation(time_dim, model_dim, 9)
|
||||
|
||||
@@ -539,7 +552,8 @@ class Kandinsky5TransformerDecoderBlock(nn.Module):
|
||||
head_dim,
|
||||
supported_attention_backends=supported_attention_backends,
|
||||
prefix=f"{prefix}.self_attention",
|
||||
use_nabla=use_nabla)
|
||||
use_nabla=use_nabla,
|
||||
quant_config=quant_config)
|
||||
|
||||
self.cross_attention_norm = LayerNormScaleShift(
|
||||
model_dim,
|
||||
@@ -552,7 +566,8 @@ class Kandinsky5TransformerDecoderBlock(nn.Module):
|
||||
model_dim,
|
||||
head_dim,
|
||||
supported_attention_backends=supported_attention_backends,
|
||||
prefix=f"{prefix}.cross_attention")
|
||||
prefix=f"{prefix}.cross_attention",
|
||||
quant_config=quant_config)
|
||||
|
||||
self.feed_forward_norm = LayerNormScaleShift(
|
||||
model_dim,
|
||||
@@ -561,7 +576,9 @@ class Kandinsky5TransformerDecoderBlock(nn.Module):
|
||||
elementwise_affine=False,
|
||||
dtype=torch.float32,
|
||||
compute_dtype=torch.float32)
|
||||
self.feed_forward = Kandinsky5FeedForward(model_dim, ff_dim)
|
||||
self.feed_forward = Kandinsky5FeedForward(
|
||||
model_dim, ff_dim, prefix=f"{prefix}.feed_forward",
|
||||
quant_config=quant_config)
|
||||
|
||||
def forward(self, visual_embed: torch.Tensor, text_embed: torch.Tensor,
|
||||
time_embed: torch.Tensor, rope: torch.Tensor,
|
||||
@@ -636,6 +653,8 @@ 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
|
||||
@@ -664,7 +683,8 @@ class Kandinsky5Transformer3DModel(BaseDiT):
|
||||
arch.ff_dim,
|
||||
head_dim,
|
||||
self._supported_attention_backends,
|
||||
prefix=f"{config.prefix}.text_transformer_blocks.{i}")
|
||||
prefix=f"{config.prefix}.text_transformer_blocks.{i}",
|
||||
quant_config=quant_config)
|
||||
for i in range(arch.num_text_blocks)
|
||||
])
|
||||
self.visual_transformer_blocks = nn.ModuleList([
|
||||
@@ -673,7 +693,8 @@ class Kandinsky5Transformer3DModel(BaseDiT):
|
||||
head_dim,
|
||||
self._supported_attention_backends,
|
||||
prefix=f"{config.prefix}.visual_transformer_blocks.{i}",
|
||||
use_nabla=arch.attention_type == "nabla")
|
||||
use_nabla=arch.attention_type == "nabla",
|
||||
quant_config=quant_config)
|
||||
for i in range(arch.num_visual_blocks)
|
||||
])
|
||||
|
||||
|
||||
@@ -1571,6 +1571,7 @@ class LTXSelfAttention(nn.Module):
|
||||
softmax_scale=None,
|
||||
causal=False,
|
||||
supported_attention_backends=(AttentionBackendEnum.TORCH_SDPA,),
|
||||
default_backend=AttentionBackendEnum.TORCH_SDPA,
|
||||
)
|
||||
|
||||
def forward(
|
||||
@@ -1853,12 +1854,15 @@ 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_self_attn_backends = (
|
||||
video_attn_backends = (
|
||||
AttentionBackendEnum.FLASH_ATTN,
|
||||
AttentionBackendEnum.TORCH_SDPA,
|
||||
AttentionBackendEnum.VIDEO_SPARSE_ATTN,
|
||||
AttentionBackendEnum.ATTN_QAT_INFER,
|
||||
AttentionBackendEnum.ATTN_QAT_TRAIN,
|
||||
)
|
||||
dense_attn_backends = (
|
||||
video_self_attn_backends = video_attn_backends + (
|
||||
AttentionBackendEnum.VIDEO_SPARSE_ATTN, )
|
||||
audio_attn_backends = (
|
||||
AttentionBackendEnum.FLASH_ATTN,
|
||||
AttentionBackendEnum.TORCH_SDPA,
|
||||
)
|
||||
@@ -1885,7 +1889,7 @@ class BasicAVTransformerBlock(torch.nn.Module):
|
||||
dim_head=video.d_head,
|
||||
norm_eps=norm_eps,
|
||||
rope_type=rope_type,
|
||||
supported_attention_backends=dense_attn_backends,
|
||||
supported_attention_backends=video_attn_backends,
|
||||
apply_gated_attention=video.apply_gated_attention,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.blocks.{idx}.attn2",
|
||||
@@ -1911,7 +1915,7 @@ class BasicAVTransformerBlock(torch.nn.Module):
|
||||
dim_head=audio.d_head,
|
||||
norm_eps=norm_eps,
|
||||
rope_type=rope_type,
|
||||
supported_attention_backends=dense_attn_backends,
|
||||
supported_attention_backends=audio_attn_backends,
|
||||
apply_gated_attention=audio.apply_gated_attention,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.blocks.{idx}.audio_attn1",
|
||||
@@ -1924,7 +1928,7 @@ class BasicAVTransformerBlock(torch.nn.Module):
|
||||
dim_head=audio.d_head,
|
||||
norm_eps=norm_eps,
|
||||
rope_type=rope_type,
|
||||
supported_attention_backends=dense_attn_backends,
|
||||
supported_attention_backends=audio_attn_backends,
|
||||
apply_gated_attention=audio.apply_gated_attention,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.blocks.{idx}.audio_attn2",
|
||||
@@ -1949,7 +1953,7 @@ class BasicAVTransformerBlock(torch.nn.Module):
|
||||
dim_head=audio.d_head,
|
||||
norm_eps=norm_eps,
|
||||
rope_type=rope_type,
|
||||
supported_attention_backends=dense_attn_backends,
|
||||
supported_attention_backends=audio_attn_backends,
|
||||
apply_gated_attention=video.apply_gated_attention,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.blocks.{idx}.audio_to_video_attn",
|
||||
@@ -1963,7 +1967,7 @@ class BasicAVTransformerBlock(torch.nn.Module):
|
||||
dim_head=audio.d_head,
|
||||
norm_eps=norm_eps,
|
||||
rope_type=rope_type,
|
||||
supported_attention_backends=dense_attn_backends,
|
||||
supported_attention_backends=audio_attn_backends,
|
||||
apply_gated_attention=audio.apply_gated_attention,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.blocks.{idx}.video_to_audio_attn",
|
||||
|
||||
@@ -38,8 +38,14 @@ 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,
|
||||
@@ -48,11 +54,15 @@ 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):
|
||||
@@ -67,6 +77,16 @@ 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
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# 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
|
||||
@@ -0,0 +1,309 @@
|
||||
# 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()
|
||||
@@ -10,12 +10,18 @@ import torch
|
||||
from diffusers.utils.torch_utils import randn_tensor
|
||||
from tqdm.auto import tqdm
|
||||
|
||||
import fastvideo.envs as envs
|
||||
from fastvideo.attention import LocalAttention
|
||||
from fastvideo.attention.backends.nabla import NablaAttentionMetadataBuilder
|
||||
from fastvideo.attention.selector import backend_name_to_enum
|
||||
from fastvideo.distributed import get_local_torch_device
|
||||
from fastvideo.fastvideo_args import FastVideoArgs
|
||||
from fastvideo.forward_context import set_forward_context
|
||||
from fastvideo.logger import init_logger
|
||||
from fastvideo.models.loader.component_loader import TransformerLoader, VAELoader
|
||||
from fastvideo.models.schedulers.scheduling_flow_match_euler_discrete import (
|
||||
FlowMatchEulerDiscreteScheduler, )
|
||||
from fastvideo.models.utils import pred_noise_to_pred_video
|
||||
from fastvideo.models.vaes.common import ParallelTiledVAE
|
||||
from fastvideo.models.vision_utils import normalize, numpy_to_pt, pil_to_numpy, resize
|
||||
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
|
||||
@@ -24,7 +30,8 @@ from fastvideo.pipelines.stages.decoding import DecodingStage
|
||||
from fastvideo.pipelines.stages.encoding import EncodingStage
|
||||
from fastvideo.pipelines.stages.validators import StageValidators as V
|
||||
from fastvideo.pipelines.stages.validators import VerificationResult
|
||||
from fastvideo.utils import PRECISION_TO_TYPE
|
||||
from fastvideo.platforms import AttentionBackendEnum
|
||||
from fastvideo.utils import PRECISION_TO_TYPE, get_mixed_precision_state
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
@@ -166,6 +173,115 @@ class Kandinsky5DenoisingStage(PipelineStage):
|
||||
seq_len = int(mask.sum(1).max().item())
|
||||
return torch.arange(seq_len, device=device)
|
||||
|
||||
# Backends whose selection is all-or-nothing: fastvideo.platforms.cuda
|
||||
# raises ImportError immediately if the ATTN_QAT_TRAIN kernel isn't
|
||||
# available (training must never silently de-quantize), rather than the
|
||||
# generic "requested backend unsupported by this layer, falling back to
|
||||
# automatic selection" path _cached_get_attn_backend takes for most
|
||||
# other backend names (see fastvideo/attention/selector.py).
|
||||
# ATTN_QAT_TRAIN is also always in Kandinsky5ArchConfig's
|
||||
# _supported_attention_backends, so that generic fallback never applies
|
||||
# to it here. That combination makes an exact backend match a safe,
|
||||
# false-positive-free signal *only* for ATTN_QAT_TRAIN.
|
||||
# ATTN_QAT_INFER must NOT be in this set: fastvideo.platforms.cuda
|
||||
# deliberately resolves it to FlashAttention when the sm_120 kernel
|
||||
# isn't built, and the QAD recipe README documents that fallback ("on
|
||||
# other GPUs the attention falls back to a supported dense backend
|
||||
# while the FP4 linear layers still run") -- asserting an exact match
|
||||
# would abort inference on every non-sm_120 machine relying on it.
|
||||
# Any other FASTVIDEO_ATTENTION_BACKEND value (e.g. a backend meant for
|
||||
# a different model family sharing the same process/env var) can
|
||||
# likewise legitimately resolve to something other than what was
|
||||
# requested.
|
||||
_STRICT_BACKENDS = frozenset({
|
||||
AttentionBackendEnum.ATTN_QAT_TRAIN,
|
||||
})
|
||||
|
||||
def _assert_local_attention_backend_engaged(self) -> None:
|
||||
"""Guard against ``LocalAttention`` silently falling back to SDPA.
|
||||
|
||||
``Kandinsky5Attention.forward`` catches the ``AssertionError``
|
||||
``LocalAttention`` raises when no pipeline forward context is set and
|
||||
falls back to plain ``F.scaled_dot_product_attention`` -- silently
|
||||
skipping whichever kernel ``FASTVIDEO_ATTENTION_BACKEND`` requested
|
||||
(the fake-quantized ``ATTN_QAT_TRAIN`` kernel).
|
||||
``LocalAttention.backend`` is resolved once at module construction,
|
||||
independent of whether a forward context is later set, so this only
|
||||
catches a backend that failed to resolve to what the env var
|
||||
requested -- it does not prove forward context was present for a
|
||||
given forward call. Pair it with always wrapping the actual
|
||||
transformer call in ``set_forward_context`` (see the denoising loops
|
||||
below), which is what prevents the runtime fallback.
|
||||
|
||||
Scoped to ``_STRICT_BACKENDS``: other backend names -- including
|
||||
``ATTN_QAT_INFER``, whose FlashAttention fallback on non-sm_120
|
||||
GPUs is documented, intentional behavior -- can legitimately differ
|
||||
from what ``LocalAttention.backend`` resolved to (see
|
||||
``_STRICT_BACKENDS``'s comment), so asserting on those would flag
|
||||
expected behavior as a bug.
|
||||
"""
|
||||
backend_env = envs.FASTVIDEO_ATTENTION_BACKEND
|
||||
if not backend_env:
|
||||
return
|
||||
expected = backend_name_to_enum(backend_env)
|
||||
if expected is None or expected not in self._STRICT_BACKENDS:
|
||||
return
|
||||
for module in self.transformer.modules():
|
||||
if isinstance(module, LocalAttention):
|
||||
assert module.backend == expected, (
|
||||
f"Kandinsky5 local attention resolved to backend {module.backend}, expected "
|
||||
f"{expected} from FASTVIDEO_ATTENTION_BACKEND={backend_env}. This likely means "
|
||||
"LocalAttention's missing-forward-context guard silently fell back to SDPA.")
|
||||
return
|
||||
|
||||
def _resolve_target_dtype(self, fastvideo_args: FastVideoArgs) -> torch.dtype:
|
||||
"""Resolve the transformer's actual compute dtype.
|
||||
|
||||
Trust ``pipeline_config.dit_precision`` directly for a normal,
|
||||
non-FSDP load -- ``TransformerLoader.load()`` asserts every
|
||||
parameter matches it exactly (``fastvideo/models/loader/
|
||||
component_loader.py``), so this holds for standalone T2V/I2V
|
||||
inference regardless of which precision (including fp32) was
|
||||
requested.
|
||||
|
||||
FSDP2-wrapped modules are the one case where that assertion doesn't
|
||||
carry forward: ``maybe_load_fsdp_model`` hardcodes its
|
||||
``MixedPrecisionPolicy`` to ``param_dtype=torch.bfloat16`` (with
|
||||
``cast_forward_inputs=False``) for every FSDP-wrapped load,
|
||||
independent of ``dit_precision`` -- this covers both the live
|
||||
transformer ``ValidationCallback`` reuses from training (whose
|
||||
``pipeline_config.dit_precision`` reflects the fp32 master-weight
|
||||
load dtype, not the actual bf16 compute dtype) and multi-GPU
|
||||
``use_fsdp_inference=True`` runs.
|
||||
|
||||
For that FSDP case, read the policy itself
|
||||
(``set_mixed_precision_policy`` records it right before
|
||||
``maybe_load_fsdp_model`` shards -- every FSDP wrap in this repo
|
||||
goes through that path), NOT the parameter storage dtypes:
|
||||
FSDP2 computes in the policy's ``param_dtype`` regardless of what
|
||||
dtype the parameters are stored/loaded in, so e.g. an fp16-loaded
|
||||
FSDP model still runs its forward in bf16 -- scanning parameters
|
||||
would resolve fp16 and, with ``cast_forward_inputs=False`` and
|
||||
autocast keyed off the wrong dtype, hand bf16-computing modules
|
||||
fp16 inputs.
|
||||
"""
|
||||
declared_dtype = PRECISION_TO_TYPE[fastvideo_args.pipeline_config.dit_precision]
|
||||
try:
|
||||
from torch.distributed.fsdp import FSDPModule
|
||||
except Exception: # pragma: no cover - FSDP not always available
|
||||
return declared_dtype
|
||||
|
||||
if not isinstance(self.transformer, FSDPModule):
|
||||
return declared_dtype
|
||||
|
||||
try:
|
||||
policy_dtype = get_mixed_precision_state().param_dtype
|
||||
except ValueError:
|
||||
policy_dtype = None
|
||||
# bf16 mirrors maybe_load_fsdp_model's hardcoded param_dtype if the
|
||||
# (thread-local) policy state is somehow unset here.
|
||||
return policy_dtype if policy_dtype is not None else torch.bfloat16
|
||||
|
||||
@staticmethod
|
||||
def fast_sta_nabla(
|
||||
T: int,
|
||||
@@ -267,9 +383,10 @@ class Kandinsky5DenoisingStage(PipelineStage):
|
||||
loader = TransformerLoader()
|
||||
self.transformer = loader.load(fastvideo_args.model_paths["transformer"], fastvideo_args)
|
||||
fastvideo_args.model_loaded["transformer"] = True
|
||||
self._assert_local_attention_backend_engaged()
|
||||
|
||||
device = get_local_torch_device()
|
||||
target_dtype = PRECISION_TO_TYPE[fastvideo_args.pipeline_config.dit_precision]
|
||||
target_dtype = self._resolve_target_dtype(fastvideo_args)
|
||||
autocast_enabled = target_dtype != torch.float32 and not fastvideo_args.disable_autocast
|
||||
latents = batch.latents
|
||||
num_channels = getattr(
|
||||
@@ -391,6 +508,156 @@ class Kandinsky5DenoisingStage(PipelineStage):
|
||||
return result
|
||||
|
||||
|
||||
class Kandinsky5DmdDenoisingStage(Kandinsky5DenoisingStage):
|
||||
"""DMD (few fixed steps, no CFG) variant of Kandinsky5DenoisingStage.
|
||||
|
||||
Reuses the parent's RoPE/scale_factor/sparse-params helpers; only the
|
||||
denoising loop differs: a fixed short timestep schedule
|
||||
(``pipeline_config.dmd_denoising_steps``) with a single forward pass per
|
||||
step and no classifier-free-guidance branch, matching DMD's distilled
|
||||
few-step generator.
|
||||
|
||||
``dmd_denoising_steps`` values (e.g. ``[1000, 750, 500, 250]``) are
|
||||
literal *final* target timesteps -- that's how ``DMD2Method`` on the
|
||||
training side resolves them: nearest-sigma lookup against a scheduler
|
||||
that has never had ``set_timesteps`` called on it, so e.g. "750" maps to
|
||||
sigma 0.75. This stage therefore keeps a private
|
||||
``FlowMatchEulerDiscreteScheduler`` (same ``shift`` as the pipeline
|
||||
scheduler) and drives it directly via predict-x0 + re-noise, exactly
|
||||
mirroring ``DMD2Method._student_rollout``'s "simulate" branch and Wan's
|
||||
own ``DmdDenoisingStage``. It must NOT reuse the pipeline's shared
|
||||
``scheduler`` object through ``scheduler.step()``: by the time this
|
||||
stage runs, ``TimestepPreparationStage`` has already called
|
||||
``scheduler.set_timesteps(timesteps=dmd_denoising_steps)`` on it, which
|
||||
re-applies the flow-match ``shift`` warp on top of values that are
|
||||
already final (e.g. sigma 0.75 -> 0.9375 at shift=5) -- a double shift
|
||||
that leaves every step far noisier than the student was trained for, so
|
||||
the sampled video is still mostly noise after the last step instead of
|
||||
converged.
|
||||
"""
|
||||
|
||||
def __init__(self, transformer, scheduler) -> None:
|
||||
super().__init__(transformer, scheduler)
|
||||
self._sample_scheduler = FlowMatchEulerDiscreteScheduler(shift=scheduler.shift)
|
||||
|
||||
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
|
||||
if batch.latents is None:
|
||||
raise ValueError("latents must be prepared before Kandinsky5 DMD denoising.")
|
||||
if not fastvideo_args.model_loaded["transformer"]:
|
||||
loader = TransformerLoader()
|
||||
self.transformer = loader.load(fastvideo_args.model_paths["transformer"], fastvideo_args)
|
||||
fastvideo_args.model_loaded["transformer"] = True
|
||||
self._assert_local_attention_backend_engaged()
|
||||
|
||||
device = get_local_torch_device()
|
||||
target_dtype = self._resolve_target_dtype(fastvideo_args)
|
||||
autocast_enabled = target_dtype != torch.float32 and not fastvideo_args.disable_autocast
|
||||
latents = batch.latents
|
||||
num_channels = getattr(
|
||||
self.transformer,
|
||||
"in_visual_dim",
|
||||
fastvideo_args.pipeline_config.dit_config.arch_config.in_visual_dim,
|
||||
)
|
||||
|
||||
prompt_embeds = batch.prompt_embeds[0].to(device=device, dtype=target_dtype)
|
||||
pooled = batch.prompt_embeds[1].to(device=device, dtype=target_dtype)
|
||||
if batch.prompt_attention_mask is None or not batch.prompt_attention_mask:
|
||||
raise ValueError("Kandinsky5 DMD requires Qwen prompt attention masks.")
|
||||
text_rope_pos = self._text_rope_pos(batch.prompt_attention_mask[0].to(device), device)
|
||||
|
||||
height = int(batch.height)
|
||||
width = int(batch.width)
|
||||
temporal_ratio = fastvideo_args.pipeline_config.vae_config.arch_config.temporal_compression_ratio
|
||||
spatial_ratio = fastvideo_args.pipeline_config.vae_config.arch_config.spatial_compression_ratio
|
||||
num_latent_frames = (int(batch.num_frames) - 1) // temporal_ratio + 1
|
||||
visual_rope_pos = [
|
||||
torch.arange(num_latent_frames, device=device),
|
||||
torch.arange(height // spatial_ratio // 2, device=device),
|
||||
torch.arange(width // spatial_ratio // 2, device=device),
|
||||
]
|
||||
scale_factor = self._scale_factor(height, width)
|
||||
sparse_params = self.get_sparse_params(latents, device)
|
||||
|
||||
dmd_steps = fastvideo_args.pipeline_config.dmd_denoising_steps
|
||||
if not dmd_steps:
|
||||
raise ValueError("Kandinsky5 DMD denoising requires "
|
||||
"pipeline_config.dmd_denoising_steps to be set.")
|
||||
timesteps = torch.tensor(dmd_steps, dtype=torch.long, device=device)
|
||||
|
||||
# I2V keeps the first (conditioning) frame fixed during denoising.
|
||||
# Key off the actual image conditioning, not transformer.visual_cond:
|
||||
# official T2V checkpoints also ship visual_cond=True, and skipping
|
||||
# frame 0 for them leaves it as undenoised noise (see the same fix
|
||||
# in Kandinsky5DenoisingStage.forward() above).
|
||||
cond_frames = 1 if batch.image_latent is not None else 0
|
||||
|
||||
with tqdm(total=len(timesteps), desc="Kandinsky5 DMD Denoising") as progress_bar:
|
||||
for i, timestep in enumerate(timesteps):
|
||||
if hasattr(self, "interrupt") and self.interrupt:
|
||||
continue
|
||||
|
||||
t_expand = timestep.unsqueeze(0).repeat(latents.shape[0]).to(device=device, dtype=target_dtype)
|
||||
attn_metadata = None
|
||||
if sparse_params is not None:
|
||||
attn_metadata = NablaAttentionMetadataBuilder().build(
|
||||
current_timestep=i,
|
||||
sta_mask=sparse_params["sta_mask"],
|
||||
P=sparse_params["P"],
|
||||
visual_shape=sparse_params["visual_shape"],
|
||||
)
|
||||
autocast_ctx = (torch.autocast(device_type="cuda", dtype=target_dtype, enabled=autocast_enabled)
|
||||
if device.type == "cuda" else contextlib.nullcontext())
|
||||
with set_forward_context(current_timestep=i, attn_metadata=attn_metadata,
|
||||
forward_batch=batch), autocast_ctx:
|
||||
pred_velocity = self.transformer(
|
||||
hidden_states=latents.to(dtype=target_dtype),
|
||||
encoder_hidden_states=prompt_embeds,
|
||||
pooled_projections=pooled,
|
||||
timestep=t_expand,
|
||||
visual_rope_pos=visual_rope_pos,
|
||||
text_rope_pos=text_rope_pos,
|
||||
scale_factor=scale_factor,
|
||||
sparse_params=sparse_params,
|
||||
return_dict=True,
|
||||
).sample
|
||||
|
||||
# Channel-last [B, T', H, W, C] -> channel-first [B, T', C, H, W]
|
||||
# to match pred_noise_to_pred_video/add_noise's expected layout
|
||||
# (same convention as Kandinsky5Model.add_noise/predict_x0).
|
||||
sample_cf = latents[:, cond_frames:, :, :, :num_channels].permute(0, 1, 4, 2, 3)
|
||||
velocity_cf = pred_velocity[:, cond_frames:].permute(0, 1, 4, 2, 3)
|
||||
b, t = sample_cf.shape[:2]
|
||||
step_timestep = timestep.reshape(1).to(device=device)
|
||||
pred_x0_cf = pred_noise_to_pred_video(
|
||||
pred_noise=velocity_cf.flatten(0, 1),
|
||||
noise_input_latent=sample_cf.flatten(0, 1),
|
||||
timestep=step_timestep,
|
||||
scheduler=self._sample_scheduler,
|
||||
).unflatten(0, (b, t))
|
||||
|
||||
if i < len(timesteps) - 1:
|
||||
next_timestep = timesteps[i + 1].reshape(1).to(device=device)
|
||||
noise = randn_tensor(
|
||||
sample_cf.shape,
|
||||
generator=batch.generator,
|
||||
device=device,
|
||||
dtype=pred_x0_cf.dtype,
|
||||
)
|
||||
next_cf = self._sample_scheduler.add_noise(
|
||||
pred_x0_cf.flatten(0, 1),
|
||||
noise.flatten(0, 1),
|
||||
next_timestep,
|
||||
).unflatten(0, (b, t))
|
||||
else:
|
||||
next_cf = pred_x0_cf
|
||||
|
||||
latents[:, cond_frames:, :, :, :num_channels] = next_cf.permute(0, 1, 3, 4, 2).to(latents.dtype)
|
||||
progress_bar.update()
|
||||
|
||||
batch.latents = latents[:, :, :, :, :num_channels]
|
||||
return batch
|
||||
|
||||
|
||||
class Kandinsky5DecodingStage(DecodingStage):
|
||||
|
||||
def __init__(self, vae: ParallelTiledVAE, pipeline=None) -> None:
|
||||
|
||||
@@ -315,14 +315,19 @@ class TextEncodingStage(PipelineStage):
|
||||
prompt_embeds = postprocess_func(outputs)
|
||||
except Exception:
|
||||
prompt_embeds, attention_mask = postprocess_func(outputs, attention_mask)
|
||||
# copy=True: when the text encoder is torch.compile'd with
|
||||
# mode="reduce-overhead"/"max-autotune" (CUDAGraphs), its output
|
||||
# tensors are backed by the graph's static buffer pool and are
|
||||
# overwritten by the next encode call (e.g. the negative prompt).
|
||||
# Retaining them past this call (batch.prompt_embeds is consumed
|
||||
# at denoising time) then raises "accessing tensor output of
|
||||
# CUDAGraphs that has been overwritten by a subsequent run", so
|
||||
# copy them out of graph-owned storage here, at the single point
|
||||
# where encoder outputs escape the compiled region.
|
||||
if is_ltx2 and getattr(outputs, "hidden_states", None):
|
||||
audio_embed = outputs.hidden_states[0].to(device=target_device)
|
||||
if dtype is not None:
|
||||
audio_embed = audio_embed.to(dtype=dtype)
|
||||
audio_embed = outputs.hidden_states[0].to(device=target_device, dtype=dtype, copy=True)
|
||||
audio_embeds_list.append(audio_embed)
|
||||
prompt_embeds = prompt_embeds.to(device=target_device)
|
||||
if dtype is not None:
|
||||
prompt_embeds = prompt_embeds.to(dtype=dtype)
|
||||
prompt_embeds = prompt_embeds.to(device=target_device, dtype=dtype, copy=True)
|
||||
embeds_list.append(prompt_embeds)
|
||||
if return_attention_mask:
|
||||
attn_masks_list.append(attention_mask.to(device=target_device))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user