Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f94673c34 | ||
|
|
c150889330 | ||
|
|
7f783e30b7 | ||
|
|
ec9db97431 | ||
|
|
fd236a06a7 | ||
|
|
54fe0be488 | ||
|
|
3a398a2bbd | ||
|
|
4e76897f1f | ||
|
|
95d8e74d65 | ||
|
|
bcd0ad7dee | ||
|
|
9acbf92455 | ||
|
|
ce4fa8ef2d | ||
|
|
41dcd065ae | ||
|
|
c0cb2228ac | ||
|
|
2fa0f70ee8 | ||
|
|
e172215880 | ||
|
|
23e3b325a4 | ||
|
|
44d0990199 | ||
|
|
59b6578906 | ||
|
|
fd8372d172 | ||
|
|
33eb7ea13c | ||
|
|
051d21958d | ||
|
|
bf29e20bf5 | ||
|
|
291fa5d9a6 | ||
|
|
954daf7fb3 | ||
|
|
e05c04a2f6 |
@@ -404,7 +404,7 @@ steps:
|
||||
- "pyproject.toml"
|
||||
- "docker/Dockerfile"
|
||||
config:
|
||||
command: "timeout 25m .buildkite/scripts/pr_test.sh"
|
||||
command: "timeout 15m .buildkite/scripts/pr_test.sh"
|
||||
label: ":test_tube: Training Tests"
|
||||
env:
|
||||
- TEST_TYPE=training
|
||||
@@ -415,7 +415,7 @@ steps:
|
||||
- "pyproject.toml"
|
||||
- "docker/Dockerfile"
|
||||
config:
|
||||
command: "timeout 25m .buildkite/scripts/pr_test.sh"
|
||||
command: "timeout 15m .buildkite/scripts/pr_test.sh"
|
||||
label: ":test_tube: Distillation DMD Tests"
|
||||
env:
|
||||
- TEST_TYPE=distillation_dmd
|
||||
|
||||
@@ -13,23 +13,16 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
# Auto-rebuild the CUDA images when a repository-controlled image input
|
||||
# changes on main. This includes the trusted SM89 kernel artifact's source,
|
||||
# metadata/key helper, ABI dependency metadata, and build orchestration.
|
||||
# Dreamverse (apps/dreamverse/docker/Dockerfile) and the ROCm Dockerfile stay
|
||||
# manual-dispatch only.
|
||||
# Auto-rebuild the CUDA images when their Dockerfile changes on main. The CUDA
|
||||
# matrix is the only lane that builds from docker/Dockerfile, so a path-scoped
|
||||
# push trigger is a sufficient change detector on its own -- no separate
|
||||
# detect-changes/paths-filter job is needed now that there is a single
|
||||
# in-scope Dockerfile. Dreamverse (apps/dreamverse/docker/Dockerfile) and the
|
||||
# rocm Dockerfile stay manual-dispatch only.
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '.dockerignore'
|
||||
- '.github/workflows/_template-build-image.yml'
|
||||
- '.github/workflows/infra-build-image.yml'
|
||||
- '.gitmodules'
|
||||
- 'docker/Dockerfile'
|
||||
- 'docker/uv-excludes'
|
||||
- 'fastvideo-kernel/**'
|
||||
- 'fastvideo/tests/modal/kernel_build_cache.py'
|
||||
- 'pyproject.toml'
|
||||
|
||||
|
||||
permissions:
|
||||
@@ -57,7 +50,7 @@ jobs:
|
||||
# 2.8.3 comes from the architecture-specific prebuilt releases.
|
||||
build-cuda-images:
|
||||
# Runs on a manual dispatch when build_cuda_matrix is set, or automatically
|
||||
# on an in-scope main push (inputs are null on push). The
|
||||
# on a push that changed docker/Dockerfile (inputs are null on push). The
|
||||
# repository guard keeps fork syncs from auto-building; manual dispatch
|
||||
# still works in forks.
|
||||
if: ${{ (github.event_name == 'push' && github.repository == 'hao-ai-lab/FastVideo') || github.event.inputs.build_cuda_matrix == 'true' }}
|
||||
|
||||
@@ -75,7 +75,6 @@ docs/distillation/examples/
|
||||
*.pkl
|
||||
|
||||
# Reference videos (negations must come after the catch-all on line below)
|
||||
!fastvideo/tests/nightly/reference_video_*.mp4
|
||||
|
||||
# Static images
|
||||
!docs/assets/images/**/*.png
|
||||
|
||||
@@ -13,9 +13,10 @@ test.describe('create inference job', () => {
|
||||
test('creates a T2V job and shows it in the queue', async ({ page }) => {
|
||||
await page.goto('/inference');
|
||||
|
||||
// The trigger opens a real menu on click, so this path works for touch,
|
||||
// mouse, and keyboard users.
|
||||
await page.getByRole('button', { name: /create job/i }).click();
|
||||
// The "Create Job" button reveals a workload menu on hover; wait for the
|
||||
// T2V item to become visible before clicking so the CSS hover transition
|
||||
// can't race the click.
|
||||
await page.getByRole('button', { name: /create job/i }).hover();
|
||||
const t2vItem = page.getByRole('menuitem', { name: /T2V/i });
|
||||
await expect(t2vItem).toBeVisible();
|
||||
await t2vItem.click();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { API_BASE, skipWithoutMock } from './helpers';
|
||||
|
||||
/**
|
||||
* Gallery page: the seeded completed inference job surfaces as a media tile
|
||||
* with playback controls or an explicit media-error fallback.
|
||||
* (an <article> wrapping a <video>) captioned with its prompt.
|
||||
*/
|
||||
test.describe('gallery', () => {
|
||||
skipWithoutMock();
|
||||
@@ -30,15 +30,12 @@ test.describe('gallery', () => {
|
||||
page.getByRole('heading', { level: 1, name: 'Gallery' }),
|
||||
).toBeVisible();
|
||||
|
||||
const tile = page.locator('article').filter({ hasText: completed!.prompt });
|
||||
await expect(tile).toBeVisible();
|
||||
await expect(
|
||||
tile.locator('video').or(tile.getByText('Preview unavailable')),
|
||||
).toBeVisible();
|
||||
// The completed job renders as an <article> containing a <video> tile.
|
||||
const tile = page
|
||||
.locator('article')
|
||||
.filter({ has: page.locator('video') });
|
||||
await expect(tile.first()).toBeVisible();
|
||||
|
||||
const video = tile.locator('video');
|
||||
if (await video.isVisible()) {
|
||||
await expect(video).toHaveAttribute('controls', '');
|
||||
}
|
||||
await expect(page.getByText(completed!.prompt)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,74 +42,6 @@ test.describe('app shell', () => {
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: section.title }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('main')).toHaveCount(1);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps navigation and content usable at responsive breakpoints', async ({
|
||||
page,
|
||||
}) => {
|
||||
for (const width of [320, 375, 414, 768]) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
await page.goto('/inference');
|
||||
|
||||
const main = page.getByRole('main');
|
||||
await expect(main).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Create Job/i }),
|
||||
).toBeVisible();
|
||||
|
||||
const initialBox = await main.boundingBox();
|
||||
expect(initialBox?.x).toBe(width < 768 ? 0 : 220);
|
||||
expect(initialBox?.width).toBe(width < 768 ? width : width - 220);
|
||||
|
||||
const navigation = page.getByRole('navigation', {
|
||||
name: 'Primary navigation',
|
||||
});
|
||||
if (width < 768) {
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Open navigation' }),
|
||||
).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Open navigation' }).click();
|
||||
}
|
||||
await expect(navigation).toBeVisible();
|
||||
await navigation.getByRole('link', { name: 'Datasets' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/datasets$/);
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('uses full-width detail drawers on mobile', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 320, height: 800 });
|
||||
await page.goto('/inference');
|
||||
|
||||
await page
|
||||
.locator('article button[aria-pressed="false"]')
|
||||
.first()
|
||||
.click();
|
||||
const jobDrawer = page.getByRole('dialog', { name: 'Job details' });
|
||||
await expect(jobDrawer).toBeVisible();
|
||||
expect(await jobDrawer.boundingBox()).toMatchObject({ x: 0, width: 320 });
|
||||
await jobDrawer.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
await page.goto('/datasets');
|
||||
await page
|
||||
.locator('article button[aria-pressed="false"]')
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const datasetDrawer = page.getByRole('dialog', {
|
||||
name: /dataset details$/,
|
||||
});
|
||||
await expect(datasetDrawer).toBeVisible();
|
||||
expect(await datasetDrawer.boundingBox()).toMatchObject({
|
||||
x: 0,
|
||||
width: 320,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Generated
-681
@@ -9,7 +9,6 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
@@ -1937,183 +1936,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu": {
|
||||
"version": "2.1.24",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.24.tgz",
|
||||
"integrity": "sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-context": "1.2.2",
|
||||
"@radix-ui/react-id": "1.1.4",
|
||||
"@radix-ui/react-menu": "2.1.24",
|
||||
"@radix-ui/react-primitive": "2.1.10",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz",
|
||||
"integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
|
||||
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz",
|
||||
"integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-id": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz",
|
||||
"integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz",
|
||||
"integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz",
|
||||
"integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-controllable-state": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz",
|
||||
"integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.7",
|
||||
"@radix-ui/react-use-effect-event": "0.0.5",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-effect-event": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz",
|
||||
"integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
|
||||
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-guards": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz",
|
||||
@@ -2195,494 +2017,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu": {
|
||||
"version": "2.1.24",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.24.tgz",
|
||||
"integrity": "sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.7",
|
||||
"@radix-ui/react-collection": "1.1.15",
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-context": "1.2.2",
|
||||
"@radix-ui/react-direction": "1.1.4",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.19",
|
||||
"@radix-ui/react-focus-guards": "1.1.6",
|
||||
"@radix-ui/react-focus-scope": "1.1.16",
|
||||
"@radix-ui/react-id": "1.1.4",
|
||||
"@radix-ui/react-popper": "1.3.7",
|
||||
"@radix-ui/react-portal": "1.1.17",
|
||||
"@radix-ui/react-presence": "1.1.10",
|
||||
"@radix-ui/react-primitive": "2.1.10",
|
||||
"@radix-ui/react-roving-focus": "1.1.19",
|
||||
"@radix-ui/react-slot": "1.3.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.4",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz",
|
||||
"integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz",
|
||||
"integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-collection": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz",
|
||||
"integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-context": "1.2.2",
|
||||
"@radix-ui/react-primitive": "2.1.10",
|
||||
"@radix-ui/react-slot": "1.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
|
||||
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz",
|
||||
"integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-direction": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz",
|
||||
"integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer": {
|
||||
"version": "1.1.19",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz",
|
||||
"integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.10",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.4",
|
||||
"@radix-ui/react-use-effect-event": "0.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-focus-guards": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz",
|
||||
"integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-focus-scope": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz",
|
||||
"integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.10",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-id": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz",
|
||||
"integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz",
|
||||
"integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.0.0",
|
||||
"@radix-ui/react-arrow": "1.1.15",
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-context": "1.2.2",
|
||||
"@radix-ui/react-primitive": "2.1.10",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.4",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4",
|
||||
"@radix-ui/react-use-rect": "1.1.4",
|
||||
"@radix-ui/react-use-size": "1.1.4",
|
||||
"@radix-ui/rect": "1.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-portal": {
|
||||
"version": "1.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz",
|
||||
"integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.10",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz",
|
||||
"integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz",
|
||||
"integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-roving-focus": {
|
||||
"version": "1.1.19",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz",
|
||||
"integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.7",
|
||||
"@radix-ui/react-collection": "1.1.15",
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-context": "1.2.2",
|
||||
"@radix-ui/react-direction": "1.1.4",
|
||||
"@radix-ui/react-id": "1.1.4",
|
||||
"@radix-ui/react-primitive": "2.1.10",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.4",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.6",
|
||||
"@radix-ui/react-use-is-hydrated": "0.1.3",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz",
|
||||
"integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz",
|
||||
"integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-controllable-state": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz",
|
||||
"integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.7",
|
||||
"@radix-ui/react-use-effect-event": "0.0.5",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-effect-event": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz",
|
||||
"integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
|
||||
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-rect": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz",
|
||||
"integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/rect": "1.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-size": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz",
|
||||
"integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/rect": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz",
|
||||
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.2.tgz",
|
||||
@@ -3076,21 +2410,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-is-hydrated": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz",
|
||||
"integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { HeaderActionsProvider } from '@/components/shell/HeaderActionsContext';
|
||||
import { getDatasets, type Dataset } from '@/lib/api';
|
||||
|
||||
import DatasetsPage from './page';
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
getDatasets: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/datasets/AddDatasetButton', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/datasets/CreateDatasetModal', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/datasets/DatasetCard', () => ({
|
||||
default: ({ dataset }: { dataset: Dataset }) => <div>{dataset.name}</div>,
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<HeaderActionsProvider>
|
||||
<DatasetsPage />
|
||||
</HeaderActionsProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('DatasetsPage', () => {
|
||||
it('shows loading content before the initial request settles', async () => {
|
||||
let resolveDatasets: (datasets: Dataset[]) => void = () => {};
|
||||
vi.mocked(getDatasets).mockReturnValue(
|
||||
new Promise<Dataset[]>((resolve) => {
|
||||
resolveDatasets = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
renderPage();
|
||||
expect(screen.getByLabelText('Loading datasets')).toBeInTheDocument();
|
||||
expect(screen.queryByText('No datasets yet.')).not.toBeInTheDocument();
|
||||
|
||||
act(() => resolveDatasets([]));
|
||||
expect(await screen.findByText('No datasets yet.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows API failures separately from an empty list and retries', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.mocked(getDatasets).mockRejectedValueOnce(new Error('network down'));
|
||||
|
||||
renderPage();
|
||||
expect(
|
||||
await screen.findByText(/Could not load datasets from the Studio API/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('No datasets yet.')).not.toBeInTheDocument();
|
||||
|
||||
vi.mocked(getDatasets).mockResolvedValueOnce([]);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Try Again' }));
|
||||
expect(await screen.findByText('No datasets yet.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
import AddDatasetButton from '@/components/datasets/AddDatasetButton';
|
||||
import CreateDatasetModal from '@/components/datasets/CreateDatasetModal';
|
||||
import DatasetCard from '@/components/datasets/DatasetCard';
|
||||
import { HeaderActions } from '@/components/shell/HeaderActionsContext';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useStore } from '@/hooks/useStore';
|
||||
import { getDatasets } from '@/lib/api';
|
||||
import type { Dataset } from '@/lib/api';
|
||||
@@ -23,28 +21,18 @@ import {
|
||||
|
||||
export default function DatasetsPage() {
|
||||
const [datasets, setDatasets] = React.useState<Dataset[]>([]);
|
||||
const [isInitialLoading, setIsInitialLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const { open } = useStore(createDatasetModalStore);
|
||||
const fetchSequence = React.useRef(0);
|
||||
|
||||
const fetchDatasets = React.useCallback(async () => {
|
||||
const sequence = ++fetchSequence.current;
|
||||
try {
|
||||
const next = await getDatasets();
|
||||
if (sequence === fetchSequence.current) {
|
||||
setDatasets(next);
|
||||
setError(null);
|
||||
}
|
||||
setDatasets(await getDatasets());
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch datasets:', err);
|
||||
if (sequence === fetchSequence.current) {
|
||||
setError(
|
||||
'Could not load datasets from the Studio API. Check the server and try again.',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (sequence === fetchSequence.current) setIsInitialLoading(false);
|
||||
// Distinguish an API outage from a genuinely empty list, so the user
|
||||
// isn't told they have no datasets when the server is unreachable.
|
||||
setError(err instanceof Error ? err.message : 'Failed to load datasets');
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -62,67 +50,28 @@ export default function DatasetsPage() {
|
||||
<HeaderActions>
|
||||
<AddDatasetButton />
|
||||
</HeaderActions>
|
||||
<div className="mx-auto flex w-full max-w-[850px] flex-col gap-6 px-4 pb-12">
|
||||
<main className="mx-auto flex w-full max-w-[850px] flex-col gap-6 px-4 pb-12">
|
||||
<Card className="p-6">
|
||||
<div aria-busy={isInitialLoading}>
|
||||
{isInitialLoading ? (
|
||||
<div
|
||||
aria-label="Loading datasets"
|
||||
className="flex flex-col gap-3 py-2"
|
||||
>
|
||||
{[0, 1, 2].map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="h-24 animate-pulse rounded-lg border border-border bg-muted/50"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : error && datasets.length === 0 ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col items-center gap-3 py-8 text-center"
|
||||
>
|
||||
<AlertTriangle
|
||||
className="size-6 text-destructive"
|
||||
aria-hidden
|
||||
/>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
{error}
|
||||
</p>
|
||||
<Button type="button" variant="outline" onClick={fetchDatasets}>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
{error ? (
|
||||
<p className="py-8 text-center text-destructive">{error}</p>
|
||||
) : datasets.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
No datasets yet.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{error && (
|
||||
<p
|
||||
role="status"
|
||||
className="mb-3 rounded-lg border border-amber-500/50 bg-amber-500/10 px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
Dataset updates are temporarily unavailable. Showing the
|
||||
most recent results.
|
||||
</p>
|
||||
)}
|
||||
{datasets.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
No datasets yet.
|
||||
</p>
|
||||
) : (
|
||||
datasets.map((ds) => (
|
||||
<DatasetCard
|
||||
key={ds.id}
|
||||
dataset={ds}
|
||||
onUpdated={fetchDatasets}
|
||||
onSelect={() => handleSelectDataset(ds)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
datasets.map((ds) => (
|
||||
<DatasetCard
|
||||
key={ds.id}
|
||||
dataset={ds}
|
||||
onUpdated={fetchDatasets}
|
||||
onSelect={() => handleSelectDataset(ds)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
<CreateDatasetModal
|
||||
isOpen={open}
|
||||
onClose={() => setCreateDatasetModalOpen(false)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import GalleryPage from './page';
|
||||
@@ -43,22 +43,6 @@ describe('GalleryPage', () => {
|
||||
expect(getJobsList).toHaveBeenCalledWith('inference');
|
||||
});
|
||||
|
||||
it('provides video controls and a visible fallback when media fails', async () => {
|
||||
vi.mocked(getJobsList).mockResolvedValue([makeJob()]);
|
||||
renderGallery();
|
||||
|
||||
const video = await screen.findByLabelText(
|
||||
'Generated video: a cat surfing a wave',
|
||||
);
|
||||
expect(video).toHaveAttribute('controls');
|
||||
|
||||
fireEvent.error(video);
|
||||
expect(screen.getByText('Preview unavailable')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('The generated file could not be loaded.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the empty state when no completed videos exist', async () => {
|
||||
vi.mocked(getJobsList).mockResolvedValue([
|
||||
makeJob({ status: 'running', output_path: null }),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ImageOff, Loader2 } from 'lucide-react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Card } from '@/components/ui/card';
|
||||
@@ -11,54 +11,6 @@ function isImage(job: Job): boolean {
|
||||
return job.output_path?.toLowerCase().endsWith('.png') ?? false;
|
||||
}
|
||||
|
||||
function GalleryMedia({ job }: { job: Job }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center text-muted-foreground"
|
||||
>
|
||||
<ImageOff className="size-7" aria-hidden />
|
||||
<span className="text-sm font-medium">Preview unavailable</span>
|
||||
<span className="text-xs">
|
||||
The generated file could not be loaded.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isImage(job)) {
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={getJobVideoUrl(job.id)}
|
||||
alt={job.prompt}
|
||||
className="block h-full w-full object-contain"
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<video
|
||||
src={getJobVideoUrl(job.id)}
|
||||
aria-label={
|
||||
job.prompt ? `Generated video: ${job.prompt}` : 'Generated video'
|
||||
}
|
||||
className="block h-full w-full object-contain"
|
||||
controls
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GalleryPage() {
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -125,7 +77,24 @@ export default function GalleryPage() {
|
||||
className="flex flex-col overflow-hidden rounded-lg border border-border bg-background"
|
||||
>
|
||||
<div className="relative aspect-video overflow-hidden bg-muted">
|
||||
<GalleryMedia job={job} />
|
||||
{isImage(job) ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={getJobVideoUrl(job.id)}
|
||||
alt={job.prompt}
|
||||
className="block h-full w-full object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
src={getJobVideoUrl(job.id)}
|
||||
className="block h-full w-full object-contain"
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className="line-clamp-3 border-t border-border px-4 py-3 text-sm text-muted-foreground"
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
--border: #e2e8f0;
|
||||
--input: #cbd5e1;
|
||||
--ring: #1d4ed8;
|
||||
--ring: #94a3b8;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
--border: #334155;
|
||||
--input: #334155;
|
||||
--ring: #7dd3fc;
|
||||
--ring: #cbd5e1;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -125,7 +125,6 @@
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
html {
|
||||
@@ -164,22 +163,6 @@ a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
:where(
|
||||
a,
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
summary,
|
||||
[role="button"],
|
||||
[role="menuitem"],
|
||||
[role="slider"],
|
||||
[tabindex]
|
||||
):focus-visible {
|
||||
outline: 3px solid var(--ring) !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
|
||||
summary {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const css = readFileSync(join(process.cwd(), 'src/app/globals.css'), 'utf8');
|
||||
|
||||
function token(block: string, name: string): string {
|
||||
const match = block.match(new RegExp(`--${name}:\\s*(#[0-9a-fA-F]{6})`));
|
||||
if (!match) throw new Error(`Missing --${name} token`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function luminance(hex: string): number {
|
||||
const channels = hex
|
||||
.slice(1)
|
||||
.match(/.{2}/g)!
|
||||
.map((channel) => parseInt(channel, 16) / 255)
|
||||
.map((channel) =>
|
||||
channel <= 0.04045
|
||||
? channel / 12.92
|
||||
: ((channel + 0.055) / 1.055) ** 2.4,
|
||||
);
|
||||
return (
|
||||
0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]
|
||||
);
|
||||
}
|
||||
|
||||
function contrast(first: string, second: string): number {
|
||||
const firstLuminance = luminance(first);
|
||||
const secondLuminance = luminance(second);
|
||||
return (
|
||||
(Math.max(firstLuminance, secondLuminance) + 0.05) /
|
||||
(Math.min(firstLuminance, secondLuminance) + 0.05)
|
||||
);
|
||||
}
|
||||
|
||||
describe('global focus styles', () => {
|
||||
it('keeps focus tokens above 3:1 against both page themes', () => {
|
||||
const light = css.match(/:root\s*{([\s\S]*?)\n}/)?.[1] ?? '';
|
||||
const dark = css.match(/\.dark\s*{([\s\S]*?)\n}/)?.[1] ?? '';
|
||||
|
||||
expect(contrast(token(light, 'ring'), token(light, 'background'))).toBeGreaterThanOrEqual(
|
||||
3,
|
||||
);
|
||||
expect(contrast(token(dark, 'ring'), token(dark, 'background'))).toBeGreaterThanOrEqual(
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
it('applies a non-animated three-pixel outline to focus-visible controls', () => {
|
||||
expect(css).toContain('):focus-visible {');
|
||||
expect(css).toContain('outline: 3px solid var(--ring) !important;');
|
||||
expect(css).toContain('outline-offset: 2px !important;');
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,8 @@ import GpuGrid from '@/components/system/GpuGrid';
|
||||
|
||||
export default function GpusPage() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[1100px] flex-col gap-6 px-4 pb-12 pt-6">
|
||||
<main className="mx-auto flex w-full max-w-[1100px] flex-col gap-6 px-4 pb-12 pt-6">
|
||||
<GpuGrid />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,20 +52,6 @@ describe('Settings page', () => {
|
||||
expect(updateOption).toHaveBeenCalledWith('numFrames', expect.any(Number));
|
||||
});
|
||||
|
||||
it('gives every slider an accessible name', () => {
|
||||
renderPage();
|
||||
|
||||
const sliders = screen.getAllByRole('slider');
|
||||
expect(sliders).toHaveLength(11);
|
||||
for (const slider of sliders) {
|
||||
expect(slider).toHaveAccessibleName();
|
||||
}
|
||||
expect(screen.getByRole('slider', { name: 'Frames' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('slider', { name: 'Guidance Scale' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls resetToDefaults when Reset to Defaults is clicked', () => {
|
||||
renderPage();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reset to Defaults' }));
|
||||
|
||||
@@ -25,16 +25,6 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('DatasetCard', () => {
|
||||
it('keeps selection and delete buttons as semantic siblings', () => {
|
||||
render(<DatasetCard dataset={dataset} onUpdated={() => {}} />);
|
||||
|
||||
const selectButton = screen.getByRole('button', { pressed: false });
|
||||
const deleteButton = screen.getByRole('button', { name: 'Delete' });
|
||||
|
||||
expect(selectButton).toHaveTextContent('My Dataset');
|
||||
expect(selectButton).not.toContainElement(deleteButton);
|
||||
});
|
||||
|
||||
it('renders the name, file count and human-readable size', () => {
|
||||
render(<DatasetCard dataset={dataset} onUpdated={() => {}} />);
|
||||
expect(screen.getByText('My Dataset')).toBeInTheDocument();
|
||||
@@ -81,7 +71,7 @@ describe('DatasetCard', () => {
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the selection and delete actions separate', () => {
|
||||
it('selects on keyboard activation of the card body but not of the Delete button', () => {
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<DatasetCard dataset={dataset} onUpdated={() => {}} onSelect={onSelect} />,
|
||||
@@ -93,12 +83,8 @@ describe('DatasetCard', () => {
|
||||
});
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
|
||||
// Activating the dedicated selection button selects the dataset.
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: /My Dataset.*3 files.*2.0 KB/,
|
||||
}),
|
||||
);
|
||||
// Activating the card body itself does select.
|
||||
fireEvent.keyDown(screen.getByText('My Dataset'), { key: 'Enter' });
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -55,33 +55,43 @@ export default function DatasetCard({
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
<div
|
||||
className={cn(
|
||||
'mb-3 flex items-start gap-3 rounded-lg border border-border bg-background px-[1.15rem] py-4',
|
||||
'mb-3 flex cursor-pointer flex-col gap-[0.6rem] rounded-lg border border-border bg-background px-[1.15rem] py-4',
|
||||
isSelected && 'border-accent-blue bg-accent-blue/5',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
onSelect();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={isSelected}
|
||||
onClick={onSelect}
|
||||
className="flex min-w-0 flex-1 cursor-pointer flex-col gap-[0.6rem] rounded-md text-left"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-[0.95rem] font-semibold">{dataset.name}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{fileCount} {fileCount === 1 ? 'file' : 'files'} · {sizeLabel}
|
||||
</span>
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</article>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{fileCount} {fileCount === 1 ? 'file' : 'files'} · {sizeLabel}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,6 @@ import * as api from '@/lib/api';
|
||||
import type { Dataset } from '@/lib/api';
|
||||
|
||||
vi.mock('@/lib/api');
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { error: vi.fn() },
|
||||
}));
|
||||
|
||||
const mockedApi = vi.mocked(api);
|
||||
|
||||
@@ -30,27 +27,6 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('DatasetSidebar', () => {
|
||||
it('fills the mobile viewport without reserving main-content width', async () => {
|
||||
const onWidthChange = vi.fn();
|
||||
|
||||
render(
|
||||
<DatasetSidebar
|
||||
dataset={dataset}
|
||||
isMobile
|
||||
onClose={() => {}}
|
||||
onWidthChange={onWidthChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const drawer = screen.getByRole('dialog', {
|
||||
name: 'My Dataset dataset details',
|
||||
});
|
||||
expect(drawer).toHaveStyle({ width: '100%', maxWidth: 'none' });
|
||||
expect(drawer).toHaveAttribute('aria-modal', 'true');
|
||||
expect(drawer).toHaveFocus();
|
||||
expect(onWidthChange).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('lists dataset files after loading', async () => {
|
||||
render(<DatasetSidebar dataset={dataset} onClose={() => {}} />);
|
||||
|
||||
@@ -67,16 +43,6 @@ describe('DatasetSidebar', () => {
|
||||
expect(mockedApi.getDatasetMediaUrl).toHaveBeenCalledWith('ds-1', 'b.mp4');
|
||||
});
|
||||
|
||||
it('shows a fallback when a dataset preview cannot load', async () => {
|
||||
render(<DatasetSidebar dataset={dataset} onClose={() => {}} />);
|
||||
|
||||
const preview = await screen.findByLabelText('Preview of a.mp4');
|
||||
fireEvent.error(preview);
|
||||
|
||||
expect(screen.getByText('Preview unavailable')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Preview of a.mp4')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('debounces caption save by 500ms', async () => {
|
||||
render(<DatasetSidebar dataset={dataset} onClose={() => {}} />);
|
||||
const textarea = await screen.findByDisplayValue('cap a');
|
||||
@@ -133,39 +99,6 @@ describe('DatasetSidebar', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('shows a failed save and lets the user retry it', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockedApi.updateDatasetCaption
|
||||
.mockRejectedValueOnce(new Error('network down'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
render(<DatasetSidebar dataset={dataset} onClose={() => {}} />);
|
||||
const textarea = await screen.findByDisplayValue('cap a');
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
fireEvent.change(textarea, { target: { value: 'needs retry' } });
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Not saved/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockedApi.updateDatasetCaption).toHaveBeenCalledTimes(2);
|
||||
expect(mockedApi.updateDatasetCaption).toHaveBeenLastCalledWith(
|
||||
'ds-1',
|
||||
'a.mp4',
|
||||
'needs retry',
|
||||
);
|
||||
expect(screen.getByText('Saved')).toBeInTheDocument();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('debounces per file: editing another caption does not cancel a pending save', async () => {
|
||||
render(<DatasetSidebar dataset={dataset} onClose={() => {}} />);
|
||||
await screen.findByDisplayValue('cap a');
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ImageOff, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import DownloadCaptions from '@/components/datasets/DownloadCaptions';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -14,14 +13,12 @@ import {
|
||||
type Dataset,
|
||||
} from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDrawerFocus } from '@/hooks/useDrawerFocus';
|
||||
|
||||
const SIDEBAR_MIN_WIDTH = 320;
|
||||
const SIDEBAR_MAX_WIDTH = 900;
|
||||
const INITIAL_PAGE_SIZE = 24;
|
||||
const PAGE_SIZE = 24;
|
||||
const SCROLL_THRESHOLD = 200;
|
||||
type CaptionSaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
|
||||
// Memoized so a caption keystroke re-renders only the edited card, not every
|
||||
// visible <video> in the grid (visibleCount grows unbounded with scrolling).
|
||||
@@ -30,101 +27,54 @@ const DatasetFileCard = React.memo(function DatasetFileCard({
|
||||
mediaUrl,
|
||||
caption,
|
||||
thumbLoaded,
|
||||
saveState,
|
||||
onCaptionChange,
|
||||
onCaptionRetry,
|
||||
onThumbLoaded,
|
||||
}: {
|
||||
fileName: string;
|
||||
mediaUrl: string;
|
||||
caption: string;
|
||||
thumbLoaded: boolean;
|
||||
saveState: CaptionSaveState;
|
||||
onCaptionChange: (fileName: string, value: string) => void;
|
||||
onCaptionRetry: (fileName: string, value: string) => void;
|
||||
onThumbLoaded: (fileName: string) => void;
|
||||
}) {
|
||||
const [mediaFailed, setMediaFailed] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMediaFailed(false);
|
||||
}, [mediaUrl]);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col overflow-hidden rounded-lg border border-border bg-background">
|
||||
{!thumbLoaded && !mediaFailed && (
|
||||
{!thumbLoaded && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-background/70">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground/40 border-t-accent-blue" />
|
||||
</div>
|
||||
)}
|
||||
{mediaFailed ? (
|
||||
<div
|
||||
role="status"
|
||||
className="flex aspect-video w-full flex-col items-center justify-center gap-1 bg-muted px-2 text-center text-muted-foreground"
|
||||
>
|
||||
<ImageOff className="size-5" aria-hidden />
|
||||
<span className="text-xs">Preview unavailable</span>
|
||||
</div>
|
||||
) : (
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<video
|
||||
src={mediaUrl}
|
||||
aria-label={`Preview of ${fileName}`}
|
||||
className="aspect-video w-full bg-border object-cover"
|
||||
muted
|
||||
autoPlay
|
||||
loop
|
||||
playsInline
|
||||
onLoadedData={() => onThumbLoaded(fileName)}
|
||||
onError={() => {
|
||||
setMediaFailed(true);
|
||||
onThumbLoaded(fileName);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<video
|
||||
src={mediaUrl}
|
||||
className="aspect-video w-full bg-border object-cover"
|
||||
muted
|
||||
autoPlay
|
||||
loop
|
||||
playsInline
|
||||
onLoadedData={() => onThumbLoaded(fileName)}
|
||||
onError={() => onThumbLoaded(fileName)}
|
||||
/>
|
||||
<Textarea
|
||||
aria-label={`Caption for ${fileName}`}
|
||||
value={caption}
|
||||
onChange={(e) => onCaptionChange(fileName, e.target.value)}
|
||||
placeholder="Caption"
|
||||
rows={2}
|
||||
className="min-h-[2.5rem] resize-y rounded-none border-0 bg-transparent p-1.5 text-xs shadow-none focus-visible:border-transparent focus-visible:ring-0"
|
||||
/>
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="flex min-h-6 items-center px-1.5 pb-1 text-[0.7rem] text-muted-foreground"
|
||||
>
|
||||
{saveState === 'saving' && <span>Saving…</span>}
|
||||
{saveState === 'saved' && <span>Saved</span>}
|
||||
{saveState === 'error' && (
|
||||
<span role="alert" className="text-destructive">
|
||||
Not saved.{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCaptionRetry(fileName, caption)}
|
||||
className="inline-flex min-h-11 items-center font-medium underline underline-offset-2"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default function DatasetSidebar({
|
||||
dataset,
|
||||
isMobile = false,
|
||||
onClose,
|
||||
onWidthChange,
|
||||
}: {
|
||||
dataset: Dataset;
|
||||
isMobile?: boolean;
|
||||
onClose: () => void;
|
||||
onWidthChange?: (w: number) => void;
|
||||
}) {
|
||||
const drawerRef = useDrawerFocus<HTMLElement>(isMobile);
|
||||
const [width, setWidth] = React.useState(400);
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
const [fileNames, setFileNames] = React.useState<string[]>([]);
|
||||
@@ -134,21 +84,17 @@ export default function DatasetSidebar({
|
||||
const [thumbLoaded, setThumbLoaded] = React.useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [captionSaveStates, setCaptionSaveStates] = React.useState<
|
||||
Record<string, CaptionSaveState>
|
||||
>({});
|
||||
|
||||
// Pending debounced caption saves, keyed per file so editing one caption
|
||||
// can't cancel another file's pending save.
|
||||
const pendingSaves = React.useRef(
|
||||
new Map<string, { timer: ReturnType<typeof setTimeout>; save: () => void }>(),
|
||||
);
|
||||
const captionVersions = React.useRef(new Map<string, number>());
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
onWidthChange?.(isMobile ? 0 : width);
|
||||
}, [isMobile, width, onWidthChange]);
|
||||
onWidthChange?.(width);
|
||||
}, [width, onWidthChange]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -160,8 +106,6 @@ export default function DatasetSidebar({
|
||||
setCaptions(data.captions);
|
||||
setVisibleCount(INITIAL_PAGE_SIZE);
|
||||
setThumbLoaded({});
|
||||
setCaptionSaveStates({});
|
||||
captionVersions.current.clear();
|
||||
})
|
||||
.catch((err) => console.error('Failed to load dataset files:', err))
|
||||
.finally(() => {
|
||||
@@ -194,51 +138,23 @@ export default function DatasetSidebar({
|
||||
});
|
||||
|
||||
const datasetId = dataset.id;
|
||||
const persistCaption = React.useCallback(
|
||||
(fileName: string, value: string, version: number) => {
|
||||
setCaptionSaveStates((prev) => ({ ...prev, [fileName]: 'saving' }));
|
||||
void updateDatasetCaption(datasetId, fileName, value)
|
||||
.then(() => {
|
||||
if (captionVersions.current.get(fileName) !== version) return;
|
||||
setCaptionSaveStates((prev) => ({ ...prev, [fileName]: 'saved' }));
|
||||
})
|
||||
.catch((error) => {
|
||||
if (captionVersions.current.get(fileName) !== version) return;
|
||||
console.error('Failed to save caption:', error);
|
||||
setCaptionSaveStates((prev) => ({ ...prev, [fileName]: 'error' }));
|
||||
toast.error('Caption was not saved', {
|
||||
description: `${fileName}: check the Studio API, then retry.`,
|
||||
});
|
||||
});
|
||||
},
|
||||
[datasetId],
|
||||
);
|
||||
|
||||
const handleCaptionChange = React.useCallback(
|
||||
(fileName: string, value: string) => {
|
||||
setCaptions((prev) => ({ ...prev, [fileName]: value }));
|
||||
setCaptionSaveStates((prev) => ({ ...prev, [fileName]: 'idle' }));
|
||||
const pending = pendingSaves.current.get(fileName);
|
||||
if (pending) clearTimeout(pending.timer);
|
||||
const version = (captionVersions.current.get(fileName) ?? 0) + 1;
|
||||
captionVersions.current.set(fileName, version);
|
||||
const save = () => persistCaption(fileName, value, version);
|
||||
const save = () => {
|
||||
updateDatasetCaption(datasetId, fileName, value).catch((err) =>
|
||||
console.error('Failed to save caption:', err),
|
||||
);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
pendingSaves.current.delete(fileName);
|
||||
save();
|
||||
}, 500);
|
||||
pendingSaves.current.set(fileName, { timer, save });
|
||||
},
|
||||
[persistCaption],
|
||||
);
|
||||
|
||||
const handleCaptionRetry = React.useCallback(
|
||||
(fileName: string, value: string) => {
|
||||
const version = (captionVersions.current.get(fileName) ?? 0) + 1;
|
||||
captionVersions.current.set(fileName, version);
|
||||
persistCaption(fileName, value, version);
|
||||
},
|
||||
[persistCaption],
|
||||
[datasetId],
|
||||
);
|
||||
|
||||
function handleScroll() {
|
||||
@@ -273,16 +189,8 @@ export default function DatasetSidebar({
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={drawerRef}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-label={`${dataset.name} dataset details`}
|
||||
aria-modal={isMobile || undefined}
|
||||
className="fixed bottom-0 right-0 top-[var(--header-height)] z-50 flex max-h-[calc(100dvh-var(--header-height))] min-w-0 shrink-0 flex-col border-l border-border bg-card md:min-w-[320px]"
|
||||
style={{
|
||||
width: isMobile ? '100%' : width,
|
||||
maxWidth: isMobile ? 'none' : SIDEBAR_MAX_WIDTH,
|
||||
}}
|
||||
className="fixed bottom-0 right-0 top-[var(--header-height)] z-50 flex max-h-[calc(100vh-var(--header-height))] min-w-[320px] shrink-0 flex-col border-l border-border bg-card"
|
||||
style={{ width, maxWidth: SIDEBAR_MAX_WIDTH }}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border px-5 py-4">
|
||||
<h2 className="m-0 min-w-0 truncate text-base font-semibold text-foreground">
|
||||
@@ -295,7 +203,7 @@ export default function DatasetSidebar({
|
||||
onClick={onClose}
|
||||
title="Close"
|
||||
aria-label="Close"
|
||||
className="flex size-11 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
className="flex items-center justify-center rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<X className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
@@ -323,9 +231,7 @@ export default function DatasetSidebar({
|
||||
mediaUrl={getDatasetMediaUrl(dataset.id, fileName)}
|
||||
caption={captions[fileName] ?? ''}
|
||||
thumbLoaded={!!thumbLoaded[fileName]}
|
||||
saveState={captionSaveStates[fileName] ?? 'idle'}
|
||||
onCaptionChange={handleCaptionChange}
|
||||
onCaptionRetry={handleCaptionRetry}
|
||||
onThumbLoaded={markThumbLoaded}
|
||||
/>
|
||||
))}
|
||||
@@ -334,14 +240,14 @@ export default function DatasetSidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isMobile && <div
|
||||
<div
|
||||
role="presentation"
|
||||
onMouseDown={onMouseDown}
|
||||
className={cn(
|
||||
'absolute bottom-0 left-0 top-0 z-[1] w-1.5 cursor-col-resize hover:bg-accent-blue/25',
|
||||
isDragging && 'bg-accent-blue/25',
|
||||
)}
|
||||
/>}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { downloadBlob } from '@/lib/utils';
|
||||
|
||||
import DownloadCaptions from './DownloadCaptions';
|
||||
|
||||
vi.mock('@/lib/utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/utils')>();
|
||||
return { ...actual, downloadBlob: vi.fn() };
|
||||
});
|
||||
|
||||
const props = {
|
||||
fileNames: ['b.mp4', 'a.mp4'],
|
||||
captions: { 'a.mp4': 'cap a', 'b.mp4': 'cap b' },
|
||||
};
|
||||
|
||||
describe('DownloadCaptions', () => {
|
||||
it('opens the format menu on click and downloads the selection', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DownloadCaptions {...props} />);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Download Captions' }),
|
||||
);
|
||||
await user.click(screen.getByRole('menuitem', { name: 'JSON' }));
|
||||
|
||||
expect(vi.mocked(downloadBlob)).toHaveBeenCalledWith(
|
||||
expect.any(Blob),
|
||||
'videos2caption.json',
|
||||
);
|
||||
});
|
||||
|
||||
it('operates entirely from the keyboard', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DownloadCaptions {...props} />);
|
||||
|
||||
screen.getByRole('button', { name: 'Download Captions' }).focus();
|
||||
await user.keyboard('{Enter}');
|
||||
const first = await screen.findByRole('menuitem', { name: 'JSON' });
|
||||
expect(first).toHaveFocus();
|
||||
await user.keyboard('{ArrowDown}{Enter}');
|
||||
|
||||
expect(vi.mocked(downloadBlob)).toHaveBeenCalledWith(
|
||||
expect.any(Blob),
|
||||
'videos.txt',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { downloadBlob } from '@/lib/utils';
|
||||
|
||||
const MENU_ITEM =
|
||||
'block min-h-11 w-full cursor-pointer select-none px-4 py-2 text-left text-sm font-medium text-foreground outline-none transition-colors data-[highlighted]:bg-muted';
|
||||
'block w-full cursor-pointer px-4 py-2 text-left text-sm font-medium text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50';
|
||||
|
||||
export default function DownloadCaptions({
|
||||
fileNames,
|
||||
@@ -63,40 +62,52 @@ export default function DownloadCaptions({
|
||||
downloadBlob(new Blob([csv], { type: 'text/csv' }), 'captions.csv');
|
||||
}
|
||||
|
||||
// Same click/keyboard-accessible menu idiom as CreateJobButton — hover-only
|
||||
// menus exclude touch and keyboard users.
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className="gap-1.5"
|
||||
<div className="group relative inline-block">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
aria-haspopup="menu"
|
||||
className="gap-1.5"
|
||||
>
|
||||
Download Captions
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-85" />
|
||||
</Button>
|
||||
{!disabled && (
|
||||
<div
|
||||
role="menu"
|
||||
className="invisible absolute right-0 top-full z-[200] min-w-full -translate-y-1 pt-1 opacity-0 transition-all group-focus-within:visible group-focus-within:translate-y-0 group-focus-within:opacity-100 group-hover:visible group-hover:translate-y-0 group-hover:opacity-100"
|
||||
>
|
||||
Download Captions
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-85" aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
collisionPadding={8}
|
||||
className="z-[200] min-w-40 overflow-hidden rounded-lg border border-border bg-popover py-1 text-popover-foreground shadow-lg"
|
||||
>
|
||||
<DropdownMenu.Item className={MENU_ITEM} onSelect={handleDownloadJson}>
|
||||
JSON
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item className={MENU_ITEM} onSelect={handleDownloadTxt}>
|
||||
TXT
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item className={MENU_ITEM} onSelect={handleDownloadCsv}>
|
||||
CSV
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-popover py-1 shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={MENU_ITEM}
|
||||
onClick={handleDownloadJson}
|
||||
>
|
||||
JSON
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={MENU_ITEM}
|
||||
onClick={handleDownloadTxt}
|
||||
>
|
||||
TXT
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={MENU_ITEM}
|
||||
onClick={handleDownloadCsv}
|
||||
>
|
||||
CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import CreateJobButton from './CreateJobButton';
|
||||
|
||||
vi.mock('./CreateJobModal', () => ({
|
||||
default: ({
|
||||
isOpen,
|
||||
workloadType,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
workloadType: string;
|
||||
}) =>
|
||||
isOpen ? (
|
||||
<div role="dialog" data-workload-type={workloadType}>
|
||||
Create job form
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
describe('CreateJobButton', () => {
|
||||
it('opens the workload menu on click and selects an item', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CreateJobButton jobType="inference" />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create Job' }));
|
||||
await user.click(screen.getByRole('menuitem', { name: /I2V/i }));
|
||||
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute(
|
||||
'data-workload-type',
|
||||
'i2v',
|
||||
);
|
||||
});
|
||||
|
||||
it('opens and operates the workload menu from the keyboard', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CreateJobButton jobType="inference" />);
|
||||
|
||||
const trigger = screen.getByRole('button', { name: 'Create Job' });
|
||||
trigger.focus();
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
const firstItem = await screen.findByRole('menuitem', { name: /T2V/i });
|
||||
expect(firstItem).toHaveFocus();
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute(
|
||||
'data-workload-type',
|
||||
't2v',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
|
||||
import CreateJobModal from '@/components/jobs/CreateJobModal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -34,35 +33,31 @@ export default function CreateJobButton({ jobType }: CreateJobButtonProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<Button type="button" className="gap-1.5">
|
||||
Create Job
|
||||
<ChevronDown className="size-3.5 opacity-85" aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
collisionPadding={8}
|
||||
className="z-[200] min-w-48 overflow-hidden rounded-lg border border-border bg-popover py-1 text-popover-foreground shadow-lg"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<DropdownMenu.Item
|
||||
key={opt.type}
|
||||
onSelect={() => openModal(opt.type)}
|
||||
className="flex min-h-11 cursor-pointer select-none flex-col justify-center px-4 py-2 text-left text-sm font-medium outline-none data-[highlighted]:bg-secondary"
|
||||
>
|
||||
{opt.label}
|
||||
<span className="mt-0.5 block text-xs font-normal text-muted-foreground">
|
||||
{opt.desc}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
<div className="group relative inline-block">
|
||||
<Button type="button" className="gap-1.5">
|
||||
Create Job
|
||||
<ChevronDown className="size-3.5 opacity-85" aria-hidden />
|
||||
</Button>
|
||||
<div
|
||||
role="menu"
|
||||
className="invisible absolute right-0 top-full z-[200] mt-1 min-w-full -translate-y-1 rounded-lg border border-border bg-popover py-1 opacity-0 shadow-lg transition-all duration-150 group-hover:visible group-hover:translate-y-0 group-hover:opacity-100"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.type}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => openModal(opt.type)}
|
||||
className="block w-full whitespace-nowrap px-4 py-2 text-left text-sm font-medium text-popover-foreground transition-colors hover:bg-secondary"
|
||||
>
|
||||
{opt.label}
|
||||
<span className="mt-0.5 block text-xs font-normal text-muted-foreground">
|
||||
{opt.desc}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<CreateJobModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
|
||||
@@ -50,57 +50,6 @@ function renderModal(
|
||||
}
|
||||
|
||||
describe('CreateJobModal', () => {
|
||||
it('shows a model loading error instead of an empty model list', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.mocked(getModels).mockRejectedValueOnce(new Error('network down'));
|
||||
|
||||
renderModal();
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Models could not be loaded/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Model')).toHaveAttribute(
|
||||
'aria-invalid',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the form open and reports job creation failures', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.mocked(createJob).mockRejectedValueOnce(new Error('API rejected job'));
|
||||
const user = userEvent.setup();
|
||||
const { onClose, onSuccess } = renderModal();
|
||||
|
||||
await screen.findByRole('option', { name: 'Wan T2V (wan/t2v-1.3b)' });
|
||||
await user.type(screen.getByLabelText('Prompt'), 'a careful test prompt');
|
||||
await user.click(screen.getByRole('button', { name: 'Create Job' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/API rejected job.*then try again/),
|
||||
).toBeInTheDocument();
|
||||
expect(onSuccess).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports image upload failures next to the file input', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.mocked(uploadImage).mockRejectedValueOnce(new Error('Upload failed'));
|
||||
const user = userEvent.setup();
|
||||
renderModal({ workloadType: 'i2v' });
|
||||
|
||||
await screen.findByRole('option', { name: 'Wan T2V (wan/t2v-1.3b)' });
|
||||
const input = screen.getByLabelText('Image');
|
||||
await user.upload(
|
||||
input,
|
||||
new File(['image'], 'input.png', { type: 'image/png' }),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Upload failed.*Choose the image again/),
|
||||
).toBeInTheDocument();
|
||||
expect(input).toHaveAttribute('aria-invalid', 'true');
|
||||
});
|
||||
|
||||
it('renders the form fields for an inference job', async () => {
|
||||
renderModal();
|
||||
|
||||
|
||||
@@ -103,17 +103,6 @@ export default function CreateJobModal({
|
||||
const [fakeScoreModelPath, setFakeScoreModelPath] = React.useState('');
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
const [isLoadingModels, setIsLoadingModels] = React.useState(false);
|
||||
const [isLoadingDatasets, setIsLoadingDatasets] = React.useState(false);
|
||||
const [modelLoadError, setModelLoadError] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [datasetLoadError, setDatasetLoadError] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [imageUploadError, setImageUploadError] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [submitError, setSubmitError] = React.useState<string | null>(null);
|
||||
const imageInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
// Seed field values from the persisted default options each time the modal
|
||||
@@ -155,10 +144,6 @@ export default function CreateJobModal({
|
||||
setImageFileName('');
|
||||
setSelectedDatasetId('');
|
||||
setSelectedValidationDatasetId('');
|
||||
setModelLoadError(null);
|
||||
setDatasetLoadError(null);
|
||||
setImageUploadError(null);
|
||||
setSubmitError(null);
|
||||
if (workloadType === 'dmd_t2v') {
|
||||
setDmdUseVsa(false);
|
||||
setDmdVsaSparsity(0.8);
|
||||
@@ -177,7 +162,6 @@ export default function CreateJobModal({
|
||||
// can't overwrite the current workload's model list/selection.
|
||||
let stale = false;
|
||||
setIsLoadingModels(true);
|
||||
setModelLoadError(null);
|
||||
getModels(inferenceWorkload)
|
||||
.then((list) => {
|
||||
if (stale) return;
|
||||
@@ -196,13 +180,7 @@ export default function CreateJobModal({
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (stale) return;
|
||||
console.error('Failed to load models:', e);
|
||||
setModels([]);
|
||||
setModelId('');
|
||||
setModelLoadError(
|
||||
'Models could not be loaded. Check the Studio API and reopen this form to try again.',
|
||||
);
|
||||
if (!stale) console.error('Failed to load models:', e);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!stale) setIsLoadingModels(false);
|
||||
@@ -215,22 +193,11 @@ export default function CreateJobModal({
|
||||
// Training jobs need a dataset; load the ready datasets when relevant.
|
||||
React.useEffect(() => {
|
||||
if (isOpen && !isInference) {
|
||||
setIsLoadingDatasets(true);
|
||||
setDatasetLoadError(null);
|
||||
getDatasets()
|
||||
.then(setReadyDatasets)
|
||||
.catch((error) => {
|
||||
console.error('Failed to load datasets:', error);
|
||||
setReadyDatasets([]);
|
||||
setDatasetLoadError(
|
||||
'Datasets could not be loaded. Check the Studio API and reopen this form to try again.',
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoadingDatasets(false));
|
||||
.catch(() => setReadyDatasets([]));
|
||||
} else {
|
||||
setReadyDatasets([]);
|
||||
setIsLoadingDatasets(false);
|
||||
setDatasetLoadError(null);
|
||||
}
|
||||
}, [isOpen, isInference]);
|
||||
|
||||
@@ -239,24 +206,16 @@ export default function CreateJobModal({
|
||||
if (!file) {
|
||||
setImagePath('');
|
||||
setImageFileName('');
|
||||
setImageUploadError(null);
|
||||
return;
|
||||
}
|
||||
setIsUploadingImage(true);
|
||||
setImageFileName(file.name);
|
||||
setImageUploadError(null);
|
||||
try {
|
||||
const { path } = await uploadImage(file);
|
||||
setImagePath(path);
|
||||
} catch (error) {
|
||||
console.error('Failed to upload image:', error);
|
||||
} catch {
|
||||
setImagePath('');
|
||||
setImageFileName('');
|
||||
setImageUploadError(
|
||||
error instanceof Error
|
||||
? `${error.message}. Choose the image again to retry.`
|
||||
: 'The image could not be uploaded. Choose it again to retry.',
|
||||
);
|
||||
} finally {
|
||||
setIsUploadingImage(false);
|
||||
}
|
||||
@@ -265,7 +224,6 @@ export default function CreateJobModal({
|
||||
function clearImage() {
|
||||
setImagePath('');
|
||||
setImageFileName('');
|
||||
setImageUploadError(null);
|
||||
if (imageInputRef.current) imageInputRef.current.value = '';
|
||||
}
|
||||
|
||||
@@ -281,7 +239,6 @@ export default function CreateJobModal({
|
||||
workloadType === 'lora_t2v' ? 'lora' : jobType
|
||||
) as JobType;
|
||||
setIsSubmitting(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const payload: CreateJobRequest = {
|
||||
model_id: modelId,
|
||||
@@ -339,11 +296,6 @@ export default function CreateJobModal({
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to create job:', err);
|
||||
setSubmitError(
|
||||
err instanceof Error
|
||||
? `${err.message}. Check the form and Studio API, then try again.`
|
||||
: 'The job could not be created. Check the form and Studio API, then try again.',
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -391,11 +343,7 @@ export default function CreateJobModal({
|
||||
value={modelId}
|
||||
onChange={(e) => setModelId(e.target.value)}
|
||||
required
|
||||
aria-describedby={
|
||||
modelLoadError ? 'modal-model-error' : undefined
|
||||
}
|
||||
aria-invalid={modelLoadError ? true : undefined}
|
||||
disabled={isSubmitting || isLoadingModels || !!modelLoadError}
|
||||
disabled={isSubmitting || isLoadingModels}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{isLoadingModels
|
||||
@@ -410,15 +358,6 @@ export default function CreateJobModal({
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
{modelLoadError && (
|
||||
<p
|
||||
id="modal-model-error"
|
||||
role="alert"
|
||||
className="text-sm text-destructive"
|
||||
>
|
||||
{modelLoadError}
|
||||
</p>
|
||||
)}
|
||||
</FieldRow>
|
||||
|
||||
{isInference && workloadType === 'i2v' && (
|
||||
@@ -430,10 +369,6 @@ export default function CreateJobModal({
|
||||
accept=".png,.jpg,.jpeg,.webp,.bmp"
|
||||
onChange={handleImageChange}
|
||||
disabled={isSubmitting || isUploadingImage}
|
||||
aria-describedby={
|
||||
imageUploadError ? 'modal-image-error' : undefined
|
||||
}
|
||||
aria-invalid={imageUploadError ? true : undefined}
|
||||
required
|
||||
className="h-auto py-2 file:mr-3 file:cursor-pointer file:rounded-md file:border-0 file:bg-secondary file:px-2 file:py-1 file:text-sm file:text-secondary-foreground"
|
||||
/>
|
||||
@@ -450,15 +385,6 @@ export default function CreateJobModal({
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{imageUploadError && (
|
||||
<p
|
||||
id="modal-image-error"
|
||||
role="alert"
|
||||
className="text-sm text-destructive"
|
||||
>
|
||||
{imageUploadError}
|
||||
</p>
|
||||
)}
|
||||
</FieldRow>
|
||||
)}
|
||||
|
||||
@@ -506,22 +432,12 @@ export default function CreateJobModal({
|
||||
id="modal-dataset"
|
||||
value={selectedDatasetId}
|
||||
onChange={(e) => setSelectedDatasetId(e.target.value)}
|
||||
aria-describedby={
|
||||
datasetLoadError ? 'modal-dataset-error' : undefined
|
||||
}
|
||||
aria-invalid={datasetLoadError ? true : undefined}
|
||||
disabled={
|
||||
isSubmitting || isLoadingDatasets || !!datasetLoadError
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{isLoadingDatasets
|
||||
? 'Loading datasets…'
|
||||
: datasetLoadError
|
||||
? 'Datasets unavailable'
|
||||
: readyDatasets.length === 0
|
||||
? 'No datasets (add in Datasets tab)'
|
||||
: 'Select a dataset…'}
|
||||
{readyDatasets.length === 0
|
||||
? 'No datasets (add in Datasets tab)'
|
||||
: 'Select a dataset…'}
|
||||
</option>
|
||||
{readyDatasets.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
@@ -529,15 +445,6 @@ export default function CreateJobModal({
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
{datasetLoadError && (
|
||||
<p
|
||||
id="modal-dataset-error"
|
||||
role="alert"
|
||||
className="text-sm text-destructive"
|
||||
>
|
||||
{datasetLoadError}
|
||||
</p>
|
||||
)}
|
||||
</FieldRow>
|
||||
<FieldRow
|
||||
htmlFor="modal-validation-dataset"
|
||||
@@ -550,9 +457,7 @@ export default function CreateJobModal({
|
||||
onChange={(e) =>
|
||||
setSelectedValidationDatasetId(e.target.value)
|
||||
}
|
||||
disabled={
|
||||
isSubmitting || isLoadingDatasets || !!datasetLoadError
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{readyDatasets.map((d) => (
|
||||
@@ -906,22 +811,9 @@ export default function CreateJobModal({
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
{submitError && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{submitError}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
isUploadingImage ||
|
||||
!!modelLoadError ||
|
||||
!!datasetLoadError
|
||||
}
|
||||
>
|
||||
{isSubmitting ? 'Creating…' : 'Create Job'}
|
||||
<div>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Creating...' : 'Create Job'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -20,14 +20,6 @@ vi.mock('@/lib/api', () => ({
|
||||
downloadJobVideo: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/utils')>();
|
||||
return {
|
||||
...actual,
|
||||
downloadBlob: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const makeJob = (overrides: Partial<Job> = {}): Job =>
|
||||
makeBaseJob({
|
||||
model_id: 'Wan2.1-T2V',
|
||||
@@ -54,16 +46,6 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('JobCard', () => {
|
||||
it('keeps selection and job action buttons as semantic siblings', () => {
|
||||
render(<JobCard job={makeJob()} />);
|
||||
|
||||
const selectButton = screen.getByRole('button', { pressed: false });
|
||||
const deleteButton = screen.getByRole('button', { name: 'Delete' });
|
||||
|
||||
expect(selectButton).toHaveTextContent('Wan2.1-T2V');
|
||||
expect(selectButton).not.toContainElement(deleteButton);
|
||||
});
|
||||
|
||||
it('renders the model, prompt, status and inference meta', () => {
|
||||
render(<JobCard job={makeJob()} />);
|
||||
expect(screen.getByText('Wan2.1-T2V')).toBeInTheDocument();
|
||||
|
||||
@@ -119,10 +119,18 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectJob() {
|
||||
function handleSelectJob(e: React.MouseEvent | React.KeyboardEvent) {
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
setActiveJobId(isSelected ? null : job.id);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleSelectJob(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadVideo(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -140,7 +148,11 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleSelectJob}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
'mb-3 flex cursor-pointer flex-col gap-2.5 rounded-lg border bg-background p-4 transition-colors last:mb-0',
|
||||
isSelected
|
||||
@@ -148,42 +160,35 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
|
||||
: 'border-border hover:border-muted-foreground/40',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={isSelected}
|
||||
onClick={handleSelectJob}
|
||||
className="flex w-full flex-col gap-2.5 rounded-md text-left"
|
||||
>
|
||||
<span className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-[0.95rem] font-semibold text-foreground">
|
||||
{job.model_id}
|
||||
</span>
|
||||
<Badge variant={BADGE_VARIANTS[job.status] ?? 'secondary'}>
|
||||
{job.status}
|
||||
</Badge>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-[0.95rem] font-semibold text-foreground">
|
||||
{job.model_id}
|
||||
</span>
|
||||
<span className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-muted-foreground">
|
||||
{job.prompt}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
|
||||
{job.job_type === 'inference' ? (
|
||||
<>
|
||||
<span>{job.num_frames} frames</span>
|
||||
<span>
|
||||
{job.height}×{job.width}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{job.workload_type?.replace(/_/g, ' ') ?? job.job_type}</span>
|
||||
)}
|
||||
{elapsedTime && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Timer className="size-3.5" aria-hidden />
|
||||
{elapsedTime}
|
||||
<Badge variant={BADGE_VARIANTS[job.status] ?? 'secondary'}>
|
||||
{job.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-muted-foreground">
|
||||
{job.prompt}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
|
||||
{job.job_type === 'inference' ? (
|
||||
<>
|
||||
<span>{job.num_frames} frames</span>
|
||||
<span>
|
||||
{job.height}×{job.width}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span>{job.workload_type?.replace(/_/g, ' ') ?? job.job_type}</span>
|
||||
)}
|
||||
{elapsedTime && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Timer className="size-3.5" aria-hidden />
|
||||
{elapsedTime}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{job.status === 'running' ? (
|
||||
<Button
|
||||
@@ -235,6 +240,6 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,32 +19,6 @@ const makeJob = (overrides: Partial<Job> = {}): Job =>
|
||||
});
|
||||
|
||||
describe('JobDetailsSidebar', () => {
|
||||
it('fills the mobile viewport without reserving main-content width', async () => {
|
||||
vi.mocked(getJobLogs).mockResolvedValue({
|
||||
lines: [],
|
||||
total: 0,
|
||||
progress: 0,
|
||||
progress_msg: '',
|
||||
phase: '',
|
||||
});
|
||||
const onWidthChange = vi.fn();
|
||||
|
||||
render(
|
||||
<JobDetailsSidebar
|
||||
job={makeJob({ status: 'completed' })}
|
||||
isMobile
|
||||
onClose={vi.fn()}
|
||||
onWidthChange={onWidthChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const drawer = screen.getByRole('dialog', { name: 'Job details' });
|
||||
expect(drawer).toHaveStyle({ width: '100%', maxWidth: 'none' });
|
||||
expect(drawer).toHaveAttribute('aria-modal', 'true');
|
||||
expect(drawer).toHaveFocus();
|
||||
expect(onWidthChange).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('renders log lines streamed from the job log poll', async () => {
|
||||
vi.mocked(getJobLogs).mockResolvedValue({
|
||||
lines: ['boot sequence started', 'loading model weights'],
|
||||
|
||||
@@ -4,7 +4,6 @@ import * as React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useDrawerFocus } from '@/hooks/useDrawerFocus';
|
||||
import { useResizable } from '@/hooks/useResizable';
|
||||
import { downloadJobLog, getJobLogs } from '@/lib/api';
|
||||
import type { Job } from '@/lib/types';
|
||||
@@ -16,16 +15,13 @@ const POLL_INTERVAL_MS = 2000;
|
||||
|
||||
export default function JobDetailsSidebar({
|
||||
job,
|
||||
isMobile = false,
|
||||
onClose,
|
||||
onWidthChange,
|
||||
}: {
|
||||
job: Job;
|
||||
isMobile?: boolean;
|
||||
onClose: () => void;
|
||||
onWidthChange?: (w: number) => void;
|
||||
}) {
|
||||
const drawerRef = useDrawerFocus<HTMLElement>(isMobile);
|
||||
const [width, setWidth] = React.useState(360);
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
@@ -57,8 +53,8 @@ export default function JobDetailsSidebar({
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
onWidthChange?.(isMobile ? 0 : width);
|
||||
}, [isMobile, width, onWidthChange]);
|
||||
onWidthChange?.(width);
|
||||
}, [width, onWidthChange]);
|
||||
|
||||
// Auto-scroll the console to the bottom whenever new lines land. Runs after
|
||||
// commit so scrollHeight reflects the freshly-rendered output.
|
||||
@@ -141,16 +137,8 @@ export default function JobDetailsSidebar({
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={drawerRef}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-label="Job details"
|
||||
aria-modal={isMobile || undefined}
|
||||
className="fixed bottom-0 right-0 top-[var(--header-height)] z-50 flex max-h-[calc(100dvh-var(--header-height))] min-w-0 shrink-0 flex-col border-l border-border bg-card md:min-w-[280px]"
|
||||
style={{
|
||||
width: isMobile ? '100%' : width,
|
||||
maxWidth: isMobile ? 'none' : SIDEBAR_MAX_WIDTH,
|
||||
}}
|
||||
className="fixed bottom-0 right-0 top-[var(--header-height)] z-50 flex max-h-[calc(100vh-var(--header-height))] min-w-[280px] shrink-0 flex-col border-l border-border bg-card"
|
||||
style={{ width, maxWidth: SIDEBAR_MAX_WIDTH }}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<h2 className="m-0 text-base font-semibold text-foreground">
|
||||
@@ -207,14 +195,14 @@ export default function JobDetailsSidebar({
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{!isMobile && <div
|
||||
<div
|
||||
role="presentation"
|
||||
onMouseDown={onMouseDown}
|
||||
className={cn(
|
||||
'absolute bottom-0 left-0 top-0 z-[1] w-1.5 cursor-col-resize hover:bg-accent-blue/25',
|
||||
isDragging && 'bg-accent-blue/25',
|
||||
)}
|
||||
/>}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import JobQueue from '@/components/jobs/JobQueue';
|
||||
@@ -37,46 +37,6 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('JobQueue', () => {
|
||||
it('shows a loading placeholder before the initial request settles', async () => {
|
||||
let resolveJobs: (jobs: Job[]) => void = () => {};
|
||||
vi.mocked(getJobsList).mockReturnValue(
|
||||
new Promise<Job[]>((resolve) => {
|
||||
resolveJobs = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
render(<JobQueue jobType="inference" />);
|
||||
|
||||
expect(screen.getByLabelText('Loading jobs')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('No inference jobs yet. Create one above.'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
act(() => resolveJobs([]));
|
||||
expect(
|
||||
await screen.findByText('No inference jobs yet. Create one above.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows request failures separately from an empty queue and retries', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.mocked(getJobsList).mockRejectedValueOnce(new Error('network down'));
|
||||
render(<JobQueue jobType="inference" />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Could not load jobs from the Studio API/),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('No inference jobs yet. Create one above.'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
vi.mocked(getJobsList).mockResolvedValueOnce([]);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Try Again' }));
|
||||
expect(
|
||||
await screen.findByText('No inference jobs yet. Create one above.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an empty placeholder and fetches for the single job type', async () => {
|
||||
render(<JobQueue jobType="inference" />);
|
||||
expect(
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
import JobCard from '@/components/jobs/JobCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useStore } from '@/hooks/useStore';
|
||||
import { getJobsList } from '@/lib/api';
|
||||
import type { Job, JobType } from '@/lib/types';
|
||||
@@ -34,8 +32,6 @@ function jobsShallowEqual(a: Job | null, b: Job | null): boolean {
|
||||
|
||||
export default function JobQueue({ jobType, jobTypesForList }: JobQueueProps) {
|
||||
const [jobs, setJobs] = React.useState<Job[]>([]);
|
||||
const [isInitialLoading, setIsInitialLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const { nonce } = useStore(jobsRefreshStore);
|
||||
const { activeJobId } = useStore(activeJobStore);
|
||||
|
||||
@@ -75,22 +71,11 @@ export default function JobQueue({ jobType, jobTypesForList }: JobQueueProps) {
|
||||
new Date(a.created_at ?? 0).getTime(),
|
||||
);
|
||||
}
|
||||
if (seq === fetchSeq.current) {
|
||||
setJobs(next);
|
||||
setError(null);
|
||||
}
|
||||
if (seq === fetchSeq.current) setJobs(next);
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch jobs:', e);
|
||||
if (seq === fetchSeq.current) {
|
||||
setError(
|
||||
'Could not load jobs from the Studio API. Check the server and try again.',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (seq === fetchSeq.current) {
|
||||
inFlight.current = false;
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
if (seq === fetchSeq.current) inFlight.current = false;
|
||||
}
|
||||
}, [typesKey]);
|
||||
|
||||
@@ -137,59 +122,20 @@ export default function JobQueue({ jobType, jobTypesForList }: JobQueueProps) {
|
||||
const multiType = typesToFetch.length > 1;
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[850px] flex-col gap-6 px-4 pb-12">
|
||||
<main className="mx-auto flex w-full max-w-[850px] flex-col gap-6 px-4 pb-12">
|
||||
<section className="p-6">
|
||||
<div aria-busy={isInitialLoading}>
|
||||
{isInitialLoading ? (
|
||||
<div
|
||||
aria-label="Loading jobs"
|
||||
className="flex flex-col gap-3 py-2"
|
||||
>
|
||||
{[0, 1, 2].map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="h-32 animate-pulse rounded-lg border border-border bg-muted/50"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : error && jobs.length === 0 ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col items-center gap-3 py-8 text-center"
|
||||
>
|
||||
<AlertTriangle
|
||||
className="size-6 text-destructive"
|
||||
aria-hidden
|
||||
/>
|
||||
<p className="max-w-md text-sm text-muted-foreground">{error}</p>
|
||||
<Button type="button" variant="outline" onClick={fetchJobs}>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{error && (
|
||||
<p
|
||||
role="status"
|
||||
className="mb-3 rounded-lg border border-amber-500/50 bg-amber-500/10 px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
Job updates are temporarily unavailable. Showing the most
|
||||
recent results.
|
||||
</p>
|
||||
)}
|
||||
{jobs.length === 0 ? (
|
||||
<div>
|
||||
{jobs.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
No {multiType ? 'jobs' : `${jobType} jobs`} yet. Create one above.
|
||||
</p>
|
||||
) : (
|
||||
jobs.map((job) => (
|
||||
<JobCard key={job.id} job={job} onJobUpdated={fetchJobs} />
|
||||
))
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
jobs.map((job) => (
|
||||
<JobCard key={job.id} job={job} onJobUpdated={fetchJobs} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { HeaderActionsProvider } from '@/components/shell/HeaderActionsContext';
|
||||
import PrimarySidebar from '@/components/shell/PrimarySidebar';
|
||||
import JobDetailsSidebar from '@/components/jobs/JobDetailsSidebar';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { useStore } from '@/hooks/useStore';
|
||||
import {
|
||||
activeDatasetStore,
|
||||
@@ -24,80 +23,46 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const { activeJob } = useStore(activeJobStore);
|
||||
const { activeDataset } = useStore(activeDatasetStore);
|
||||
const isMobile = useMediaQuery('(max-width: 767px)');
|
||||
|
||||
const [primaryWidth, setPrimaryWidth] = React.useState(220);
|
||||
const [secondaryWidth, setSecondaryWidth] = React.useState(0);
|
||||
const [primaryOpen, setPrimaryOpen] = React.useState(false);
|
||||
|
||||
const jobSidebarOpen = JOB_ROUTES.includes(pathname) && activeJob != null;
|
||||
const datasetSidebarOpen =
|
||||
pathname === '/datasets' && activeDataset != null;
|
||||
const secondaryOpen = jobSidebarOpen || datasetSidebarOpen;
|
||||
// Mobile detail drawers claim aria-modal, so everything behind them must
|
||||
// actually be inert — the platform enforces what the ARIA claims.
|
||||
const drawerModal = isMobile && secondaryOpen;
|
||||
|
||||
React.useEffect(() => {
|
||||
initDefaultOptions();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
setPrimaryOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
React.useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape' || document.querySelector('[data-modal]')) return;
|
||||
if (primaryOpen) {
|
||||
setPrimaryOpen(false);
|
||||
return;
|
||||
if (e.key === 'Escape' && !document.querySelector('[data-modal]')) {
|
||||
if (activeJobStore.get().activeJob) setActiveJobId(null);
|
||||
if (activeDatasetStore.get().activeDataset) setActiveDatasetId(null);
|
||||
}
|
||||
if (activeJobStore.get().activeJob) setActiveJobId(null);
|
||||
if (activeDatasetStore.get().activeDataset) setActiveDatasetId(null);
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [primaryOpen]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<HeaderActionsProvider>
|
||||
<div
|
||||
style={{ display: 'contents' }}
|
||||
inert={drawerModal ? true : undefined}
|
||||
>
|
||||
<Header
|
||||
navigationOpen={primaryOpen}
|
||||
onNavigationToggle={() => setPrimaryOpen((open) => !open)}
|
||||
/>
|
||||
</div>
|
||||
<Header />
|
||||
<div
|
||||
className="flex overflow-hidden"
|
||||
style={{
|
||||
marginTop: 'var(--header-height)',
|
||||
height: 'calc(100dvh - var(--header-height))',
|
||||
height: 'calc(100vh - var(--header-height))',
|
||||
}}
|
||||
>
|
||||
<PrimarySidebar
|
||||
isMobile={isMobile}
|
||||
mobileOpen={primaryOpen}
|
||||
onMobileClose={() => setPrimaryOpen(false)}
|
||||
onWidthChange={setPrimaryWidth}
|
||||
/>
|
||||
{primaryOpen && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close navigation"
|
||||
onClick={() => setPrimaryOpen(false)}
|
||||
className="fixed inset-x-0 bottom-0 top-[var(--header-height)] z-40 bg-black/55 md:hidden"
|
||||
/>
|
||||
)}
|
||||
<PrimarySidebar onWidthChange={setPrimaryWidth} />
|
||||
<main
|
||||
className="flex min-w-0 flex-1 flex-col overflow-auto"
|
||||
inert={drawerModal ? true : undefined}
|
||||
style={{
|
||||
marginLeft: isMobile ? 0 : primaryWidth,
|
||||
marginRight: isMobile || !secondaryOpen ? 0 : secondaryWidth,
|
||||
marginLeft: primaryWidth,
|
||||
marginRight: secondaryOpen ? secondaryWidth : 0,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -105,7 +70,6 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
{jobSidebarOpen && activeJob && (
|
||||
<JobDetailsSidebar
|
||||
job={activeJob}
|
||||
isMobile={isMobile}
|
||||
onClose={() => setActiveJobId(null)}
|
||||
onWidthChange={setSecondaryWidth}
|
||||
/>
|
||||
@@ -113,7 +77,6 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
{datasetSidebarOpen && activeDataset && (
|
||||
<DatasetSidebar
|
||||
dataset={activeDataset}
|
||||
isMobile={isMobile}
|
||||
onClose={() => setActiveDatasetId(null)}
|
||||
onWidthChange={setSecondaryWidth}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { Menu, X } from 'lucide-react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { useHeaderActions } from '@/components/shell/HeaderActionsContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||
|
||||
const TAB_TITLES: Record<string, string> = {
|
||||
@@ -17,47 +15,25 @@ const TAB_TITLES: Record<string, string> = {
|
||||
'/settings': 'Settings',
|
||||
};
|
||||
|
||||
export default function Header({
|
||||
navigationOpen,
|
||||
onNavigationToggle,
|
||||
}: {
|
||||
navigationOpen: boolean;
|
||||
onNavigationToggle: () => void;
|
||||
}) {
|
||||
export default function Header() {
|
||||
const pathname = usePathname();
|
||||
const { actions } = useHeaderActions();
|
||||
const title = TAB_TITLES[pathname] ?? 'FastVideo';
|
||||
|
||||
return (
|
||||
<header className="fixed inset-x-0 top-0 z-[100] flex h-[var(--header-height)] items-center gap-2 border-b border-border bg-background/80 px-2 backdrop-blur sm:px-4 md:gap-6 md:px-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={navigationOpen ? 'Close navigation' : 'Open navigation'}
|
||||
aria-controls="primary-navigation"
|
||||
aria-expanded={navigationOpen}
|
||||
onClick={onNavigationToggle}
|
||||
className="shrink-0 md:hidden"
|
||||
>
|
||||
{navigationOpen ? (
|
||||
<X className="size-5" aria-hidden />
|
||||
) : (
|
||||
<Menu className="size-5" aria-hidden />
|
||||
)}
|
||||
</Button>
|
||||
<header className="fixed inset-x-0 top-0 z-[100] flex h-[var(--header-height)] items-center gap-6 border-b border-border bg-background/80 px-6 backdrop-blur">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="FastVideo Logo"
|
||||
width={100}
|
||||
height={42}
|
||||
className="hidden h-[42px] w-[78px] shrink-0 object-contain min-[361px]:block md:w-[100px]"
|
||||
className="block h-[42px] w-[100px]"
|
||||
/>
|
||||
<h1 className="sr-only m-0 flex-1 text-xl font-semibold tracking-tight md:not-sr-only">
|
||||
<h1 className="m-0 flex-1 text-xl font-semibold tracking-tight">
|
||||
{title}
|
||||
</h1>
|
||||
<div className="ml-auto flex min-w-0 items-center gap-2 md:gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{actions}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
@@ -20,18 +19,12 @@ const JOB_ROUTES = [
|
||||
] as const;
|
||||
|
||||
const TAB_BASE =
|
||||
'block min-h-11 px-5 py-[0.65rem] text-left text-sm text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground';
|
||||
'block px-5 py-[0.65rem] text-left text-sm text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground';
|
||||
const TAB_ACTIVE = 'bg-accent-blue/10 font-medium text-accent-blue';
|
||||
|
||||
export default function PrimarySidebar({
|
||||
isMobile,
|
||||
mobileOpen,
|
||||
onMobileClose,
|
||||
onWidthChange,
|
||||
}: {
|
||||
isMobile: boolean;
|
||||
mobileOpen: boolean;
|
||||
onMobileClose: () => void;
|
||||
onWidthChange?: (w: number) => void;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
@@ -45,8 +38,8 @@ export default function PrimarySidebar({
|
||||
const isJobsActive = JOB_ROUTES.some((r) => pathname === r.href);
|
||||
|
||||
React.useEffect(() => {
|
||||
onWidthChange?.(isMobile ? 0 : layoutWidth);
|
||||
}, [isMobile, layoutWidth, onWidthChange]);
|
||||
onWidthChange?.(layoutWidth);
|
||||
}, [layoutWidth, onWidthChange]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (JOB_ROUTES.some((r) => pathname === r.href)) {
|
||||
@@ -65,37 +58,11 @@ export default function PrimarySidebar({
|
||||
|
||||
return (
|
||||
<aside
|
||||
id="primary-navigation"
|
||||
aria-hidden={isMobile && !mobileOpen}
|
||||
inert={isMobile && !mobileOpen ? true : undefined}
|
||||
className={cn(
|
||||
'fixed bottom-0 left-0 top-[var(--header-height)] z-50 flex max-h-[calc(100dvh-var(--header-height))] shrink-0 flex-col border-r border-border bg-card transition-transform duration-200 md:translate-x-0',
|
||||
mobileOpen ? 'translate-x-0' : '-translate-x-full',
|
||||
)}
|
||||
style={{
|
||||
width: isMobile
|
||||
? 'min(18rem, calc(100vw - 3rem))'
|
||||
: effectiveWidth,
|
||||
}}
|
||||
className="fixed bottom-0 left-0 top-[var(--header-height)] z-50 flex max-h-[calc(100vh-var(--header-height))] shrink-0 flex-col border-r border-border bg-card"
|
||||
style={{ width: effectiveWidth }}
|
||||
>
|
||||
{isMobile && (
|
||||
<div className="flex h-14 items-center justify-between border-b border-border px-4">
|
||||
<span className="text-sm font-semibold">Navigation</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMobileClose}
|
||||
aria-label="Close navigation"
|
||||
className="flex size-11 items-center justify-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<X className="size-5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<nav
|
||||
aria-label="Primary navigation"
|
||||
className="flex flex-col overflow-y-auto py-2"
|
||||
>
|
||||
<nav className="flex flex-col py-2">
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
@@ -128,8 +95,6 @@ export default function PrimarySidebar({
|
||||
<Link
|
||||
key={route.href}
|
||||
href={route.href}
|
||||
aria-current={pathname === route.href ? 'page' : undefined}
|
||||
onClick={onMobileClose}
|
||||
className={cn(
|
||||
TAB_BASE,
|
||||
'px-4 py-2 text-[0.85rem]',
|
||||
@@ -144,32 +109,24 @@ export default function PrimarySidebar({
|
||||
</div>
|
||||
<Link
|
||||
href="/datasets"
|
||||
aria-current={pathname === '/datasets' ? 'page' : undefined}
|
||||
onClick={onMobileClose}
|
||||
className={cn(TAB_BASE, pathname === '/datasets' && TAB_ACTIVE)}
|
||||
>
|
||||
Datasets
|
||||
</Link>
|
||||
<Link
|
||||
href="/gallery"
|
||||
aria-current={pathname === '/gallery' ? 'page' : undefined}
|
||||
onClick={onMobileClose}
|
||||
className={cn(TAB_BASE, pathname === '/gallery' && TAB_ACTIVE)}
|
||||
>
|
||||
Gallery
|
||||
</Link>
|
||||
<Link
|
||||
href="/gpus"
|
||||
aria-current={pathname === '/gpus' ? 'page' : undefined}
|
||||
onClick={onMobileClose}
|
||||
className={cn(TAB_BASE, pathname === '/gpus' && TAB_ACTIVE)}
|
||||
>
|
||||
GPUs
|
||||
</Link>
|
||||
<Link
|
||||
href="/settings"
|
||||
aria-current={pathname === '/settings' ? 'page' : undefined}
|
||||
onClick={onMobileClose}
|
||||
className={cn(TAB_BASE, pathname === '/settings' && TAB_ACTIVE)}
|
||||
>
|
||||
Settings
|
||||
@@ -177,7 +134,7 @@ export default function PrimarySidebar({
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{!isMobile && <div
|
||||
<div
|
||||
className={cn(
|
||||
'absolute bottom-0 p-2',
|
||||
isCollapsed ? '-right-[60px] top-0' : 'right-0',
|
||||
@@ -188,7 +145,8 @@ export default function PrimarySidebar({
|
||||
onClick={() => setIsCollapsed((v) => !v)}
|
||||
title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
className={cn(
|
||||
'flex size-11 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground',
|
||||
'flex items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground',
|
||||
isCollapsed ? 'p-3' : 'p-2',
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
@@ -201,9 +159,9 @@ export default function PrimarySidebar({
|
||||
<path d={isCollapsed ? 'M9 18l6-6-6-6' : 'M15 18l-6-6 6-6'} />
|
||||
</svg>
|
||||
</button>
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{!isMobile && !isCollapsed && (
|
||||
{!isCollapsed && (
|
||||
<div
|
||||
role="presentation"
|
||||
onMouseDown={onMouseDown}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import GpuGrid from './GpuGrid';
|
||||
@@ -73,30 +73,4 @@ describe('GpuGrid', () => {
|
||||
await screen.findByText(/Could not reach the API server/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the last snapshot visible and warns when a refresh fails', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.mocked(getGpus)
|
||||
.mockResolvedValueOnce(SNAPSHOT)
|
||||
.mockRejectedValueOnce(new Error('network down'));
|
||||
|
||||
render(<GpuGrid />);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(screen.getAllByText('NVIDIA B200')).toHaveLength(2);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(/values below may be stale/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getAllByText('NVIDIA B200')).toHaveLength(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { getGpus, type GpuInfo, type GpuSnapshot } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -99,8 +97,7 @@ function GpuCard({ gpu }: { gpu: GpuInfo }) {
|
||||
|
||||
export default function GpuGrid() {
|
||||
const [snapshot, setSnapshot] = React.useState<GpuSnapshot | null>(null);
|
||||
const [fetchError, setFetchError] = React.useState<string | null>(null);
|
||||
const [retryToken, setRetryToken] = React.useState(0);
|
||||
const [fetchError, setFetchError] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let mounted = true;
|
||||
@@ -113,14 +110,10 @@ export default function GpuGrid() {
|
||||
const next = await getGpus();
|
||||
if (mounted) {
|
||||
setSnapshot(next);
|
||||
setFetchError(null);
|
||||
setFetchError(false);
|
||||
}
|
||||
} catch {
|
||||
if (mounted) {
|
||||
setFetchError(
|
||||
'GPU status could not be refreshed. The values below may be stale.',
|
||||
);
|
||||
}
|
||||
if (mounted) setFetchError(true);
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
@@ -132,27 +125,14 @@ export default function GpuGrid() {
|
||||
mounted = false;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [retryToken]);
|
||||
}, []);
|
||||
|
||||
if (fetchError && !snapshot) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col items-center gap-3 py-8 text-center"
|
||||
>
|
||||
<AlertTriangle className="size-6 text-destructive" aria-hidden />
|
||||
<p className="text-muted-foreground">
|
||||
Could not reach the API server. GPU status needs the Studio API server
|
||||
running.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setRetryToken((token) => token + 1)}
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
Could not reach the API server. GPU status needs the studio API server
|
||||
running.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (!snapshot) {
|
||||
@@ -170,22 +150,9 @@ export default function GpuGrid() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{fetchError && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="flex flex-wrap items-center gap-3 rounded-lg border border-amber-500/50 bg-amber-500/10 px-3 py-2 text-sm"
|
||||
>
|
||||
<AlertTriangle className="size-4 text-amber-600" aria-hidden />
|
||||
<span className="min-w-0 flex-1">{fetchError}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setRetryToken((token) => token + 1)}
|
||||
>
|
||||
Refresh Now
|
||||
</Button>
|
||||
</div>
|
||||
<p className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-600 dark:text-amber-400">
|
||||
Lost contact with the API server — showing the last known values.
|
||||
</p>
|
||||
)}
|
||||
<div className="grid gap-4 [grid-template-columns:repeat(auto-fill,minmax(280px,1fr))]">
|
||||
{snapshot.gpus.map((gpu) => (
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { Button } from './button';
|
||||
import { Input } from './input';
|
||||
import { NativeSelect } from './native-select';
|
||||
import { Slider } from './slider';
|
||||
import { Switch } from './switch';
|
||||
|
||||
describe('shared control accessibility', () => {
|
||||
it('keeps button, input, and select targets at least 44px tall', () => {
|
||||
render(
|
||||
<>
|
||||
<Button size="sm">Small action</Button>
|
||||
<Input aria-label="Text value" />
|
||||
<NativeSelect aria-label="Choice" defaultValue="one">
|
||||
<option value="one">One</option>
|
||||
</NativeSelect>
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Small action' })).toHaveClass(
|
||||
'h-11',
|
||||
);
|
||||
expect(screen.getByRole('textbox', { name: 'Text value' })).toHaveClass(
|
||||
'h-11',
|
||||
);
|
||||
expect(screen.getByRole('combobox', { name: 'Choice' })).toHaveClass(
|
||||
'h-11',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses 44px switch and slider interaction surfaces', () => {
|
||||
render(
|
||||
<>
|
||||
<Switch aria-label="Enabled" />
|
||||
<Slider aria-label="Amount" defaultValue={[50]} />
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('switch', { name: 'Enabled' })).toHaveClass(
|
||||
'size-11',
|
||||
);
|
||||
expect(screen.getByRole('slider', { name: 'Amount' })).toHaveClass(
|
||||
'size-11',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -29,12 +29,11 @@ const badgeVariants = cva(
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLSpanElement>,
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
// A span (phrasing content), so badges stay valid inside buttons and links.
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
@@ -7,7 +7,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl border !text-sm !font-semibold transition-colors duration-150 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl border !text-sm !font-semibold transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/40 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -18,11 +18,11 @@ const buttonVariants = cva(
|
||||
destructive: "border-rose-500/60 bg-rose-600/90 text-white hover:bg-rose-500",
|
||||
},
|
||||
size: {
|
||||
default: "h-11 px-4 py-2",
|
||||
sm: "h-11 rounded-lg px-3 !text-xs",
|
||||
lg: "h-12 px-5 !text-sm",
|
||||
icon: "size-11",
|
||||
"icon-sm": "size-11",
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-lg px-3 !text-xs",
|
||||
lg: "h-11 px-5 !text-sm",
|
||||
icon: "size-10",
|
||||
"icon-sm": "size-8",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -42,7 +42,7 @@ const DialogContent = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-2 top-2 flex size-11 items-center justify-center rounded-lg text-muted-foreground opacity-70 transition-opacity hover:bg-secondary hover:opacity-100 disabled:pointer-events-none sm:right-4 sm:top-4">
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-lg p-1 text-muted-foreground opacity-70 transition-opacity hover:bg-secondary hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-sky-400/40 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
|
||||
@@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-xl border border-input bg-card/60 px-3 py-2 text-sm text-foreground shadow-sm backdrop-blur-md transition-colors placeholder:text-muted-foreground focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-10 w-full rounded-xl border border-input bg-card/60 px-3 py-2 text-sm text-foreground shadow-sm backdrop-blur-md transition-colors placeholder:text-muted-foreground focus-visible:border-sky-400/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/25 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -11,7 +11,7 @@ const NativeSelect = React.forwardRef<
|
||||
<select
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-11 w-full appearance-none rounded-xl border border-input bg-card px-3 py-2 text-sm text-foreground shadow-sm transition-colors focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'flex h-10 w-full appearance-none rounded-xl border border-input bg-card px-3 py-2 text-sm text-foreground shadow-sm transition-colors focus-visible:border-sky-400/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/25 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-11 w-full items-center justify-between gap-2 rounded-xl border border-input bg-card px-3 py-2 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
'flex h-10 w-full items-center justify-between gap-2 rounded-xl border border-input bg-card px-3 py-2 text-sm text-foreground shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-sky-400/70 focus:ring-2 focus:ring-sky-400/25 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -117,7 +117,7 @@ const SelectItem = React.forwardRef<
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex min-h-11 w-full cursor-default select-none items-center rounded-xl py-2 pl-8 pr-3 text-sm text-foreground outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground',
|
||||
'relative flex w-full cursor-default select-none items-center rounded-xl py-2 pl-8 pr-3 text-sm text-foreground outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -8,39 +8,21 @@ import { cn } from '@/lib/utils';
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full touch-none select-none items-center',
|
||||
className,
|
||||
id,
|
||||
'aria-label': ariaLabel,
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
'aria-describedby': ariaDescribedBy,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex h-11 w-full touch-none select-none items-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-border">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-accent-blue" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb
|
||||
id={id}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
className="relative block size-11 rounded-full bg-transparent after:absolute after:left-1/2 after:top-1/2 after:size-4 after:-translate-x-1/2 after:-translate-y-1/2 after:rounded-full after:border-2 after:border-accent-blue after:bg-accent-blue after:shadow after:content-[''] hover:after:border-accent-blue/80 disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
</SliderPrimitive.Root>
|
||||
),
|
||||
);
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-border">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-accent-blue" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border-2 border-accent-blue bg-accent-blue shadow transition-colors hover:border-accent-blue/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/40 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider };
|
||||
|
||||
@@ -12,14 +12,14 @@ const Switch = React.forwardRef<
|
||||
<SwitchPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer relative inline-flex size-11 shrink-0 cursor-pointer items-center justify-center rounded-xl bg-transparent transition-colors before:absolute before:h-5 before:w-9 before:rounded-full before:border before:border-border before:bg-background data-[state=checked]:before:border-accent-blue data-[state=checked]:before:bg-accent-blue disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/40 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-accent-blue data-[state=checked]:bg-accent-blue data-[state=unchecked]:bg-background',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none absolute left-1.5 top-[15px] z-[1] block h-3.5 w-3.5 rounded-full bg-muted-foreground shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=checked]:bg-white',
|
||||
'pointer-events-none block h-3.5 w-3.5 rounded-full bg-muted-foreground shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=checked]:bg-white data-[state=unchecked]:translate-x-0.5',
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
|
||||
@@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/40 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -8,7 +8,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"tex
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-24 w-full resize-y rounded-xl border border-input bg-card px-3 py-2 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex min-h-24 w-full resize-y rounded-xl border border-input bg-card px-3 py-2 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:border-sky-400/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/25 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
/**
|
||||
* Move focus into a drawer when it opens as a modal (mobile), and hand it
|
||||
* back to the previously focused element on close. Pairs with `inert` on
|
||||
* the background content — together they make `aria-modal` truthful.
|
||||
*/
|
||||
export function useDrawerFocus<T extends HTMLElement>(active: boolean) {
|
||||
const ref = React.useRef<T | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) return;
|
||||
const previous = document.activeElement;
|
||||
ref.current?.focus();
|
||||
return () => {
|
||||
if (previous instanceof HTMLElement) previous.focus();
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mediaQuery = window.matchMedia(query);
|
||||
const updateMatch = () => setMatches(mediaQuery.matches);
|
||||
|
||||
updateMatch();
|
||||
mediaQuery.addEventListener('change', updateMatch);
|
||||
return () => mediaQuery.removeEventListener('change', updateMatch);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# Causal WanTrack Control
|
||||
|
||||
This standalone prototype prepares one image and continuously generates causal
|
||||
WanTrack blocks. Handle updates received during block N are committed only at
|
||||
the next block boundary, so generated frames and control history are immutable.
|
||||
One GPU session may generate at a time. The SF checkpoint uses its fixed
|
||||
DMD four-step schedule (`method.dmd_denoising_steps`, default
|
||||
`[1000, 750, 500, 250]` with warp) without classifier-free guidance; the
|
||||
server does not accept client overrides for steps or guidance.
|
||||
|
||||
Set a Diffusers-format causal WanTrack export and its Self-Forcing training
|
||||
YAML, then launch:
|
||||
|
||||
```bash
|
||||
export WANTRACK_MODEL_DIR=/path/to/wantrack-causal-export
|
||||
export WANTRACK_YAML_PATH=/path/to/sf/config/run.yaml
|
||||
export WANTRACK_TAEHV_CHECKPOINT=/path/to/taew2_1.pth
|
||||
python -m apps.wantrack_control
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:8010`. FFmpeg with `libx264` is required. Completed
|
||||
blocks are preserved under `WANTRACK_OUTPUT_DIR` (or the system temporary
|
||||
directory) and concatenated into a downloadable MP4 on Stop, disconnect, or a
|
||||
recoverable failure.
|
||||
|
||||
The interactive preview uses the official TAEHV `StreamingTAEHV` decoder with
|
||||
the Wan 2.1 `taew2_1.pth` weights. The full Wan VAE remains loaded only because
|
||||
input preparation still needs its encoder.
|
||||
|
||||
The `/ws` endpoint accepts `prepare`, `start`, `control_update`, and `stop`
|
||||
JSON messages. It emits phase-specific `progress`, `prepared`,
|
||||
`session_started`, `block_started`, `block_encoding`, `control_applied`,
|
||||
`media_init`, `media_segment_complete`, `stream_complete`, and terminal
|
||||
`error` events. Binary frames carry one fMP4 initialization section followed
|
||||
by ordered block fragments.
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Standalone causal WanTrack control prototype."""
|
||||
|
||||
from apps.wantrack_control.server import create_app
|
||||
|
||||
__all__ = ["create_app"]
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Launch the standalone WanTrack control server."""
|
||||
|
||||
import os
|
||||
|
||||
import uvicorn
|
||||
|
||||
|
||||
def main() -> None:
|
||||
uvicorn.run(
|
||||
"apps.wantrack_control.server:app",
|
||||
host=os.getenv("WANTRACK_HOST", "127.0.0.1"),
|
||||
port=int(os.getenv("WANTRACK_PORT", "8010")),
|
||||
reload=False,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Video-only fragmented-MP4 encoding and completed-prefix finalization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
from collections.abc import Iterable
|
||||
import uuid
|
||||
|
||||
import numpy as np
|
||||
|
||||
MEDIA_MIME = 'video/mp4; codecs="avc1.42E01E"'
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EncodedMediaSegment:
|
||||
block_index: int
|
||||
init_bytes: bytes
|
||||
media_bytes: bytes
|
||||
path: Path
|
||||
mime: str = MEDIA_MIME
|
||||
|
||||
|
||||
def split_fmp4(data: bytes) -> tuple[bytes, bytes]:
|
||||
"""Split top-level fMP4 initialization boxes from moof/mdat media."""
|
||||
offset = 0
|
||||
first_fragment: int | None = None
|
||||
while offset + 8 <= len(data):
|
||||
size = int.from_bytes(data[offset:offset + 4], "big")
|
||||
box_type = data[offset + 4:offset + 8]
|
||||
header = 8
|
||||
if size == 1:
|
||||
if offset + 16 > len(data):
|
||||
break
|
||||
size = int.from_bytes(data[offset + 8:offset + 16], "big")
|
||||
header = 16
|
||||
elif size == 0:
|
||||
size = len(data) - offset
|
||||
if size < header or offset + size > len(data):
|
||||
raise ValueError("invalid top-level MP4 box")
|
||||
if box_type == b"moof":
|
||||
first_fragment = offset
|
||||
break
|
||||
offset += size
|
||||
if first_fragment is None:
|
||||
raise ValueError("ffmpeg output did not contain an fMP4 moof box")
|
||||
init_bytes = data[:first_fragment]
|
||||
media_bytes = data[first_fragment:]
|
||||
if b"ftyp" not in init_bytes or b"moov" not in init_bytes:
|
||||
raise ValueError("ffmpeg output is missing fMP4 initialization boxes")
|
||||
return init_bytes, media_bytes
|
||||
|
||||
|
||||
class FMP4BlockWriter:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
output_root: str | os.PathLike[str],
|
||||
*,
|
||||
fps: float,
|
||||
ffmpeg_bin: str | None = None,
|
||||
) -> None:
|
||||
self.fps = float(fps)
|
||||
if self.fps <= 0:
|
||||
raise ValueError("fps must be positive")
|
||||
resolved_ffmpeg = ffmpeg_bin or shutil.which(os.getenv("WANTRACK_FFMPEG_BIN", "ffmpeg"))
|
||||
if not resolved_ffmpeg:
|
||||
raise RuntimeError("ffmpeg is required for WanTrack streaming")
|
||||
self.ffmpeg_bin = resolved_ffmpeg
|
||||
root = Path(output_root)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
self.session_id = uuid.uuid4().hex
|
||||
self.session_dir = root / self.session_id
|
||||
self.session_dir.mkdir()
|
||||
self._block_paths: list[Path] = []
|
||||
|
||||
@property
|
||||
def block_paths(self) -> tuple[Path, ...]:
|
||||
return tuple(self._block_paths)
|
||||
|
||||
def encode_block(
|
||||
self,
|
||||
frames: np.ndarray,
|
||||
block_index: int,
|
||||
) -> EncodedMediaSegment:
|
||||
frames = np.asarray(frames)
|
||||
if frames.ndim != 4 or frames.shape[-1] != 3:
|
||||
raise ValueError("frames must have shape [T, H, W, 3]")
|
||||
if frames.shape[0] <= 0:
|
||||
raise ValueError("cannot encode an empty frame block")
|
||||
frames = np.ascontiguousarray(frames, dtype=np.uint8)
|
||||
height, width = int(frames.shape[1]), int(frames.shape[2])
|
||||
gop = max(1, int(frames.shape[0]))
|
||||
command = [
|
||||
self.ffmpeg_bin,
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-pix_fmt",
|
||||
"rgb24",
|
||||
"-s:v",
|
||||
f"{width}x{height}",
|
||||
"-r",
|
||||
f"{self.fps:g}",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"ultrafast",
|
||||
"-tune",
|
||||
"zerolatency",
|
||||
"-profile:v",
|
||||
"baseline",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-g",
|
||||
str(gop),
|
||||
"-keyint_min",
|
||||
str(gop),
|
||||
"-sc_threshold",
|
||||
"0",
|
||||
"-movflags",
|
||||
"+empty_moov+default_base_moof+frag_keyframe",
|
||||
"-frag_duration",
|
||||
str(max(1, round(1_000_000 * frames.shape[0] / self.fps))),
|
||||
"-f",
|
||||
"mp4",
|
||||
"pipe:1",
|
||||
]
|
||||
result = subprocess.run(
|
||||
command,
|
||||
input=frames.tobytes(),
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0 or not result.stdout:
|
||||
stderr = result.stderr.decode("utf-8", errors="replace").strip()
|
||||
raise RuntimeError(f"ffmpeg failed to encode WanTrack block {block_index}: "
|
||||
f"{stderr or f'exit {result.returncode}'}")
|
||||
init_bytes, media_bytes = split_fmp4(result.stdout)
|
||||
path = self.session_dir / f"block_{int(block_index):06d}.mp4"
|
||||
path.write_bytes(result.stdout)
|
||||
self._block_paths.append(path)
|
||||
return EncodedMediaSegment(
|
||||
block_index=int(block_index),
|
||||
init_bytes=init_bytes,
|
||||
media_bytes=media_bytes,
|
||||
path=path,
|
||||
)
|
||||
|
||||
def finalize(self) -> Path | None:
|
||||
if not self._block_paths:
|
||||
return None
|
||||
output_path = self.session_dir / "wantrack_control.mp4"
|
||||
concat_path = self.session_dir / "concat.txt"
|
||||
concat_path.write_text(
|
||||
"".join(f"file '{self._concat_escape(path)}'\n" for path in self._block_paths),
|
||||
encoding="utf-8",
|
||||
)
|
||||
command = [
|
||||
self.ffmpeg_bin,
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
str(concat_path),
|
||||
"-an",
|
||||
"-c",
|
||||
"copy",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(output_path),
|
||||
]
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0 or not output_path.is_file():
|
||||
return self._finalize_reencode(output_path)
|
||||
return output_path if output_path.stat().st_size > 0 else None
|
||||
|
||||
@staticmethod
|
||||
def _concat_escape(path: Path) -> str:
|
||||
return str(path.resolve()).replace("'", "'\\''")
|
||||
|
||||
def _finalize_reencode(self, output_path: Path) -> Path | None:
|
||||
concat_path = self.session_dir / "concat.txt"
|
||||
command = [
|
||||
self.ffmpeg_bin,
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
str(concat_path),
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"fast",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(output_path),
|
||||
]
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if (result.returncode == 0 and output_path.is_file() and output_path.stat().st_size > 0):
|
||||
return output_path
|
||||
return None
|
||||
|
||||
|
||||
def total_size(paths: Iterable[Path]) -> int:
|
||||
return sum(path.stat().st_size for path in paths if path.is_file())
|
||||
@@ -0,0 +1,422 @@
|
||||
"""FastAPI/WebSocket server for causal WanTrack control."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from contextlib import suppress
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from PIL import Image
|
||||
|
||||
from apps.wantrack_control.media import FMP4BlockWriter
|
||||
|
||||
_STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||
|
||||
|
||||
def _decode_image(value: str) -> bytes:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError("prepare.image must be a non-empty base64 string")
|
||||
encoded = value.split(",", 1)[1] if value.startswith("data:") else value
|
||||
try:
|
||||
data = base64.b64decode(encoded, validate=True)
|
||||
except Exception as exc:
|
||||
raise ValueError("prepare.image is not valid base64") from exc
|
||||
if not data:
|
||||
raise ValueError("prepare.image decoded to no bytes")
|
||||
return data
|
||||
|
||||
|
||||
def _encode_image(image: Image.Image) -> str:
|
||||
output = io.BytesIO()
|
||||
image.save(output, format="PNG")
|
||||
encoded = base64.b64encode(output.getvalue()).decode("ascii")
|
||||
return f"data:image/png;base64,{encoded}"
|
||||
|
||||
|
||||
def _block_value(block: Any, key: str, default: Any = None) -> Any:
|
||||
if isinstance(block, dict):
|
||||
return block.get(key, default)
|
||||
return getattr(block, key, default)
|
||||
|
||||
|
||||
class _RuntimeProvider:
|
||||
|
||||
def __init__(self, runtime: Any | None) -> None:
|
||||
self._runtime = runtime
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def get(self) -> Any:
|
||||
if self._runtime is not None:
|
||||
return self._runtime
|
||||
async with self._lock:
|
||||
if self._runtime is None:
|
||||
model_dir = os.getenv("WANTRACK_MODEL_DIR", "").strip()
|
||||
yaml_path = os.getenv("WANTRACK_YAML_PATH", "").strip()
|
||||
taehv_checkpoint = os.getenv("WANTRACK_TAEHV_CHECKPOINT", "").strip()
|
||||
if not model_dir or not yaml_path or not taehv_checkpoint:
|
||||
raise RuntimeError("Set WANTRACK_MODEL_DIR, WANTRACK_YAML_PATH, and "
|
||||
"WANTRACK_TAEHV_CHECKPOINT before preparing a session")
|
||||
from fastvideo.train.models.wantrack.runtime import (
|
||||
WanTrackInferenceRuntime, )
|
||||
|
||||
self._runtime = await asyncio.to_thread(
|
||||
WanTrackInferenceRuntime.from_export,
|
||||
model_dir,
|
||||
yaml_path,
|
||||
taehv_checkpoint,
|
||||
)
|
||||
return self._runtime
|
||||
|
||||
|
||||
def create_app(
|
||||
runtime: Any | None = None,
|
||||
*,
|
||||
output_dir: str | os.PathLike[str] | None = None,
|
||||
writer_factory: Any = FMP4BlockWriter,
|
||||
) -> FastAPI:
|
||||
app = FastAPI(title="Causal WanTrack Control")
|
||||
app.state.runtime_provider = _RuntimeProvider(runtime)
|
||||
app.state.active_generation = asyncio.Lock()
|
||||
app.state.downloads = {}
|
||||
if output_dir is None:
|
||||
resolved_output_dir: str | os.PathLike[str] = os.getenv(
|
||||
"WANTRACK_OUTPUT_DIR",
|
||||
str(Path(tempfile.gettempdir()) / "wantrack_control"),
|
||||
)
|
||||
else:
|
||||
resolved_output_dir = output_dir
|
||||
app.state.output_dir = Path(resolved_output_dir)
|
||||
app.state.writer_factory = writer_factory
|
||||
|
||||
app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static")
|
||||
|
||||
@app.get("/")
|
||||
async def index() -> FileResponse:
|
||||
return FileResponse(_STATIC_DIR / "index.html")
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz() -> dict[str, Any]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"active": app.state.active_generation.locked(),
|
||||
}
|
||||
|
||||
@app.get("/downloads/{download_id}")
|
||||
async def download(download_id: str) -> FileResponse:
|
||||
path = app.state.downloads.get(download_id)
|
||||
if path is None or not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="download not found")
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="video/mp4",
|
||||
filename="wantrack_control.mp4",
|
||||
)
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket) -> None:
|
||||
await websocket.accept()
|
||||
send_lock = asyncio.Lock()
|
||||
prepared: Any | None = None
|
||||
session: Any | None = None
|
||||
generation_task: asyncio.Task[None] | None = None
|
||||
stop_event = asyncio.Event()
|
||||
owns_generation_lock = False
|
||||
connected = True
|
||||
|
||||
async def send_json(payload: dict[str, Any]) -> None:
|
||||
nonlocal connected
|
||||
if not connected:
|
||||
return
|
||||
async with send_lock:
|
||||
try:
|
||||
await websocket.send_json(payload)
|
||||
except Exception:
|
||||
connected = False
|
||||
raise
|
||||
|
||||
async def send_bytes(payload: bytes) -> None:
|
||||
nonlocal connected
|
||||
if not connected:
|
||||
return
|
||||
async with send_lock:
|
||||
try:
|
||||
await websocket.send_bytes(payload)
|
||||
except Exception:
|
||||
connected = False
|
||||
raise
|
||||
|
||||
async def run_generation(runtime_value: Any) -> None:
|
||||
nonlocal owns_generation_lock, connected, generation_task
|
||||
writer = app.state.writer_factory(
|
||||
app.state.output_dir,
|
||||
fps=float(getattr(runtime_value, "fps", 16.0)),
|
||||
)
|
||||
init_sent = False
|
||||
last_applied_revision = 0
|
||||
terminal_error: str | None = None
|
||||
try:
|
||||
while not stop_event.is_set():
|
||||
block_index = int(getattr(session, "block_index", 0))
|
||||
block_started_at = time.perf_counter()
|
||||
await send_json({
|
||||
"type": "block_started",
|
||||
"block_index": block_index,
|
||||
"num_inference_steps": len(
|
||||
getattr(runtime_value, "dmd_denoising_steps", [1000, 750, 500, 250])),
|
||||
"dmd_denoising_steps": list(
|
||||
getattr(runtime_value, "dmd_denoising_steps", [1000, 750, 500, 250])),
|
||||
"cfg_enabled": False,
|
||||
})
|
||||
block = await asyncio.to_thread(session.generate_next_block)
|
||||
generated_at = time.perf_counter()
|
||||
applied_revision = int(_block_value(block, "applied_revision", 0))
|
||||
if applied_revision > last_applied_revision:
|
||||
last_applied_revision = applied_revision
|
||||
await send_json({
|
||||
"type": "control_applied",
|
||||
"revision": applied_revision,
|
||||
"block_index": int(_block_value(block, "block_index", block_index)),
|
||||
"radius": float(_block_value(block, "radius", 0.0)),
|
||||
"active_handle_ids": list(_block_value(block, "active_handle_ids", ())),
|
||||
})
|
||||
frames = _block_value(block, "pixel_frames")
|
||||
await send_json({
|
||||
"type": "block_encoding",
|
||||
"block_index": block_index,
|
||||
})
|
||||
encoded = await asyncio.to_thread(
|
||||
writer.encode_block,
|
||||
frames,
|
||||
int(_block_value(block, "block_index", block_index)),
|
||||
)
|
||||
encoded_at = time.perf_counter()
|
||||
if not init_sent:
|
||||
await send_json({
|
||||
"type": "media_init",
|
||||
"mime": encoded.mime,
|
||||
})
|
||||
await send_bytes(encoded.init_bytes)
|
||||
init_sent = True
|
||||
await send_bytes(encoded.media_bytes)
|
||||
await send_json({
|
||||
"type": "media_segment_complete",
|
||||
"block_index": encoded.block_index,
|
||||
"bytes": len(encoded.media_bytes),
|
||||
"generation_ms": round((generated_at - block_started_at) * 1000),
|
||||
"encoding_ms": round((encoded_at - generated_at) * 1000),
|
||||
})
|
||||
except Exception as exc:
|
||||
terminal_error = str(exc) or type(exc).__name__
|
||||
finally:
|
||||
if session is not None:
|
||||
with suppress(Exception):
|
||||
await asyncio.to_thread(
|
||||
session.close,
|
||||
"error" if terminal_error else ("disconnect" if not connected else "stop"),
|
||||
)
|
||||
final_path = await asyncio.to_thread(writer.finalize)
|
||||
download_url = None
|
||||
if final_path is not None:
|
||||
app.state.downloads[writer.session_id] = final_path
|
||||
download_url = f"/downloads/{writer.session_id}"
|
||||
if owns_generation_lock:
|
||||
app.state.active_generation.release()
|
||||
owns_generation_lock = False
|
||||
if terminal_error:
|
||||
if connected:
|
||||
with suppress(Exception):
|
||||
await send_json({
|
||||
"type": "error",
|
||||
"message": terminal_error,
|
||||
"download_url": download_url,
|
||||
})
|
||||
elif connected:
|
||||
with suppress(Exception):
|
||||
await send_json({
|
||||
"type": "stream_complete",
|
||||
"blocks": len(writer.block_paths),
|
||||
"download_url": download_url,
|
||||
})
|
||||
generation_task = None
|
||||
|
||||
async def handle_message(message: dict[str, Any]) -> None:
|
||||
nonlocal prepared, session, generation_task
|
||||
nonlocal owns_generation_lock
|
||||
message_type = str(message.get("type", "")).strip()
|
||||
if message_type == "prepare":
|
||||
if generation_task is not None:
|
||||
raise ValueError("prepare is unavailable during generation")
|
||||
prepare_started_at = time.perf_counter()
|
||||
await send_json({
|
||||
"type": "progress",
|
||||
"phase": "loading_model",
|
||||
"message": "Loading Track-v0",
|
||||
"detail": "The first request loads the SF checkpoint onto the GPU.",
|
||||
})
|
||||
runtime_value = await app.state.runtime_provider.get()
|
||||
await send_json({
|
||||
"type": "progress",
|
||||
"phase": "preparing_input",
|
||||
"message": "Preparing image and prompt",
|
||||
"detail": "Encoding text, the reference image, and its first-frame latent.",
|
||||
})
|
||||
image_bytes = _decode_image(message.get("image", ""))
|
||||
prompt = str(message.get("prompt", "") or "")
|
||||
prepared = await asyncio.to_thread(
|
||||
runtime_value.prepare,
|
||||
image_bytes,
|
||||
prompt,
|
||||
)
|
||||
processed_image = getattr(prepared, "image", None)
|
||||
if not isinstance(processed_image, Image.Image):
|
||||
processed_image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
|
||||
await send_json({
|
||||
"type": "prepared",
|
||||
"image": _encode_image(processed_image),
|
||||
"width": processed_image.width,
|
||||
"height": processed_image.height,
|
||||
"fps": float(getattr(runtime_value, "fps", 16.0)),
|
||||
"chunk_size": int(getattr(runtime_value, "chunk_size", 3)),
|
||||
"causal_recipe": getattr(runtime_value, "causal_recipe", {}),
|
||||
"decoder": str(getattr(runtime_value, "decoder_name", "unknown")),
|
||||
"prepare_ms": round((time.perf_counter() - prepare_started_at) * 1000),
|
||||
})
|
||||
return
|
||||
|
||||
if message_type == "start":
|
||||
if prepared is None:
|
||||
raise ValueError("prepare must complete before start")
|
||||
if generation_task is not None:
|
||||
raise ValueError("session is already generating")
|
||||
handles = message.get("handles")
|
||||
if not isinstance(handles, list) or not handles:
|
||||
raise ValueError("start requires at least one handle")
|
||||
if app.state.active_generation.locked():
|
||||
raise RuntimeError("another WanTrack session is already generating")
|
||||
start_started_at = time.perf_counter()
|
||||
await send_json({
|
||||
"type": "progress",
|
||||
"phase": "starting_session",
|
||||
"message": "Starting causal session",
|
||||
"detail": "Initializing controls and the causal KV cache.",
|
||||
})
|
||||
await app.state.active_generation.acquire()
|
||||
owns_generation_lock = True
|
||||
runtime_value = await app.state.runtime_provider.get()
|
||||
session = runtime_value.create_session()
|
||||
dmd_steps = list(
|
||||
getattr(runtime_value, "dmd_denoising_steps", [1000, 750, 500, 250]))
|
||||
sampling = {
|
||||
"seed": int(message.get("seed", 0)),
|
||||
"num_inference_steps": len(dmd_steps),
|
||||
"text_guidance_scale": 1.0,
|
||||
"motion_guidance_scale": 1.0,
|
||||
"motion_cfg": False,
|
||||
}
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
session.start,
|
||||
prepared,
|
||||
getattr(prepared, "prompt", str(message.get("prompt", "") or "")),
|
||||
handles,
|
||||
sampling,
|
||||
radius=float(message.get("radius", 0.15)),
|
||||
)
|
||||
except Exception:
|
||||
app.state.active_generation.release()
|
||||
owns_generation_lock = False
|
||||
raise
|
||||
await send_json({
|
||||
"type": "session_started",
|
||||
"fps": float(getattr(runtime_value, "fps", 16.0)),
|
||||
"chunk_size": int(getattr(runtime_value, "chunk_size", 3)),
|
||||
"num_inference_steps": len(dmd_steps),
|
||||
"dmd_denoising_steps": dmd_steps,
|
||||
"cfg_enabled": False,
|
||||
"causal_recipe": getattr(runtime_value, "causal_recipe", {}),
|
||||
"decoder": str(getattr(runtime_value, "decoder_name", "unknown")),
|
||||
"start_ms": round((time.perf_counter() - start_started_at) * 1000),
|
||||
})
|
||||
stop_event.clear()
|
||||
generation_task = asyncio.create_task(run_generation(runtime_value))
|
||||
return
|
||||
|
||||
if message_type == "control_update":
|
||||
if session is None:
|
||||
raise ValueError("control_update requires a running session")
|
||||
revision = int(message.get("revision", 0))
|
||||
accepted = await asyncio.to_thread(
|
||||
session.apply_control_revision,
|
||||
revision,
|
||||
samples=message.get("samples"),
|
||||
add=message.get("add"),
|
||||
remove=message.get("remove"),
|
||||
handles=message.get("handles"),
|
||||
radius=message.get("radius"),
|
||||
)
|
||||
if not accepted:
|
||||
await send_json({
|
||||
"type": "control_applied",
|
||||
"revision": revision,
|
||||
"status": "ignored_stale",
|
||||
})
|
||||
return
|
||||
|
||||
if message_type == "stop":
|
||||
if generation_task is None:
|
||||
raise ValueError("stop requires a running session")
|
||||
stop_event.set()
|
||||
return
|
||||
raise ValueError(f"unknown client message type: {message_type!r}")
|
||||
|
||||
try:
|
||||
while True:
|
||||
receive_task = asyncio.create_task(websocket.receive_json())
|
||||
waiters: set[asyncio.Task[Any]] = {receive_task}
|
||||
if generation_task is not None:
|
||||
waiters.add(generation_task)
|
||||
done, _ = await asyncio.wait(
|
||||
waiters,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
if generation_task is not None and generation_task in done:
|
||||
receive_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await receive_task
|
||||
await generation_task
|
||||
return
|
||||
message = await receive_task
|
||||
try:
|
||||
await handle_message(message)
|
||||
except Exception as exc:
|
||||
await send_json({
|
||||
"type": "error",
|
||||
"message": str(exc) or type(exc).__name__,
|
||||
})
|
||||
if generation_task is not None:
|
||||
stop_event.set()
|
||||
except WebSocketDisconnect:
|
||||
connected = False
|
||||
except Exception:
|
||||
connected = False
|
||||
finally:
|
||||
stop_event.set()
|
||||
if generation_task is not None:
|
||||
with suppress(Exception):
|
||||
await generation_task
|
||||
elif owns_generation_lock:
|
||||
app.state.active_generation.release()
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,308 @@
|
||||
(() => {
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const imageInput = $("image");
|
||||
const promptInput = $("prompt");
|
||||
const prepareButton = $("prepare");
|
||||
const canvas = $("canvas");
|
||||
const context = canvas.getContext("2d");
|
||||
const addButton = $("add");
|
||||
const removeButton = $("remove");
|
||||
const gridInput = $("grid");
|
||||
const radiusInput = $("radius");
|
||||
const radiusOutput = document.querySelector(".radius output");
|
||||
const startButton = $("start");
|
||||
const stopButton = $("stop");
|
||||
const video = $("video");
|
||||
const status = $("status");
|
||||
const statusLabel = $("status-label");
|
||||
const statusDetail = $("status-detail");
|
||||
const download = $("download");
|
||||
|
||||
let socket;
|
||||
let preparedImage;
|
||||
let handles = [];
|
||||
let selectedId = null;
|
||||
let addMode = false;
|
||||
let dragging = false;
|
||||
let generating = false;
|
||||
let revision = 0;
|
||||
let sessionStart = 0;
|
||||
let mediaSource;
|
||||
let sourceBuffer;
|
||||
let mediaQueue = [];
|
||||
let streamComplete = false;
|
||||
|
||||
function setStatus(value, detail = "", busy = false) {
|
||||
statusLabel.textContent = value;
|
||||
statusDetail.textContent = detail;
|
||||
status.dataset.busy = String(busy);
|
||||
}
|
||||
function seconds(milliseconds) {
|
||||
return `${(Number(milliseconds || 0) / 1000).toFixed(1)}s`;
|
||||
}
|
||||
function connect() {
|
||||
const scheme = location.protocol === "https:" ? "wss" : "ws";
|
||||
socket = new WebSocket(`${scheme}://${location.host}/ws`);
|
||||
socket.binaryType = "arraybuffer";
|
||||
socket.onopen = () => setStatus("Ready", "Choose an image to begin.");
|
||||
socket.onclose = () => setStatus("Disconnected", "Refresh after the server reconnects.");
|
||||
socket.onerror = () => setStatus("Connection error", "The control server is unreachable.");
|
||||
socket.onmessage = async (event) => {
|
||||
if (typeof event.data !== "string") {
|
||||
mediaQueue.push(event.data);
|
||||
flushMedia();
|
||||
return;
|
||||
}
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === "progress") {
|
||||
setStatus(message.message || "Working", message.detail || "", true);
|
||||
} else if (message.type === "prepared") {
|
||||
preparedImage = new Image();
|
||||
preparedImage.onload = () => {
|
||||
canvas.width = message.width;
|
||||
canvas.height = message.height;
|
||||
draw();
|
||||
};
|
||||
preparedImage.src = message.image;
|
||||
handles = [];
|
||||
selectedId = null;
|
||||
addButton.disabled = false;
|
||||
startButton.disabled = true;
|
||||
prepareButton.disabled = false;
|
||||
prepareButton.textContent = "Prepare again";
|
||||
const recipe = message.causal_recipe || {};
|
||||
setStatus(
|
||||
`Prepared in ${seconds(message.prepare_ms)}`,
|
||||
`${message.fps} FPS · ${message.decoder || "unknown decoder"} · ${recipe.rope_cache_policy || "unknown"} RoPE · local ${recipe.local_attn_size ?? "?"} · sink ${recipe.sink_size ?? "?"} · Add a handle, then press Start.`,
|
||||
);
|
||||
} else if (message.type === "session_started") {
|
||||
generating = true;
|
||||
sessionStart = performance.now();
|
||||
startButton.disabled = true;
|
||||
stopButton.disabled = false;
|
||||
setStatus(
|
||||
`Session started in ${seconds(message.start_ms)}`,
|
||||
"4-step SF · CFG off · Building the first causal block.",
|
||||
true,
|
||||
);
|
||||
} else if (message.type === "block_started") {
|
||||
setStatus(
|
||||
`Generating block ${message.block_index}`,
|
||||
"DMD 4-step, single conditional branch. Controls update at the next block.",
|
||||
true,
|
||||
);
|
||||
} else if (message.type === "block_encoding") {
|
||||
setStatus(
|
||||
`Encoding block ${message.block_index}`,
|
||||
"The model is done; packaging frames for immediate playback.",
|
||||
true,
|
||||
);
|
||||
} else if (message.type === "control_applied") {
|
||||
if (message.status === "ignored_stale") {
|
||||
setStatus(`Stale revision ${message.revision} ignored`, "Drag again to send a newer control update.");
|
||||
} else {
|
||||
setStatus(`Control ${message.revision} applied`, "The updated motion is active in this block.");
|
||||
}
|
||||
} else if (message.type === "media_init") {
|
||||
setupMediaSource(message.mime);
|
||||
} else if (message.type === "media_segment_complete") {
|
||||
setStatus(
|
||||
`Playing through block ${message.block_index}`,
|
||||
`Generated in ${seconds(message.generation_ms)} · encoded in ${seconds(message.encoding_ms)} · drag handles for the next block.`,
|
||||
);
|
||||
} else if (message.type === "stream_complete") {
|
||||
generating = false;
|
||||
streamComplete = true;
|
||||
stopButton.disabled = true;
|
||||
startButton.disabled = false;
|
||||
if (message.download_url) {
|
||||
download.href = message.download_url;
|
||||
download.hidden = false;
|
||||
}
|
||||
flushMedia();
|
||||
setStatus(`Complete · ${message.blocks} blocks`, "The final MP4 is ready to download.");
|
||||
} else if (message.type === "error") {
|
||||
generating = false;
|
||||
prepareButton.disabled = false;
|
||||
stopButton.disabled = true;
|
||||
if (message.download_url) {
|
||||
download.href = message.download_url;
|
||||
download.hidden = false;
|
||||
}
|
||||
setStatus("Request failed", message.message || "Unknown error");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function setupMediaSource(mime) {
|
||||
mediaQueue = [];
|
||||
streamComplete = false;
|
||||
mediaSource = new MediaSource();
|
||||
video.src = URL.createObjectURL(mediaSource);
|
||||
mediaSource.addEventListener("sourceopen", () => {
|
||||
sourceBuffer = mediaSource.addSourceBuffer(mime);
|
||||
sourceBuffer.mode = "sequence";
|
||||
sourceBuffer.addEventListener("updateend", flushMedia);
|
||||
flushMedia();
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
function flushMedia() {
|
||||
if (!sourceBuffer || sourceBuffer.updating) return;
|
||||
if (mediaQueue.length) {
|
||||
sourceBuffer.appendBuffer(mediaQueue.shift());
|
||||
return;
|
||||
}
|
||||
if (streamComplete && mediaSource && mediaSource.readyState === "open") {
|
||||
mediaSource.endOfStream();
|
||||
}
|
||||
video.play().catch(() => {});
|
||||
}
|
||||
|
||||
function draw() {
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (preparedImage) context.drawImage(preparedImage, 0, 0, canvas.width, canvas.height);
|
||||
if (gridInput.checked) {
|
||||
context.strokeStyle = "rgba(255,255,255,.12)";
|
||||
context.lineWidth = 1;
|
||||
for (let index = 0; index < 50; index += 1) {
|
||||
const x = index * canvas.width / 49;
|
||||
const y = index * canvas.height / 49;
|
||||
context.beginPath(); context.moveTo(x, 0); context.lineTo(x, canvas.height); context.stroke();
|
||||
context.beginPath(); context.moveTo(0, y); context.lineTo(canvas.width, y); context.stroke();
|
||||
}
|
||||
}
|
||||
for (const handle of handles) {
|
||||
const x = handle.x * canvas.width;
|
||||
const y = handle.y * canvas.height;
|
||||
context.beginPath();
|
||||
context.arc(x, y, handle.id === selectedId ? 9 : 7, 0, Math.PI * 2);
|
||||
context.fillStyle = handle.id === selectedId ? "#ffcf4a" : "#58c7ff";
|
||||
context.fill();
|
||||
context.strokeStyle = "#111";
|
||||
context.lineWidth = 2;
|
||||
context.stroke();
|
||||
}
|
||||
removeButton.disabled = !selectedId;
|
||||
startButton.disabled = !preparedImage || handles.length === 0 || generating;
|
||||
}
|
||||
|
||||
function canvasPoint(event) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)),
|
||||
y: Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height)),
|
||||
};
|
||||
}
|
||||
function nearest(point) {
|
||||
let match = null;
|
||||
let distance = Infinity;
|
||||
for (const handle of handles) {
|
||||
const value = Math.hypot(handle.x - point.x, handle.y - point.y);
|
||||
if (value < distance && value < 18 / canvas.clientWidth) {
|
||||
match = handle;
|
||||
distance = value;
|
||||
}
|
||||
}
|
||||
return match;
|
||||
}
|
||||
function sendControl(extra = {}) {
|
||||
if (!generating) return;
|
||||
revision += 1;
|
||||
socket.send(JSON.stringify({ type: "control_update", revision, ...extra }));
|
||||
}
|
||||
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
if (!preparedImage) return;
|
||||
const point = canvasPoint(event);
|
||||
if (addMode) {
|
||||
const handle = { id: crypto.randomUUID(), ...point };
|
||||
handles.push(handle);
|
||||
selectedId = handle.id;
|
||||
addMode = false;
|
||||
addButton.textContent = "Add handle";
|
||||
sendControl({ add: [handle] });
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
const handle = nearest(point);
|
||||
selectedId = handle ? handle.id : null;
|
||||
dragging = Boolean(handle);
|
||||
if (dragging) canvas.setPointerCapture(event.pointerId);
|
||||
draw();
|
||||
});
|
||||
canvas.addEventListener("pointermove", (event) => {
|
||||
if (!dragging || !selectedId) return;
|
||||
const point = canvasPoint(event);
|
||||
const handle = handles.find((item) => item.id === selectedId);
|
||||
if (!handle) return;
|
||||
handle.x = point.x; handle.y = point.y;
|
||||
sendControl({ samples: [{ id: handle.id, ...point, timestamp_ms: performance.now() - sessionStart }] });
|
||||
draw();
|
||||
});
|
||||
canvas.addEventListener("pointerup", () => { dragging = false; });
|
||||
addButton.addEventListener("click", () => {
|
||||
addMode = !addMode;
|
||||
addButton.textContent = addMode ? "Click canvas" : "Add handle";
|
||||
});
|
||||
removeButton.addEventListener("click", () => {
|
||||
if (!selectedId) return;
|
||||
const removed = selectedId;
|
||||
handles = handles.filter((item) => item.id !== removed);
|
||||
selectedId = null;
|
||||
sendControl({ remove: [removed] });
|
||||
draw();
|
||||
});
|
||||
gridInput.addEventListener("change", draw);
|
||||
radiusInput.addEventListener("input", () => {
|
||||
radiusOutput.textContent = Number(radiusInput.value).toFixed(2);
|
||||
sendControl({ radius: Number(radiusInput.value) });
|
||||
});
|
||||
|
||||
prepareButton.addEventListener("click", async () => {
|
||||
const file = imageInput.files[0];
|
||||
if (!file) {
|
||||
setStatus("Choose an image", "Prepare needs a reference frame.");
|
||||
return;
|
||||
}
|
||||
prepareButton.disabled = true;
|
||||
prepareButton.textContent = "Preparing…";
|
||||
setStatus("Reading image", "Using the browser's native file reader.", true);
|
||||
try {
|
||||
const image = await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = () => reject(reader.error || new Error("Failed to read image"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
setStatus("Sending image", "The server will encode the prompt and reference frame next.", true);
|
||||
socket.send(JSON.stringify({
|
||||
type: "prepare",
|
||||
image,
|
||||
prompt: promptInput.value,
|
||||
}));
|
||||
} catch (error) {
|
||||
prepareButton.disabled = false;
|
||||
prepareButton.textContent = "Prepare";
|
||||
setStatus("Could not read image", error.message || String(error));
|
||||
}
|
||||
});
|
||||
startButton.addEventListener("click", () => {
|
||||
revision = 0;
|
||||
download.hidden = true;
|
||||
startButton.disabled = true;
|
||||
setStatus("Sending controls", "Starting the fixed DMD 4-step, CFG-free SF sampler.", true);
|
||||
socket.send(JSON.stringify({
|
||||
type: "start",
|
||||
handles,
|
||||
radius: Number(radiusInput.value),
|
||||
seed: Number($("seed").value),
|
||||
}));
|
||||
});
|
||||
stopButton.addEventListener("click", () => {
|
||||
socket.send(JSON.stringify({ type: "stop" }));
|
||||
stopButton.disabled = true;
|
||||
setStatus("Finishing current block");
|
||||
});
|
||||
connect();
|
||||
})();
|
||||
@@ -0,0 +1,56 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>WanTrack Control</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<div>
|
||||
<h1>WanTrack Control</h1>
|
||||
<p>Drag points to steer the next causal block.</p>
|
||||
</div>
|
||||
<div id="status" role="status" aria-live="polite">
|
||||
<span id="status-label">Disconnected</span>
|
||||
<small id="status-detail">Waiting for the server connection.</small>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="controls">
|
||||
<label class="file">Image <input id="image" type="file" accept="image/*"></label>
|
||||
<label class="prompt">Prompt <input id="prompt" type="text" placeholder="Optional scene description"></label>
|
||||
<button id="prepare">Prepare</button>
|
||||
</section>
|
||||
|
||||
<section class="workspace">
|
||||
<div class="canvas-panel">
|
||||
<div class="canvas-toolbar">
|
||||
<button id="add" disabled>Add handle</button>
|
||||
<button id="remove" disabled>Delete selected</button>
|
||||
<label><input id="grid" type="checkbox"> Grid</label>
|
||||
</div>
|
||||
<canvas id="canvas" width="832" height="480"></canvas>
|
||||
<label class="radius">Radius <input id="radius" type="range" min="0.03" max="0.45" step="0.01" value="0.15"><output>0.15</output></label>
|
||||
</div>
|
||||
<div class="video-panel">
|
||||
<video id="video" muted autoplay playsinline controls></video>
|
||||
<a id="download" hidden>Download completed MP4</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="sampling">
|
||||
<label>Seed <input id="seed" type="number" value="0" step="1"></label>
|
||||
<div class="recipe">
|
||||
<strong>SF checkpoint</strong>
|
||||
<span>DMD [1000,750,500,250] · CFG off · TAEHV · relativistic RoPE · local 6 · sink 1</span>
|
||||
</div>
|
||||
<button id="start" disabled>Start</button>
|
||||
<button id="stop" disabled>Stop</button>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,57 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
background: #111315;
|
||||
color: #edf0f2;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; }
|
||||
main { width: min(1180px, calc(100% - 32px)); margin: 24px auto; }
|
||||
header { display: flex; align-items: start; justify-content: space-between; gap: 16px; }
|
||||
h1 { margin: 0; font-size: 22px; }
|
||||
p { margin: 6px 0 20px; color: #929ba3; }
|
||||
#status {
|
||||
display: grid; grid-template-columns: auto 1fr; gap: 2px 9px; min-width: 300px;
|
||||
padding: 9px 12px; border-radius: 10px; background: #23282d; color: #dce2e7;
|
||||
}
|
||||
#status::before {
|
||||
content: ""; grid-row: 1 / 3; align-self: center; width: 8px; height: 8px;
|
||||
border-radius: 50%; background: #62d58b;
|
||||
}
|
||||
#status[data-busy="true"]::before {
|
||||
width: 12px; height: 12px; border: 2px solid #65717a; border-top-color: #edf0f2;
|
||||
background: transparent; animation: spin .8s linear infinite;
|
||||
}
|
||||
#status-label { font-size: 12px; font-weight: 700; }
|
||||
#status-detail { color: #9ea8b0; font-size: 11px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.controls, .sampling, .canvas-toolbar { display: flex; align-items: end; gap: 10px; flex-wrap: wrap; }
|
||||
label { color: #aeb6bd; font-size: 12px; }
|
||||
input[type="text"], input[type="number"], input[type="file"] {
|
||||
display: block; margin-top: 5px; min-height: 36px; border: 1px solid #353b41;
|
||||
border-radius: 7px; background: #1a1e22; color: inherit; padding: 7px 9px;
|
||||
}
|
||||
.prompt { flex: 1; }
|
||||
.prompt input { width: 100%; }
|
||||
button, #download {
|
||||
border: 0; border-radius: 7px; background: #e7ebee; color: #111315;
|
||||
min-height: 36px; padding: 8px 13px; font-weight: 650; cursor: pointer;
|
||||
}
|
||||
button:disabled { opacity: .38; cursor: default; }
|
||||
.workspace { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 16px 0; }
|
||||
.canvas-panel, .video-panel { min-width: 0; border: 1px solid #2e3439; background: #181c20; border-radius: 10px; padding: 10px; }
|
||||
.canvas-toolbar { margin-bottom: 8px; }
|
||||
canvas, video { display: block; width: 100%; aspect-ratio: 832 / 480; object-fit: contain; background: #090a0b; border-radius: 6px; }
|
||||
canvas { touch-action: none; cursor: crosshair; }
|
||||
.radius { display: flex; align-items: center; gap: 9px; margin-top: 10px; }
|
||||
.radius input { flex: 1; }
|
||||
#download { display: inline-block; margin-top: 10px; text-decoration: none; }
|
||||
#download[hidden] { display: none; }
|
||||
.sampling input { width: 92px; }
|
||||
.recipe {
|
||||
display: grid; gap: 2px; min-height: 36px; padding: 6px 10px;
|
||||
border: 1px solid #353b41; border-radius: 7px; background: #1a1e22;
|
||||
}
|
||||
.recipe strong { color: #edf0f2; font-size: 12px; }
|
||||
.recipe span { color: #8f99a1; font-size: 11px; }
|
||||
@media (max-width: 800px) { .workspace { grid-template-columns: 1fr; } }
|
||||
@@ -0,0 +1,267 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import threading
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from apps.wantrack_control.media import EncodedMediaSegment
|
||||
from apps.wantrack_control.server import create_app
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Prepared:
|
||||
image: Image.Image
|
||||
prompt: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Block:
|
||||
block_index: int
|
||||
pixel_frames: np.ndarray
|
||||
applied_revision: int
|
||||
radius: float = 0.15
|
||||
active_handle_ids: tuple[str, ...] = ("h", )
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(
|
||||
self,
|
||||
gates: list[threading.Event],
|
||||
*,
|
||||
fail_at: int | None = None,
|
||||
) -> None:
|
||||
self.gates = gates
|
||||
self.fail_at = fail_at
|
||||
self.block_index = 0
|
||||
self.pending_revision = 0
|
||||
self.closed_reason = None
|
||||
|
||||
def start(self, image, prompt, handles, sampling, *, radius):
|
||||
assert image.prompt == prompt
|
||||
assert handles
|
||||
assert sampling == {
|
||||
"seed": 0,
|
||||
"num_inference_steps": 4,
|
||||
"text_guidance_scale": 1.0,
|
||||
"motion_guidance_scale": 1.0,
|
||||
"motion_cfg": False,
|
||||
}
|
||||
assert radius > 0
|
||||
|
||||
def apply_control_revision(self, revision, **kwargs):
|
||||
del kwargs
|
||||
if revision <= self.pending_revision:
|
||||
return False
|
||||
self.pending_revision = revision
|
||||
return True
|
||||
|
||||
def generate_next_block(self):
|
||||
index = self.block_index
|
||||
applied = self.pending_revision
|
||||
if index < len(self.gates):
|
||||
assert self.gates[index].wait(timeout=5)
|
||||
if self.fail_at == index:
|
||||
raise RuntimeError("fake generation failure")
|
||||
self.block_index += 1
|
||||
return _Block(
|
||||
block_index=index,
|
||||
pixel_frames=np.zeros((2, 8, 8, 3), dtype=np.uint8),
|
||||
applied_revision=applied,
|
||||
)
|
||||
|
||||
def close(self, reason):
|
||||
self.closed_reason = reason
|
||||
|
||||
|
||||
class _FakeRuntime:
|
||||
fps = 16.0
|
||||
chunk_size = 3
|
||||
decoder_name = "TAEHV (taew2_1)"
|
||||
causal_recipe = {
|
||||
"local_attn_size": 6,
|
||||
"sink_size": 1,
|
||||
"rope_cache_policy": "relativistic",
|
||||
}
|
||||
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
|
||||
def prepare(self, image_bytes, prompt):
|
||||
assert image_bytes
|
||||
return _Prepared(Image.new("RGB", (16, 12), "blue"), prompt)
|
||||
|
||||
def create_session(self):
|
||||
return self.session
|
||||
|
||||
|
||||
class _FakeWriter:
|
||||
def __init__(self, output_root, *, fps):
|
||||
assert fps == 16.0
|
||||
self.session_id = "fake-download"
|
||||
self.session_dir = Path(output_root) / self.session_id
|
||||
self.session_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.block_paths = []
|
||||
|
||||
def encode_block(self, frames, block_index):
|
||||
assert frames.shape[-1] == 3
|
||||
path = self.session_dir / f"block-{block_index}.mp4"
|
||||
path.write_bytes(b"complete-block")
|
||||
self.block_paths.append(path)
|
||||
return EncodedMediaSegment(
|
||||
block_index=block_index,
|
||||
init_bytes=b"init",
|
||||
media_bytes=f"media-{block_index}".encode(),
|
||||
path=path,
|
||||
)
|
||||
|
||||
def finalize(self):
|
||||
if not self.block_paths:
|
||||
return None
|
||||
path = self.session_dir / "wantrack_control.mp4"
|
||||
path.write_bytes(b"".join(item.read_bytes()
|
||||
for item in self.block_paths))
|
||||
return path
|
||||
|
||||
|
||||
def _json(websocket):
|
||||
message = websocket.receive()
|
||||
assert message["type"] == "websocket.send"
|
||||
assert "text" in message
|
||||
import json
|
||||
return json.loads(message["text"])
|
||||
|
||||
|
||||
def _bytes(websocket):
|
||||
message = websocket.receive()
|
||||
assert message["type"] == "websocket.send"
|
||||
return message["bytes"]
|
||||
|
||||
|
||||
def _prepare_and_start(websocket):
|
||||
import base64
|
||||
import io
|
||||
|
||||
image = io.BytesIO()
|
||||
Image.new("RGB", (8, 8)).save(image, format="PNG")
|
||||
websocket.send_json({
|
||||
"type": "prepare",
|
||||
"image": base64.b64encode(image.getvalue()).decode(),
|
||||
"prompt": "",
|
||||
})
|
||||
assert _json(websocket)["phase"] == "loading_model"
|
||||
assert _json(websocket)["phase"] == "preparing_input"
|
||||
prepared = _json(websocket)
|
||||
assert prepared["type"] == "prepared"
|
||||
assert prepared["prepare_ms"] >= 0
|
||||
assert prepared["causal_recipe"]["rope_cache_policy"] == "relativistic"
|
||||
assert prepared["decoder"] == "TAEHV (taew2_1)"
|
||||
websocket.send_json({
|
||||
"type": "start",
|
||||
"handles": [{
|
||||
"id": "h",
|
||||
"x": 0.5,
|
||||
"y": 0.5,
|
||||
}],
|
||||
"radius": 0.15,
|
||||
# Client overrides are ignored for the fixed SF recipe.
|
||||
"steps": 99,
|
||||
"text_guidance": 9.0,
|
||||
"motion_guidance": 9.0,
|
||||
})
|
||||
assert _json(websocket)["phase"] == "starting_session"
|
||||
started = _json(websocket)
|
||||
assert started["type"] == "session_started"
|
||||
assert started["num_inference_steps"] == 4
|
||||
assert started["cfg_enabled"] is False
|
||||
assert started["causal_recipe"]["local_attn_size"] == 6
|
||||
assert started["decoder"] == "TAEHV (taew2_1)"
|
||||
|
||||
|
||||
def test_two_block_binary_order_future_update_and_stop(tmp_path):
|
||||
gates = [threading.Event(), threading.Event()]
|
||||
session = _FakeSession(gates)
|
||||
app = create_app(
|
||||
_FakeRuntime(session),
|
||||
output_dir=tmp_path,
|
||||
writer_factory=_FakeWriter,
|
||||
)
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/ws") as websocket:
|
||||
_prepare_and_start(websocket)
|
||||
started = _json(websocket)
|
||||
assert started["type"] == "block_started"
|
||||
assert started["block_index"] == 0
|
||||
assert started["num_inference_steps"] == 4
|
||||
assert started["cfg_enabled"] is False
|
||||
websocket.send_json({
|
||||
"type": "control_update",
|
||||
"revision": 1,
|
||||
"samples": [{
|
||||
"id": "h",
|
||||
"x": 0.7,
|
||||
"y": 0.5,
|
||||
"timestamp_ms": 10,
|
||||
}],
|
||||
})
|
||||
websocket.send_json({
|
||||
"type": "control_update",
|
||||
"revision": 1,
|
||||
"samples": [],
|
||||
})
|
||||
stale = _json(websocket)
|
||||
assert stale["status"] == "ignored_stale"
|
||||
gates[0].set()
|
||||
assert _json(websocket)["type"] == "block_encoding"
|
||||
assert _json(websocket)["type"] == "media_init"
|
||||
assert _bytes(websocket) == b"init"
|
||||
assert _bytes(websocket) == b"media-0"
|
||||
assert _json(websocket)["type"] == "media_segment_complete"
|
||||
|
||||
assert _json(websocket)["type"] == "block_started"
|
||||
websocket.send_json({"type": "stop"})
|
||||
gates[1].set()
|
||||
applied = _json(websocket)
|
||||
assert applied["type"] == "control_applied"
|
||||
assert applied["revision"] == 1
|
||||
assert _json(websocket)["type"] == "block_encoding"
|
||||
assert _bytes(websocket) == b"media-1"
|
||||
assert _json(websocket)["type"] == "media_segment_complete"
|
||||
complete = _json(websocket)
|
||||
assert complete["type"] == "stream_complete"
|
||||
assert complete["blocks"] == 2
|
||||
assert complete["download_url"]
|
||||
response = client.get(complete["download_url"])
|
||||
assert response.status_code == 200
|
||||
assert response.content
|
||||
assert client.get("/healthz").json()["active"] is False
|
||||
|
||||
|
||||
def test_error_releases_lock_and_preserves_completed_prefix(tmp_path):
|
||||
gates = [threading.Event(), threading.Event()]
|
||||
gates[0].set()
|
||||
gates[1].set()
|
||||
session = _FakeSession(gates, fail_at=1)
|
||||
app = create_app(
|
||||
_FakeRuntime(session),
|
||||
output_dir=tmp_path,
|
||||
writer_factory=_FakeWriter,
|
||||
)
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/ws") as websocket:
|
||||
_prepare_and_start(websocket)
|
||||
assert _json(websocket)["type"] == "block_started"
|
||||
assert _json(websocket)["type"] == "block_encoding"
|
||||
assert _json(websocket)["type"] == "media_init"
|
||||
assert _bytes(websocket) == b"init"
|
||||
assert _bytes(websocket) == b"media-0"
|
||||
assert _json(websocket)["type"] == "media_segment_complete"
|
||||
assert _json(websocket)["type"] == "block_started"
|
||||
error = _json(websocket)
|
||||
assert error["type"] == "error"
|
||||
assert error["download_url"]
|
||||
assert client.get(error["download_url"]).content
|
||||
assert client.get("/healthz").json()["active"] is False
|
||||
+5
-30
@@ -186,40 +186,15 @@ RUN --mount=type=cache,target=/opt/uv/cache \
|
||||
|
||||
COPY . .
|
||||
|
||||
# Build immutable FastVideo kernel wheels for the published image. The requested
|
||||
# architecture remains installed for normal image users; amd64 images also carry
|
||||
# an SM89 artifact so the predominant L40S Modal lanes can reuse it exactly.
|
||||
ARG FASTVIDEO_KERNEL_PREBUILT_DIR=/opt/fastvideo-kernel-prebuilt
|
||||
# Install FastVideo Unified Kernel exactly once. build.sh initializes only its
|
||||
# CUTLASS/ThunderKittens submodules, then compiles for TORCH_CUDA_ARCH_LIST
|
||||
# (default Hopper sm_90a) without requiring a live GPU.
|
||||
RUN --mount=type=cache,target=/opt/uv/cache \
|
||||
source $HOME/.local/bin/env && \
|
||||
source /opt/venv/bin/activate && \
|
||||
default_arch="${TORCH_CUDA_ARCH_LIST}" && \
|
||||
default_wheel_dir="${FASTVIDEO_KERNEL_PREBUILT_DIR}/${default_arch}" && \
|
||||
export TORCH_CUDA_ARCH_LIST="${default_arch}" && \
|
||||
cd fastvideo-kernel && \
|
||||
CMAKE_ARGS= CMAKE_BUILD_PARALLEL_LEVEL=${CMAKE_BUILD_PARALLEL_LEVEL} \
|
||||
./build.sh --wheel-dir "${default_wheel_dir}" && \
|
||||
cd /FastVideo && \
|
||||
CMAKE_ARGS= python fastvideo/tests/modal/kernel_build_cache.py write-build-info \
|
||||
--wheel-dir "${default_wheel_dir}" \
|
||||
--output "${default_wheel_dir}/metadata.json" && \
|
||||
if [[ "${TARGETARCH:-amd64}" == "amd64" && "${default_arch}" != "8.9" ]]; then \
|
||||
export TORCH_CUDA_ARCH_LIST=8.9 && \
|
||||
l40s_wheel_dir="${FASTVIDEO_KERNEL_PREBUILT_DIR}/8.9" && \
|
||||
cd /FastVideo/fastvideo-kernel && \
|
||||
CMAKE_ARGS= CMAKE_BUILD_PARALLEL_LEVEL=${CMAKE_BUILD_PARALLEL_LEVEL} \
|
||||
./build.sh --wheel-dir "${l40s_wheel_dir}" && \
|
||||
cd /FastVideo && \
|
||||
CMAKE_ARGS= python fastvideo/tests/modal/kernel_build_cache.py write-build-info \
|
||||
--wheel-dir "${l40s_wheel_dir}" \
|
||||
--output "${l40s_wheel_dir}/metadata.json"; \
|
||||
fi && \
|
||||
export TORCH_CUDA_ARCH_LIST="${default_arch}" && \
|
||||
default_wheel="$(find "${default_wheel_dir}" -maxdepth 1 -type f \
|
||||
\( -name 'fastvideo_kernel-*.whl' -o -name 'fastvideo-kernel-*.whl' \) \
|
||||
| sort | tail -n 1)" && \
|
||||
uv pip install "${default_wheel}" \
|
||||
--reinstall-package fastvideo-kernel --no-deps
|
||||
CMAKE_BUILD_PARALLEL_LEVEL=${CMAKE_BUILD_PARALLEL_LEVEL} \
|
||||
TORCH_CUDA_ARCH_LIST=${TORCH_CUDA_ARCH_LIST} ./build.sh
|
||||
|
||||
# Install FastVideo itself (editable) now that the source is present, and set up
|
||||
# shell configuration. Dependencies and the local kernel are already installed,
|
||||
|
||||
@@ -269,26 +269,16 @@ The docs job:
|
||||
### Docker Images
|
||||
|
||||
`.github/workflows/infra-build-image.yml` supports manual `workflow_dispatch`
|
||||
runs and automatically rebuilds the CUDA matrix when a repository-controlled
|
||||
image input changes on `main` in the canonical repository. Those inputs include
|
||||
the CUDA Dockerfile and reusable workflow, dependency metadata, Docker context
|
||||
policy, `fastvideo-kernel/**`, and the kernel artifact metadata/key helper.
|
||||
Manual runs let maintainers choose which image families to build. The
|
||||
`fastvideo-dev` matrix builds Python 3.12 images for CUDA 12.6 and CUDA 13 on
|
||||
native `amd64` and `arm64` runners, then publishes one multi-platform manifest
|
||||
per CUDA version. CUDA 12.6 owns the `py3.12-latest` and global `latest` tags, as
|
||||
well as the explicit `py3.12-cuda12.6.3-latest` alias. CUDA 13 is published under
|
||||
the explicit `py3.12-cuda13.0.0-latest` tag. This publication policy does not
|
||||
change the unparameterized `docker/Dockerfile` build defaults, which remain CUDA
|
||||
13 and `cu130`.
|
||||
|
||||
Published amd64 development images keep their configured Hopper kernel wheel
|
||||
installed and also carry an immutable SM89 wheel under
|
||||
`/opt/fastvideo-kernel-prebuilt`. Modal PR and SSIM jobs select the exact
|
||||
source, ABI, and GPU-architecture match from that directory, so L40S jobs reuse
|
||||
the trusted image artifact while kernel-changing PRs still build locally. Once
|
||||
a kernel or artifact-key change reaches `main`, the image workflow republishes
|
||||
the matching trusted artifact before later jobs consume the updated image tag.
|
||||
runs and automatically rebuilds the CUDA matrix when `docker/Dockerfile`
|
||||
changes on `main` in the canonical repository. Manual runs let maintainers
|
||||
choose which image families to build. The `fastvideo-dev` matrix builds Python
|
||||
3.12 images for CUDA 12.6 and CUDA 13 on native `amd64` and `arm64` runners,
|
||||
then publishes one multi-platform manifest per CUDA version. CUDA 12.6 owns the
|
||||
`py3.12-latest` and global `latest` tags, as well as the explicit
|
||||
`py3.12-cuda12.6.3-latest` alias. CUDA 13 is published under the explicit
|
||||
`py3.12-cuda13.0.0-latest` tag. This publication policy does not change the
|
||||
unparameterized `docker/Dockerfile` build defaults, which remain CUDA 13 and
|
||||
`cu130`.
|
||||
|
||||
The optional Dreamverse matrix builds backend and UI images for CUDA 12.6 and
|
||||
CUDA 13 on `amd64`. Dreamverse remains `amd64`-only because its FA4 dependency
|
||||
|
||||
@@ -134,4 +134,4 @@ If you're planning to contribute to FastVideo please see the following page:
|
||||
|
||||
If you encounter any issues during installation, please open an issue on our [GitHub repository](https://github.com/hao-ai-lab/FastVideo).
|
||||
|
||||
You can also join our [Slack community](https://join.slack.com/t/fastvideo/shared_invite/zt-3f4lao1uq-u~Ipx6Lt4J27AlD2y~IdLQ) for additional support.
|
||||
You can also join our [Slack community](https://join.slack.com/t/fastvideo/shared_invite/zt-38u6p1jqe-yDI1QJOCEnbtkLoaI5bjZQ) for additional support.
|
||||
|
||||
@@ -100,4 +100,4 @@ If you're planning to contribute to FastVideo please see the following page:
|
||||
|
||||
If you encounter any issues during installation, please open an issue on our [GitHub repository](https://github.com/hao-ai-lab/FastVideo).
|
||||
|
||||
You can also join our [Slack community](https://join.slack.com/t/fastvideo/shared_invite/zt-3f4lao1uq-u~Ipx6Lt4J27AlD2y~IdLQ) for additional support.
|
||||
You can also join our [Slack community](https://join.slack.com/t/fastvideo/shared_invite/zt-38u6p1jqe-yDI1QJOCEnbtkLoaI5bjZQ) for additional support.
|
||||
|
||||
@@ -134,7 +134,7 @@ uv pip install "https://github.com/mjun0812/flash-attention-prebuild-wheels/rele
|
||||
|
||||
If you hit other issues, please open an issue on our
|
||||
[GitHub repository](https://github.com/hao-ai-lab/FastVideo). You can also join
|
||||
our [Slack community](https://join.slack.com/t/fastvideo/shared_invite/zt-3f4lao1uq-u~Ipx6Lt4J27AlD2y~IdLQ)
|
||||
our [Slack community](https://join.slack.com/t/fastvideo/shared_invite/zt-38u6p1jqe-yDI1QJOCEnbtkLoaI5bjZQ)
|
||||
for additional support.
|
||||
|
||||
## Development Environment Setup
|
||||
|
||||
@@ -11,7 +11,7 @@ This guide explains how to implement a custom diffusion pipeline in FastVideo, l
|
||||
4. **Register Your Pipeline** - Make it discoverable by the framework
|
||||
5. **Configure Your Pipeline** - (Coming soon)
|
||||
|
||||
Need help? Join our [Slack community](https://join.slack.com/t/fastvideo/shared_invite/zt-3f4lao1uq-u~Ipx6Lt4J27AlD2y~IdLQ).
|
||||
Need help? Join our [Slack community](https://join.slack.com/t/fastvideo/shared_invite/zt-38u6p1jqe-yDI1QJOCEnbtkLoaI5bjZQ).
|
||||
|
||||
## Step 1: Pipeline Modules
|
||||
|
||||
|
||||
@@ -134,4 +134,5 @@ If the generated video doesn't match your prompt:
|
||||
- Learn about [Advanced Inference Configurations](configuration.md)
|
||||
- Learn about using [Optimizations](optimizations.md)
|
||||
- See [Examples](examples/examples_inference_index.md) for more usage scenarios
|
||||
- Join our [Community Slack](https://join.slack.com/t/fastvideo/shared_invite/zt-3f4lao1uq-u~Ipx6Lt4J27AlD2y~IdLQ).
|
||||
- Join our [Community Discord](https://discord.gg/JA7cksDz86).
|
||||
- Join our [Community Slack](https://join.slack.com/t/fastvideo/shared_invite/zt-38u6p1jqe-yDI1QJOCEnbtkLoaI5bjZQ).
|
||||
|
||||
@@ -119,22 +119,6 @@ pip install "nvidia-cutlass-dsl>=4.5.2" apache-tvm-ffi flashinfer-python
|
||||
The `--no-deps` flag prevents upgrading torch/torchvision. Use the supported
|
||||
PyTorch 2.12.0 and CUDA 13 environment for this kernel.
|
||||
|
||||
Branch-to-`nvidia-cutlass-dsl` compatibility (the fork tracks the CuTe DSL API
|
||||
surface closely):
|
||||
|
||||
| fork branch | cutlass-dsl | notes |
|
||||
|---|---|---|
|
||||
| `fp4` | `==4.4.2` (+ `nvidia-cutlass-dsl-libs-base==4.4.2`) | validated set on GB200: `quack-kernels==0.4.1`, `flashinfer-python==0.6.8`, `CUTE_DSL_ENABLE_TVM_FFI=1`, `FASTVIDEO_FA4=1` |
|
||||
| `fix/cutlass-dsl-4.5` | `>=4.5.2` | carries the `cute.core.ThrMma` -> `cute.ThrMma` fix |
|
||||
| any | 4.6-era | unsupported: `cute.make_fragment` was removed at module level; fails at CuTe JIT trace |
|
||||
|
||||
`FASTVIDEO_FA4=1` is required alongside the fork: it ships no compiled
|
||||
FlashAttention-2, so dense attention paths raise ImportError without the FA4
|
||||
opt-in. The same kernel also serves `ATTN_QAT_INFER` on sm_100a/sm_103a
|
||||
(datacenter Blackwell) — the selection log's receipt line
|
||||
(`ATTN_QAT_INFER resolved: ...`) records the arch, kernel, and quantization
|
||||
mode that actually bound.
|
||||
|
||||
#### Usage
|
||||
|
||||
Enable FP4 attention via the `--nvfp4_fa4` flag:
|
||||
|
||||
@@ -13,92 +13,6 @@ For the canonical, code-level list of model IDs recognized by
|
||||
We do this because we believe VSA is strictly better than STA for the
|
||||
actively maintained `main` inference path.
|
||||
|
||||
## Registered Model IDs
|
||||
|
||||
Every Hugging Face model ID registered in `fastvideo/registry.py` on `main`
|
||||
(commit `8d89f30d`), grouped by family. Any ID below can
|
||||
be passed to `VideoGenerator.from_pretrained(...)`; FastVideo resolves the
|
||||
matching pipeline and sampling defaults. The **Family** column is a
|
||||
documentation grouping: it follows each registration's declared `model_family`,
|
||||
except `black-forest-labs/FLUX.1-dev`, which declares none and is listed under
|
||||
`flux` for readability. The **Workloads** column shows each
|
||||
registration's declared `workload_types`; `—` means the entry is registered
|
||||
without a UI workload option but is still loadable by ID. The **Example**
|
||||
column links a runnable script in `examples/inference/basic/` where one exists.
|
||||
|
||||
| Family | HuggingFace Model ID | Workloads | Example |
|
||||
|--------|----------------------|-----------|---------|
|
||||
| cosmos | `nvidia/Cosmos-Predict2-2B-Video2World` | T2V | — |
|
||||
| cosmos25 | `KyleShao/Cosmos-Predict2.5-2B-Diffusers` | T2V | [basic_cosmos2_5_t2w.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_cosmos2_5_t2w.py) |
|
||||
| cosmos25 | `nvidia/Cosmos-Predict2.5-14B` | T2V | [basic_cosmos2_5_t2w.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_cosmos2_5_t2w.py) |
|
||||
| dreamx_world | `FastVideo/DreamX-World-5B-Cam-Diffusers` | I2V | [basic_dreamx_world.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_dreamx_world.py) |
|
||||
| dreamx_world | `FastVideo/DreamX-World-5B-Diffusers` | I2V | [basic_dreamx_world.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_dreamx_world.py) |
|
||||
| flux | `black-forest-labs/FLUX.1-dev` | T2I | [basic_flux_dev.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_flux_dev.py) |
|
||||
| flux2 | `black-forest-labs/FLUX.2-klein-4B`<br>`black-forest-labs/FLUX.2-klein-9B` | T2I | [basic_flux2_klein.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_flux2_klein.py) |
|
||||
| flux2 | `black-forest-labs/FLUX.2-dev` | T2I | [basic_flux2.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_flux2.py) |
|
||||
| gamecraft | `FastVideo/HunyuanGameCraft-Diffusers` | I2V | [basic_gamecraft.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_gamecraft.py) |
|
||||
| gen3c | `FastVideo/GEN3C-Cosmos-7B-Diffusers` | T2V | [basic_gen3c.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_gen3c.py) |
|
||||
| glm_image | `zai-org/GLM-Image` | T2I | [basic_glm_image.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_glm_image.py) |
|
||||
| hunyuan | `hunyuanvideo-community/HunyuanVideo` | T2V | — |
|
||||
| hunyuan | `FastVideo/FastHunyuan-diffusers` | T2V | — |
|
||||
| hunyuan15 | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v` | T2V | [basic_hy15.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_hy15.py) |
|
||||
| hunyuan15 | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_i2v_step_distilled` | I2V | [basic_hy15.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_hy15.py) |
|
||||
| hunyuan15 | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_t2v` | T2V | [basic_hy15.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_hy15.py) |
|
||||
| hunyuan15 | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_i2v_distilled` | I2V | [basic_hy15.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_hy15.py) |
|
||||
| hunyuan15 | `weizhou03/HunyuanVideo-1.5-Diffusers-1080p`<br>`weizhou03/HunyuanVideo-1.5-Diffusers-1080p-2SR` | — | [basic_hy15_1080p.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_hy15_1080p.py) |
|
||||
| hyworld | `FastVideo/HY-WorldPlay-Bidirectional-Diffusers` | — | [basic_hyworld.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_hyworld.py) |
|
||||
| kandinsky5 | `kandinskylab/Kandinsky-5.0-T2V-Lite-sft-5s-Diffusers` | T2V | [basic_kandinsky5_t2v.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_kandinsky5_t2v.py) |
|
||||
| kandinsky5 | `kandinskylab/Kandinsky-5.0-T2V-Pro-sft-5s-Diffusers` | T2V | [basic_kandinsky5_t2v.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_kandinsky5_t2v.py) |
|
||||
| kandinsky5 | `kandinskylab/Kandinsky-5.0-T2V-Lite-distilled16steps-5s-Diffusers` | T2V | [basic_kandinsky5_t2v.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_kandinsky5_t2v.py) |
|
||||
| kandinsky5 | `kandinskylab/Kandinsky-5.0-T2V-Pro-distilled-5s-Diffusers` | T2V | [basic_kandinsky5_t2v.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_kandinsky5_t2v.py) |
|
||||
| kandinsky5 | `kandinskylab/Kandinsky-5.0-I2V-Lite-5s-Diffusers` | I2V | [basic_kandinsky5_i2v.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_kandinsky5_i2v.py) |
|
||||
| kandinsky5 | `kandinskylab/Kandinsky-5.0-I2V-Pro-sft-5s-Diffusers` | I2V | [basic_kandinsky5_i2v.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_kandinsky5_i2v.py) |
|
||||
| kandinsky5 | `kandinskylab/Kandinsky-5.0-I2V-Pro-distilled-5s-Diffusers` | I2V | [basic_kandinsky5_i2v.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_kandinsky5_i2v.py) |
|
||||
| lingbot_video | `FastVideo/LingBot-Video-MoE-30B-A3B-Diffusers` | T2V | [basic_lingbot_video.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_lingbot_video.py) |
|
||||
| lingbot_video | `FastVideo/LingBot-Video-Dense-1.3B-Diffusers` | T2V | [basic_lingbot_video.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_lingbot_video.py) |
|
||||
| lingbotworld | `FastVideo/LingBot-World-Base-Cam-Diffusers` | I2V | [basic_lingbotworld_base_cam.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_lingbotworld_base_cam.py) |
|
||||
| lingbotworld2 | `robbyant/lingbot-world-v2-14b-causal-fast` | I2V | [basic_lingbotworld2_causal_fast.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_lingbotworld2_causal_fast.py) |
|
||||
| longcat | `FastVideo/LongCat-Video-T2V-Diffusers` | T2V | [basic_longcat_t2v.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_longcat_t2v.py) |
|
||||
| longcat | `FastVideo/LongCat-Video-I2V-Diffusers` | I2V | [basic_longcat_i2v.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_longcat_i2v.py) |
|
||||
| longcat | `FastVideo/LongCat-Video-VC-Diffusers` | — | [basic_longcat_vc.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_longcat_vc.py) |
|
||||
| ltx2 | `FastVideo/LTX2-Distilled-Diffusers`<br>`FastVideo/LTX2.3-Distilled-Diffusers`<br>`FastVideo/LTX-2.3-Distilled-Diffusers` | T2V | [basic_ltx2_distilled.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_ltx2_distilled.py) |
|
||||
| ltx2 | `Lightricks/LTX-2.3`<br>`FastVideo/LTX2.3-base`<br>`FastVideo/LTX2.3-Diffusers` | T2V | [basic_ltx2.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_ltx2.py) |
|
||||
| ltx2 | `Lightricks/LTX-2`<br>`FastVideo/LTX2-base`<br>`FastVideo/LTX2-Diffusers` | T2V | [basic_ltx2.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_ltx2.py) |
|
||||
| matrixgame | `FastVideo/Matrix-Game-2.0-Base-Distilled-Diffusers`<br>`FastVideo/Matrix-Game-2.0-GTA-Distilled-Diffusers`<br>`FastVideo/Matrix-Game-2.0-TempleRun-Distilled-Diffusers`<br>`FastVideo/Matrix-Game-2.0-Base-Diffusers`<br>`FastVideo/Matrix-Game-2.0-GTA-Diffusers`<br>`FastVideo/Matrix-Game-2.0-TempleRun-Diffusers`<br>`mignonjia/mg_longtuning_distilled_zelda`<br>`mignonjia/mg_sf_distilled_zelda_1k_steps`<br>`mignonjia/mg_sf_distilled_zelda`<br>`mignonjia/mg_causal_zelda`<br>`mignonjia/mg_bidirectional_zelda` | I2V | [basic_matrixgame2.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_matrixgame2.py) |
|
||||
| matrixgame | `FastVideo/Matrix-Game-3.0-Base-Distilled-Diffusers` | I2V | [basic_matrixgame3.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_matrixgame3.py) |
|
||||
| sd35 | `stabilityai/stable-diffusion-3.5-medium` | T2I | [basic_sd35_t2i.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_sd35_t2i.py) |
|
||||
| stable_audio | `FastVideo/stable-audio-open-1.0-Diffusers` | T2V | [basic_stable_audio.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_stable_audio.py) |
|
||||
| stable_audio | `FastVideo/stable-audio-open-small-Diffusers` | T2V | [basic_stable_audio_small.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_stable_audio_small.py) |
|
||||
| turbodiffusion | `loayrashid/TurboWan2.1-T2V-1.3B-Diffusers` | T2V | [basic_turbodiffusion.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_turbodiffusion.py) |
|
||||
| turbodiffusion | `loayrashid/TurboWan2.1-T2V-14B-Diffusers` | T2V | [basic_turbodiffusion_14b.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_turbodiffusion_14b.py) |
|
||||
| turbodiffusion | `loayrashid/TurboWan2.2-I2V-A14B-Diffusers` | I2V | [basic_turbodiffusion_i2v.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_turbodiffusion_i2v.py) |
|
||||
| wan | `Wan-AI/Wan2.1-T2V-1.3B-Diffusers` | T2V | [basic.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic.py) |
|
||||
| wan | `Wan-AI/Wan2.1-T2V-14B-Diffusers`<br>`FastVideo/Wan2.1-VSA-T2V-14B-720P-Diffusers` | T2V | — |
|
||||
| wan | `Wan-AI/Wan2.1-I2V-14B-480P-Diffusers` | I2V | — |
|
||||
| wan | `Wan-AI/Wan2.1-I2V-14B-720P-Diffusers` | I2V | — |
|
||||
| wan | `weizhou03/Wan2.1-Fun-1.3B-InP-Diffusers` | I2V | — |
|
||||
| wan | `IRMChen/Wan2.1-Fun-1.3B-Control-Diffusers` | — | [basic_wan2_2_Fun.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_wan2_2_Fun.py) |
|
||||
| wan | `FastVideo/FastWan2.1-T2V-1.3B-Diffusers`<br>`FastVideo/FastWan2.1-T2V-14B-480P-Diffusers` | T2V | [basic_dmd.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_dmd.py) |
|
||||
| wan | `Wan-AI/Wan2.2-TI2V-5B-Diffusers` | T2V, I2V | [basic_wan2_2_ti2v.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_wan2_2_ti2v.py) |
|
||||
| wan | `FastVideo/FastWan2.2-TI2V-5B-FullAttn-Diffusers`<br>`FastVideo/FastWan2.2-TI2V-5B-Diffusers` | T2V, I2V | [basic_dmd.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_dmd.py) |
|
||||
| wan | `decart-ai/Lucy-Edit-Dev`<br>`decart-ai/Lucy-Edit-1.1-Dev` | — | [basic_lucy_edit.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_lucy_edit.py) |
|
||||
| wan | `Wan-AI/Wan2.2-T2V-A14B-Diffusers` | T2V | [basic_wan2_2.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_wan2_2.py) |
|
||||
| wan | `Wan-AI/Wan2.2-I2V-A14B-Diffusers` | I2V | [basic_wan2_2_i2v.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_wan2_2_i2v.py) |
|
||||
| wan | `wlsaidhi/SFWan2.1-T2V-1.3B-Diffusers` | T2V | [basic_self_forcing_causal.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_self_forcing_causal.py) |
|
||||
| wan | `rand0nmr/SFWan2.2-T2V-A14B-Diffusers` | T2V | [basic_self_forcing_causal_wan2_2_t2v.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_self_forcing_causal_wan2_2_t2v.py) |
|
||||
| wan | `FastVideo/SFWan2.2-I2V-A14B-Preview-Diffusers` | I2V | [basic_self_forcing_causal_wan2_2_i2v.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_self_forcing_causal_wan2_2_i2v.py) |
|
||||
| zimage | `Tongyi-MAI/Z-Image-Turbo` | T2I | [basic_zimage.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_zimage.py) |
|
||||
|
||||
**Note (stable_audio)**: the Stable Audio Open pipelines generate audio
|
||||
(`StableAudioT2AConfig` / `StableAudioOpenSmallConfig`); they are registered
|
||||
under the generic T2V workload option in the registry.
|
||||
|
||||
**Note (Wan-VACE)**: not currently supported — no VACE pipeline or registered
|
||||
model ID exists on `main`
|
||||
([#1435](https://github.com/hao-ai-lab/FastVideo/issues/1435)). The closest
|
||||
supported path is the Wan2.1-Fun control pipeline
|
||||
(`IRMChen/Wan2.1-Fun-1.3B-Control-Diffusers`).
|
||||
|
||||
The symbols used have the following meanings:
|
||||
|
||||
- ✅ = Full compatibility
|
||||
@@ -111,9 +25,6 @@ The `HuggingFace Model ID` can be passed directly to
|
||||
`from_pretrained()`. FastVideo then uses model-specific default settings for
|
||||
pipeline initialization and sampling.
|
||||
|
||||
Registered models absent from this table have not been validated against these
|
||||
optimizations: absence means **untested**, not incompatible.
|
||||
|
||||
<style>
|
||||
/* Target tables in this section */
|
||||
#models-x-optimization + p + table {
|
||||
@@ -145,7 +56,7 @@ optimizations: absence means **untested**, not incompatible.
|
||||
| Model Name | HuggingFace Model ID | Resolutions | TeaCache | Sliding Tile Attn (Legacy Branch) | Sage Attn | VSA | BSA |
|
||||
|------------|---------------------|-------------|----------|-------------------|-----------|-----|-----|
|
||||
| FastWan2.1 T2V 1.3B | `FastVideo/FastWan2.1-T2V-1.3B-Diffusers` | 480P | ⭕ | ⭕ | ⭕ | ✅ | ⭕ |
|
||||
| FastWan2.2 TI2V 5B Full Attn | `FastVideo/FastWan2.2-TI2V-5B-FullAttn-Diffusers` | 720P | ⭕ | ⭕ | ⭕ | ✅ | ⭕ |
|
||||
| FastWan2.2 TI2V 5B Full Attn* | `FastVideo/FastWan2.2-TI2V-5B-FullAttn-Diffusers` | 720P | ⭕ | ⭕ | ⭕ | ✅ | ⭕ |
|
||||
| Wan2.2 TI2V 5B | `Wan-AI/Wan2.2-TI2V-5B-Diffusers` | 720P | ⭕ | ⭕ | ✅ | ⭕ | ⭕ |
|
||||
| DreamX-World 5B Cam | `FastVideo/DreamX-World-5B-Cam-Diffusers` | 480P | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ |
|
||||
| DreamX-World 5B AR | `FastVideo/DreamX-World-5B-Diffusers` | 704px1280p | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ |
|
||||
@@ -154,14 +65,14 @@ optimizations: absence means **untested**, not incompatible.
|
||||
| Wan2.2 I2V A14B | `Wan-AI/Wan2.2-I2V-A14B-Diffusers` | 480P<br>720P | ❌ | ❌ | ✅ | ⭕ | ⭕ |
|
||||
| HunyuanVideo | `hunyuanvideo-community/HunyuanVideo` | 720px1280p<br>544px960p | ❌ | ✅ | ✅ | ⭕ | ⭕ |
|
||||
| FastHunyuan | `FastVideo/FastHunyuan-diffusers` | 720px1280p<br>544px960p | ❌ | ✅ | ✅ | ⭕ | ⭕ |
|
||||
| Wan2.1 T2V 1.3B | `Wan-AI/Wan2.1-T2V-1.3B-Diffusers` | 480P | ✅ | ✅ | ✅ | ⭕ | ⭕ |
|
||||
| Wan2.1 T2V 14B | `Wan-AI/Wan2.1-T2V-14B-Diffusers` | 480P, 720P | ✅ | ✅ | ✅ | ⭕ | ⭕ |
|
||||
| Wan2.1 I2V 480P | `Wan-AI/Wan2.1-I2V-14B-480P-Diffusers` | 480P | ✅ | ✅ | ✅ | ⭕ | ⭕ |
|
||||
| Wan2.1 T2V 1.3B | `Wan-AI/Wan2.1-T2V-1.3B-Diffusers` | 480P | ✅ | ✅* | ✅ | ⭕ | ⭕ |
|
||||
| Wan2.1 T2V 14B | `Wan-AI/Wan2.1-T2V-14B-Diffusers` | 480P, 720P | ✅ | ✅* | ✅ | ⭕ | ⭕ |
|
||||
| Wan2.1 I2V 480P | `Wan-AI/Wan2.1-I2V-14B-480P-Diffusers` | 480P | ✅ | ✅* | ✅ | ⭕ | ⭕ |
|
||||
| Wan2.1 I2V 720P | `Wan-AI/Wan2.1-I2V-14B-720P-Diffusers` | 720P | ✅ | ✅ | ✅ | ⭕ | ⭕ |
|
||||
| TurboWan2.1 T2V 1.3B | `loayrashid/TurboWan2.1-T2V-1.3B-Diffusers` | 480P | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ |
|
||||
| TurboWan2.1 T2V 14B | `loayrashid/TurboWan2.1-T2V-14B-Diffusers` | 480P, 720P | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ |
|
||||
| TurboWan2.2 I2V A14B | `loayrashid/TurboWan2.2-I2V-A14B-Diffusers` | 480P<br>720P | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ |
|
||||
| LongCat T2V 13.6B | `FastVideo/LongCat-Video-T2V-Diffusers` | 480P<br>720P | ❌ | ❌ | ❌ | ⭕ | ✅ |
|
||||
| LongCat T2V 13.6B | See note** | 480P<br>720P | ❌ | ❌ | ❌ | ⭕ | ✅ |
|
||||
| Matrix Game 2.0 Base Distilled | `FastVideo/Matrix-Game-2.0-Base-Distilled-Diffusers` | 352x640 | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ |
|
||||
| Matrix Game 2.0 GTA Distilled | `FastVideo/Matrix-Game-2.0-GTA-Distilled-Diffusers` | 352x640 | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ |
|
||||
| Matrix Game 2.0 TempleRun Distilled | `FastVideo/Matrix-Game-2.0-TempleRun-Distilled-Diffusers` | 352x640 | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ |
|
||||
@@ -187,21 +98,6 @@ resolve default pipeline and sampling configuration for it.
|
||||
`FastVideo/GEN3C-Cosmos-7B-Diffusers`) or convert locally with
|
||||
`scripts/checkpoint_conversion/convert_gen3c_to_fastvideo.py`.
|
||||
|
||||
## Hardware and OS
|
||||
|
||||
Per the installation guides:
|
||||
|
||||
- **NVIDIA GPU (x86_64)** — CUDA 12.6 or 13.0; see the
|
||||
[GPU install guide](../getting_started/installation/gpu.md).
|
||||
- **NVIDIA DGX Spark (GB10, aarch64)** — CUDA 13, from-source kernel build; see
|
||||
the [DGX Spark install guide](../getting_started/installation/spark.md).
|
||||
- **Apple silicon (MPS)** — macOS 14 or newer; see the
|
||||
[MPS install guide](../getting_started/installation/mps.md) and
|
||||
[`basic_mps.py`](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_mps.py).
|
||||
|
||||
Optimization-specific hardware constraints (e.g. STA requiring Hopper) are
|
||||
listed under [Special requirements](#special-requirements).
|
||||
|
||||
## Special requirements
|
||||
|
||||
### Sliding Tile Attention
|
||||
|
||||
@@ -212,29 +212,6 @@ pipeline:
|
||||
flow_shift: 8
|
||||
```
|
||||
|
||||
Registered transformer linear-quantization configs can also be selected by
|
||||
name. For example, the LTX-2 NVFP4-QAT recipe applies real FP4 forward GEMMs
|
||||
with a straight-through-estimator backward to its deployment-targeted
|
||||
attention/FFN projections:
|
||||
|
||||
```yaml
|
||||
pipeline:
|
||||
dit_config:
|
||||
quant_config: nvfp4_qat_train
|
||||
```
|
||||
|
||||
The LTX-2 recipe in
|
||||
`examples/train/configs/overfit_ltx2_t2v_nvfp4_qat.yaml` combines that linear
|
||||
configuration with `models.student.attention_backend: ATTN_QAT_TRAIN` for
|
||||
video-attention forward/backward. On sm120, its validation callback temporarily
|
||||
switches those layers to `ATTN_QAT_INFER`.
|
||||
On GB200, set `callbacks.validation.attn_qat_infer: false` to keep validation on
|
||||
the train-time QAT backend; the inference kernel is sm120-only.
|
||||
|
||||
User-adaptable LTX-2 fine-tuning recipes (full, LoRA, and NVFP4 QAT) live in
|
||||
`examples/train/configs/fine_tuning/ltx2/`, alongside the other model
|
||||
families under `examples/train/configs/fine_tuning/`.
|
||||
|
||||
---
|
||||
|
||||
## Training Methods
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
# WanTrack training
|
||||
|
||||
WanTrack extends Wan I2V with sparse point tracks. The same model wrapper and
|
||||
conditioning path are used for preprocessing, training, validation, and
|
||||
standalone inference. Both bidirectional and causal checkpoints are supported.
|
||||
|
||||
## Build an initialization checkpoint
|
||||
|
||||
WanTrack uses the pretrained control slot from Wan2.1-Fun Control as its track
|
||||
slot. Convert a VideoX-Fun control checkpoint together with a diffusers-format
|
||||
Wan2.1-Fun InP base:
|
||||
|
||||
```bash
|
||||
python scripts/checkpoint_conversion/wan_fun_control_to_trackwan.py \
|
||||
--inp-base models/Wan2.1-Fun-1.3B-InP-Diffusers \
|
||||
--control-ckpt models/Wan2.1-Fun-1.3B-Control/diffusion_pytorch_model.safetensors \
|
||||
--out models/wantrack-control-init
|
||||
```
|
||||
|
||||
For the causal transformer, add `--causal` and use a separate output directory:
|
||||
|
||||
```bash
|
||||
python scripts/checkpoint_conversion/wan_fun_control_to_trackwan.py \
|
||||
--inp-base models/Wan2.1-Fun-1.3B-InP-Diffusers \
|
||||
--control-ckpt models/Wan2.1-Fun-1.3B-Control/diffusion_pytorch_model.safetensors \
|
||||
--out models/wantrack-control-causal-init \
|
||||
--causal
|
||||
```
|
||||
|
||||
`--causal` selects `CausalTrackWanTransformer3DModel` in the generated
|
||||
transformer config. Without it, the converter selects
|
||||
`TrackWanTransformer3DModel`.
|
||||
|
||||
The converted patch input has 52 channels:
|
||||
|
||||
| Channels | Meaning |
|
||||
|----------|---------|
|
||||
| `0:16` | Noisy video latent |
|
||||
| `16:20` | I2V mask |
|
||||
| `20:36` | First-frame latent |
|
||||
| `36:52` | Track map |
|
||||
|
||||
The first three entries form the usual 36-channel Wan I2V input. A
|
||||
`TrackEncoder` rasterizes sparse points on the VAE grid and appends the
|
||||
16-channel track map.
|
||||
|
||||
## Prepare point-track data
|
||||
|
||||
Each video needs an `.npz` sidecar referenced by `points_path` in its metadata.
|
||||
The archive must contain:
|
||||
|
||||
- `tracks`: floating-point source-pixel coordinates with shape `[T, N, 2]`.
|
||||
The final dimension is `(x, y)`.
|
||||
- `visibility`: a boolean or numeric visibility mask with shape `[T, N]`.
|
||||
|
||||
`T` is the source-video timeline and `N` is the number of point tracks. The
|
||||
preprocessor applies the same temporal sample and center-crop/resize transform
|
||||
to the sidecar as it applies to the video. Coordinates should therefore be in
|
||||
the original video's pixel space, not normalized or pre-cropped. Points outside
|
||||
the retained crop are marked invisible.
|
||||
|
||||
For example, a merged-dataset annotation can contain:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"path": "videos/clip_0001.mp4",
|
||||
"points_path": "tracks/clip_0001.npz",
|
||||
"cap": ["A cyclist follows a winding road."],
|
||||
"resolution": {"width": 1920, "height": 1080},
|
||||
"fps": 24.0,
|
||||
"duration": 5.0,
|
||||
"num_frames": 120
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
The paths are relative to the dataset root named in the merge file:
|
||||
|
||||
```text
|
||||
data/wantrack/raw,data/wantrack/metadata.json
|
||||
```
|
||||
|
||||
All examples combined into one training batch must have a stackable point
|
||||
dimension. Keeping `N` fixed across the dataset is the simplest option.
|
||||
|
||||
Run the I2V-track preprocessor with matching pixel and latent lengths. Wan's
|
||||
temporal compression is four, so 81 pixel frames produce 21 latent frames:
|
||||
|
||||
```bash
|
||||
torchrun --nproc_per_node=1 fastvideo/pipelines/preprocess/v1_preprocess.py \
|
||||
--model_path models/wantrack-control-init \
|
||||
--data_merge_path data/wantrack/data_merge.txt \
|
||||
--output_dir data/wantrack/preprocessed \
|
||||
--preprocess_task i2v_track \
|
||||
--num_frames 81 \
|
||||
--num_latent_t 21 \
|
||||
--max_height 480 \
|
||||
--max_width 832 \
|
||||
--train_fps 16 \
|
||||
--preprocess_video_batch_size 1
|
||||
```
|
||||
|
||||
The trainer reads the resulting
|
||||
`data/wantrack/preprocessed/combined_parquet_dataset` directory with
|
||||
`preprocessed_data_type: i2v_track`.
|
||||
|
||||
## Train the bidirectional model
|
||||
|
||||
The bidirectional training wrapper is
|
||||
`fastvideo.train.models.wantrack.WanTrackModel`; it loads
|
||||
`TrackWanTransformer3DModel`.
|
||||
|
||||
```bash
|
||||
torchrun --nproc_per_node=8 -m fastvideo.train.entrypoint.train \
|
||||
--config examples/train/configs/fine_tuning/wantrack/bidirectional_i2v.yaml
|
||||
```
|
||||
|
||||
The example optionally subsamples points and applies temporal track masking.
|
||||
Track IDs are sampled once for a training sample and then reused by every
|
||||
denoising call and its conditional/unconditional branches. Re-sampling IDs per
|
||||
call would change the point embeddings even when the coordinates are
|
||||
unchanged.
|
||||
|
||||
## Train the causal model
|
||||
|
||||
The causal wrapper is
|
||||
`fastvideo.train.models.wantrack.WanTrackCausalModel`; it loads
|
||||
`CausalTrackWanTransformer3DModel`. The example uses
|
||||
`TeacherForcingSFTMethod`, so clean history and the noisy current chunk receive
|
||||
the same I2V and track conditioning:
|
||||
|
||||
```bash
|
||||
torchrun --nproc_per_node=8 -m fastvideo.train.entrypoint.train \
|
||||
--config examples/train/configs/fine_tuning/wantrack/causal_i2v.yaml
|
||||
```
|
||||
|
||||
Track encoding is causal at the VAE temporal boundary. The implementation
|
||||
left-pads the complete source track sequence at its global beginning, encodes
|
||||
it, and then slices the latent track map at the current latent `start_frame`.
|
||||
It must not independently pad and encode each later chunk: doing so would
|
||||
reset temporal context and produce a different feature for the same point
|
||||
history.
|
||||
|
||||
The same stable `track_ids` are reused across all chunks, denoising steps, and
|
||||
CFG branches. Causal sampling follows the RobotWM streaming contract:
|
||||
`predict_noise_streaming()` owns the KV caches, each denoised block is committed
|
||||
once as clean context, and caches are cleared at the sample boundary.
|
||||
|
||||
## Validation and inference
|
||||
|
||||
Both example configs use
|
||||
`fastvideo.train.callbacks.track_validation.TrackValidationCallback`. It loads
|
||||
fixed samples from the preprocessed WanTrack parquet, builds conditions through
|
||||
the student's normal `prepare_batch()` path, generates videos, overlays the
|
||||
active tracks, and logs them to the configured tracker. Set
|
||||
`callbacks.track_validation.val_data_path` to use a dedicated validation
|
||||
parquet; otherwise it samples from the training parquet.
|
||||
|
||||
The callback and standalone callers share two helpers:
|
||||
|
||||
```python
|
||||
from fastvideo.train.models.wantrack.inference import (
|
||||
prepare_wantrack_batch,
|
||||
sample_wantrack,
|
||||
)
|
||||
|
||||
batch = prepare_wantrack_batch(
|
||||
model,
|
||||
raw_batch,
|
||||
seed=1000,
|
||||
latents_source="zeros",
|
||||
)
|
||||
latents = sample_wantrack(
|
||||
model,
|
||||
batch,
|
||||
num_inference_steps=30,
|
||||
seed=1000,
|
||||
text_guidance_scale=3.0,
|
||||
motion_guidance_scale=1.5,
|
||||
)
|
||||
video = model.decode_latents(latents)
|
||||
```
|
||||
|
||||
`sample_wantrack()` denoises the complete clip for `WanTrackModel`. For
|
||||
`WanTrackCausalModel`, it uses the existing `CausalModelBase` streaming API and
|
||||
the transformer's configured block size; it does not modify or fork the common
|
||||
Wan causal denoising stage.
|
||||
@@ -77,9 +77,7 @@ If forcing a backend fails, verify optional dependencies are installed:
|
||||
- `SAGE_ATTN`: SageAttention package
|
||||
- `SAGE_ATTN_THREE`: upstream `sageattn3` package
|
||||
- `ATTN_QAT_INFER`: `fastvideo-kernel` checkout/source install that exposes
|
||||
`attn_qat_infer`, AND a consumer-Blackwell (sm_120/sm_121) GPU -- on any
|
||||
other device the backend reports unavailable (even if a CUDA 13 wheel
|
||||
bundles the extension) and selection falls back to FlashAttention
|
||||
`attn_qat_infer`
|
||||
- `ATTN_QAT_TRAIN`: `fastvideo-kernel`; its runtime-JIT Triton implementation
|
||||
selects an optimized route on SM100, joins the quantized and STE P@V paths on
|
||||
SM120, and retains the previous route for unsupported configurations. See
|
||||
|
||||
@@ -191,22 +191,6 @@ def main() -> None:
|
||||
print(f"Using refine upsampler: {refine_upsampler_path}")
|
||||
|
||||
pipeline_config = PipelineConfig.from_pretrained(model_root)
|
||||
# LTX-2 NVFP4 deploy contract (train==deploy surface):
|
||||
# * Linears: NVFP4 block-scaled GEMMs (per-16 E2M1 + E4M3 SFs) on every
|
||||
# arch, via flashinfer.
|
||||
# * ATTN_QAT_INFER attention differs per arch: sm_120a/sm_121a use the
|
||||
# fastvideo-kernel CUTLASS (SageAttention3-FP4) scheme that
|
||||
# ATTN_QAT_TRAIN simulates; sm_100a (GB200) / sm_103a (GB300) use the
|
||||
# FP4 FA4 kernel (flash-attention-fp4) with per-16 block-scaled NVFP4
|
||||
# Q/K and BF16 P/V -- a train-sim mismatch that is gated by MS-SSIM
|
||||
# measurement, not assumed equal. The selection receipt is logged at
|
||||
# backend resolution ("ATTN_QAT_INFER resolved: ...").
|
||||
# Original-weight retention: the default purges the always-FP4 layers'
|
||||
# bf16 originals after conversion. Refine-only layers (the cross-modal
|
||||
# AV projections) always keep theirs: the base stage profile runs them
|
||||
# dense by deployment contract -- in the two-stage fast profile AND the
|
||||
# distilled single-stage deploy. retain_original_weights=True keeps
|
||||
# everything (debugging).
|
||||
pipeline_config.dit_config.quant_config = NVFP4Config()
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
torch_compile_kwargs = {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Run causal WanTrack Self-Forcing I2V through FastVideo.
|
||||
"""
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from fastvideo import VideoGenerator
|
||||
from fastvideo.api import (
|
||||
EngineConfig,
|
||||
GenerationRequest,
|
||||
GeneratorConfig,
|
||||
InputConfig,
|
||||
OffloadConfig,
|
||||
OutputConfig,
|
||||
ParallelismConfig,
|
||||
PipelineSelection,
|
||||
SamplingConfig,
|
||||
)
|
||||
from fastvideo.models.dits.trackwan.utils import load_tracks
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Run causal WanTrack Self-Forcing I2V.")
|
||||
parser.add_argument(
|
||||
"--model-path",
|
||||
default="path/to/ckpt",
|
||||
help="Local diffusers-format Track-v0 weights directory (or HF id).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--image",
|
||||
default="https://huggingface.co/datasets/YiYiXu/testing-images/resolve/main/wan_i2v_input.JPG",
|
||||
help="Condition image path or URL.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tracks",
|
||||
default=None,
|
||||
help=("Track package: .pt/.npz dict with track_points+track_visibility "
|
||||
"(optional track_ids), or a directory containing those files."),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--track-points",
|
||||
default=None,
|
||||
help="Optional standalone track_points tensor file (.pt/.npy/.npz).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--track-visibility",
|
||||
default=None,
|
||||
help="Optional standalone track_visibility tensor file (.pt/.npy/.npz).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--track-ids",
|
||||
default=None,
|
||||
help="Optional standalone track_ids tensor file (.pt/.npy/.npz).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
default="video_samples_wantrack_causal_sf/output.mp4",
|
||||
help="Output mp4 path.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--prompt",
|
||||
default=("Summer beach vacation style, a white cat wearing sunglasses "
|
||||
"sits on a surfboard. The fluffy-furred feline gazes directly "
|
||||
"at the camera with a relaxed expression."),
|
||||
help="Text prompt.",
|
||||
)
|
||||
parser.add_argument("--height", type=int, default=480)
|
||||
parser.add_argument("--width", type=int, default=832)
|
||||
parser.add_argument("--num-frames", type=int, default=121)
|
||||
parser.add_argument("--fps", type=int, default=16)
|
||||
parser.add_argument("--steps", type=int, default=4)
|
||||
parser.add_argument("--guidance-scale", type=float, default=1.0)
|
||||
parser.add_argument("--seed", type=int, default=1000)
|
||||
parser.add_argument(
|
||||
"--num-tracks",
|
||||
type=int,
|
||||
default=8,
|
||||
help="Demo track count when no track files are provided.",
|
||||
)
|
||||
parser.add_argument("--num-gpus", type=int, default=1)
|
||||
parser.add_argument("--tp-size", type=int, default=None)
|
||||
parser.add_argument("--sp-size", type=int, default=None)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
|
||||
output = Path(args.output)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
tp_size = args.tp_size if args.tp_size is not None else (args.num_gpus if args.num_gpus > 1 else 1)
|
||||
sp_size = args.sp_size if args.sp_size is not None else (1 if args.num_gpus > 1 else args.num_gpus)
|
||||
|
||||
tracks = load_tracks(
|
||||
tracks_path=args.tracks,
|
||||
track_points_path=args.track_points,
|
||||
track_visibility_path=args.track_visibility,
|
||||
track_ids_path=args.track_ids,
|
||||
num_frames=args.num_frames,
|
||||
num_tracks=args.num_tracks,
|
||||
seed=args.seed,
|
||||
)
|
||||
|
||||
generator_config = GeneratorConfig(
|
||||
model_path=args.model_path,
|
||||
engine=EngineConfig(
|
||||
num_gpus=args.num_gpus,
|
||||
use_fsdp_inference=False,
|
||||
parallelism=ParallelismConfig(tp_size=tp_size, sp_size=sp_size),
|
||||
offload=OffloadConfig(
|
||||
dit=False,
|
||||
vae=False,
|
||||
text_encoder=True,
|
||||
image_encoder=True,
|
||||
pin_cpu_memory=True,
|
||||
),
|
||||
),
|
||||
pipeline=PipelineSelection(workload_type="i2v"),
|
||||
)
|
||||
|
||||
generator = VideoGenerator.from_config(generator_config)
|
||||
try:
|
||||
request = GenerationRequest(
|
||||
prompt=args.prompt,
|
||||
inputs=InputConfig(
|
||||
image_path=args.image,
|
||||
track_points=tracks["track_points"],
|
||||
track_visibility=tracks["track_visibility"],
|
||||
track_ids=tracks["track_ids"],
|
||||
),
|
||||
sampling=SamplingConfig(
|
||||
height=args.height,
|
||||
width=args.width,
|
||||
num_frames=args.num_frames,
|
||||
fps=args.fps,
|
||||
num_inference_steps=args.steps,
|
||||
guidance_scale=args.guidance_scale,
|
||||
seed=args.seed,
|
||||
),
|
||||
output=OutputConfig(
|
||||
output_path=str(output.parent),
|
||||
output_video_name=output.stem,
|
||||
save_video=True,
|
||||
return_frames=False,
|
||||
),
|
||||
)
|
||||
result = generator.generate(request)
|
||||
if isinstance(result, list):
|
||||
result = result[0]
|
||||
print(f"Saved video to {result.video_path}")
|
||||
finally:
|
||||
generator.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -6,6 +6,10 @@ original single-file version to make each concern independently reviewable.
|
||||
The folder name matches the sibling `gradio_local_demo*.py` demos in this
|
||||
directory so both flat and packaged demos read consistently.
|
||||
|
||||
> **Status: draft.** This package is structurally in place but will not run
|
||||
> against the current upstream `fastvideo` package. See *Blocking prereqs*
|
||||
> below.
|
||||
|
||||
## Layout
|
||||
|
||||
| File | Purpose |
|
||||
@@ -34,21 +38,27 @@ python -m gradio_local_demo_ltx2_3 --port 7860
|
||||
GPU requirement: a single FP4-capable GPU (B200 or comparable) for the
|
||||
"real-time 1080p" speed claim. Lower tiers will still run but slower.
|
||||
|
||||
## Upstream surfaces the demo relies on
|
||||
## Blocking prereqs (why this draft PR cannot be merged yet)
|
||||
|
||||
Everything the demo needs ships in the upstream `fastvideo` package:
|
||||
The upstream `fastvideo` package is missing three pieces that the demo
|
||||
currently depends on verbatim. Each needs its own upstreaming PR before this
|
||||
demo can actually boot:
|
||||
|
||||
- **`fastvideo.layers.quantization.nvfp4_config.NVFP4Config`** — the demo sets
|
||||
`pipeline_config.dit_config.quant_config = NVFP4Config()` in `app.py` (same
|
||||
pattern as `examples/inference/basic/basic_ltx2_distilled_fast_profile.py`).
|
||||
- **LTX-2.3 refine kwargs on `VideoGenerator`** — `ltx2_refine_enabled`,
|
||||
`ltx2_refine_upsampler_path`, `ltx2_refine_lora_path`,
|
||||
`ltx2_refine_num_inference_steps`, `ltx2_refine_guidance_scale`,
|
||||
`ltx2_refine_add_noise`, and `ltx2_vae_tiling` are wired in
|
||||
`fastvideo/fastvideo_args.py`, backed by
|
||||
`fastvideo/pipelines/basic/ltx2/stages/ltx2_refine.py`.
|
||||
- **`SamplingParam`** — imported from the public `fastvideo` package root
|
||||
(`fastvideo.api.sampling_param` under the hood).
|
||||
1. **`fastvideo.layers.quantization.fp4_config.FP4Config`** — the demo sets
|
||||
`pipeline_config.dit_config.quant_config = FP4Config()` in `app.py`.
|
||||
Upstream only ships `absmax_fp8.py` and `base_config.py` under
|
||||
`fastvideo/layers/quantization/`.
|
||||
2. **LTX-2.3 refine / image-conditioning kwargs on `VideoGenerator`** —
|
||||
`ltx2_refine_enabled`, `ltx2_refine_upsampler_path`, `ltx2_refine_lora_path`,
|
||||
`ltx2_refine_num_inference_steps`, `ltx2_refine_guidance_scale`,
|
||||
`ltx2_refine_add_noise`, `ltx2_images`, `ltx2_image_crf`. Upstream
|
||||
`fastvideo/fastvideo_args.py` currently wires only `ltx2_vae_tiling`.
|
||||
The backing stages (`ltx2_refine.py`, `ltx2_i2v_conditioning.py`) are
|
||||
also missing from `fastvideo/pipelines/stages/`.
|
||||
3. **`fastvideo.configs.sample.base.SamplingParam`** — the import path used
|
||||
by this demo. Upstream moved sampling params to
|
||||
`fastvideo.api.sampling_param`. A re-export shim at the old path, or an
|
||||
import update here once the other two prereqs land, will resolve it.
|
||||
|
||||
## Environment variables
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@ from pathlib import Path
|
||||
|
||||
import gradio as gr
|
||||
|
||||
from fastvideo import SamplingParam
|
||||
from fastvideo.configs.pipelines.base import PipelineConfig
|
||||
from fastvideo.configs.sample.base import SamplingParam
|
||||
from fastvideo.entrypoints.video_generator import VideoGenerator
|
||||
from fastvideo.layers.quantization.nvfp4_config import NVFP4Config
|
||||
from fastvideo.layers.quantization.fp4_config import FP4Config
|
||||
from fastvideo.utils import maybe_download_model
|
||||
|
||||
from .config import (
|
||||
@@ -44,7 +44,7 @@ def main():
|
||||
resolved_model_path = Path(model_root)
|
||||
|
||||
pipeline_config = PipelineConfig.from_pretrained(str(resolved_model_path))
|
||||
pipeline_config.dit_config.quant_config = NVFP4Config()
|
||||
pipeline_config.dit_config.quant_config = FP4Config()
|
||||
refine_upsampler_path = resolve_refine_upsampler_path(resolved_model_path)
|
||||
print(f"Using refine upsampler: {refine_upsampler_path}")
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
import torch
|
||||
import torch._inductor.config
|
||||
|
||||
from fastvideo import SamplingParam
|
||||
from fastvideo.configs.sample.base import SamplingParam
|
||||
|
||||
LOCAL_DEMO_DIR = Path(__file__).resolve().parent
|
||||
CLASSIFIER_DIR = Path(
|
||||
|
||||
@@ -5,7 +5,7 @@ from copy import deepcopy
|
||||
|
||||
import gradio as gr
|
||||
|
||||
from fastvideo import SamplingParam
|
||||
from fastvideo.configs.sample.base import SamplingParam
|
||||
from fastvideo.entrypoints.video_generator import VideoGenerator
|
||||
|
||||
from .config import (
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""LTX-2.3 distilled text-to-video with the optimized NVFP4 inference stack.
|
||||
|
||||
Runs `FastVideo/LTX-2.3-Distilled-Diffusers` on a single GPU with the full
|
||||
validated optimization stack:
|
||||
|
||||
* NVFP4 block-scaled linear layers (per-16 E2M1 weights + E4M3 scale factors),
|
||||
* ATTN_QAT_INFER FP4 attention (arch-resolved kernel, receipt logged),
|
||||
* torch.compile (fullgraph) over the DiT, text encoder, and VAE,
|
||||
* single-stage 8-step distilled sampling at guidance 1.0.
|
||||
|
||||
Quick start
|
||||
-----------
|
||||
# On GB200-class ARM hosts, unset LD_LIBRARY_PATH (see Hardware notes):
|
||||
env -u LD_LIBRARY_PATH python examples/inference/ltx2_3/optimized_nvfp4_t2v.py
|
||||
|
||||
# Optional overrides:
|
||||
# export LTX23_MODEL_PATH=/path/to/local/snapshot
|
||||
# export LTX23_T2V_PROMPT="a red fox running through fresh snow"
|
||||
# export LTX23_OUTPUT_DIR=outputs_video/ltx2_3_nvfp4_t2v
|
||||
|
||||
Hardware notes
|
||||
--------------
|
||||
- On GB200 / Blackwell, run with `env -u LD_LIBRARY_PATH ...` to avoid a
|
||||
system-cuBLAS / torch-cuBLAS mismatch that fails every GEMM (some ARM
|
||||
container images ship an LD_LIBRARY_PATH that breaks torch.compile's
|
||||
toolchain discovery). The `_inductor.shape_padding = False` line below
|
||||
also avoids a pad_mm landmine on the same generation of cards.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import torch._inductor.config as _inductor
|
||||
|
||||
from fastvideo import VideoGenerator
|
||||
from fastvideo.configs.pipelines.base import PipelineConfig
|
||||
from fastvideo.layers.quantization.nvfp4_config import NVFP4Config
|
||||
from fastvideo.utils import maybe_download_model
|
||||
|
||||
# ATTN_QAT_INFER is the FP4 attention half of the NVFP4 deploy contract. It
|
||||
# resolves per arch (CUTLASS SageAttention3-FP4 on sm_120a/sm_121a, FP4 FA4
|
||||
# on sm_100a/sm_103a) and logs a one-line receipt of what actually bound.
|
||||
os.environ.setdefault("FASTVIDEO_ATTENTION_BACKEND", "ATTN_QAT_INFER")
|
||||
os.environ.setdefault("FASTVIDEO_STAGE_LOGGING", "1")
|
||||
|
||||
# Inductor knobs. The first one (shape_padding=False) is mandatory on
|
||||
# Blackwell to avoid a cuBLAS INVALID_VALUE crash inside pad_mm. The rest
|
||||
# are the same matmul-friendliness flags the sibling LTX-2 examples use.
|
||||
_inductor.shape_padding = False
|
||||
_inductor.conv_1x1_as_mm = True # treat 1x1 convolutions as matrix muls
|
||||
_inductor.coordinate_descent_tuning = True
|
||||
_inductor.coordinate_descent_check_all_directions = True
|
||||
_inductor.epilogue_fusion = False # do not fuse pointwise ops into matmuls
|
||||
|
||||
MODEL_ID = os.path.expandvars(
|
||||
os.path.expanduser(
|
||||
os.getenv("LTX23_MODEL_PATH", "FastVideo/LTX-2.3-Distilled-Diffusers")
|
||||
)
|
||||
)
|
||||
OUTPUT_DIR = Path(os.getenv("LTX23_OUTPUT_DIR", "outputs_video/ltx2_3_nvfp4_t2v"))
|
||||
DEFAULT_PROMPT = (
|
||||
"A fashion model takes a slow step forward and shifts her weight, "
|
||||
"the soft fabric of her clothing swaying and rippling with the "
|
||||
"motion, her hair shifting gently, soft even studio lighting on a "
|
||||
"clean light background, elegant slow-motion runway feel."
|
||||
)
|
||||
PROMPT = os.getenv("LTX23_T2V_PROMPT", DEFAULT_PROMPT)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
model_root = maybe_download_model(MODEL_ID)
|
||||
print(f"Model: {model_root}")
|
||||
print(f"Output dir: {OUTPUT_DIR.resolve()}")
|
||||
|
||||
# Loading the pipeline config *with model_path* binds model-specific
|
||||
# tuning (notably VAE precision/decoder defaults) into the config.
|
||||
pipeline_config = PipelineConfig.from_pretrained(model_root)
|
||||
|
||||
# NVFP4 linear layers on the DiT. The default purges the original BF16
|
||||
# weights of the always-FP4 linears right after conversion — a large
|
||||
# peak-memory reduction. Refine-only layers (the cross-modal AV
|
||||
# projections) always keep theirs: the base stage profile runs them
|
||||
# dense by deployment contract. retain_original_weights=True keeps
|
||||
# everything (debugging).
|
||||
pipeline_config.dit_config.quant_config = NVFP4Config()
|
||||
|
||||
# fullgraph=True is supported on the NVFP4 path: the FP4 quantize step
|
||||
# is a registered custom op (fastvideo::nvfp4_quantize_fa4), so dynamo
|
||||
# traces through it without graph breaks. mode="default" — the two
|
||||
# CUDAGraph modes ("reduce-overhead" / "max-autotune") are a separate
|
||||
# opt-in, not part of this validated preset.
|
||||
torch_compile_kwargs = {
|
||||
"backend": "inductor",
|
||||
"fullgraph": True,
|
||||
"mode": "default",
|
||||
"dynamic": False,
|
||||
}
|
||||
|
||||
generator = VideoGenerator.from_pretrained(
|
||||
model_root,
|
||||
num_gpus=1,
|
||||
pipeline_config=pipeline_config,
|
||||
# Compile the DiT, text encoder, and VAE — all three stages benefit,
|
||||
# and the VAE's codec submodules compile cleanly under fullgraph.
|
||||
enable_torch_compile=True,
|
||||
enable_torch_compile_text_encoder=True,
|
||||
enable_torch_compile_vae=True,
|
||||
torch_compile_kwargs=torch_compile_kwargs,
|
||||
torch_compile_kwargs_vae=torch_compile_kwargs,
|
||||
# Keep everything resident — no CPU offload for serving-style runs.
|
||||
dit_cpu_offload=False,
|
||||
text_encoder_cpu_offload=False,
|
||||
vae_cpu_offload=False,
|
||||
ltx2_vae_tiling=False,
|
||||
)
|
||||
|
||||
common_kwargs = dict(
|
||||
prompt=PROMPT,
|
||||
negative_prompt="", # distilled is CFG-free; no negative needed
|
||||
guidance_scale=1.0, # CFG=1 for distilled
|
||||
height=1280, width=832, # portrait runway aspect
|
||||
num_frames=121, fps=24, # ~5s clip
|
||||
# Single-stage 8-step distilled sampling — the validated preset for
|
||||
# this checkpoint (no two-stage refine; the NVFP4 deploy contract
|
||||
# runs the distilled single-stage recipe).
|
||||
num_inference_steps=8,
|
||||
save_video=True,
|
||||
)
|
||||
|
||||
try:
|
||||
# Warmup: pays cold compile + first-shape guard work, untimed.
|
||||
print("\n[warmup] compiling + generating…")
|
||||
generator.generate_video(
|
||||
output_path=str(OUTPUT_DIR / "_warmup.mp4"),
|
||||
seed=7,
|
||||
**common_kwargs,
|
||||
)
|
||||
(OUTPUT_DIR / "_warmup.mp4").unlink(missing_ok=True)
|
||||
|
||||
# Measured run.
|
||||
out_path = OUTPUT_DIR / "output_ltx2_3_nvfp4_t2v.mp4"
|
||||
print(f"\n[measured] generating: {out_path}")
|
||||
t0 = time.perf_counter()
|
||||
result = generator.generate_video(
|
||||
output_path=str(out_path),
|
||||
seed=2002,
|
||||
**common_kwargs,
|
||||
)
|
||||
wall = time.perf_counter() - t0
|
||||
e2e = (result.get("e2e_latency") if isinstance(result, dict) else None) or wall
|
||||
print(f"[measured] e2e={e2e:.2f}s wall={wall:.2f}s")
|
||||
finally:
|
||||
generator.shutdown()
|
||||
|
||||
# Expected receipts — verify these two lines in your own run's log:
|
||||
#
|
||||
# 1. ATTN_QAT_INFER routing receipt (logged at backend resolution; on a
|
||||
# GB200-class part it reads):
|
||||
#
|
||||
# ATTN_QAT_INFER resolved: arch=sm_100 kernel=flash-attention-fp4 \
|
||||
# qk_mode=nvfp4(per-16-e4m3-sf) pv_mode=bf16 train_sim_mismatch=measured
|
||||
#
|
||||
# 2. NVFP4 weight purge receipt (logged after model conversion; N/M/X
|
||||
# depend on the checkpoint):
|
||||
#
|
||||
# NVFP4 weight purge receipt: purged N original bf16 weight tensors \
|
||||
# (X.XX GiB freed); retained M (refine-only dense fallback or \
|
||||
# retain_original_weights).
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,86 @@
|
||||
models:
|
||||
student:
|
||||
_target_: fastvideo.train.models.wan.WanCausalModel
|
||||
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
|
||||
trainable: true
|
||||
transformer_override_safetensor: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A12/export/tf/transformer/model.safetensors
|
||||
teacher:
|
||||
_target_: fastvideo.train.models.wan.WanCausalModel
|
||||
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
|
||||
trainable: false
|
||||
transformer_override_safetensor: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A12/export/tf/transformer/model.safetensors
|
||||
ema:
|
||||
_target_: fastvideo.train.models.wan.WanCausalModel
|
||||
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
|
||||
trainable: false
|
||||
transformer_override_safetensor: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A12/export/tf/transformer/model.safetensors
|
||||
method:
|
||||
_target_: fastvideo.train.methods.consistency_model.causal_cd.CausalConsistencyDistillationMethod
|
||||
discrete_cd_N: 48
|
||||
guidance_scale: 3.0
|
||||
ema_decay: 0.99
|
||||
ema_start_step: 200
|
||||
training:
|
||||
distributed:
|
||||
num_gpus: 4
|
||||
sp_size: 1
|
||||
tp_size: 1
|
||||
hsdp_replicate_dim: 1
|
||||
hsdp_shard_dim: 4
|
||||
data:
|
||||
data_path: /mnt/lustre/vlm-s4duan/openvid_1m/combined_parquet_dataset
|
||||
dataloader_type: streaming
|
||||
streaming_manifest_path: /mnt/lustre/vlm-k1kong/dataset-index/openvid/streaming-t2v-v2.json
|
||||
streaming_read_batch_size: 2
|
||||
streaming_shuffle_row_groups: true
|
||||
dataloader_num_workers: 0
|
||||
train_batch_size: 2
|
||||
training_cfg_rate: 0.0
|
||||
seed: 1000
|
||||
num_latent_t: 21
|
||||
num_height: 480
|
||||
num_width: 832
|
||||
num_frames: 81
|
||||
optimizer:
|
||||
learning_rate: 2.0e-06
|
||||
betas:
|
||||
- 0.0
|
||||
- 0.999
|
||||
weight_decay: 0.01
|
||||
lr_scheduler: constant
|
||||
lr_warmup_steps: 0
|
||||
loop:
|
||||
max_train_steps: 2000
|
||||
gradient_accumulation_steps: 8
|
||||
checkpoint:
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A12/cd/checkpoints
|
||||
training_state_checkpointing_steps: 1000
|
||||
checkpoints_total_limit: 2
|
||||
tracker:
|
||||
trackers:
|
||||
- wandb
|
||||
project_name: causal_forcing_openvid_a12_a15
|
||||
run_name: A12_sink1_local6_relativistic_chunk3_cd2k_from_tf3k_openvid_81f21l_gbs64
|
||||
model:
|
||||
enable_gradient_checkpointing_type: full
|
||||
callbacks:
|
||||
grad_clip:
|
||||
max_grad_norm: 1.0
|
||||
validation:
|
||||
pipeline_target: fastvideo.pipelines.basic.wan.wan_causal_pipeline.WanCausalPipeline
|
||||
dataset_file: /mnt/nfs/vlm-k1kong/FastVideo-openvid-a12-a15-final-20260717/examples/training/finetune/Wan2.1-VSA/Wan-Syn-Data/validation_4.json
|
||||
every_steps: 200
|
||||
sampling_steps:
|
||||
- 4
|
||||
guidance_scale: 3.0
|
||||
num_frames: 81
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A12/cd/validation
|
||||
offload_training_state: true
|
||||
unload_pipeline_after_validation: true
|
||||
pipeline:
|
||||
flow_shift: 5
|
||||
dit_config:
|
||||
local_attn_size: 6
|
||||
sink_size: 1
|
||||
rope_cache_policy: relativistic
|
||||
causal_train_attention: triton
|
||||
@@ -0,0 +1,110 @@
|
||||
models:
|
||||
student:
|
||||
_target_: fastvideo.train.models.wan.WanCausalModel
|
||||
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
|
||||
trainable: true
|
||||
transformer_override_safetensor: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A12/export/cd/transformer/model.safetensors
|
||||
teacher:
|
||||
_target_: fastvideo.train.models.wan.WanModel
|
||||
init_from: Wan-AI/Wan2.1-T2V-14B-Diffusers
|
||||
trainable: false
|
||||
disable_custom_init_weights: true
|
||||
critic:
|
||||
_target_: fastvideo.train.models.wan.WanModel
|
||||
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
|
||||
trainable: true
|
||||
disable_custom_init_weights: true
|
||||
method:
|
||||
_target_: fastvideo.train.methods.distribution_matching.self_forcing.SelfForcingMethod
|
||||
rollout_mode: simulate
|
||||
generator_update_interval: 5
|
||||
real_score_guidance_scale: 4.0
|
||||
dmd_denoising_steps:
|
||||
- 1000
|
||||
- 750
|
||||
- 500
|
||||
- 250
|
||||
warp_denoising_step: true
|
||||
chunk_size: 3
|
||||
student_sample_type: sde
|
||||
context_noise: 0.0
|
||||
same_step_across_blocks: true
|
||||
enable_gradient_in_rollout: true
|
||||
start_gradient_frame: 0
|
||||
fake_score_learning_rate: 4.0e-07
|
||||
fake_score_betas:
|
||||
- 0.0
|
||||
- 0.999
|
||||
fake_score_lr_scheduler: constant
|
||||
training:
|
||||
distributed:
|
||||
num_gpus: 4
|
||||
sp_size: 1
|
||||
tp_size: 1
|
||||
hsdp_replicate_dim: 1
|
||||
hsdp_shard_dim: 4
|
||||
data:
|
||||
data_path: /mnt/lustre/vlm-s4duan/openvid_1m/combined_parquet_dataset
|
||||
dataloader_type: streaming
|
||||
streaming_manifest_path: /mnt/lustre/vlm-k1kong/dataset-index/openvid/streaming-t2v-v2.json
|
||||
streaming_read_batch_size: 2
|
||||
streaming_shuffle_row_groups: true
|
||||
dataloader_num_workers: 0
|
||||
train_batch_size: 2
|
||||
training_cfg_rate: 0.0
|
||||
seed: 1000
|
||||
num_latent_t: 21
|
||||
num_height: 480
|
||||
num_width: 832
|
||||
num_frames: 81
|
||||
optimizer:
|
||||
learning_rate: 2.0e-06
|
||||
betas:
|
||||
- 0.0
|
||||
- 0.999
|
||||
weight_decay: 0.01
|
||||
lr_scheduler: constant
|
||||
lr_warmup_steps: 0
|
||||
loop:
|
||||
max_train_steps: 1000
|
||||
gradient_accumulation_steps: 8
|
||||
checkpoint:
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A12/sf/checkpoints
|
||||
training_state_checkpointing_steps: 1000
|
||||
checkpoints_total_limit: 1
|
||||
tracker:
|
||||
trackers:
|
||||
- wandb
|
||||
project_name: causal_forcing_openvid_a12_a15
|
||||
run_name: A12_sink1_local6_relativistic_chunk3_sf1k_from_cd2k_openvid_81f21l_gbs64
|
||||
model:
|
||||
enable_gradient_checkpointing_type: full
|
||||
callbacks:
|
||||
ema:
|
||||
decay: 0.99
|
||||
start_iter: 200
|
||||
grad_clip:
|
||||
max_grad_norm: 1.0
|
||||
validation:
|
||||
pipeline_target: fastvideo.pipelines.basic.wan.wan_causal_dmd_pipeline.WanCausalDMDPipeline
|
||||
dataset_file: /mnt/nfs/vlm-k1kong/FastVideo-openvid-a12-a15-final-20260717/examples/training/finetune/Wan2.1-VSA/Wan-Syn-Data/validation_4.json
|
||||
every_steps: 200
|
||||
sampling_steps:
|
||||
- 4
|
||||
guidance_scale: 3.0
|
||||
num_frames: 81
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A12/sf/validation
|
||||
offload_training_state: true
|
||||
unload_pipeline_after_validation: true
|
||||
sampling_timesteps:
|
||||
- 1000
|
||||
- 750
|
||||
- 500
|
||||
- 250
|
||||
pipeline:
|
||||
flow_shift: 5
|
||||
dit_config:
|
||||
local_attn_size: 6
|
||||
sink_size: 1
|
||||
rope_cache_policy: relativistic
|
||||
causal_train_attention: triton
|
||||
@@ -0,0 +1,72 @@
|
||||
models:
|
||||
student:
|
||||
_target_: fastvideo.train.models.wan.WanCausalModel
|
||||
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
|
||||
trainable: true
|
||||
method:
|
||||
_target_: fastvideo.train.methods.fine_tuning.tfsft.TeacherForcingSFTMethod
|
||||
chunk_size: 3
|
||||
training:
|
||||
distributed:
|
||||
num_gpus: 4
|
||||
sp_size: 1
|
||||
tp_size: 1
|
||||
hsdp_replicate_dim: 1
|
||||
hsdp_shard_dim: 4
|
||||
data:
|
||||
data_path: /mnt/lustre/vlm-s4duan/openvid_1m/combined_parquet_dataset
|
||||
dataloader_type: streaming
|
||||
streaming_manifest_path: /mnt/lustre/vlm-k1kong/dataset-index/openvid/streaming-t2v-v2.json
|
||||
streaming_read_batch_size: 2
|
||||
streaming_shuffle_row_groups: true
|
||||
dataloader_num_workers: 0
|
||||
train_batch_size: 2
|
||||
training_cfg_rate: 0.0
|
||||
seed: 1000
|
||||
num_latent_t: 21
|
||||
num_height: 480
|
||||
num_width: 832
|
||||
num_frames: 81
|
||||
optimizer:
|
||||
learning_rate: 2.0e-06
|
||||
betas:
|
||||
- 0.0
|
||||
- 0.999
|
||||
weight_decay: 0.01
|
||||
lr_scheduler: constant
|
||||
lr_warmup_steps: 0
|
||||
loop:
|
||||
max_train_steps: 3000
|
||||
gradient_accumulation_steps: 8
|
||||
checkpoint:
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A12/tf/checkpoints
|
||||
training_state_checkpointing_steps: 1000
|
||||
checkpoints_total_limit: 3
|
||||
tracker:
|
||||
trackers:
|
||||
- wandb
|
||||
project_name: causal_forcing_openvid_a12_a15
|
||||
run_name: A12_sink1_local6_relativistic_chunk3_tf3k_openvid_81f21l_gbs64
|
||||
model:
|
||||
enable_gradient_checkpointing_type: full
|
||||
callbacks:
|
||||
grad_clip:
|
||||
max_grad_norm: 1.0
|
||||
validation:
|
||||
pipeline_target: fastvideo.pipelines.basic.wan.wan_causal_pipeline.WanCausalPipeline
|
||||
dataset_file: /mnt/nfs/vlm-k1kong/FastVideo-openvid-a12-a15-final-20260717/examples/training/finetune/Wan2.1-VSA/Wan-Syn-Data/validation_4.json
|
||||
every_steps: 200
|
||||
sampling_steps:
|
||||
- 40
|
||||
guidance_scale: 6.0
|
||||
num_frames: 81
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A12/tf/validation
|
||||
offload_training_state: true
|
||||
unload_pipeline_after_validation: true
|
||||
pipeline:
|
||||
flow_shift: 5
|
||||
dit_config:
|
||||
local_attn_size: 6
|
||||
sink_size: 1
|
||||
rope_cache_policy: relativistic
|
||||
causal_train_attention: triton
|
||||
@@ -0,0 +1,86 @@
|
||||
models:
|
||||
student:
|
||||
_target_: fastvideo.train.models.wan.WanCausalModel
|
||||
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
|
||||
trainable: true
|
||||
transformer_override_safetensor: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A13/export/tf/transformer/model.safetensors
|
||||
teacher:
|
||||
_target_: fastvideo.train.models.wan.WanCausalModel
|
||||
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
|
||||
trainable: false
|
||||
transformer_override_safetensor: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A13/export/tf/transformer/model.safetensors
|
||||
ema:
|
||||
_target_: fastvideo.train.models.wan.WanCausalModel
|
||||
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
|
||||
trainable: false
|
||||
transformer_override_safetensor: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A13/export/tf/transformer/model.safetensors
|
||||
method:
|
||||
_target_: fastvideo.train.methods.consistency_model.causal_cd.CausalConsistencyDistillationMethod
|
||||
discrete_cd_N: 48
|
||||
guidance_scale: 3.0
|
||||
ema_decay: 0.99
|
||||
ema_start_step: 200
|
||||
training:
|
||||
distributed:
|
||||
num_gpus: 4
|
||||
sp_size: 1
|
||||
tp_size: 1
|
||||
hsdp_replicate_dim: 1
|
||||
hsdp_shard_dim: 4
|
||||
data:
|
||||
data_path: /mnt/lustre/vlm-s4duan/openvid_1m/combined_parquet_dataset
|
||||
dataloader_type: streaming
|
||||
streaming_manifest_path: /mnt/lustre/vlm-k1kong/dataset-index/openvid/streaming-t2v-v2.json
|
||||
streaming_read_batch_size: 2
|
||||
streaming_shuffle_row_groups: true
|
||||
dataloader_num_workers: 0
|
||||
train_batch_size: 2
|
||||
training_cfg_rate: 0.0
|
||||
seed: 1000
|
||||
num_latent_t: 21
|
||||
num_height: 480
|
||||
num_width: 832
|
||||
num_frames: 81
|
||||
optimizer:
|
||||
learning_rate: 2.0e-06
|
||||
betas:
|
||||
- 0.0
|
||||
- 0.999
|
||||
weight_decay: 0.01
|
||||
lr_scheduler: constant
|
||||
lr_warmup_steps: 0
|
||||
loop:
|
||||
max_train_steps: 2000
|
||||
gradient_accumulation_steps: 8
|
||||
checkpoint:
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A13/cd/checkpoints
|
||||
training_state_checkpointing_steps: 1000
|
||||
checkpoints_total_limit: 2
|
||||
tracker:
|
||||
trackers:
|
||||
- wandb
|
||||
project_name: causal_forcing_openvid_a12_a15
|
||||
run_name: A13_sink0_local6_relativistic_chunk3_cd2k_from_tf3k_openvid_81f21l_gbs64
|
||||
model:
|
||||
enable_gradient_checkpointing_type: full
|
||||
callbacks:
|
||||
grad_clip:
|
||||
max_grad_norm: 1.0
|
||||
validation:
|
||||
pipeline_target: fastvideo.pipelines.basic.wan.wan_causal_pipeline.WanCausalPipeline
|
||||
dataset_file: /mnt/nfs/vlm-k1kong/FastVideo-openvid-a12-a15-final-20260717/examples/training/finetune/Wan2.1-VSA/Wan-Syn-Data/validation_4.json
|
||||
every_steps: 200
|
||||
sampling_steps:
|
||||
- 4
|
||||
guidance_scale: 3.0
|
||||
num_frames: 81
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A13/cd/validation
|
||||
offload_training_state: true
|
||||
unload_pipeline_after_validation: true
|
||||
pipeline:
|
||||
flow_shift: 5
|
||||
dit_config:
|
||||
local_attn_size: 6
|
||||
sink_size: 0
|
||||
rope_cache_policy: relativistic
|
||||
causal_train_attention: triton
|
||||
@@ -0,0 +1,110 @@
|
||||
models:
|
||||
student:
|
||||
_target_: fastvideo.train.models.wan.WanCausalModel
|
||||
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
|
||||
trainable: true
|
||||
transformer_override_safetensor: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A13/export/cd/transformer/model.safetensors
|
||||
teacher:
|
||||
_target_: fastvideo.train.models.wan.WanModel
|
||||
init_from: Wan-AI/Wan2.1-T2V-14B-Diffusers
|
||||
trainable: false
|
||||
disable_custom_init_weights: true
|
||||
critic:
|
||||
_target_: fastvideo.train.models.wan.WanModel
|
||||
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
|
||||
trainable: true
|
||||
disable_custom_init_weights: true
|
||||
method:
|
||||
_target_: fastvideo.train.methods.distribution_matching.self_forcing.SelfForcingMethod
|
||||
rollout_mode: simulate
|
||||
generator_update_interval: 5
|
||||
real_score_guidance_scale: 4.0
|
||||
dmd_denoising_steps:
|
||||
- 1000
|
||||
- 750
|
||||
- 500
|
||||
- 250
|
||||
warp_denoising_step: true
|
||||
chunk_size: 3
|
||||
student_sample_type: sde
|
||||
context_noise: 0.0
|
||||
same_step_across_blocks: true
|
||||
enable_gradient_in_rollout: true
|
||||
start_gradient_frame: 0
|
||||
fake_score_learning_rate: 4.0e-07
|
||||
fake_score_betas:
|
||||
- 0.0
|
||||
- 0.999
|
||||
fake_score_lr_scheduler: constant
|
||||
training:
|
||||
distributed:
|
||||
num_gpus: 4
|
||||
sp_size: 1
|
||||
tp_size: 1
|
||||
hsdp_replicate_dim: 1
|
||||
hsdp_shard_dim: 4
|
||||
data:
|
||||
data_path: /mnt/lustre/vlm-s4duan/openvid_1m/combined_parquet_dataset
|
||||
dataloader_type: streaming
|
||||
streaming_manifest_path: /mnt/lustre/vlm-k1kong/dataset-index/openvid/streaming-t2v-v2.json
|
||||
streaming_read_batch_size: 2
|
||||
streaming_shuffle_row_groups: true
|
||||
dataloader_num_workers: 0
|
||||
train_batch_size: 2
|
||||
training_cfg_rate: 0.0
|
||||
seed: 1000
|
||||
num_latent_t: 21
|
||||
num_height: 480
|
||||
num_width: 832
|
||||
num_frames: 81
|
||||
optimizer:
|
||||
learning_rate: 2.0e-06
|
||||
betas:
|
||||
- 0.0
|
||||
- 0.999
|
||||
weight_decay: 0.01
|
||||
lr_scheduler: constant
|
||||
lr_warmup_steps: 0
|
||||
loop:
|
||||
max_train_steps: 1000
|
||||
gradient_accumulation_steps: 8
|
||||
checkpoint:
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A13/sf/checkpoints
|
||||
training_state_checkpointing_steps: 1000
|
||||
checkpoints_total_limit: 1
|
||||
tracker:
|
||||
trackers:
|
||||
- wandb
|
||||
project_name: causal_forcing_openvid_a12_a15
|
||||
run_name: A13_sink0_local6_relativistic_chunk3_sf1k_from_cd2k_openvid_81f21l_gbs64
|
||||
model:
|
||||
enable_gradient_checkpointing_type: full
|
||||
callbacks:
|
||||
ema:
|
||||
decay: 0.99
|
||||
start_iter: 200
|
||||
grad_clip:
|
||||
max_grad_norm: 1.0
|
||||
validation:
|
||||
pipeline_target: fastvideo.pipelines.basic.wan.wan_causal_dmd_pipeline.WanCausalDMDPipeline
|
||||
dataset_file: /mnt/nfs/vlm-k1kong/FastVideo-openvid-a12-a15-final-20260717/examples/training/finetune/Wan2.1-VSA/Wan-Syn-Data/validation_4.json
|
||||
every_steps: 200
|
||||
sampling_steps:
|
||||
- 4
|
||||
guidance_scale: 3.0
|
||||
num_frames: 81
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A13/sf/validation
|
||||
offload_training_state: true
|
||||
unload_pipeline_after_validation: true
|
||||
sampling_timesteps:
|
||||
- 1000
|
||||
- 750
|
||||
- 500
|
||||
- 250
|
||||
pipeline:
|
||||
flow_shift: 5
|
||||
dit_config:
|
||||
local_attn_size: 6
|
||||
sink_size: 0
|
||||
rope_cache_policy: relativistic
|
||||
causal_train_attention: triton
|
||||
@@ -0,0 +1,72 @@
|
||||
models:
|
||||
student:
|
||||
_target_: fastvideo.train.models.wan.WanCausalModel
|
||||
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
|
||||
trainable: true
|
||||
method:
|
||||
_target_: fastvideo.train.methods.fine_tuning.tfsft.TeacherForcingSFTMethod
|
||||
chunk_size: 3
|
||||
training:
|
||||
distributed:
|
||||
num_gpus: 4
|
||||
sp_size: 1
|
||||
tp_size: 1
|
||||
hsdp_replicate_dim: 1
|
||||
hsdp_shard_dim: 4
|
||||
data:
|
||||
data_path: /mnt/lustre/vlm-s4duan/openvid_1m/combined_parquet_dataset
|
||||
dataloader_type: streaming
|
||||
streaming_manifest_path: /mnt/lustre/vlm-k1kong/dataset-index/openvid/streaming-t2v-v2.json
|
||||
streaming_read_batch_size: 2
|
||||
streaming_shuffle_row_groups: true
|
||||
dataloader_num_workers: 0
|
||||
train_batch_size: 2
|
||||
training_cfg_rate: 0.0
|
||||
seed: 1000
|
||||
num_latent_t: 21
|
||||
num_height: 480
|
||||
num_width: 832
|
||||
num_frames: 81
|
||||
optimizer:
|
||||
learning_rate: 2.0e-06
|
||||
betas:
|
||||
- 0.0
|
||||
- 0.999
|
||||
weight_decay: 0.01
|
||||
lr_scheduler: constant
|
||||
lr_warmup_steps: 0
|
||||
loop:
|
||||
max_train_steps: 3000
|
||||
gradient_accumulation_steps: 8
|
||||
checkpoint:
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A13/tf/checkpoints
|
||||
training_state_checkpointing_steps: 1000
|
||||
checkpoints_total_limit: 3
|
||||
tracker:
|
||||
trackers:
|
||||
- wandb
|
||||
project_name: causal_forcing_openvid_a12_a15
|
||||
run_name: A13_sink0_local6_relativistic_chunk3_tf3k_openvid_81f21l_gbs64
|
||||
model:
|
||||
enable_gradient_checkpointing_type: full
|
||||
callbacks:
|
||||
grad_clip:
|
||||
max_grad_norm: 1.0
|
||||
validation:
|
||||
pipeline_target: fastvideo.pipelines.basic.wan.wan_causal_pipeline.WanCausalPipeline
|
||||
dataset_file: /mnt/nfs/vlm-k1kong/FastVideo-openvid-a12-a15-final-20260717/examples/training/finetune/Wan2.1-VSA/Wan-Syn-Data/validation_4.json
|
||||
every_steps: 200
|
||||
sampling_steps:
|
||||
- 40
|
||||
guidance_scale: 6.0
|
||||
num_frames: 81
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A13/tf/validation
|
||||
offload_training_state: true
|
||||
unload_pipeline_after_validation: true
|
||||
pipeline:
|
||||
flow_shift: 5
|
||||
dit_config:
|
||||
local_attn_size: 6
|
||||
sink_size: 0
|
||||
rope_cache_policy: relativistic
|
||||
causal_train_attention: triton
|
||||
@@ -0,0 +1,86 @@
|
||||
models:
|
||||
student:
|
||||
_target_: fastvideo.train.models.wan.WanCausalModel
|
||||
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
|
||||
trainable: true
|
||||
transformer_override_safetensor: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A14/export/tf/transformer/model.safetensors
|
||||
teacher:
|
||||
_target_: fastvideo.train.models.wan.WanCausalModel
|
||||
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
|
||||
trainable: false
|
||||
transformer_override_safetensor: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A14/export/tf/transformer/model.safetensors
|
||||
ema:
|
||||
_target_: fastvideo.train.models.wan.WanCausalModel
|
||||
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
|
||||
trainable: false
|
||||
transformer_override_safetensor: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A14/export/tf/transformer/model.safetensors
|
||||
method:
|
||||
_target_: fastvideo.train.methods.consistency_model.causal_cd.CausalConsistencyDistillationMethod
|
||||
discrete_cd_N: 48
|
||||
guidance_scale: 3.0
|
||||
ema_decay: 0.99
|
||||
ema_start_step: 200
|
||||
training:
|
||||
distributed:
|
||||
num_gpus: 4
|
||||
sp_size: 1
|
||||
tp_size: 1
|
||||
hsdp_replicate_dim: 1
|
||||
hsdp_shard_dim: 4
|
||||
data:
|
||||
data_path: /mnt/lustre/vlm-s4duan/openvid_1m/combined_parquet_dataset
|
||||
dataloader_type: streaming
|
||||
streaming_manifest_path: /mnt/lustre/vlm-k1kong/dataset-index/openvid/streaming-t2v-v2.json
|
||||
streaming_read_batch_size: 2
|
||||
streaming_shuffle_row_groups: true
|
||||
dataloader_num_workers: 0
|
||||
train_batch_size: 2
|
||||
training_cfg_rate: 0.0
|
||||
seed: 1000
|
||||
num_latent_t: 21
|
||||
num_height: 480
|
||||
num_width: 832
|
||||
num_frames: 81
|
||||
optimizer:
|
||||
learning_rate: 2.0e-06
|
||||
betas:
|
||||
- 0.0
|
||||
- 0.999
|
||||
weight_decay: 0.01
|
||||
lr_scheduler: constant
|
||||
lr_warmup_steps: 0
|
||||
loop:
|
||||
max_train_steps: 2000
|
||||
gradient_accumulation_steps: 8
|
||||
checkpoint:
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A14/cd/checkpoints
|
||||
training_state_checkpointing_steps: 1000
|
||||
checkpoints_total_limit: 2
|
||||
tracker:
|
||||
trackers:
|
||||
- wandb
|
||||
project_name: causal_forcing_openvid_a12_a15
|
||||
run_name: A14_sink1_local6_absolute_chunk3_cd2k_from_tf3k_openvid_81f21l_gbs64
|
||||
model:
|
||||
enable_gradient_checkpointing_type: full
|
||||
callbacks:
|
||||
grad_clip:
|
||||
max_grad_norm: 1.0
|
||||
validation:
|
||||
pipeline_target: fastvideo.pipelines.basic.wan.wan_causal_pipeline.WanCausalPipeline
|
||||
dataset_file: /mnt/nfs/vlm-k1kong/FastVideo-openvid-a12-a15-final-20260717/examples/training/finetune/Wan2.1-VSA/Wan-Syn-Data/validation_4.json
|
||||
every_steps: 200
|
||||
sampling_steps:
|
||||
- 4
|
||||
guidance_scale: 3.0
|
||||
num_frames: 81
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A14/cd/validation
|
||||
offload_training_state: true
|
||||
unload_pipeline_after_validation: true
|
||||
pipeline:
|
||||
flow_shift: 5
|
||||
dit_config:
|
||||
local_attn_size: 6
|
||||
sink_size: 1
|
||||
rope_cache_policy: absolute
|
||||
causal_train_attention: triton
|
||||
@@ -0,0 +1,110 @@
|
||||
models:
|
||||
student:
|
||||
_target_: fastvideo.train.models.wan.WanCausalModel
|
||||
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
|
||||
trainable: true
|
||||
transformer_override_safetensor: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A14/export/cd/transformer/model.safetensors
|
||||
teacher:
|
||||
_target_: fastvideo.train.models.wan.WanModel
|
||||
init_from: Wan-AI/Wan2.1-T2V-14B-Diffusers
|
||||
trainable: false
|
||||
disable_custom_init_weights: true
|
||||
critic:
|
||||
_target_: fastvideo.train.models.wan.WanModel
|
||||
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
|
||||
trainable: true
|
||||
disable_custom_init_weights: true
|
||||
method:
|
||||
_target_: fastvideo.train.methods.distribution_matching.self_forcing.SelfForcingMethod
|
||||
rollout_mode: simulate
|
||||
generator_update_interval: 5
|
||||
real_score_guidance_scale: 4.0
|
||||
dmd_denoising_steps:
|
||||
- 1000
|
||||
- 750
|
||||
- 500
|
||||
- 250
|
||||
warp_denoising_step: true
|
||||
chunk_size: 3
|
||||
student_sample_type: sde
|
||||
context_noise: 0.0
|
||||
same_step_across_blocks: true
|
||||
enable_gradient_in_rollout: true
|
||||
start_gradient_frame: 0
|
||||
fake_score_learning_rate: 4.0e-07
|
||||
fake_score_betas:
|
||||
- 0.0
|
||||
- 0.999
|
||||
fake_score_lr_scheduler: constant
|
||||
training:
|
||||
distributed:
|
||||
num_gpus: 4
|
||||
sp_size: 1
|
||||
tp_size: 1
|
||||
hsdp_replicate_dim: 1
|
||||
hsdp_shard_dim: 4
|
||||
data:
|
||||
data_path: /mnt/lustre/vlm-s4duan/openvid_1m/combined_parquet_dataset
|
||||
dataloader_type: streaming
|
||||
streaming_manifest_path: /mnt/lustre/vlm-k1kong/dataset-index/openvid/streaming-t2v-v2.json
|
||||
streaming_read_batch_size: 2
|
||||
streaming_shuffle_row_groups: true
|
||||
dataloader_num_workers: 0
|
||||
train_batch_size: 2
|
||||
training_cfg_rate: 0.0
|
||||
seed: 1000
|
||||
num_latent_t: 21
|
||||
num_height: 480
|
||||
num_width: 832
|
||||
num_frames: 81
|
||||
optimizer:
|
||||
learning_rate: 2.0e-06
|
||||
betas:
|
||||
- 0.0
|
||||
- 0.999
|
||||
weight_decay: 0.01
|
||||
lr_scheduler: constant
|
||||
lr_warmup_steps: 0
|
||||
loop:
|
||||
max_train_steps: 1000
|
||||
gradient_accumulation_steps: 8
|
||||
checkpoint:
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A14/sf/checkpoints
|
||||
training_state_checkpointing_steps: 1000
|
||||
checkpoints_total_limit: 1
|
||||
tracker:
|
||||
trackers:
|
||||
- wandb
|
||||
project_name: causal_forcing_openvid_a12_a15
|
||||
run_name: A14_sink1_local6_absolute_chunk3_sf1k_from_cd2k_openvid_81f21l_gbs64
|
||||
model:
|
||||
enable_gradient_checkpointing_type: full
|
||||
callbacks:
|
||||
ema:
|
||||
decay: 0.99
|
||||
start_iter: 200
|
||||
grad_clip:
|
||||
max_grad_norm: 1.0
|
||||
validation:
|
||||
pipeline_target: fastvideo.pipelines.basic.wan.wan_causal_dmd_pipeline.WanCausalDMDPipeline
|
||||
dataset_file: /mnt/nfs/vlm-k1kong/FastVideo-openvid-a12-a15-final-20260717/examples/training/finetune/Wan2.1-VSA/Wan-Syn-Data/validation_4.json
|
||||
every_steps: 200
|
||||
sampling_steps:
|
||||
- 4
|
||||
guidance_scale: 3.0
|
||||
num_frames: 81
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A14/sf/validation
|
||||
offload_training_state: true
|
||||
unload_pipeline_after_validation: true
|
||||
sampling_timesteps:
|
||||
- 1000
|
||||
- 750
|
||||
- 500
|
||||
- 250
|
||||
pipeline:
|
||||
flow_shift: 5
|
||||
dit_config:
|
||||
local_attn_size: 6
|
||||
sink_size: 1
|
||||
rope_cache_policy: absolute
|
||||
causal_train_attention: triton
|
||||
@@ -0,0 +1,72 @@
|
||||
models:
|
||||
student:
|
||||
_target_: fastvideo.train.models.wan.WanCausalModel
|
||||
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
|
||||
trainable: true
|
||||
method:
|
||||
_target_: fastvideo.train.methods.fine_tuning.tfsft.TeacherForcingSFTMethod
|
||||
chunk_size: 3
|
||||
training:
|
||||
distributed:
|
||||
num_gpus: 4
|
||||
sp_size: 1
|
||||
tp_size: 1
|
||||
hsdp_replicate_dim: 1
|
||||
hsdp_shard_dim: 4
|
||||
data:
|
||||
data_path: /mnt/lustre/vlm-s4duan/openvid_1m/combined_parquet_dataset
|
||||
dataloader_type: streaming
|
||||
streaming_manifest_path: /mnt/lustre/vlm-k1kong/dataset-index/openvid/streaming-t2v-v2.json
|
||||
streaming_read_batch_size: 2
|
||||
streaming_shuffle_row_groups: true
|
||||
dataloader_num_workers: 0
|
||||
train_batch_size: 2
|
||||
training_cfg_rate: 0.0
|
||||
seed: 1000
|
||||
num_latent_t: 21
|
||||
num_height: 480
|
||||
num_width: 832
|
||||
num_frames: 81
|
||||
optimizer:
|
||||
learning_rate: 2.0e-06
|
||||
betas:
|
||||
- 0.0
|
||||
- 0.999
|
||||
weight_decay: 0.01
|
||||
lr_scheduler: constant
|
||||
lr_warmup_steps: 0
|
||||
loop:
|
||||
max_train_steps: 3000
|
||||
gradient_accumulation_steps: 8
|
||||
checkpoint:
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A14/tf/checkpoints
|
||||
training_state_checkpointing_steps: 1000
|
||||
checkpoints_total_limit: 3
|
||||
tracker:
|
||||
trackers:
|
||||
- wandb
|
||||
project_name: causal_forcing_openvid_a12_a15
|
||||
run_name: A14_sink1_local6_absolute_chunk3_tf3k_openvid_81f21l_gbs64
|
||||
model:
|
||||
enable_gradient_checkpointing_type: full
|
||||
callbacks:
|
||||
grad_clip:
|
||||
max_grad_norm: 1.0
|
||||
validation:
|
||||
pipeline_target: fastvideo.pipelines.basic.wan.wan_causal_pipeline.WanCausalPipeline
|
||||
dataset_file: /mnt/nfs/vlm-k1kong/FastVideo-openvid-a12-a15-final-20260717/examples/training/finetune/Wan2.1-VSA/Wan-Syn-Data/validation_4.json
|
||||
every_steps: 200
|
||||
sampling_steps:
|
||||
- 40
|
||||
guidance_scale: 6.0
|
||||
num_frames: 81
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A14/tf/validation
|
||||
offload_training_state: true
|
||||
unload_pipeline_after_validation: true
|
||||
pipeline:
|
||||
flow_shift: 5
|
||||
dit_config:
|
||||
local_attn_size: 6
|
||||
sink_size: 1
|
||||
rope_cache_policy: absolute
|
||||
causal_train_attention: triton
|
||||
@@ -0,0 +1,89 @@
|
||||
models:
|
||||
student:
|
||||
_target_: fastvideo.train.models.wan.WanCausalModel
|
||||
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
|
||||
trainable: true
|
||||
transformer_override_safetensor: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A15/export/tf/transformer/model.safetensors
|
||||
num_frames_per_block: 1
|
||||
teacher:
|
||||
_target_: fastvideo.train.models.wan.WanCausalModel
|
||||
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
|
||||
trainable: false
|
||||
transformer_override_safetensor: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A15/export/tf/transformer/model.safetensors
|
||||
num_frames_per_block: 1
|
||||
ema:
|
||||
_target_: fastvideo.train.models.wan.WanCausalModel
|
||||
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
|
||||
trainable: false
|
||||
transformer_override_safetensor: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A15/export/tf/transformer/model.safetensors
|
||||
num_frames_per_block: 1
|
||||
method:
|
||||
_target_: fastvideo.train.methods.consistency_model.causal_cd.CausalConsistencyDistillationMethod
|
||||
discrete_cd_N: 48
|
||||
guidance_scale: 3.0
|
||||
ema_decay: 0.99
|
||||
ema_start_step: 200
|
||||
training:
|
||||
distributed:
|
||||
num_gpus: 4
|
||||
sp_size: 1
|
||||
tp_size: 1
|
||||
hsdp_replicate_dim: 1
|
||||
hsdp_shard_dim: 4
|
||||
data:
|
||||
data_path: /mnt/lustre/vlm-s4duan/openvid_1m/combined_parquet_dataset
|
||||
dataloader_type: streaming
|
||||
streaming_manifest_path: /mnt/lustre/vlm-k1kong/dataset-index/openvid/streaming-t2v-v2.json
|
||||
streaming_read_batch_size: 2
|
||||
streaming_shuffle_row_groups: true
|
||||
dataloader_num_workers: 0
|
||||
train_batch_size: 2
|
||||
training_cfg_rate: 0.0
|
||||
seed: 1000
|
||||
num_latent_t: 21
|
||||
num_height: 480
|
||||
num_width: 832
|
||||
num_frames: 81
|
||||
optimizer:
|
||||
learning_rate: 2.0e-06
|
||||
betas:
|
||||
- 0.0
|
||||
- 0.999
|
||||
weight_decay: 0.01
|
||||
lr_scheduler: constant
|
||||
lr_warmup_steps: 0
|
||||
loop:
|
||||
max_train_steps: 2000
|
||||
gradient_accumulation_steps: 8
|
||||
checkpoint:
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A15/cd/checkpoints
|
||||
training_state_checkpointing_steps: 1000
|
||||
checkpoints_total_limit: 2
|
||||
tracker:
|
||||
trackers:
|
||||
- wandb
|
||||
project_name: causal_forcing_openvid_a12_a15
|
||||
run_name: A15_sink1_local6_relativistic_framewise_cd2k_from_tf3k_openvid_81f21l_gbs64
|
||||
model:
|
||||
enable_gradient_checkpointing_type: full
|
||||
callbacks:
|
||||
grad_clip:
|
||||
max_grad_norm: 1.0
|
||||
validation:
|
||||
pipeline_target: fastvideo.pipelines.basic.wan.wan_causal_pipeline.WanCausalPipeline
|
||||
dataset_file: /mnt/nfs/vlm-k1kong/FastVideo-openvid-a12-a15-final-20260717/examples/training/finetune/Wan2.1-VSA/Wan-Syn-Data/validation_4.json
|
||||
every_steps: 200
|
||||
sampling_steps:
|
||||
- 4
|
||||
guidance_scale: 3.0
|
||||
num_frames: 81
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A15/cd/validation
|
||||
offload_training_state: true
|
||||
unload_pipeline_after_validation: true
|
||||
pipeline:
|
||||
flow_shift: 5
|
||||
dit_config:
|
||||
local_attn_size: 6
|
||||
sink_size: 1
|
||||
rope_cache_policy: relativistic
|
||||
causal_train_attention: triton
|
||||
@@ -0,0 +1,111 @@
|
||||
models:
|
||||
student:
|
||||
_target_: fastvideo.train.models.wan.WanCausalModel
|
||||
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
|
||||
trainable: true
|
||||
transformer_override_safetensor: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A15/export/cd/transformer/model.safetensors
|
||||
num_frames_per_block: 1
|
||||
teacher:
|
||||
_target_: fastvideo.train.models.wan.WanModel
|
||||
init_from: Wan-AI/Wan2.1-T2V-14B-Diffusers
|
||||
trainable: false
|
||||
disable_custom_init_weights: true
|
||||
critic:
|
||||
_target_: fastvideo.train.models.wan.WanModel
|
||||
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
|
||||
trainable: true
|
||||
disable_custom_init_weights: true
|
||||
method:
|
||||
_target_: fastvideo.train.methods.distribution_matching.self_forcing.SelfForcingMethod
|
||||
rollout_mode: simulate
|
||||
generator_update_interval: 5
|
||||
real_score_guidance_scale: 4.0
|
||||
dmd_denoising_steps:
|
||||
- 1000
|
||||
- 750
|
||||
- 500
|
||||
- 250
|
||||
warp_denoising_step: true
|
||||
chunk_size: 1
|
||||
student_sample_type: sde
|
||||
context_noise: 0.0
|
||||
same_step_across_blocks: true
|
||||
enable_gradient_in_rollout: true
|
||||
start_gradient_frame: 0
|
||||
fake_score_learning_rate: 4.0e-07
|
||||
fake_score_betas:
|
||||
- 0.0
|
||||
- 0.999
|
||||
fake_score_lr_scheduler: constant
|
||||
training:
|
||||
distributed:
|
||||
num_gpus: 4
|
||||
sp_size: 1
|
||||
tp_size: 1
|
||||
hsdp_replicate_dim: 1
|
||||
hsdp_shard_dim: 4
|
||||
data:
|
||||
data_path: /mnt/lustre/vlm-s4duan/openvid_1m/combined_parquet_dataset
|
||||
dataloader_type: streaming
|
||||
streaming_manifest_path: /mnt/lustre/vlm-k1kong/dataset-index/openvid/streaming-t2v-v2.json
|
||||
streaming_read_batch_size: 2
|
||||
streaming_shuffle_row_groups: true
|
||||
dataloader_num_workers: 0
|
||||
train_batch_size: 2
|
||||
training_cfg_rate: 0.0
|
||||
seed: 1000
|
||||
num_latent_t: 21
|
||||
num_height: 480
|
||||
num_width: 832
|
||||
num_frames: 81
|
||||
optimizer:
|
||||
learning_rate: 2.0e-06
|
||||
betas:
|
||||
- 0.0
|
||||
- 0.999
|
||||
weight_decay: 0.01
|
||||
lr_scheduler: constant
|
||||
lr_warmup_steps: 0
|
||||
loop:
|
||||
max_train_steps: 1000
|
||||
gradient_accumulation_steps: 8
|
||||
checkpoint:
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A15/sf/checkpoints
|
||||
training_state_checkpointing_steps: 1000
|
||||
checkpoints_total_limit: 1
|
||||
tracker:
|
||||
trackers:
|
||||
- wandb
|
||||
project_name: causal_forcing_openvid_a12_a15
|
||||
run_name: A15_sink1_local6_relativistic_framewise_sf1k_from_cd2k_openvid_81f21l_gbs64
|
||||
model:
|
||||
enable_gradient_checkpointing_type: full
|
||||
callbacks:
|
||||
ema:
|
||||
decay: 0.99
|
||||
start_iter: 200
|
||||
grad_clip:
|
||||
max_grad_norm: 1.0
|
||||
validation:
|
||||
pipeline_target: fastvideo.pipelines.basic.wan.wan_causal_dmd_pipeline.WanCausalDMDPipeline
|
||||
dataset_file: /mnt/nfs/vlm-k1kong/FastVideo-openvid-a12-a15-final-20260717/examples/training/finetune/Wan2.1-VSA/Wan-Syn-Data/validation_4.json
|
||||
every_steps: 200
|
||||
sampling_steps:
|
||||
- 4
|
||||
guidance_scale: 3.0
|
||||
num_frames: 81
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A15/sf/validation
|
||||
offload_training_state: true
|
||||
unload_pipeline_after_validation: true
|
||||
sampling_timesteps:
|
||||
- 1000
|
||||
- 750
|
||||
- 500
|
||||
- 250
|
||||
pipeline:
|
||||
flow_shift: 5
|
||||
dit_config:
|
||||
local_attn_size: 6
|
||||
sink_size: 1
|
||||
rope_cache_policy: relativistic
|
||||
causal_train_attention: triton
|
||||
@@ -0,0 +1,73 @@
|
||||
models:
|
||||
student:
|
||||
_target_: fastvideo.train.models.wan.WanCausalModel
|
||||
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
|
||||
trainable: true
|
||||
num_frames_per_block: 1
|
||||
method:
|
||||
_target_: fastvideo.train.methods.fine_tuning.tfsft.TeacherForcingSFTMethod
|
||||
chunk_size: 1
|
||||
training:
|
||||
distributed:
|
||||
num_gpus: 4
|
||||
sp_size: 1
|
||||
tp_size: 1
|
||||
hsdp_replicate_dim: 1
|
||||
hsdp_shard_dim: 4
|
||||
data:
|
||||
data_path: /mnt/lustre/vlm-s4duan/openvid_1m/combined_parquet_dataset
|
||||
dataloader_type: streaming
|
||||
streaming_manifest_path: /mnt/lustre/vlm-k1kong/dataset-index/openvid/streaming-t2v-v2.json
|
||||
streaming_read_batch_size: 2
|
||||
streaming_shuffle_row_groups: true
|
||||
dataloader_num_workers: 0
|
||||
train_batch_size: 2
|
||||
training_cfg_rate: 0.0
|
||||
seed: 1000
|
||||
num_latent_t: 21
|
||||
num_height: 480
|
||||
num_width: 832
|
||||
num_frames: 81
|
||||
optimizer:
|
||||
learning_rate: 2.0e-06
|
||||
betas:
|
||||
- 0.0
|
||||
- 0.999
|
||||
weight_decay: 0.01
|
||||
lr_scheduler: constant
|
||||
lr_warmup_steps: 0
|
||||
loop:
|
||||
max_train_steps: 3000
|
||||
gradient_accumulation_steps: 8
|
||||
checkpoint:
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A15/tf/checkpoints
|
||||
training_state_checkpointing_steps: 1000
|
||||
checkpoints_total_limit: 3
|
||||
tracker:
|
||||
trackers:
|
||||
- wandb
|
||||
project_name: causal_forcing_openvid_a12_a15
|
||||
run_name: A15_sink1_local6_relativistic_framewise_tf3k_openvid_81f21l_gbs64
|
||||
model:
|
||||
enable_gradient_checkpointing_type: full
|
||||
callbacks:
|
||||
grad_clip:
|
||||
max_grad_norm: 1.0
|
||||
validation:
|
||||
pipeline_target: fastvideo.pipelines.basic.wan.wan_causal_pipeline.WanCausalPipeline
|
||||
dataset_file: /mnt/nfs/vlm-k1kong/FastVideo-openvid-a12-a15-final-20260717/examples/training/finetune/Wan2.1-VSA/Wan-Syn-Data/validation_4.json
|
||||
every_steps: 200
|
||||
sampling_steps:
|
||||
- 40
|
||||
guidance_scale: 6.0
|
||||
num_frames: 81
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A15/tf/validation
|
||||
offload_training_state: true
|
||||
unload_pipeline_after_validation: true
|
||||
pipeline:
|
||||
flow_shift: 5
|
||||
dit_config:
|
||||
local_attn_size: 6
|
||||
sink_size: 1
|
||||
rope_cache_policy: relativistic
|
||||
causal_train_attention: triton
|
||||
@@ -0,0 +1,52 @@
|
||||
# OpenVid causal A12-A15 plan (NOT STARTED)
|
||||
|
||||
Prepared from commit `30ada30e4c6b05aa68cd1eb8940a34d149457147`. This directory contains configuration only;
|
||||
no training command was launched.
|
||||
|
||||
Data source: `/mnt/lustre/vlm-s4duan/openvid_1m/combined_parquet_dataset` (4494 parquet files, about 5.5 TiB). It is owned by
|
||||
`vlm-s4duan`; filesystem read permission exists, but obtain the owner's consent
|
||||
before launching and coordinate I/O. Do not write into that directory.
|
||||
|
||||
The opt-in streaming loader projects only the 15 T2V columns, reads each
|
||||
assigned row group sequentially, and stores its JSON manifest at
|
||||
`/mnt/lustre/vlm-k1kong/dataset-index/openvid/streaming-t2v-v2.json` in user-owned Lustre. It uses zero DataLoader workers and never
|
||||
writes a cache or index into the shared source tree.
|
||||
|
||||
All stages use 4 GPUs, microbatch 2/rank, gradient accumulation 8, hence global
|
||||
batch = 2 * 4 * 8 = 64. `dataloader_num_workers=0` limits shared-memory and
|
||||
Lustre prefetch pressure.
|
||||
|
||||
All four conditions use exactly 21 latent frames / 81 raw frames in TF, CD,
|
||||
SF, and validation. Chunk-3 conditions therefore use seven identical
|
||||
three-latent blocks. A15 is length-matched and uses framewise blocks.
|
||||
|
||||
A15 "framewise" means `num_frames_per_block=1` on every causal role, plus
|
||||
`method.chunk_size=1` in TF and SF. Causal CD has no independent-frame timestep
|
||||
option: it samples one t/t_next pair and broadcasts it over T, so A15 CD is
|
||||
framewise causal attention but not framewise diffusion-time sampling.
|
||||
|
||||
The requested LR/betas apply to each stage's main optimizer: 2e-6 and
|
||||
(0.0, 0.999). SF critic keeps the proven DMD value 4e-7 with (0.0, 0.999).
|
||||
|
||||
To launch one condition later:
|
||||
|
||||
export WANDB_API_KEY=...
|
||||
bash /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/scripts/run_condition.sh A12 /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A12 29820
|
||||
|
||||
To run all four sequentially on one 4-GPU node:
|
||||
|
||||
export WANDB_API_KEY=...
|
||||
bash /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/scripts/run_all_sequential.sh /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15
|
||||
|
||||
Online W&B is the default. For an intentionally offline launch, omit the key
|
||||
and set `WANDB_MODE=offline`. Training checkpoints still resume from `latest`,
|
||||
but W&B does not merge separate offline process restarts into one run; sync the
|
||||
resulting offline runs individually later.
|
||||
|
||||
To validate all three configs for one condition without starting training,
|
||||
creating W&B state, or requiring a key:
|
||||
|
||||
PREFLIGHT_ONLY=1 bash /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/scripts/run_condition.sh A12 /mnt/lustre/vlm-k1kong/experiments/openvid_causal_a12_a15/20260717_014659_openvid_a12_a15/A12 29820
|
||||
|
||||
`PREFLIGHT_ONLY=1` is also supported by `run_all_sequential.sh`; it validates
|
||||
all twelve configs and exits before queue state or checkpoint checks.
|
||||
@@ -0,0 +1,40 @@
|
||||
# MixKit-21 causal-kernel teacher-forcing ablation
|
||||
|
||||
All runs use the same training and validation settings. Only the three
|
||||
attention axes listed in `experiment_matrix.tsv` vary.
|
||||
|
||||
| ID | sink | local | RoPE | lane | primary contrast |
|
||||
|---|---:|---:|---|---|---|
|
||||
| A01 | 0 | 21 | absolute | node0 | rope policy control |
|
||||
| A02 | 0 | 21 | relativistic | node1 | rope policy control |
|
||||
| A03 | 1 | 21 | relativistic | node0 | sink at local 21 |
|
||||
| A04 | 0 | 6 | relativistic | node0 | local window at sink 0 |
|
||||
| A05 | 1 | 6 | relativistic | node0 | sink at local 6 |
|
||||
| A06 | 0 | 12 | relativistic | node1 | local window at sink 0 |
|
||||
| A07 | 1 | 12 | relativistic | node1 | sink at local 12 |
|
||||
| A08 | 3 | 12 | relativistic | node1 | sink size at local 12 |
|
||||
|
||||
Fixed invariants:
|
||||
|
||||
- MixKit precomputed data at 480x832 with 21 stored latent frames.
|
||||
- Teacher forcing for 2,000 steps, batch size 1, chunk size 3.
|
||||
- Fused training attention: `causal_train_attention: triton`.
|
||||
- Validation at 249 pixel frames, equivalent to 63 Wan latent frames.
|
||||
- Validation reuses the training transformer and pipeline config, so sink,
|
||||
local window, and RoPE policy stay identical between training and inference.
|
||||
- Four GPUs, full gradient checkpointing, validation/checkpoint every 200/1000
|
||||
steps, and W&B online tracking in a dedicated project.
|
||||
|
||||
Validate the matrix and template:
|
||||
|
||||
```bash
|
||||
python scripts/train/manage_mixkit21_tf_ablation.py validate
|
||||
```
|
||||
|
||||
Run one lane after supplying `WANDB_API_KEY` at runtime:
|
||||
|
||||
```bash
|
||||
SEQUENCE_ID=<timestamp> LANE=node0 \
|
||||
RUN_CONDITIONS="A01 A03 A04 A05" \
|
||||
bash scripts/train/run_mixkit21_tf_ablation_lane.sh
|
||||
```
|
||||
@@ -0,0 +1,9 @@
|
||||
id condition sink_size local_attn_size rope_cache_policy lane primary_contrast
|
||||
A01 sink0_local21_absolute 0 21 absolute node0 A01-vs-A02: rope policy control
|
||||
A02 sink0_local21_relative 0 21 relativistic node1 A01-vs-A02: rope policy control
|
||||
A03 sink1_local21_relative 1 21 relativistic node0 A02-vs-A03: sink at local21
|
||||
A04 sink0_local6_relative 0 6 relativistic node0 A02-vs-A04: local window at sink0
|
||||
A05 sink1_local6_relative 1 6 relativistic node0 A04-vs-A05: sink at local6
|
||||
A06 sink0_local12_relative 0 12 relativistic node1 A02-vs-A06: local window at sink0
|
||||
A07 sink1_local12_relative 1 12 relativistic node1 A06-vs-A07: sink at local12
|
||||
A08 sink3_local12_relative 3 12 relativistic node1 A06-vs-A07-vs-A08: sink size at local12
|
||||
|
@@ -0,0 +1,74 @@
|
||||
models:
|
||||
student:
|
||||
_target_: fastvideo.train.models.wan.WanCausalModel
|
||||
init_from: Wan-AI/Wan2.1-T2V-1.3B-Diffusers
|
||||
trainable: true
|
||||
|
||||
method:
|
||||
_target_: fastvideo.train.methods.fine_tuning.tfsft.TeacherForcingSFTMethod
|
||||
chunk_size: 3
|
||||
|
||||
training:
|
||||
distributed:
|
||||
num_gpus: 4
|
||||
sp_size: 1
|
||||
tp_size: 1
|
||||
hsdp_replicate_dim: 1
|
||||
hsdp_shard_dim: 4
|
||||
|
||||
data:
|
||||
data_path: /mnt/lustre/vlm-k1kong/datasets/HD-Mixkit-Finetune-Wan/combined_parquet_dataset
|
||||
dataloader_num_workers: 0
|
||||
train_batch_size: 1
|
||||
training_cfg_rate: 0.0
|
||||
seed: 1000
|
||||
num_latent_t: 21
|
||||
num_height: 480
|
||||
num_width: 832
|
||||
num_frames: 81
|
||||
|
||||
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: __MAX_TRAIN_STEPS__
|
||||
gradient_accumulation_steps: 1
|
||||
|
||||
checkpoint:
|
||||
output_dir: __CHECKPOINT_DIR__
|
||||
training_state_checkpointing_steps: __CHECKPOINT_STEPS__
|
||||
checkpoints_total_limit: 2
|
||||
|
||||
tracker:
|
||||
trackers: [wandb]
|
||||
project_name: __PROJECT_NAME__
|
||||
run_name: __WANDB_RUN_NAME__
|
||||
|
||||
model:
|
||||
enable_gradient_checkpointing_type: full
|
||||
|
||||
callbacks:
|
||||
grad_clip:
|
||||
max_grad_norm: 1.0
|
||||
validation:
|
||||
pipeline_target: fastvideo.pipelines.basic.wan.wan_causal_pipeline.WanCausalPipeline
|
||||
dataset_file: __VALIDATION_DATASET_FILE__
|
||||
every_steps: __VALIDATION_EVERY_STEPS__
|
||||
sampling_steps: [__VALIDATION_SAMPLING_STEPS__]
|
||||
guidance_scale: 6.0
|
||||
num_frames: 249
|
||||
output_dir: __VALIDATION_DIR__
|
||||
offload_training_state: true
|
||||
unload_pipeline_after_validation: true
|
||||
|
||||
pipeline:
|
||||
flow_shift: 5
|
||||
dit_config:
|
||||
local_attn_size: __LOCAL_ATTN_SIZE__
|
||||
sink_size: __SINK_SIZE__
|
||||
rope_cache_policy: __ROPE_CACHE_POLICY__
|
||||
causal_train_attention: triton
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"caption": "A lone rider guides a horse across an open field at sunset, with steady motion and a slowly changing background."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# WanTrack causal synth stage-2 run
|
||||
|
||||
This directory archives the exact configuration and launch artifacts used by
|
||||
the completed WanTrack causal run on 2026-07-23/24.
|
||||
|
||||
- Run root:
|
||||
`/mnt/lustre/vlm-k1kong/experiments/wantrack_causal/20260723_070728/wantrack-ckpt600-block3-local6-sink1-relative-gbs32`
|
||||
- Initial checkpoint:
|
||||
`/mnt/lustre/vlm-k1kong/models/wantrack-synth-stage2-ckpt600-bias`
|
||||
- Dataset:
|
||||
`/mnt/lustre/vlm-s4duan/data/combined_synth_parquets`
|
||||
- Validation dataset:
|
||||
`/mnt/lustre/vlm-s4duan/val_examples_mixed/combined_parquet_dataset`
|
||||
- Topology: 4 rack-3 nodes, 4 GPUs per node, micro-batch 2, global batch 32
|
||||
- Attention: 3 latent frames per block, sink 1, local window 6,
|
||||
relativistic RoPE
|
||||
- Schedule: TF 3000, CD 2000, SF 1000 optimizer steps
|
||||
- Validation: every 250 steps; TF uses 30 denoising steps while CD and SF use
|
||||
4 denoising steps
|
||||
- Checkpoints: every 500 optimizer steps
|
||||
|
||||
The TF job resumed from checkpoint 2000 after its validation configuration was
|
||||
corrected to multi-step sampling. The YAML files in this directory are the
|
||||
final files used by the run, including their absolute cluster paths.
|
||||
|
||||
`cluster/run_pipeline_node.sh` launches the resumable TF -> CD -> SF pipeline
|
||||
and exports TF `student`, CD `ema`, and SF `student_ema`. It requires
|
||||
`WANDB_API_KEY` to be injected through the process environment. The gallery
|
||||
upload script similarly requires an in-memory `HF_TOKEN`; no credentials are
|
||||
stored here.
|
||||
|
||||
The original Kubernetes manifest retains the historical workload label
|
||||
`wantrack-causal-framewise-gbs32`. That label is stale metadata: the training
|
||||
authority is the three YAML files, all of which use
|
||||
`num_frames_per_block: 3`.
|
||||
|
||||
The SF EMA export intentionally contains only the trainable checkpoint role.
|
||||
For standalone inference, four frozen track-encoder parameters were restored
|
||||
from the initialization checkpoint. See `receipts/sf-full-export.json` for the
|
||||
full bundle provenance and hashes.
|
||||
|
||||
The `gallery/` scripts produced 64 generated examples plus their 64 matching
|
||||
ground-truth videos from the full SF EMA bundle.
|
||||
@@ -0,0 +1,95 @@
|
||||
models:
|
||||
student:
|
||||
_target_: fastvideo.train.models.wantrack.WanTrackCausalModel
|
||||
init_from: /mnt/lustre/vlm-k1kong/models/wantrack-synth-stage2-ckpt600-bias
|
||||
transformer_override_safetensor: /mnt/lustre/vlm-k1kong/experiments/wantrack_causal/20260723_070728/wantrack-ckpt600-block3-local6-sink1-relative-gbs32/export/tf/transformer/model.safetensors
|
||||
trainable: true
|
||||
num_frames_per_block: 3
|
||||
freeze_track_encoder: true
|
||||
track_augmentation:
|
||||
enabled: true
|
||||
sparse_object_sampling: true
|
||||
extra_points: 20
|
||||
extra_point_sampling: random
|
||||
track_dropout_probability: 0.5
|
||||
temporal_mask_probability: 0.2
|
||||
temporal_mask_chunk_size: 8
|
||||
motion_dropout_probability: 0.3
|
||||
text_dropout_probability: 0.0
|
||||
teacher:
|
||||
_target_: fastvideo.train.models.wantrack.WanTrackCausalModel
|
||||
init_from: /mnt/lustre/vlm-k1kong/models/wantrack-synth-stage2-ckpt600-bias
|
||||
transformer_override_safetensor: /mnt/lustre/vlm-k1kong/experiments/wantrack_causal/20260723_070728/wantrack-ckpt600-block3-local6-sink1-relative-gbs32/export/tf/transformer/model.safetensors
|
||||
trainable: false
|
||||
num_frames_per_block: 3
|
||||
freeze_track_encoder: true
|
||||
ema:
|
||||
_target_: fastvideo.train.models.wantrack.WanTrackCausalModel
|
||||
init_from: /mnt/lustre/vlm-k1kong/models/wantrack-synth-stage2-ckpt600-bias
|
||||
transformer_override_safetensor: /mnt/lustre/vlm-k1kong/experiments/wantrack_causal/20260723_070728/wantrack-ckpt600-block3-local6-sink1-relative-gbs32/export/tf/transformer/model.safetensors
|
||||
trainable: false
|
||||
num_frames_per_block: 3
|
||||
freeze_track_encoder: true
|
||||
method:
|
||||
_target_: fastvideo.train.methods.consistency_model.causal_cd.CausalConsistencyDistillationMethod
|
||||
discrete_cd_N: 48
|
||||
guidance_scale: 3.0
|
||||
ema_decay: 0.99
|
||||
ema_start_step: 200
|
||||
training:
|
||||
distributed:
|
||||
num_gpus: 16
|
||||
sp_size: 1
|
||||
tp_size: 1
|
||||
hsdp_replicate_dim: 4
|
||||
hsdp_shard_dim: 4
|
||||
data:
|
||||
data_path: /mnt/lustre/vlm-s4duan/data/combined_synth_parquets
|
||||
preprocessed_data_type: i2v_track
|
||||
dataloader_num_workers: 4
|
||||
train_batch_size: 2
|
||||
training_cfg_rate: 0.0
|
||||
seed: 1000
|
||||
num_latent_t: 31
|
||||
num_height: 480
|
||||
num_width: 832
|
||||
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: 2000
|
||||
gradient_accumulation_steps: 1
|
||||
checkpoint:
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/wantrack_causal/20260723_070728/wantrack-ckpt600-block3-local6-sink1-relative-gbs32/cd/checkpoints
|
||||
training_state_checkpointing_steps: 500
|
||||
checkpoints_total_limit: 4
|
||||
tracker:
|
||||
trackers: [wandb]
|
||||
entity: kaiqin_kong_ucsd
|
||||
project_name: wantrack_causal_synth_stage2
|
||||
run_name: wantrack_ckpt600_block3_sink1_local6_relative_cd2k_gbs32
|
||||
model:
|
||||
enable_gradient_checkpointing_type: full
|
||||
callbacks:
|
||||
grad_clip:
|
||||
max_grad_norm: 1.0
|
||||
wantrack_validation:
|
||||
_target_: fastvideo.train.callbacks.track_validation.TrackValidationCallback
|
||||
every_steps: 250
|
||||
val_data_path: /mnt/lustre/vlm-s4duan/val_examples_mixed/combined_parquet_dataset
|
||||
num_val_samples: 4
|
||||
num_inference_steps: 4
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/wantrack_causal/20260723_070728/wantrack-ckpt600-block3-local6-sink1-relative-gbs32/cd/validation
|
||||
seed: 1000
|
||||
validate_at_start: false
|
||||
pipeline:
|
||||
flow_shift: 6
|
||||
dit_config:
|
||||
local_attn_size: 6
|
||||
sink_size: 1
|
||||
rope_cache_policy: relativistic
|
||||
causal_train_attention: triton
|
||||
@@ -0,0 +1,106 @@
|
||||
models:
|
||||
student:
|
||||
_target_: fastvideo.train.models.wantrack.WanTrackCausalModel
|
||||
init_from: /mnt/lustre/vlm-k1kong/models/wantrack-synth-stage2-ckpt600-bias
|
||||
transformer_override_safetensor: /mnt/lustre/vlm-k1kong/experiments/wantrack_causal/20260723_070728/wantrack-ckpt600-block3-local6-sink1-relative-gbs32/export/cd/transformer/model.safetensors
|
||||
trainable: true
|
||||
num_frames_per_block: 3
|
||||
freeze_track_encoder: true
|
||||
track_augmentation:
|
||||
enabled: true
|
||||
sparse_object_sampling: true
|
||||
extra_points: 20
|
||||
extra_point_sampling: random
|
||||
track_dropout_probability: 0.5
|
||||
temporal_mask_probability: 0.2
|
||||
temporal_mask_chunk_size: 8
|
||||
motion_dropout_probability: 0.3
|
||||
text_dropout_probability: 0.0
|
||||
teacher:
|
||||
_target_: fastvideo.train.models.wantrack.WanTrackModel
|
||||
init_from: /mnt/lustre/vlm-k1kong/models/wantrack-synth-stage2-ckpt600-bias
|
||||
trainable: false
|
||||
freeze_track_encoder: true
|
||||
disable_custom_init_weights: true
|
||||
critic:
|
||||
_target_: fastvideo.train.models.wantrack.WanTrackModel
|
||||
init_from: /mnt/lustre/vlm-k1kong/models/wantrack-synth-stage2-ckpt600-bias
|
||||
trainable: true
|
||||
freeze_track_encoder: true
|
||||
disable_custom_init_weights: true
|
||||
method:
|
||||
_target_: fastvideo.train.methods.distribution_matching.self_forcing.SelfForcingMethod
|
||||
rollout_mode: simulate
|
||||
generator_update_interval: 5
|
||||
real_score_guidance_scale: 4.0
|
||||
dmd_denoising_steps: [1000, 750, 500, 250]
|
||||
warp_denoising_step: true
|
||||
chunk_size: 3
|
||||
student_sample_type: sde
|
||||
context_noise: 0.0
|
||||
same_step_across_blocks: true
|
||||
enable_gradient_in_rollout: true
|
||||
start_gradient_frame: 0
|
||||
fake_score_learning_rate: 4.0e-7
|
||||
fake_score_betas: [0.0, 0.999]
|
||||
fake_score_lr_scheduler: constant
|
||||
training:
|
||||
distributed:
|
||||
num_gpus: 16
|
||||
sp_size: 1
|
||||
tp_size: 1
|
||||
hsdp_replicate_dim: 4
|
||||
hsdp_shard_dim: 4
|
||||
data:
|
||||
data_path: /mnt/lustre/vlm-s4duan/data/combined_synth_parquets
|
||||
preprocessed_data_type: i2v_track
|
||||
dataloader_num_workers: 4
|
||||
train_batch_size: 2
|
||||
training_cfg_rate: 0.0
|
||||
seed: 1000
|
||||
num_latent_t: 31
|
||||
num_height: 480
|
||||
num_width: 832
|
||||
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: 1000
|
||||
gradient_accumulation_steps: 1
|
||||
checkpoint:
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/wantrack_causal/20260723_070728/wantrack-ckpt600-block3-local6-sink1-relative-gbs32/sf/checkpoints
|
||||
training_state_checkpointing_steps: 500
|
||||
checkpoints_total_limit: 2
|
||||
tracker:
|
||||
trackers: [wandb]
|
||||
entity: kaiqin_kong_ucsd
|
||||
project_name: wantrack_causal_synth_stage2
|
||||
run_name: wantrack_ckpt600_block3_sink1_local6_relative_sf1k_gbs32
|
||||
model:
|
||||
enable_gradient_checkpointing_type: full
|
||||
callbacks:
|
||||
ema:
|
||||
decay: 0.99
|
||||
start_iter: 200
|
||||
grad_clip:
|
||||
max_grad_norm: 1.0
|
||||
wantrack_validation:
|
||||
_target_: fastvideo.train.callbacks.track_validation.TrackValidationCallback
|
||||
every_steps: 250
|
||||
val_data_path: /mnt/lustre/vlm-s4duan/val_examples_mixed/combined_parquet_dataset
|
||||
num_val_samples: 4
|
||||
num_inference_steps: 4
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/wantrack_causal/20260723_070728/wantrack-ckpt600-block3-local6-sink1-relative-gbs32/sf/validation
|
||||
seed: 1000
|
||||
validate_at_start: false
|
||||
pipeline:
|
||||
flow_shift: 6
|
||||
dit_config:
|
||||
local_attn_size: 6
|
||||
sink_size: 1
|
||||
rope_cache_policy: relativistic
|
||||
causal_train_attention: triton
|
||||
@@ -0,0 +1,79 @@
|
||||
models:
|
||||
student:
|
||||
_target_: fastvideo.train.models.wantrack.WanTrackCausalModel
|
||||
init_from: /mnt/lustre/vlm-k1kong/models/wantrack-synth-stage2-ckpt600-bias
|
||||
trainable: true
|
||||
num_frames_per_block: 3
|
||||
freeze_track_encoder: true
|
||||
track_augmentation:
|
||||
enabled: true
|
||||
sparse_object_sampling: true
|
||||
extra_points: 20
|
||||
extra_point_sampling: random
|
||||
track_dropout_probability: 0.5
|
||||
temporal_mask_probability: 0.2
|
||||
temporal_mask_chunk_size: 8
|
||||
motion_dropout_probability: 0.3
|
||||
text_dropout_probability: 0.0
|
||||
method:
|
||||
_target_: fastvideo.train.methods.fine_tuning.tfsft.TeacherForcingSFTMethod
|
||||
chunk_size: 3
|
||||
training:
|
||||
distributed:
|
||||
num_gpus: 16
|
||||
sp_size: 1
|
||||
tp_size: 1
|
||||
hsdp_replicate_dim: 4
|
||||
hsdp_shard_dim: 4
|
||||
data:
|
||||
data_path: /mnt/lustre/vlm-s4duan/data/combined_synth_parquets
|
||||
preprocessed_data_type: i2v_track
|
||||
dataloader_num_workers: 4
|
||||
train_batch_size: 2
|
||||
training_cfg_rate: 0.0
|
||||
seed: 1000
|
||||
num_latent_t: 31
|
||||
num_height: 480
|
||||
num_width: 832
|
||||
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: 3000
|
||||
gradient_accumulation_steps: 1
|
||||
checkpoint:
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/wantrack_causal/20260723_070728/wantrack-ckpt600-block3-local6-sink1-relative-gbs32/tf/checkpoints
|
||||
training_state_checkpointing_steps: 500
|
||||
checkpoints_total_limit: 6
|
||||
tracker:
|
||||
trackers: [wandb]
|
||||
entity: kaiqin_kong_ucsd
|
||||
project_name: wantrack_causal_synth_stage2
|
||||
run_name: wantrack_ckpt600_block3_sink1_local6_relative_tf3k_gbs32_resume2000_multistep
|
||||
model:
|
||||
enable_gradient_checkpointing_type: full
|
||||
callbacks:
|
||||
grad_clip:
|
||||
max_grad_norm: 1.0
|
||||
wantrack_validation:
|
||||
_target_: fastvideo.train.callbacks.track_validation.TrackValidationCallback
|
||||
every_steps: 250
|
||||
val_data_path: /mnt/lustre/vlm-s4duan/val_examples_mixed/combined_parquet_dataset
|
||||
num_val_samples: 4
|
||||
num_inference_steps: 30
|
||||
guidance_scale: 3.0
|
||||
motion_guidance_scale: 1.5
|
||||
output_dir: /mnt/lustre/vlm-k1kong/experiments/wantrack_causal/20260723_070728/wantrack-ckpt600-block3-local6-sink1-relative-gbs32/tf/validation
|
||||
seed: 1000
|
||||
validate_at_start: false
|
||||
pipeline:
|
||||
flow_shift: 6
|
||||
dit_config:
|
||||
local_attn_size: 6
|
||||
sink_size: 1
|
||||
rope_cache_policy: relativistic
|
||||
causal_train_attention: triton
|
||||
@@ -1,70 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Stage-2 QAT-aware DMD2 distillation for Kandinsky5 T2V (480p).
|
||||
#
|
||||
# Usage:
|
||||
# bash distill_dmd_qat.sh <stage1-checkpoint> [--dotted.key value ...]
|
||||
#
|
||||
# <stage1-checkpoint> is either:
|
||||
# - a raw stage-1 DCP checkpoint written by
|
||||
# examples/train/configs/fine_tuning/kandinsky5/finetune_qat.sh
|
||||
# (a checkpoint-<N>/ dir with a dcp/ subdir, or the stage-1 output_dir
|
||||
# itself -- dcp_to_diffusers auto-picks the latest checkpoint). It is
|
||||
# converted to a diffusers export (<checkpoint>-diffusers) with
|
||||
# `dcp_to_diffusers --role student --verify` before launching --
|
||||
# models.*.init_from need a diffusers model directory (model_index.json
|
||||
# + component subfolders), not the raw DCP layout; or
|
||||
# - an already-exported diffusers model dir (model_index.json present),
|
||||
# used as-is with no conversion.
|
||||
#
|
||||
# The export is passed to all three models.*.init_from overrides: student,
|
||||
# teacher, and critic all load the same weights. Teacher/critic are
|
||||
# automatically masked back to dense attention and full-precision weights
|
||||
# by the _loading_teacher_critic_model gate in
|
||||
# fastvideo/models/loader/component_loader.py (family-agnostic, no
|
||||
# Kandinsky5-specific handling needed) -- NOT by pointing them at a
|
||||
# different export.
|
||||
#
|
||||
# FASTVIDEO_ATTENTION_BACKEND=ATTN_QAT_TRAIN keeps the student's dense/local
|
||||
# attention fake-quantized during distillation.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../../.." && pwd)"
|
||||
|
||||
CHECKPOINT="${1:?Usage: $0 <stage1-checkpoint (DCP dir or diffusers export)> [extra --dotted.key overrides...]}"
|
||||
shift
|
||||
|
||||
if [[ "${CHECKPOINT}" != /* ]]; then
|
||||
CHECKPOINT="$(pwd)/${CHECKPOINT}"
|
||||
fi
|
||||
CHECKPOINT="${CHECKPOINT%/}"
|
||||
|
||||
if [[ -f "${CHECKPOINT}/model_index.json" ]]; then
|
||||
# Already a diffusers export -- use as-is.
|
||||
INIT_FROM="${CHECKPOINT}"
|
||||
else
|
||||
# Convert the raw DCP checkpoint. Runs BEFORE ATTN_QAT_TRAIN is
|
||||
# exported below: backend selection for that env var refuses to start
|
||||
# (ImportError) when the training kernel isn't built, and the
|
||||
# conversion itself must not depend on it. --overwrite keeps relaunches
|
||||
# from silently reusing a stale export for a newer checkpoint;
|
||||
# --verify strictly reloads the exported transformer so a key-mapping
|
||||
# bug fails here, not deep inside the training launch below.
|
||||
INIT_FROM="${CHECKPOINT}-diffusers"
|
||||
echo "Converting stage-1 DCP checkpoint ${CHECKPOINT} -> ${INIT_FROM}"
|
||||
python -m fastvideo.train.entrypoint.dcp_to_diffusers \
|
||||
--checkpoint "${CHECKPOINT}" \
|
||||
--output-dir "${INIT_FROM}" \
|
||||
--role student \
|
||||
--overwrite \
|
||||
--verify
|
||||
fi
|
||||
|
||||
export FASTVIDEO_ATTENTION_BACKEND=ATTN_QAT_TRAIN
|
||||
|
||||
bash "${REPO_ROOT}/examples/train/run.sh" \
|
||||
"${SCRIPT_DIR}/dmd2_t2v_480p_qat.yaml" \
|
||||
--models.student.init_from "${INIT_FROM}" \
|
||||
--models.teacher.init_from "${INIT_FROM}" \
|
||||
--models.critic.init_from "${INIT_FROM}" \
|
||||
"$@"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user