Compare commits
130
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63a02d55f4 | ||
|
|
3f7d495fbd | ||
|
|
808bb8e697 | ||
|
|
661fa51d9e | ||
|
|
59843ee2d1 | ||
|
|
cb80364aaa | ||
|
|
92cc893cfc | ||
|
|
6ef5b6b28f | ||
|
|
948e9d7610 | ||
|
|
ec5a7c7e73 | ||
|
|
c31dd6853e | ||
|
|
ac919b1c87 | ||
|
|
d4eab03809 | ||
|
|
c1332cb4fb | ||
|
|
8bc4e538d0 | ||
|
|
495dd27d33 | ||
|
|
4d0571eabf | ||
|
|
7f0c06e7f2 | ||
|
|
aea5cadb5f | ||
|
|
c5444621f9 | ||
|
|
c6d0233eab | ||
|
|
de8e9e20b4 | ||
|
|
4fe7638101 | ||
|
|
6c23b4552a | ||
|
|
7c6f8f3f23 | ||
|
|
21f25791e2 | ||
|
|
d203b5f557 | ||
|
|
b588be05d1 | ||
|
|
42d4f524a0 | ||
|
|
7349e07cec | ||
|
|
8af0c43a65 | ||
|
|
34a0a81be1 | ||
|
|
0356205b84 | ||
|
|
719a1879bd | ||
|
|
b57180bf97 | ||
|
|
7cebf5f82c | ||
|
|
dd0f4b6753 | ||
|
|
d303b4e03a | ||
|
|
31b719ae49 | ||
|
|
887aaf3d3e | ||
|
|
b1d89eba1f | ||
|
|
995a5fdf97 | ||
|
|
70a70b689e | ||
|
|
4d6ac89b43 | ||
|
|
4171cacd93 | ||
|
|
b2ade71467 | ||
|
|
82ed9fe58d | ||
|
|
3d8cc4f0a0 | ||
|
|
0557f7a7d9 | ||
|
|
dc66cd97ef | ||
|
|
6da206e196 | ||
|
|
87f98c9b8b | ||
|
|
e60601df7f | ||
|
|
eed9c4bfbf | ||
|
|
1dee77f4a4 | ||
|
|
b80148819c | ||
|
|
c3b971488e | ||
|
|
88e753f281 | ||
|
|
77832059cc | ||
|
|
633d393568 | ||
|
|
5854aec2ce | ||
|
|
30e45c2411 | ||
|
|
2a4fe697a6 | ||
|
|
921db7479d | ||
|
|
7f539424cb | ||
|
|
19a838f54f | ||
|
|
d922ab2cbc | ||
|
|
9ea77d37f3 | ||
|
|
2e35b0c6bd | ||
|
|
1c627a3f98 | ||
|
|
a931efe33a | ||
|
|
041e5e9029 | ||
|
|
efcc245c2e | ||
|
|
922e7e0813 | ||
|
|
c62a8514b0 | ||
|
|
3eb8081801 | ||
|
|
3505d09564 | ||
|
|
570607945c | ||
|
|
d3a821cdcf | ||
|
|
3f24578139 | ||
|
|
89fcf08378 | ||
|
|
c2930b2aa1 | ||
|
|
019239690b | ||
|
|
d6119c1f82 | ||
|
|
84214c80bb | ||
|
|
c2d7143c72 | ||
|
|
afdb6fbfa5 | ||
|
|
ba4c02d883 | ||
|
|
2c137931f3 | ||
|
|
36682797a0 | ||
|
|
0ef1357a77 | ||
|
|
a75d19786a | ||
|
|
be548a78ea | ||
|
|
6a610e2bc9 | ||
|
|
321d5112b4 | ||
|
|
ba75ad82db | ||
|
|
58caa5109f | ||
|
|
2f3ca8aaad | ||
|
|
3a67319cb6 | ||
|
|
266fa044b3 | ||
|
|
f3398db868 | ||
|
|
fda02036bc | ||
|
|
68179cd752 | ||
|
|
2dd5760291 | ||
|
|
af2ee9c78a | ||
|
|
1c80371b27 | ||
|
|
eef473225d | ||
|
|
44fb84ef6a | ||
|
|
e8597b7448 | ||
|
|
e2252c0a5e | ||
|
|
72cb427cd9 | ||
|
|
63030cf6ec | ||
|
|
773d44b875 | ||
|
|
1df513922f | ||
|
|
30c45620a2 | ||
|
|
6b2c731596 | ||
|
|
e6022c20b2 | ||
|
|
460f6e398e | ||
|
|
d2ffec5cce | ||
|
|
cb12e88713 | ||
|
|
0958c344b8 | ||
|
|
71dc27ea7b | ||
|
|
1263449d2a | ||
|
|
b4de5a9f1b | ||
|
|
8acd8e21f9 | ||
|
|
d45d82334f | ||
|
|
6392bd40a9 | ||
|
|
17f07bc313 | ||
|
|
325861fb99 | ||
|
|
c5088670c8 |
@@ -0,0 +1,41 @@
|
||||
---
|
||||
date: 2026-05-22
|
||||
experiment: PR #1386 DreamVerse app CI backend tests
|
||||
category: infrastructure
|
||||
severity: important
|
||||
---
|
||||
|
||||
# DreamVerse App CI Streaming Imports Need GPU
|
||||
|
||||
## What Happened
|
||||
|
||||
DreamVerse app CI backend pytest collection imports FastVideo streaming surfaces.
|
||||
When those tests run in a CPU-only Modal environment, collection can fail before
|
||||
any app assertions run with Triton reporting:
|
||||
|
||||
```text
|
||||
RuntimeError: 0 active drivers
|
||||
```
|
||||
|
||||
## Root Cause
|
||||
|
||||
Some streaming import paths can import `fastvideo_kernel` at module import time.
|
||||
Triton then probes for an active GPU driver during pytest collection. A CPU-only
|
||||
Modal container has no active driver, so the failure appears as an import-time
|
||||
collection error rather than a DreamVerse app behavior failure.
|
||||
|
||||
## Fix / Workaround
|
||||
|
||||
For PR #1386, use a surgical CI fix: allocate a GPU to
|
||||
`run_dreamverse_app_tests`. Do not refactor core streaming/kernel imports just to
|
||||
unstick this app CI path.
|
||||
|
||||
Keep `build_kernel=False` for this job. The DreamVerse app backend test imports
|
||||
streaming surfaces but does not need to rebuild or exercise custom kernels.
|
||||
|
||||
## Prevention
|
||||
|
||||
When adding or modifying DreamVerse app CI jobs that import FastVideo streaming
|
||||
modules, make the GPU requirement explicit if the import graph may touch
|
||||
`fastvideo_kernel`. Prefer small CI resource fixes for app test collection issues
|
||||
unless the product code genuinely requires lazy import cleanup.
|
||||
@@ -31,7 +31,7 @@ FastVideo-WorldModel/
|
||||
│ │ │ └── distribution_matching/ # DMD2Method, SelfForcingMethod
|
||||
│ │ ├── models/ # Per-role model wrappers (ModelBase, CausalModelBase)
|
||||
│ │ │ ├── wan/ # WanModel, WanCausalModel
|
||||
│ │ │ └── matrixgame/ # MatrixGameModel, MatrixGameCausalModel
|
||||
│ │ │ └── matrixgame2/ # MatrixGame2Model, MatrixGame2CausalModel
|
||||
│ │ ├── callbacks/ # Composable hooks (grad_clip, ema, validation)
|
||||
│ │ └── utils/ # Config, builder, checkpoint, optimizer, tracking
|
||||
│ ├── training/ # Legacy training infrastructure (being phased out)
|
||||
@@ -44,7 +44,7 @@ FastVideo-WorldModel/
|
||||
│ │ ├── wan_distillation_pipeline.py # Wan distillation
|
||||
│ │ ├── self_forcing_distillation_pipeline.py # Self-forcing distill
|
||||
│ │ ├── ltx2_training_pipeline.py # LTX-2 training
|
||||
│ │ └── matrixgame_training_pipeline.py # MatrixGame training
|
||||
│ │ └── matrixgame2_training_pipeline.py # Matrix-Game 2.0 training
|
||||
│ ├── attention/ # Attention backends
|
||||
│ ├── distributed/ # Sequence/tensor parallel utilities
|
||||
│ ├── layers/ # Tensor-parallel layers
|
||||
@@ -95,7 +95,7 @@ FastVideo-WorldModel/
|
||||
| Wan distillation (DMD) | `fastvideo/training/wan_distillation_pipeline.py` | `torchrun --nproc_per_node N` |
|
||||
| Self-forcing distill | `fastvideo/training/wan_self_forcing_distillation_pipeline.py` | `torchrun --nproc_per_node N` |
|
||||
| LTX-2 finetune | `fastvideo/training/ltx2_training_pipeline.py` | `torchrun --nproc_per_node N` |
|
||||
| MatrixGame | `fastvideo/training/matrixgame_training_pipeline.py` | `torchrun --nproc_per_node N` |
|
||||
| Matrix-Game 2.0 | `fastvideo/training/matrixgame2_training_pipeline.py` | `torchrun --nproc_per_node N` |
|
||||
|
||||
## W&B Integration
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ follow-up actions see [open-threads.md](open-threads.md).
|
||||
|
||||
**Last updated:** 2026-05-06 (added D-21 — chunk-stutter root cause is software libx264 encoding consuming ~22% of segment wall-time, NOT a migration regression; verified by 3 parallel explore agents that NVFP4 + torch.compile coverage matches FastVideo-internal exactly; landed opt-in NVENC build path in install_native_ffmpeg.sh + `--nvenc`/`--no-nvenc` flag in dreamverse-deploy.sh + `apps/dreamverse/server/benchmarks/benchmark_av_streaming.py` regression test + memory dir update; default codec stays `libx264` for backward compat, opt-in via `--nvenc`. Earlier: added D-12 — GpuPool layer separation, Oracle review post-#1257-merge; added D-13 — prompt enhancer / LLMProvider abstraction shape, Oracle review pre-#1258-merge; added D-14 — streaming auxiliaries cohesion, Oracle review during #1284 review cycle; added D-15 — streaming router placement + sticky/active-active deferral, Oracle review during #1286 review cycle; added D-16 — streaming router polish round 2, second-pass review on top of D-15 covering bridge cancellation hygiene, registry state machine, httpx hard-fail, replica YAML parsing, and `websockets` dep; added D-17 — strategy reversal: abandon 6-PR split in favor of single mega-PR #1288 on `will/ltx2_sr_port`; added D-18 — Option B+ chosen: Dreamverse FE+product-server move into FastVideo as `apps/dreamverse/` subfolder while generic backend stays at `fastvideo.entrypoints.streaming.*`; integration-review.md deprecated, integration-plan.md is the executable migration plan; added D-19 — D-18 executed: 5 commits land on `will/dreamverse-monorepo`, fix-up commits corrected the integration-plan's invalid "delete generic-merged, import public substitutes" assumption — generic-merged files carried product-local instead, e2e passes against migrated code with /proc-verified evidence; added D-20 — segment-2 BrokenPipe root cause was a TWO-direction silent drop of LTX-2 audio kwargs in public `VideoGenerator`).
|
||||
|
||||
**Update 2026-05:** Dreamverse frontend tooling migrated from standalone pnpm to standalone npm. `apps/dreamverse/web/package-lock.json` is authoritative; see PR #1385.
|
||||
|
||||
## Status legend
|
||||
|
||||
- ✅ **Resolved** — decision made and implementation complete (or no implementation needed)
|
||||
@@ -297,14 +299,14 @@ Playwright (8/8 PASS in 5.1s):
|
||||
- **Python ML library** stays at root: `fastvideo/`, `fastvideo-kernel/`.
|
||||
- **Generic backend** stays at `fastvideo.entrypoints.streaming.*` (already there per #1257/#1258/#1284/#1286/#1288).
|
||||
- **Dreamverse product** moves into `apps/dreamverse/{server,web,prompts,serve_configs,scripts}/`.
|
||||
- **Tooling**: uv workspace for Python (`[tool.uv.workspace] members = ["apps/dreamverse/server"]`), standalone pnpm for the FE (no root `package.json`), split CI workflows with path-filter triggers.
|
||||
- **Tooling**: uv workspace for Python (`[tool.uv.workspace] members = ["apps/dreamverse/server"]`), standalone npm for the FE (no root `package.json`), split CI workflows with path-filter triggers.
|
||||
|
||||
**Rationale:**
|
||||
|
||||
- Drops the cross-repo coordination overhead identified in the post-#1286 rebase cycle (D-17 handled by consolidating into mega-PR; D-18 prevents the next round of cross-repo coordination from happening).
|
||||
- Keeps the architectural separation Option D recommended (FastVideo owns reusable runtime; product owns product). The boundary is now `apps/dreamverse/` directory rather than two repos.
|
||||
- Single repo means atomic cross-cutting refactors (e.g. GpuPool API change + Dreamverse adoption) ship as one PR.
|
||||
- OSS precedents support the shape (chainlit uv-workspace + pnpm; open-webui Python + Svelte with paths-ignore CI). The librarian explicitly noted no precedent for "Python ML library + Next.js product merged into library namespace" — but this isn't that pattern. Dreamverse goes into a sibling directory, NOT into `fastvideo.entrypoints.dreamverse.*`. Library namespace stays clean.
|
||||
- OSS precedents support the shape (chainlit uv-workspace + frontend package manager; open-webui Python + Svelte with paths-ignore CI). The librarian explicitly noted no precedent for "Python ML library + Next.js product merged into library namespace" — but this isn't that pattern. Dreamverse goes into a sibling directory, NOT into `fastvideo.entrypoints.dreamverse.*`. Library namespace stays clean.
|
||||
|
||||
**Why not Option D (separate repos):**
|
||||
|
||||
|
||||
@@ -311,7 +311,7 @@ Notes:
|
||||
development and CI.
|
||||
- Product server release is Docker/deploy workflow, not PyPI.
|
||||
|
||||
### Frontend build: standalone pnpm
|
||||
### Frontend build: standalone npm
|
||||
|
||||
Do not add a root `package.json`.
|
||||
|
||||
@@ -319,14 +319,14 @@ Keep all frontend tooling under:
|
||||
|
||||
```text
|
||||
apps/dreamverse/web/package.json
|
||||
apps/dreamverse/web/pnpm-lock.yaml
|
||||
apps/dreamverse/web/package-lock.json
|
||||
apps/dreamverse/web/playwright.config.*
|
||||
```
|
||||
|
||||
Rationale:
|
||||
|
||||
- FastVideo remains primarily a Python ML library.
|
||||
- Python contributors should not need Node or pnpm for normal work.
|
||||
- Python contributors should not need Node or npm for normal work.
|
||||
- This intentionally diverges from chainlit's root JS workspace pattern and
|
||||
follows the simpler open-webui-style split.
|
||||
|
||||
@@ -455,31 +455,24 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# IMPORTANT: pnpm/action-setup MUST run BEFORE setup-node when using
|
||||
# cache: pnpm — setup-node otherwise can't find pnpm to populate cache.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: pnpm
|
||||
cache-dependency-path: apps/dreamverse/web/pnpm-lock.yaml
|
||||
cache: npm
|
||||
cache-dependency-path: apps/dreamverse/web/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm run typecheck --if-present
|
||||
run: npm run typecheck --if-present
|
||||
|
||||
- name: Unit tests
|
||||
run: pnpm run test --if-present
|
||||
run: npm run test --if-present
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
run: npm run build
|
||||
|
||||
# NOTE: Playwright tests require a running backend. Until Phase 4 lands
|
||||
# `/healthz`/`/readyz`/`/status`/`/prompt-system-config`/`/curated-presets`
|
||||
@@ -489,13 +482,13 @@ jobs:
|
||||
# `fastvideo.entrypoints.streaming.build_app` and add product routes).
|
||||
#
|
||||
# - name: Install Playwright browsers
|
||||
# run: pnpm exec playwright install --with-deps chromium
|
||||
# run: npm exec -- playwright install --with-deps chromium
|
||||
#
|
||||
# - name: Playwright (re-enable in Phase 4)
|
||||
# run: pnpm exec playwright test
|
||||
# run: npm exec -- playwright test
|
||||
```
|
||||
|
||||
**Playwright config update needed** when moving FE: `Dreamverse/apps/web/playwright.config.ts` line 39 currently uses `npm run dev`; change to `pnpm run dev` post-move ([source](file:///home/william5lin/Dreamverse/apps/web/playwright.config.ts#L39)).
|
||||
**Playwright config update needed** when moving FE: keep `Dreamverse/apps/web/playwright.config.ts` line 39 on `npm run dev` after the move ([source](file:///home/william5lin/Dreamverse/apps/web/playwright.config.ts#L39)).
|
||||
|
||||
#### `.github/workflows/ci-dreamverse-backend.yml`
|
||||
|
||||
@@ -604,7 +597,7 @@ monorepo CI path.
|
||||
| uv workspaces | https://docs.astral.sh/uv/concepts/workspaces/ | Authoritative Python workspace model. |
|
||||
| Hatch monorepo | https://hatch.pypa.io/latest/how-to/environment/workspace/ | Alternative workspace model; not selected. |
|
||||
| chainlit | https://github.com/Chainlit/chainlit | uv workspace precedent plus **per-language CI split** (separate `check-frontend.yaml` / `check-backend.yaml` workflows path-filtered by directory). Not a PR-level split — independent of D-17 single-mega-PR decision. |
|
||||
| open-webui | https://github.com/open-webui/open-webui | Frontend path filtering (`paths-ignore` on backend-only changes) and separate release tracks. **Note:** open-webui has a root `package.json`; we are choosing standalone-pnpm despite the precedent, to avoid forcing Python-only contributors to install Node. |
|
||||
| open-webui | https://github.com/open-webui/open-webui | Frontend path filtering (`paths-ignore` on backend-only changes) and separate release tracks. **Note:** open-webui has a root `package.json`; we are choosing standalone npm under `apps/dreamverse/web/` despite the precedent, to avoid forcing Python-only contributors to install Node. |
|
||||
| streamlit | https://github.com/streamlit/streamlit | Split Python and JS testing in one repo. |
|
||||
| gradio | https://github.com/gradio-app/gradio | Python package plus JS workspace precedent. |
|
||||
| full-stack-fastapi-template-nextjs | https://github.com/nemanjam/full-stack-fastapi-template-nextjs | Separate frontend/backend build and deploy workflows. |
|
||||
@@ -623,8 +616,8 @@ class runs where, because not all tests can run on `ubuntu-latest` CI.
|
||||
| **Unit** | (none / `unit`) | `ci-dreamverse-backend.yml` (ubuntu-latest CI) + locally | Pure logic, no GPU, no live service. Mocked FastVideo backends, schema validation, helper functions. | `test_config.py`, `test_rewrite_prompt_payload.py`, `test_session_init_image.py`, the new `test_import_contract.py` |
|
||||
| **Integration (fakes)** | `integration` | `ci-dreamverse-backend.yml` + locally | FastAPI test client + in-process fakes/mocks for GPU pool. Validates routes, request/response shapes, session state machine. | `test_health_endpoints.py`, `test_mock_server.py`, `test_entrypoints.py`, `test_prompt_safety.py`, `test_batching.py` (deleted) |
|
||||
| **Live-service GPU** | `gpu` (skip-by-default in CI) | **Local GPU4 manual QA** + Buildkite-Modal (when added) | Real `fastvideo serve` process + real model weights + real WebSocket round-trips. Validates LTX-2 streaming, NVFP4 wiring, continuation state, frame emission. | `test_realtime_stress.py` (947 LOC), `test_session_logging.py` (1278 LOC) — these spin up real workers per their current shape |
|
||||
| **Frontend unit / build** | (n/a — pnpm) | `ci-dreamverse-frontend.yml` (ubuntu-latest, no GPU) | Vitest + tsc + Next.js build. No backend needed. | `apps/dreamverse/web/src/**/*.test.ts(x)` |
|
||||
| **Frontend Playwright E2E** | (n/a — pnpm) | **Local GPU4 manual QA** until Phase 4 lands public health routes; then `ci-dreamverse-frontend.yml` against a mock backend OR a deployed staging | Real browser → real backend WebSocket flow. Requires `/healthz`, `/readyz`, `/status`, `/prompt-system-config`, `/curated-presets`, `/v1/stream`. | `apps/dreamverse/web/e2e/{backend-health,frontend-shell,preset-prompt-generation}.spec.ts` |
|
||||
| **Frontend unit / build** | (n/a — npm) | `ci-dreamverse-frontend.yml` (ubuntu-latest, no GPU) | Vitest + tsc + Next.js build. No backend needed. | `apps/dreamverse/web/src/**/*.test.ts(x)` |
|
||||
| **Frontend Playwright E2E** | (n/a — npm) | **Local GPU4 manual QA** until Phase 4 lands public health routes; then `ci-dreamverse-frontend.yml` against a mock backend OR a deployed staging | Real browser → real backend WebSocket flow. Requires `/healthz`, `/readyz`, `/status`, `/prompt-system-config`, `/curated-presets`, `/v1/stream`. | `apps/dreamverse/web/e2e/{backend-health,frontend-shell,preset-prompt-generation}.spec.ts` |
|
||||
| **FastVideo public contract** | (none) | Existing FastVideo CI (`ci-precommit` + Buildkite for GPU) | Schema/shape guards that this migration must not break. | `fastvideo/tests/contract/test_dreamverse_shape.py`, `test_dynamo_shape.py`, `test_generate_async.py` |
|
||||
| **FastVideo SSIM regression** | (Buildkite path-filter) | Buildkite-Modal | Inference-quality gates for ported models. | `fastvideo/tests/ssim/test_*.py` |
|
||||
|
||||
@@ -709,7 +702,7 @@ CUDA_VISIBLE_DEVICES=4 uv run --locked --package dreamverse-server \
|
||||
# Smoke-test from another terminal
|
||||
curl -s http://localhost:8009/health | jq .
|
||||
curl -s http://localhost:8009/readyz | jq . # Phase 4+ only
|
||||
# Drive the FE against it: cd apps/dreamverse/web && pnpm run dev
|
||||
# Drive the FE against it: cd apps/dreamverse/web && npm run dev
|
||||
```
|
||||
|
||||
This is the **manual QA gate** for any phase that touches the live-service
|
||||
@@ -907,7 +900,7 @@ mass move. This should be a small PR.
|
||||
6. Add `apps/dreamverse/web/.*` to pre-commit global exclude.
|
||||
7. Add `docs/contributing/dreamverse-development.md` with local dev commands:
|
||||
backend `uv run --locked --package dreamverse-server --extra test pytest ...`;
|
||||
frontend `cd apps/dreamverse/web && pnpm install && pnpm run build`.
|
||||
frontend `cd apps/dreamverse/web && npm ci && npm run build`.
|
||||
8. Run `uv lock` to regenerate `uv.lock` with the new workspace member; commit
|
||||
the lock change in the same PR.
|
||||
9. Land D-12-A docstring caveat: mark `GpuPool` experimental/server-internal
|
||||
@@ -990,7 +983,7 @@ These are **explicit shims** — Phase 4 is responsible for their promotion to p
|
||||
|
||||
- [`config.py:13`](file:///home/william5lin/Dreamverse/server/config.py#L13): `_APP_ROOT / "apps" / "web"` → `_APP_ROOT / "web"` (since `_APP_ROOT` will resolve to `apps/dreamverse/` in the new layout).
|
||||
- [`apps/web/next.config.ts:11`](file:///home/william5lin/Dreamverse/apps/web/next.config.ts#L11): `outputFileTracingRoot: path.resolve(__dirname, "../..")` → `path.resolve(__dirname, "../../..")` (one extra `..` since the FE is one level deeper in the monorepo).
|
||||
- [`playwright.config.ts:39`](file:///home/william5lin/Dreamverse/apps/web/playwright.config.ts#L39): `command: "npm run dev"` → `command: "pnpm run dev"` (matches Phase 1 tooling decision).
|
||||
- [`playwright.config.ts:39`](file:///home/william5lin/Dreamverse/apps/web/playwright.config.ts#L39): keep `command: "npm run dev"` (matches Phase 1 tooling decision).
|
||||
- Any hardcoded `../FastVideo` paths in scripts/configs → make repo-root-relative since they now share a repo.
|
||||
|
||||
**Steps:**
|
||||
@@ -1084,9 +1077,9 @@ scripts, and product docs after backend tests are green.
|
||||
|
||||
**Verification gate:**
|
||||
|
||||
- `cd apps/dreamverse/web && pnpm install --frozen-lockfile` succeeds.
|
||||
- `cd apps/dreamverse/web && pnpm run build` succeeds.
|
||||
- `cd apps/dreamverse/web && pnpm run test --if-present` succeeds (Vitest + tsc).
|
||||
- `cd apps/dreamverse/web && npm ci` succeeds.
|
||||
- `cd apps/dreamverse/web && npm run build` succeeds.
|
||||
- `cd apps/dreamverse/web && npm run test --if-present` succeeds (Vitest + tsc).
|
||||
- **Frontend CI Playwright is intentionally DEFERRED to Phase 4** — at Phase 3
|
||||
the public `build_app` does not yet expose `/healthz`+`/readyz`+`/status`+
|
||||
`/prompt-system-config`+`/curated-presets`. The `ci-dreamverse-frontend.yml`
|
||||
@@ -1094,7 +1087,7 @@ scripts, and product docs after backend tests are green.
|
||||
reactivates them. No PR note required.
|
||||
- **Manual GPU4 Playwright smoke** (recommended): on this dev node, run
|
||||
`apps/dreamverse/web/e2e/frontend-shell.spec.ts` against the GPU4-deployed
|
||||
backend from Phase 2 manual QA + a `pnpm run dev` frontend at port 5274
|
||||
backend from Phase 2 manual QA + a `npm run dev` frontend at port 5274
|
||||
to confirm shell hydration. `backend-health.spec.ts` and
|
||||
`preset-prompt-generation.spec.ts` will fail until Phase 4 — that is
|
||||
expected; document as deferred.
|
||||
@@ -1249,7 +1242,7 @@ product-specific prompt orchestration.
|
||||
| CI cost increase | Medium | Medium | Add Dreamverse path-specific workflows; add `paths-ignore` to broad workflows; rely on Buildkite monorepo diff watch lists. | CI owner |
|
||||
| FastVideo PyPI release accidentally includes Dreamverse app | High | Low | Add `apps*` to `[tool.setuptools.packages.find]` and `[tool.wheel]` excludes; verify built wheel contents. | Release owner |
|
||||
| Release cadence coupling | Medium | Medium | Keep FastVideo PyPI version release unchanged; Dreamverse uses Docker/Vercel deploy from app paths. | Release owner |
|
||||
| Frontend tooling drift | Medium | Medium | Pin pnpm lockfile in `apps/dreamverse/web/`; no root JS workspace. | Frontend owner |
|
||||
| Frontend tooling drift | Medium | Medium | Pin npm lockfile in `apps/dreamverse/web/`; no root JS workspace. | Frontend owner |
|
||||
| Security surface enlargement | Medium | Medium | Product routes stay in `apps/dreamverse/server`; only generic health/streaming routes go into FastVideo. | Backend owner |
|
||||
| Product-specific API leakage into `fastvideo.*` | High | Medium | Enforce import/module boundary; keep curated presets and prompt UX product-local. | Architecture owner |
|
||||
| Migration regression | High | Medium | Phase gates; backend before frontend; can stop after any phase with Dreamverse repo still usable until Phase 7. | Migration owner |
|
||||
|
||||
@@ -6,7 +6,10 @@ recommended next action.
|
||||
For why each item is open see [decisions-log.md](decisions-log.md). For
|
||||
PR-level context see [pr-roadmap.md](pr-roadmap.md).
|
||||
|
||||
**Last updated:** 2026-05-12 (added DR-4 follow-up for PR #1330's skipped
|
||||
**Last updated:** 2026-05-14 (added DR-7 follow-up to remove LTX2 debug
|
||||
logging env vars and replace them with per-pipeline/model state. Earlier: 2026-05-14 added DR-6 follow-up for PR #1335's Gemma
|
||||
lazy-load/device-placement behavior outside compiled `forward`. Earlier: 2026-05-13 added DR-5 follow-up for PR #1333's LTX2
|
||||
distilled SSIM reference refresh. Earlier: 2026-05-12 added DR-4 follow-up for PR #1330's skipped
|
||||
`App websocket integration` suite. Earlier: 2026-05-05 D-20 broken-pipe root cause + fix landed on
|
||||
`will/dreamverse-monorepo` @ `5eaf0a13`; added new thread D-20-CP for
|
||||
cherry-picking the public-API audio routing fix to `will/ltx2_sr_port`
|
||||
@@ -34,6 +37,9 @@ different vehicle.).
|
||||
| **DR-2** | Med | Decide `cerebras_ifm` provider path: (a) public Literal + `CerebrasIFMProvider` shipped, OR (b) Dreamverse-side custom provider via `enhancer.register_provider(...)` | S (decision) + S-M (impl) | Resolves the cerebras_ifm gap left by PR #1258. Same item as legacy #3 below; DR-2 is the Dreamverse-side framing. |
|
||||
| **DR-3** | Low | Replace Dreamverse `PromptEnhancer._run_blocking_request` manual thread/queue polling with `asyncio.to_thread` after the PR #1327 prompt-enhancer compatibility surface is retired or isolated | S | Review comment #1327 (`prompt_enhancer.py`) is valid, but deferred to avoid patching the local fork in this PR. |
|
||||
| **DR-4** | Low | Investigate unskipping PR #1330's skipped public `App websocket integration` suite | S-M | Public PR #1330 has `describe.skip(...)` around 27 websocket tests while the internal equivalent suite is active with 25 tests. The 2 public-only tests cover backend unreachable / GPU workers not ready. Not blocking while skipped, but stale assertions may need safe refresh before unskip. |
|
||||
| **DR-5** | Low | Regenerate LTX2-Distilled latent SSIM references under the intended neutral/distilled defaults, then remove the PR #1333 historical full-guidance pins | S-M | PR #1333 changed public LTX2 distilled defaults to neutral/distilled values, but existing LTX2 latent SSIM references appear to have been generated with historical full-guidance defaults. The current PR pins the SSIM test to old values to keep CI compatible until references are refreshed. |
|
||||
| **DR-6** | Low | Decide whether `LTX2GemmaTextEncoderModel` needs a non-forward device-placement hook after lazy Gemma load | S | PR #1335 should remove the `model.device` / `model.to(...)` guard from `forward` for Dynamo/fullgraph compatibility. Non-compiled runs probably do not need it because `gemma_model` moves Gemma at first load, but a later wrapper `.to(...)` after lazy load could leave Gemma on the old device unless lifecycle placement handles it. |
|
||||
| **DR-7** | Low | Remove LTX2 debug logging env-var plumbing and replace it with per-pipeline/model debug state | S-M | PR #1335 review flagged that `initialize_pipeline()` mutates process-global LTX2 debug env vars, which can leak/race across pipeline instances. Deleting only the mutation is low-risk but loses config-driven debug logging; deleting all reads without replacement would remove useful SSIM/latent drift diagnostics. |
|
||||
| **3** | Med | Add `cerebras_ifm` to `PromptEnhancerConfig.provider` Literal + provider | S-M | Public-side resolution if DR-2 picks (a) |
|
||||
| **4** | Med | Expose `layer_profile` on typed `engine.quantization` | M | Removes Dreamverse's `experimental["pipeline_config"]` dodge for stage profiles |
|
||||
| **5** | Med | Design typed `dit_config.quant_config` carrier | L design + L impl | Removes broader `experimental["pipeline_config"]` escape hatch |
|
||||
@@ -421,6 +427,65 @@ current PR #1330 review.
|
||||
stack end-to-end and compare the public assertions against the internal active
|
||||
suite.
|
||||
|
||||
### Item DR-6: Gemma lazy-load device placement outside `forward`
|
||||
|
||||
**Why:** PR #1335 review flagged this pattern in
|
||||
`fastvideo/models/encoders/gemma.py::LTX2GemmaTextEncoderModel.forward`:
|
||||
|
||||
```py
|
||||
if model.device != target_device:
|
||||
model.to(device=target_device)
|
||||
```
|
||||
|
||||
The immediate concern is the compiled/Dynamo path: `model.device` and
|
||||
`model.to(...)` inside `forward` can introduce non-tensor/device parsing work
|
||||
that fullgraph tracing should not see. Removing the guard from `forward` is the
|
||||
right PR #1335 review fix.
|
||||
|
||||
For non-compiled execution, the guard is mostly defensive rather than required:
|
||||
`gemma_model` already moves the lazily loaded HF Gemma model to the wrapper's
|
||||
current parameter device on first load. The remaining edge case is a lifecycle
|
||||
sequence where Gemma is loaded, then the parent wrapper is later moved to a
|
||||
different device; in that case Gemma could stay behind unless placement is
|
||||
handled outside `forward`.
|
||||
|
||||
**Action:** After PR #1335 review is unblocked, decide whether FastVideo needs a
|
||||
small lifecycle hook/helper for this class so lazy Gemma is moved whenever the
|
||||
wrapper/device placement changes. If yes, implement it outside `forward`; if no,
|
||||
document that Gemma must be loaded after final device placement.
|
||||
|
||||
**Effort:** Small.
|
||||
|
||||
**Dependencies:** Not blocking PR #1335 if the forward-path guard is removed and
|
||||
the normal load path keeps placing Gemma on the wrapper's current device.
|
||||
|
||||
### Item DR-7: Remove LTX2 debug logging env-var plumbing
|
||||
|
||||
**Why:** PR #1335 review flagged that
|
||||
`fastvideo/pipelines/basic/ltx2/ltx2_pipeline.py::initialize_pipeline()` sets
|
||||
and pops LTX2 debug env vars such as `LTX2_PIPELINE_DEBUG_LOG`,
|
||||
`LTX2_PIPELINE_DEBUG_PATH`, `LTX2_DEBUG_DETAIL`, and
|
||||
`LTX2_PIPELINE_DEBUG_DETAIL_PATH`. Those env vars are process-global, so one
|
||||
pipeline instance can enable, overwrite, or clear debug behavior for another
|
||||
pipeline instance running in the same process.
|
||||
|
||||
Deleting only the `initialize_pipeline()` env mutation is low risk for normal
|
||||
generation, but it would stop config-driven debug logging unless replaced.
|
||||
Deleting all env-var reads without replacement is riskier because these logs are
|
||||
useful for SSIM/latent drift diagnosis and may be used by local debug scripts.
|
||||
|
||||
**Action:** Replace LTX2 debug env-var plumbing with per-pipeline/model debug
|
||||
state. Use pipeline/model config for construction-time hooks and a
|
||||
`ForwardContext`/`ForwardBatch`-style carrier for forward-time logging. Keep
|
||||
external env-var compatibility only if there is a documented operator workflow
|
||||
that still needs it.
|
||||
|
||||
**Effort:** Small-Medium.
|
||||
|
||||
**Dependencies:** Not blocking PR #1335 if the immediate fix is limited to
|
||||
removing process-global mutation from pipeline initialization while preserving
|
||||
existing externally supplied env-var reads.
|
||||
|
||||
### Item D-12-C: Avoid locking `PoolAssignment.gpu_id: int` as public
|
||||
|
||||
**Why:** Today `PoolAssignment` exposes `gpu_id: int`, assuming
|
||||
|
||||
@@ -12,7 +12,7 @@ _Last updated: 2026-03-02_
|
||||
|
||||
| Metric | Category | Status | Location | Trust |
|
||||
|--------|----------|--------|----------|-------|
|
||||
| **FVD** | Distribution | ✅ Implemented | `benchmarks/fvd/` | High |
|
||||
| **FVD** | Distribution | ✅ Implemented | `fastvideo/eval/metrics/common/fvd/` | High |
|
||||
| **SSIM** | Reference | ✅ Implemented | `fastvideo/tests/ssim/` | High |
|
||||
| **LPIPS** | Perceptual | ✅ Implemented | `scripts/lora_extraction/` | Medium |
|
||||
| **Loss trajectory** | Training signal | ✅ Implemented | W&B `train_loss` | Medium |
|
||||
@@ -27,8 +27,8 @@ _Last updated: 2026-03-02_
|
||||
### FVD — Fréchet Video Distance
|
||||
|
||||
**Category**: Distribution-level quality metric
|
||||
**Status**: ✅ Fully implemented in `benchmarks/fvd/`
|
||||
**Trust**: High — standard protocol, I3D feature extractor
|
||||
**Status**: ✅ Registered as the `common.fvd` eval metric in `fastvideo/eval/metrics/common/fvd/`
|
||||
**Trust**: High — standard protocol, I3D feature extractor (CLIP / VideoMAE backbones also available, research-grade)
|
||||
|
||||
#### What It Measures
|
||||
FVD measures the distance between the **distribution** of generated videos and
|
||||
@@ -59,30 +59,39 @@ Lower FVD = generated videos are more statistically similar to real videos.
|
||||
#### How to Use
|
||||
|
||||
```python
|
||||
# Programmatic
|
||||
from benchmarks.fvd import compute_fvd_with_config, FVDConfig
|
||||
# Programmatic — drive the metric directly for custom kwargs
|
||||
from fastvideo.eval import get_metric
|
||||
|
||||
config = FVDConfig.fvd2048_16f() # Standard: 2048 videos, 16 frames
|
||||
results = compute_fvd_with_config('data/real/', 'outputs/gen/', config)
|
||||
print(f"FVD: {results['fvd']:.2f}")
|
||||
metric = get_metric("common.fvd", extractor="i3d") # or "clip" / "videomae"
|
||||
metric.to("cuda")
|
||||
metric.setup()
|
||||
metric.reset()
|
||||
|
||||
# First sample carries the reference set; later samples reuse the cache.
|
||||
metric.accumulate({"video": gen_tensors[0], "reference": real_tensors})
|
||||
for gen in gen_tensors[1:]:
|
||||
metric.accumulate({"video": gen})
|
||||
|
||||
result = metric.finalize()
|
||||
print(f"FVD: {result.score:.2f}")
|
||||
```
|
||||
|
||||
```bash
|
||||
# CLI
|
||||
python -m benchmarks.fvd.cli \
|
||||
--real-path data/real/ \
|
||||
--gen-path outputs/gen/ \
|
||||
--protocol fvd2048_16f
|
||||
# CLI — folder of generated mp4s vs a reference folder
|
||||
python examples/inference/eval/eval_fvd.py \
|
||||
--gen-dir outputs/gen/ \
|
||||
--reference-dir data/real/ \
|
||||
--extractor i3d \
|
||||
--output fvd_scores.json
|
||||
```
|
||||
|
||||
**Preset protocols**:
|
||||
| Protocol | Videos | Frames | Use Case |
|
||||
|----------|--------|--------|----------|
|
||||
| `fvd2048_16f` | 2048 | 16 | Standard benchmark (papers) |
|
||||
| `fvd2048_128f` | 2048 | 128 | Long video evaluation |
|
||||
| `quick_test` | 100 | 16 | Fast dev iteration |
|
||||
**Feature extractors**: `i3d` (default, standard FVD spec used in papers),
|
||||
`clip`, `videomae` (research-grade; not directly comparable to published
|
||||
FVD numbers).
|
||||
|
||||
**Feature extractors**: `i3d` (default, standard), `clip`, `videomae`
|
||||
**Protocol**: standard FVD uses 2048 generated + 2048 reference videos at
|
||||
16 frames each. A warning fires below 256 — the score becomes
|
||||
statistically unreliable.
|
||||
|
||||
#### Interpretation
|
||||
| FVD Range | Interpretation |
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
# Performance Dashboard Memory
|
||||
|
||||
Date: 2026-06-16
|
||||
Branch: `ci/dashboard`
|
||||
|
||||
## Purpose
|
||||
|
||||
This branch adds a local live dashboard for FastVideo performance benchmark
|
||||
history. It is intended for maintainer/operator use: inspect latest benchmark
|
||||
status, compare current values with recent baseline context, and view trends
|
||||
from the Hugging Face performance-tracking dataset.
|
||||
|
||||
The dashboard is a FastAPI + React app. It is separate from the existing
|
||||
Svelte `ui/` app.
|
||||
|
||||
## Main Files Added Or Changed
|
||||
|
||||
Backend:
|
||||
|
||||
- `fastvideo/performance_dashboard/__init__.py`
|
||||
- `fastvideo/performance_dashboard/__main__.py`
|
||||
- `fastvideo/performance_dashboard/api.py`
|
||||
- `fastvideo/performance_dashboard/metrics.py`
|
||||
- `fastvideo/performance_dashboard/service.py`
|
||||
|
||||
Frontend:
|
||||
|
||||
- `performance_dashboard/frontend/package.json`
|
||||
- `performance_dashboard/frontend/package-lock.json`
|
||||
- `performance_dashboard/frontend/tsconfig.json`
|
||||
- `performance_dashboard/frontend/vite.config.ts`
|
||||
- `performance_dashboard/frontend/index.html`
|
||||
- `performance_dashboard/frontend/scripts/build.mjs`
|
||||
- `performance_dashboard/frontend/src/main.tsx`
|
||||
- `performance_dashboard/frontend/src/api.ts`
|
||||
- `performance_dashboard/frontend/src/App.tsx`
|
||||
- `performance_dashboard/frontend/src/styles.css`
|
||||
|
||||
Docs/tests:
|
||||
|
||||
- `performance_dashboard/README.md`
|
||||
- `docs/contributing/performance_benchmarks.md`
|
||||
- `fastvideo/tests/performance/test_dashboard_service.py`
|
||||
- `fastvideo/tests/performance/test_dashboard_api.py`
|
||||
|
||||
Shared HF utility change:
|
||||
|
||||
- `fastvideo/tests/performance/hf_store.py`
|
||||
|
||||
## Data Source
|
||||
|
||||
The source of truth remains the Hugging Face dataset repo used by existing
|
||||
performance CI:
|
||||
|
||||
```text
|
||||
HF_REPO_ID=FastVideo/performance-tracking
|
||||
```
|
||||
|
||||
The dataset stores normalized JSON records emitted by
|
||||
`fastvideo/tests/performance/compare_baseline.py`. The current v1 normalized
|
||||
schema includes:
|
||||
|
||||
- `model_id`
|
||||
- `timestamp`
|
||||
- `commit_sha`
|
||||
- `gpu_type`
|
||||
- `latency`
|
||||
- `throughput`
|
||||
- `memory`
|
||||
- `text_encoder_time_s`
|
||||
- `dit_time_s`
|
||||
- `vae_decode_time_s`
|
||||
- `success`
|
||||
|
||||
Records are grouped by `(model_id, gpu_type)` for v1 dashboard behavior.
|
||||
|
||||
## Local Cache
|
||||
|
||||
The backend syncs the HF dataset to a local cache directory:
|
||||
|
||||
```text
|
||||
PERFORMANCE_TRACKING_ROOT=/tmp/fastvideo-perf-dashboard
|
||||
```
|
||||
|
||||
If `PERFORMANCE_TRACKING_ROOT` is not set, the dashboard defaults to:
|
||||
|
||||
```text
|
||||
/tmp/fastvideo-perf-dashboard
|
||||
```
|
||||
|
||||
The sync is performed through the existing helper:
|
||||
|
||||
```python
|
||||
fastvideo.tests.performance.hf_store.sync_from_hf(...)
|
||||
```
|
||||
|
||||
The dashboard then loads JSON files from the local cache through:
|
||||
|
||||
```python
|
||||
fastvideo.tests.performance.hf_store.load_records(...)
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
Originally `hf_store.py` only read `HF_API_KEY`. This caused local dashboard
|
||||
runs to fail when users had standard Hugging Face token variables set.
|
||||
|
||||
`hf_store.py` now resolves tokens from the first available variable in:
|
||||
|
||||
```text
|
||||
HF_API_KEY
|
||||
HUGGINGFACE_HUB_TOKEN
|
||||
HF_TOKEN
|
||||
```
|
||||
|
||||
For local use:
|
||||
|
||||
```bash
|
||||
export HF_TOKEN=hf_...
|
||||
```
|
||||
|
||||
If the HF repo is private or gated, the token must have dataset read access.
|
||||
|
||||
## Backend API
|
||||
|
||||
The FastAPI app is created by:
|
||||
|
||||
```python
|
||||
fastvideo.performance_dashboard.api:create_app
|
||||
```
|
||||
|
||||
The module-level app is:
|
||||
|
||||
```python
|
||||
fastvideo.performance_dashboard.api:app
|
||||
```
|
||||
|
||||
Endpoints:
|
||||
|
||||
- `GET /api/performance/health`
|
||||
- `POST /api/performance/refresh`
|
||||
- `GET /api/performance/records?days=90`
|
||||
- `GET /api/performance/summary?days=90`
|
||||
- `GET /api/performance/trends?days=90`
|
||||
|
||||
`POST /api/performance/refresh` forces a fresh HF sync.
|
||||
|
||||
## Status Semantics
|
||||
|
||||
Important: the dashboard intentionally separates stored CI status from
|
||||
recomputed context.
|
||||
|
||||
Stored status:
|
||||
|
||||
- Comes directly from the latest JSON record's `success` field.
|
||||
- This is what the dashboard displays as `Stored Status`.
|
||||
- This is the primary latest status.
|
||||
|
||||
Recomputed status:
|
||||
|
||||
- Calculated locally from cached records for explanatory context.
|
||||
- Uses the latest record's metric values compared to the median of the latest
|
||||
five previous successful records in the same `(model_id, gpu_type)` group.
|
||||
- Displayed separately as `Recomputed`.
|
||||
- Does not override the stored JSON `success` status.
|
||||
|
||||
This distinction was added after observing that recomputing pass/fail from the
|
||||
local cache can disagree with the status originally uploaded by CI.
|
||||
|
||||
## Time Window Behavior
|
||||
|
||||
The default dashboard time window is 90 days.
|
||||
|
||||
The selected `days` value affects:
|
||||
|
||||
- trend charts
|
||||
- record browsing/filtering
|
||||
|
||||
The selected `days` value does not affect:
|
||||
|
||||
- latest stored status
|
||||
- latest summary baseline context
|
||||
|
||||
Reason: latest status should not change when users widen or narrow the trend
|
||||
window. The API keeps `days` on `/summary` only for shared frontend filter
|
||||
state, but summary loading uses all cached records.
|
||||
|
||||
This fixed a bug where changing from roughly 35 days to 42 days could change
|
||||
the latest status from pass to fail because older records entered the local
|
||||
baseline window.
|
||||
|
||||
## Metric Logic
|
||||
|
||||
Dashboard metric definitions live in:
|
||||
|
||||
```text
|
||||
fastvideo/performance_dashboard/metrics.py
|
||||
```
|
||||
|
||||
Tracked metrics:
|
||||
|
||||
- `latency` lower is better
|
||||
- `throughput` higher is better
|
||||
- `memory` lower is better
|
||||
- `text_encoder_time_s` lower is better
|
||||
- `dit_time_s` lower is better
|
||||
- `vae_decode_time_s` lower is better
|
||||
|
||||
Baseline context uses the median of up to five previous successful records for
|
||||
the same `(model_id, gpu_type)`.
|
||||
|
||||
## Frontend Behavior
|
||||
|
||||
The React app:
|
||||
|
||||
- fetches `/api/performance/summary`
|
||||
- fetches `/api/performance/trends`
|
||||
- displays summary cards
|
||||
- displays latest rows by model/GPU
|
||||
- displays native SVG trend charts
|
||||
- has model/GPU/day filters
|
||||
- includes a refresh button
|
||||
- auto-refreshes every five minutes
|
||||
|
||||
The UI is implemented without a charting library. Trend charts are native SVG
|
||||
in `performance_dashboard/frontend/src/App.tsx`.
|
||||
|
||||
The production frontend build uses `esbuild` through
|
||||
`performance_dashboard/frontend/scripts/build.mjs`. Vite is still used for the
|
||||
dev server and `/api` proxy.
|
||||
|
||||
Why esbuild for production build:
|
||||
|
||||
- Vite/Rollup hit a local macOS native optional dependency code-signing issue
|
||||
in this environment.
|
||||
- Direct esbuild worked reliably and is sufficient for this small dashboard.
|
||||
|
||||
## Static Serving
|
||||
|
||||
After frontend build, the FastAPI server serves:
|
||||
|
||||
- static JS/CSS from `performance_dashboard/frontend/dist/assets`
|
||||
- `performance_dashboard/frontend/dist/index.html` for the dashboard page
|
||||
|
||||
This allows a single local port to serve both the API and UI.
|
||||
|
||||
## Local Run Workflow
|
||||
|
||||
Build frontend:
|
||||
|
||||
```bash
|
||||
cd performance_dashboard/frontend
|
||||
conda run -n fastvideo env PATH=/Applications/Codex.app/Contents/Resources/cua_node/bin:/usr/local/bin:/usr/bin:/bin \
|
||||
/Applications/Codex.app/Contents/Resources/cua_node/bin/npm install
|
||||
conda run -n fastvideo env PATH=/Applications/Codex.app/Contents/Resources/cua_node/bin:/usr/local/bin:/usr/bin:/bin \
|
||||
/Applications/Codex.app/Contents/Resources/cua_node/bin/npm run build
|
||||
```
|
||||
|
||||
Run dashboard:
|
||||
|
||||
```bash
|
||||
export HF_TOKEN=hf_...
|
||||
python -m fastvideo.performance_dashboard --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Open locally:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
## ngrok Workflow
|
||||
|
||||
`python -m fastvideo.performance_dashboard --host 0.0.0.0 --port 8000`
|
||||
starts the actual local dashboard server.
|
||||
|
||||
`ngrok http 8000` does not start the dashboard. It exposes the already-running
|
||||
local server through a temporary public URL.
|
||||
|
||||
Typical flow:
|
||||
|
||||
```bash
|
||||
python -m fastvideo.performance_dashboard --host 0.0.0.0 --port 8000
|
||||
ngrok http 8000
|
||||
```
|
||||
|
||||
Use the HTTPS URL printed by ngrok to view the dashboard remotely.
|
||||
|
||||
## Verification Commands
|
||||
|
||||
Backend tests:
|
||||
|
||||
```bash
|
||||
conda run -n fastvideo python -m pytest \
|
||||
fastvideo/tests/performance/test_dashboard_service.py \
|
||||
fastvideo/tests/performance/test_dashboard_api.py \
|
||||
-q
|
||||
```
|
||||
|
||||
Expected after latest changes:
|
||||
|
||||
```text
|
||||
8 passed
|
||||
```
|
||||
|
||||
Frontend build:
|
||||
|
||||
```bash
|
||||
cd performance_dashboard/frontend
|
||||
conda run -n fastvideo env PATH=/Applications/Codex.app/Contents/Resources/cua_node/bin:/usr/local/bin:/usr/bin:/bin \
|
||||
/Applications/Codex.app/Contents/Resources/cua_node/bin/npm run build
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
tsc && node scripts/build.mjs
|
||||
```
|
||||
|
||||
with exit code 0.
|
||||
|
||||
## Known Notes
|
||||
|
||||
- `performance_dashboard/frontend/node_modules/` and
|
||||
`performance_dashboard/frontend/dist/` are ignored by git.
|
||||
- `npm install` reported two high-severity audit findings in dependency tree.
|
||||
`npm audit fix --force` was not run because it can introduce breaking
|
||||
dependency upgrades.
|
||||
- Existing `fastvideo` package imports may emit platform warnings such as NPU
|
||||
or macOS torch distributed messages. These are not dashboard-specific errors.
|
||||
|
||||
@@ -14,7 +14,7 @@ based on **Wan2.1** (SkyReels-V2) DiT models with causal attention for
|
||||
auto-regressive streaming generation.
|
||||
|
||||
**Key techniques you will work with:**
|
||||
- Full finetuning and LoRA on Wan / LTX-2 / MatrixGame models
|
||||
- Full finetuning and LoRA on Wan / LTX-2 / Matrix-Game 2.0 models
|
||||
- DMD-based distillation (few-step generation)
|
||||
- Self-Forcing distillation (causal streaming)
|
||||
- Diffusion-Forcing SFT (DFSFT) for causal models
|
||||
@@ -225,10 +225,10 @@ callbacks:
|
||||
|----------|-----------|----------|
|
||||
| Wan T2V finetune | `fastvideo/training/wan_training_pipeline.py` | Standard text-to-video finetune / LoRA |
|
||||
| Wan I2V finetune | `fastvideo/training/wan_i2v_training_pipeline.py` | Image-to-video (first frame conditioned) |
|
||||
| MatrixGame finetune | `fastvideo/training/matrixgame_training_pipeline.py` | Action-conditioned world model |
|
||||
| MatrixGame AR diffusion | `fastvideo/training/matrixgame_ar_diffusion_pipeline.py` | AR diffusion-forcing training |
|
||||
| MatrixGame ODE-init | `fastvideo/training/matrixgame_ode_causal_pipeline.py` | ODE-trajectory init |
|
||||
| MatrixGame self-forcing distill | `fastvideo/training/matrixgame_self_forcing_distillation_pipeline.py` | Self-forcing distillation |
|
||||
| Matrix-Game 2.0 finetune | `fastvideo/training/matrixgame2_training_pipeline.py` | Action-conditioned world model |
|
||||
| Matrix-Game 2.0 AR diffusion | `fastvideo/training/matrixgame2_ar_diffusion_pipeline.py` | AR diffusion-forcing training |
|
||||
| Matrix-Game 2.0 ODE-init | `fastvideo/training/matrixgame2_ode_causal_pipeline.py` | ODE-trajectory init |
|
||||
| Matrix-Game 2.0 self-forcing distill | `fastvideo/training/matrixgame2_self_forcing_distillation_pipeline.py` | Self-forcing distillation |
|
||||
| LTX-2 finetune | `fastvideo/training/ltx2_training_pipeline.py` | LTX-2 architecture finetuning |
|
||||
| Wan DMD distillation | `fastvideo/training/wan_distillation_pipeline.py` | Few-step distillation via DMD |
|
||||
| Self-Forcing distill | `fastvideo/training/wan_self_forcing_distillation_pipeline.py` | Causal streaming distillation |
|
||||
@@ -258,7 +258,7 @@ Read `.agents/memory/evaluation-registry/README.md` for the full metric catalog.
|
||||
|--------|-------------|-------|
|
||||
| **Loss trajectory** | Every run, real-time from W&B | Medium |
|
||||
| **SSIM** | When comparing against reference outputs | High |
|
||||
| **FVD** | For benchmarking model quality (`benchmarks/fvd/`) | High |
|
||||
| **FVD** | For benchmarking model quality (`common.fvd` eval metric; example: `examples/inference/eval/eval_fvd.py`) | High |
|
||||
| **LPIPS** | LoRA merge validation | Medium |
|
||||
| **Human preference** | Major checkpoints | Highest |
|
||||
|
||||
@@ -278,8 +278,8 @@ Read `.agents/memory/evaluation-registry/README.md` for the full metric catalog.
|
||||
|
||||
## World Model–Specific Concepts
|
||||
|
||||
### Action Injection (MatrixGame)
|
||||
The MatrixGame pipeline adds **action modules** to each DiT block, enabling
|
||||
### Action Injection (Matrix-Game 2.0)
|
||||
The Matrix-Game 2.0 pipeline adds **action modules** to each DiT block, enabling
|
||||
frame-level mouse/keyboard input conditioning. The action sequence is injected
|
||||
per-frame alongside the latent video tokens.
|
||||
|
||||
|
||||
@@ -197,6 +197,12 @@ Tolerance guide:
|
||||
Element-wise `assert_close` alone is not enough for deep full-DiT parity. Also
|
||||
log global abs-mean drift and per-modality summaries.
|
||||
|
||||
When a non-skip component parity run is numerically red after weight/input
|
||||
checks, invoke `../add-model-08-trace/SKILL.md` before adding bespoke forward
|
||||
hooks. Use `docs/contributing/activation_trace.md` to keep
|
||||
`FASTVIDEO_TRACE_LAYERS`, `FASTVIDEO_TRACE_STATS`, and `FASTVIDEO_TRACE_STEPS`
|
||||
identical across FastVideo and upstream traces.
|
||||
|
||||
Useful local commands:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -94,8 +94,12 @@ Run the shared parity-debug loop. The component test command is:
|
||||
pytest <parity_test> -v -s
|
||||
```
|
||||
|
||||
For numerical drift, narrow the first divergent block with per-block hooks or
|
||||
intermediate tensor comparisons before changing layers.
|
||||
For numerical drift, use `../add-model-08-trace/SKILL.md` before writing bespoke
|
||||
hooks. Start with FastVideo's activation trace (`fastvideo/hooks/activation_trace.py`;
|
||||
`docs/contributing/activation_trace.md`) and a block-level regex such as
|
||||
`FASTVIDEO_TRACE_LAYERS="^block\.layers\.[0-9]+$"`. Only fall back to custom
|
||||
per-block hooks if the needed boundary or statistic is not exposed by
|
||||
`FASTVIDEO_TRACE_STATS`.
|
||||
|
||||
## Escape Hatches
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: add-model-08-trace
|
||||
description: Use during /add-model Phase 6 when component parity has failed and root cause requires layer-by-layer divergence analysis. Instruments both the official reference and FastVideo port with forward hooks to find the first numerical divergence point.
|
||||
description: Use during /add-model Phase 6 when component parity has failed and root cause requires layer-by-layer divergence analysis. Uses FastVideo activation trace first, falling back to custom hooks only for boundaries or stats the utility cannot observe.
|
||||
---
|
||||
|
||||
# Add-Model Trace
|
||||
@@ -38,104 +38,90 @@ Required inputs before starting:
|
||||
- Shared deterministic test inputs (same tensors on both sides).
|
||||
- The component parity test file path and its current failure output.
|
||||
|
||||
## Hard Rules: Instrumentation Hierarchy
|
||||
## Primary Path: FastVideo Activation Trace
|
||||
|
||||
Apply these in priority order. Use the highest-priority method that works for
|
||||
the target site.
|
||||
Use FastVideo's first-class activation trace before writing custom hooks:
|
||||
`fastvideo/hooks/activation_trace.py`, documented in
|
||||
`docs/contributing/activation_trace.md`.
|
||||
|
||||
### (1) Forward hooks (PREFERRED)
|
||||
Pipeline runs attach trace to the transformer during pipeline initialization.
|
||||
Component-only parity harnesses may call `attach_activation_trace(model)` from
|
||||
local test/debug code; do not add trace calls to production model code.
|
||||
|
||||
Prefix the failing parity command with a tight layer regex:
|
||||
|
||||
```bash
|
||||
FASTVIDEO_TRACE_ACTIVATIONS=1 \
|
||||
FASTVIDEO_TRACE_LAYERS="^block\.layers\.[0-9]+$" \
|
||||
FASTVIDEO_TRACE_STATS="abs_mean,sum,max,shape" \
|
||||
FASTVIDEO_TRACE_STEPS="0" \
|
||||
FASTVIDEO_TRACE_OUTPUT="/tmp/opencode/fv_trace.jsonl" \
|
||||
pytest tests/local_tests -k "parity" -v -s
|
||||
```
|
||||
|
||||
Match the layer regex to the actual `model.named_modules()` names. Empty or
|
||||
broad regexes are expensive; prefer block-level names first, then narrow to
|
||||
submodules after the first divergent block is known.
|
||||
|
||||
## Trace Compare Contract
|
||||
|
||||
One JSONL file per side. FastVideo output should use `FASTVIDEO_TRACE_OUTPUT`;
|
||||
the upstream harness should emit the same JSONL shape:
|
||||
|
||||
```json
|
||||
{"module":"block.layers.0","tensor":"out","step":0,"abs_mean":0.0123,"sum":1.0,"max":0.5,"shape":[1,16,32]}
|
||||
```
|
||||
|
||||
Compare rows by `(module, step, tensor)`. The first row whose `shape`,
|
||||
`abs_mean`, or `max` diverges beyond the component tolerance is the first broken
|
||||
boundary. Keep `FASTVIDEO_TRACE_LAYERS`, `FASTVIDEO_TRACE_STATS`, and
|
||||
`FASTVIDEO_TRACE_STEPS` identical between sides; if row order differs, sort or
|
||||
normalize before diffing.
|
||||
|
||||
## Drill-Down Loop
|
||||
|
||||
**Initial run:** trace every top-level block (`^block\.layers\.[0-9]+$` or the
|
||||
family's equivalent). Identify the first block index where `abs_mean` or `max`
|
||||
drifts beyond tolerance while earlier blocks match.
|
||||
|
||||
**Drill run:** tighten `FASTVIDEO_TRACE_LAYERS` to submodules inside the first
|
||||
divergent block: attention output, MLP projections, norm outputs, modality
|
||||
adapters, or other named boundaries exposed by `named_modules()`.
|
||||
|
||||
**Iterate:** if the first divergent operation is a free function or tensor op not
|
||||
visible as an `nn.Module`, use the fallback instrumentation hierarchy below.
|
||||
|
||||
The loop ends when the first divergent submodule or operation is identified with
|
||||
a file:line citation in the official source.
|
||||
|
||||
## Fallback Instrumentation Hierarchy
|
||||
|
||||
Use these only when activation trace cannot observe the needed boundary or
|
||||
statistic.
|
||||
|
||||
### (1) Custom forward hooks
|
||||
|
||||
`module.register_forward_hook(...)` and `register_forward_pre_hook(...)`.
|
||||
Always within `try/finally` with `handle.remove()`. Zero source residue.
|
||||
|
||||
```python
|
||||
handle = module.register_forward_hook(fn)
|
||||
try:
|
||||
output = model(inputs)
|
||||
finally:
|
||||
handle.remove()
|
||||
```
|
||||
|
||||
### (2) Runtime monkey-patch (PREFERRED over source edits)
|
||||
### (2) Runtime monkey-patch
|
||||
|
||||
`module.attr = wrapped_func` or `cls.method = wrapped_method`, restored via
|
||||
`try/finally` (save original first). Use for free functions and non-Module
|
||||
sites such as activation functions (`swiglu`, `apply_rotary_emb`) that cannot
|
||||
be hooked as `nn.Module` submodules.
|
||||
|
||||
```python
|
||||
original = cls.method
|
||||
cls.method = wrapped
|
||||
try:
|
||||
output = model(inputs)
|
||||
finally:
|
||||
cls.method = original
|
||||
```
|
||||
`try/finally` (save original first). Use for free functions and non-Module sites
|
||||
such as activation functions (`swiglu`, `apply_rotary_emb`).
|
||||
|
||||
### (3) Source edits in FastVideo's own code
|
||||
|
||||
Only when (1) and (2) are insufficient. Track all edits within a single named
|
||||
`git stash` boundary OR a temporary branch. Run `git diff` before closing the
|
||||
investigation to confirm the stash or branch is clean. The cleanup gate
|
||||
enforces this.
|
||||
investigation to confirm cleanup.
|
||||
|
||||
### (4) Source edits in official repo source
|
||||
|
||||
Allowed if EITHER:
|
||||
|
||||
- (a) The official repo is a git-tracked clone (e.g. `daVinci-MagiHuman/` at
|
||||
the repo root): use `git diff` in the clone path to verify cleanup.
|
||||
- (b) It's installed editable (`pip install -e .`): use `git diff` in the
|
||||
editable source path to verify cleanup.
|
||||
|
||||
If the official repo is installed non-editable in site-packages: back up the
|
||||
target file (`cp original.py original.py.trace-backup`) before editing, then
|
||||
restore from backup at the end (or `pip install --force-reinstall <pkg>`).
|
||||
The cleanup gate verifies via diff-against-backup or zero-diff-in-clone.
|
||||
|
||||
## Logging Contract
|
||||
|
||||
One log file per side. Paths:
|
||||
|
||||
```
|
||||
/tmp/opencode/<family>_<component>_up_layers.log
|
||||
/tmp/opencode/<family>_<component>_fv_layers.log
|
||||
```
|
||||
|
||||
Format: one line per captured tensor, space-separated:
|
||||
|
||||
```
|
||||
<name> <shape> <abs_mean> <sum> <min> <max>
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
block[00] (1,512,1024) 0.012345 6.3210 -0.4321 0.4321
|
||||
```
|
||||
|
||||
Keep the format diff-friendly. Running `diff /tmp/opencode/x_up.log
|
||||
/tmp/opencode/x_fv.log` should highlight the first divergent line directly.
|
||||
Retain side-by-side stdout output alongside the per-side files for human
|
||||
review.
|
||||
|
||||
## Drill-Down Loop
|
||||
|
||||
**Initial run:** attach hooks to every top-level block (`model.block.layers[i]`
|
||||
or equivalent). Identify the first block index `NN` where abs_mean relative
|
||||
drift exceeds 0.5% compared to the previous block.
|
||||
|
||||
**Drill run:** set `<FAMILY>_DEBUG_DRILL_LAYER=NN` and re-run. The script
|
||||
attaches submodule hooks inside block `NN`: attention output, mlp.pre_norm,
|
||||
mlp.up_gate_proj, mlp.down_proj input (via pre-hook) and output, mlp output,
|
||||
attn_post_norm (if present), mlp_post_norm (if present).
|
||||
|
||||
**Iterate:** if the drill run points to a free function (e.g. an activation
|
||||
not wrapped in an `nn.Module`), switch to a monkey-patch (method 2) to
|
||||
intercept its output via the next module's pre-hook.
|
||||
|
||||
The loop ends when the first divergent submodule is identified with a
|
||||
file:line citation in the official source.
|
||||
Allowed only when hook and monkey-patch approaches cannot capture the site.
|
||||
For git-tracked or editable official clones, use `git diff` in the clone path to
|
||||
verify cleanup. For non-editable site-packages, back up the target file before
|
||||
editing and restore it before handoff.
|
||||
|
||||
## Hypothesis Toggles
|
||||
|
||||
@@ -194,11 +180,13 @@ Escalate to the calling bucket skill when:
|
||||
|
||||
Return to the calling subagent with:
|
||||
|
||||
- File paths to per-side logs (`/tmp/opencode/<family>_<component>_{up,fv}_layers.log`).
|
||||
- The identified first divergent layer or submodule name.
|
||||
- FastVideo trace JSONL path and upstream trace JSONL path.
|
||||
- Trace settings used: `FASTVIDEO_TRACE_LAYERS`, `FASTVIDEO_TRACE_STATS`, and
|
||||
`FASTVIDEO_TRACE_STEPS`.
|
||||
- The first divergent `(module, step, tensor)` row and observed drift.
|
||||
- The upstream file:line citation where the divergence originates.
|
||||
- Hypothesis verdict if an A/B toggle was used (e.g. "PATCH_LINEAR=1 closes
|
||||
the gap, confirming dtype-cast difference in PackedExpertLinear").
|
||||
- Fallback hook/patch verdict if activation trace could not observe the boundary.
|
||||
- Hypothesis verdict if an A/B toggle was used, for example `PATCH_LINEAR=1`.
|
||||
- Cleanup-gate status: `[cleanup-gate] PASS` or a list of unresolved items.
|
||||
|
||||
The calling agent uses this to scope the production fix in the FastVideo
|
||||
@@ -206,10 +194,14 @@ component file.
|
||||
|
||||
## References
|
||||
|
||||
- `templates/block_trace_debug.py` in this skill directory: the canonical
|
||||
template this skill generalizes.
|
||||
- `docs/contributing/activation_trace.md` for canonical activation-trace env vars,
|
||||
JSONL output, cost model, and troubleshooting.
|
||||
- `fastvideo/hooks/activation_trace.py` for the implementation and
|
||||
`attach_activation_trace(model)` entry point.
|
||||
- `templates/block_trace_debug.py` in this skill directory: fallback custom-hook
|
||||
template when activation trace cannot observe the needed boundary or stat.
|
||||
- `tests/local_tests/transformers/_debug_magi_human_block_parity.py` in the
|
||||
FastVideo3 repo: the worked magi-human example this skill was extracted from.
|
||||
FastVideo3 repo: historical worked example for custom hook/patch debugging.
|
||||
- `add-model/SKILL.md` Phase 6: the calling context for this skill.
|
||||
- `add-model-03-port-dit/SKILL.md`, `add-model-04-port-vae/SKILL.md`,
|
||||
`add-model-05-port-encoder/SKILL.md`, `add-model-06-port-generic/SKILL.md`:
|
||||
|
||||
@@ -157,6 +157,13 @@ Debug pipeline drift in this order:
|
||||
channel order, sample rate, FPS, and final slicing.
|
||||
7. Add targeted stage-level diagnostics to identify the first divergent stage.
|
||||
|
||||
If stage diagnostics show the first bad stage is transformer/denoising or a
|
||||
mid-DiT block, enable activation trace before adding ad hoc pipeline prints; see
|
||||
`docs/contributing/activation_trace.md` and `../add-model-08-trace/SKILL.md`.
|
||||
Keep `FASTVIDEO_TRACE_LAYERS`, `FASTVIDEO_TRACE_STATS`, and
|
||||
`FASTVIDEO_TRACE_STEPS` identical across reruns so pipeline parity traces diff
|
||||
one-to-one.
|
||||
|
||||
If the first divergence belongs to component implementation, strict loading, or
|
||||
conversion mapping, stop pipeline edits and return `next_step=return_to_phase_6`
|
||||
with the exact failing evidence. Do not patch conversion from this skill.
|
||||
|
||||
@@ -267,6 +267,11 @@ conversion, route it through `../add-model-07-conversion/SKILL.md` with a retry
|
||||
request matching `contracts/conversion_request.md`, then resume the component
|
||||
skill with the updated conversion handoff.
|
||||
|
||||
When a component failure narrows to layer-by-layer numerical drift, load
|
||||
`../add-model-08-trace/SKILL.md` before writing custom hooks. It uses
|
||||
`fastvideo/hooks/activation_trace.py`; canonical env vars and JSONL format are
|
||||
documented in `docs/contributing/activation_trace.md`.
|
||||
|
||||
Phase 6 ends only when every required component handoff reports
|
||||
`parity_status=non_skip_pass`, or when a precise blocker or escape hatch is
|
||||
recorded in `port_state_file`.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: add-reward-model
|
||||
description: Use when adding reusable reward models under fastvideo/train/methods/rl/rewards for RLHF or online RL training.
|
||||
---
|
||||
|
||||
# Add Reward Model
|
||||
|
||||
Use for reward models consumed by RL methods.
|
||||
|
||||
## Placement
|
||||
|
||||
- Put reusable reward code under `fastvideo/train/methods/rl/rewards/`.
|
||||
- Expose public builders from `fastvideo/train/methods/rl/rewards/__init__.py`.
|
||||
- Keep method-specific aggregation or advantage logic out of reward classes.
|
||||
|
||||
## Media Inputs
|
||||
|
||||
- Reward callables receive decoded media tensors.
|
||||
- Accept single-frame tensors as `[B, C, H, W]` and multi-frame tensors as `[B, C, T, H, W]` when practical.
|
||||
- Frame selection is reward-specific. Frame scorers such as PickScore and CLIPScore should explicitly select frame `0`; temporal rewards should inspect whichever frames they need.
|
||||
- Return one scalar reward per prompt/sample.
|
||||
|
||||
## Attribution
|
||||
|
||||
- If code is ported or closely adapted from another repo, add a short comment or docstring naming the source file/function.
|
||||
- Preserve SPDX headers used by FastVideo files.
|
||||
|
||||
## Tests
|
||||
|
||||
- Unit-test tensor layout handling without loading large reward checkpoints.
|
||||
- Allow fake scorer injection for multi-reward tests.
|
||||
- Test weighted reward aggregation and metric keys.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: add-rl-method
|
||||
description: Use when adding or modifying an RL/RLHF method under fastvideo/train/methods/rl, including DiffusionNFT-like methods.
|
||||
---
|
||||
|
||||
# Add RL Method
|
||||
|
||||
Use for new RL methods in the modular `fastvideo/train` stack.
|
||||
|
||||
## Required Shape
|
||||
|
||||
- Add the method under `fastvideo/train/methods/rl/`.
|
||||
- Subclass `TrainingMethod`.
|
||||
- Keep model-family logic in `ModelBase` wrappers.
|
||||
- Decode generated latents through `ModelBase.decode_latents`; add that hook to the new model wrapper instead of decoding inside the RL method.
|
||||
- Use `fastvideo/train/methods/rl/common/sampling.py` for generation unless the method has a documented reason to avoid sampling.
|
||||
- Use `fastvideo/train/methods/rl/common/prompt_sampling.py` for reusable grouped prompt sampling patterns such as DiffusionNFT K-repeat.
|
||||
- Use `fastvideo/train/methods/rl/rewards/` for reward models.
|
||||
|
||||
## Optimization
|
||||
|
||||
- Return `manages_optimization() == True` only when the method must own a nonstandard outer/inner loop.
|
||||
- If using managed optimization, implement `managed_train_step(data_stream, iteration)`.
|
||||
- Existing trainer callbacks, checkpointing, tracking, and validation should still work.
|
||||
|
||||
## Config
|
||||
|
||||
- Put method knobs under `method`.
|
||||
- Put sampler knobs under `method.sampling`.
|
||||
- Do not put scheduler or trajectory policy into model configs.
|
||||
- Do not split a diffusers-style scheduler from its built-in `step()` solver in YAML; use `trajectory` only for higher-level ODE vs re-noise behavior.
|
||||
- Avoid fixed timestep lists in examples unless reproducing a known baseline; prefer scheduler-generated defaults.
|
||||
|
||||
## Tests
|
||||
|
||||
- Add fake-model tests for sampler/method behavior.
|
||||
- Add config parse tests for the public YAML.
|
||||
- Confirm existing train methods stay on the default Trainer path.
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: dreamverse-deploy
|
||||
description: Use when redeploying the migrated Dreamverse app backend and frontend on a chosen local GPU; tears down existing ports, launches services, and waits for readiness checks.
|
||||
---
|
||||
|
||||
# dreamverse-deploy — redeploy migrated Dreamverse on a chosen GPU
|
||||
|
||||
**Scope:** project (lives in this repo at `.agents/skills/dreamverse-deploy/`)
|
||||
@@ -17,7 +22,7 @@ both `/readyz` and the FE root to return 200.
|
||||
`cerebras-cloud-sdk`, `openai` installed (override the default path with
|
||||
`DREAMVERSE_PYTHON=/path/to/python`)
|
||||
- `~/.env` exporting `CEREBRAS_API_KEY`, `GROQ_API_KEY`, etc.
|
||||
- pnpm installed at `/home/william5lin/.local/share/pnpm/pnpm` (or in `$PATH`)
|
||||
- npm available in `$PATH` (or set `NPM=/path/to/npm`)
|
||||
- `gcc-13` + `g++-13` at `/usr/bin/` (workaround for nvcc gcc-15 rejection)
|
||||
- **Recommended:** native ffmpeg env file at `apps/dreamverse/scripts/ffmpeg-env.sh`
|
||||
(built once via `bash apps/dreamverse/scripts/install_native_ffmpeg.sh`).
|
||||
@@ -105,7 +110,7 @@ Flags can appear in any position relative to the positional args. Explicit flag
|
||||
5. Launches the backend via `apps/dreamverse/scripts/dreamverse-server` in a
|
||||
detached `setsid` session, captures PID.
|
||||
6. Polls `/readyz` until 200 (max 5 min).
|
||||
7. Launches the frontend via `pnpm run dev:devtools` in a detached session,
|
||||
7. Launches the frontend via `npm run dev:devtools` in a detached session,
|
||||
captures PID.
|
||||
8. Polls FE `/` until 200 (max 60s).
|
||||
9. Prints URLs, PIDs, and log paths.
|
||||
@@ -120,7 +125,7 @@ Flags can appear in any position relative to the positional args. Explicit flag
|
||||
PLAYWRIGHT_SKIP_WEBSERVER=1 BACKEND_URL=http://127.0.0.1:8009 \
|
||||
PLAYWRIGHT_BASE_URL=http://127.0.0.1:5274 \
|
||||
NEXT_PUBLIC_INCLUDE_DEVTOOLS=1 \
|
||||
pnpm exec playwright test
|
||||
npm exec -- playwright test
|
||||
```
|
||||
The fast suite (8 specs, ~5s) runs by default; the long-running
|
||||
two-segment audio-continuation spec is gated behind
|
||||
@@ -146,7 +151,7 @@ PLAYWRIGHT_SKIP_WEBSERVER=1 \
|
||||
PLAYWRIGHT_BASE_URL=http://127.0.0.1:5274 \
|
||||
NEXT_PUBLIC_INCLUDE_DEVTOOLS=1 \
|
||||
PLAYWRIGHT_LONG_RUNNING=1 \
|
||||
pnpm exec playwright test e2e/long-running-segments.spec.ts
|
||||
npm exec -- playwright test e2e/long-running-segments.spec.ts
|
||||
```
|
||||
|
||||
Expected runtime: ~7-9 minutes on a B200 (torch.compile max-autotune
|
||||
|
||||
@@ -185,17 +185,10 @@ CONDA_ENV_PYTHON="${DREAMVERSE_PYTHON:-${HOME}/miniconda3/envs/fv-main/bin/pytho
|
||||
"${CONDA_ENV_PYTHON}" -c 'import flashinfer' 2>/dev/null \
|
||||
|| bail "flashinfer-python not installed in ${CONDA_ENV_PYTHON} (run: ${CONDA_ENV_PYTHON} -m pip install flashinfer-python --no-build-isolation)"
|
||||
|
||||
PNPM="${PNPM:-}"
|
||||
if [[ -n "${PNPM}" ]]; then
|
||||
PNPM_REQUESTED="${PNPM}"
|
||||
PNPM="$(command -v "${PNPM}" 2>/dev/null || true)"
|
||||
[[ -n "${PNPM}" ]] || bail "pnpm not executable or not in PATH: ${PNPM_REQUESTED} (set PNPM to override)"
|
||||
elif [[ -x "${HOME}/.local/share/pnpm/pnpm" ]]; then
|
||||
PNPM="${HOME}/.local/share/pnpm/pnpm"
|
||||
else
|
||||
PNPM="$(command -v pnpm 2>/dev/null || true)"
|
||||
fi
|
||||
[[ -n "${PNPM}" ]] && [[ -x "${PNPM}" ]] || bail "pnpm not found. Set PNPM, install at ${HOME}/.local/share/pnpm/pnpm, or add pnpm to PATH"
|
||||
NPM="${NPM:-npm}"
|
||||
NPM_REQUESTED="${NPM}"
|
||||
NPM="$(command -v "${NPM}" 2>/dev/null || true)"
|
||||
[[ -n "${NPM}" ]] && [[ -x "${NPM}" ]] || bail "npm not executable or not in PATH: ${NPM_REQUESTED} (set NPM to override)"
|
||||
|
||||
GCC13="$(command -v "${GCC13:-gcc-13}" 2>/dev/null || true)"
|
||||
GPP13="$(command -v "${GPP13:-g++-13}" 2>/dev/null || true)"
|
||||
@@ -386,7 +379,7 @@ frontend_log="${LOG_DIR}/frontend-port${FRONTEND_PORT}.log"
|
||||
# requested port differs, run `next dev --port` directly with devtools env.
|
||||
fe_cmd="run dev:devtools"
|
||||
if [[ "${FRONTEND_PORT}" != "5274" ]]; then
|
||||
fe_cmd="exec next dev --port ${FRONTEND_PORT}"
|
||||
fe_cmd="exec -- next dev --port ${FRONTEND_PORT}"
|
||||
fi
|
||||
|
||||
setsid bash -c "
|
||||
@@ -395,7 +388,7 @@ setsid bash -c "
|
||||
export BACKEND_URL=http://127.0.0.1:${BACKEND_PORT}
|
||||
export BACKEND_HOST=127.0.0.1
|
||||
export BACKEND_PORT=${BACKEND_PORT}
|
||||
exec '${PNPM}' ${fe_cmd}
|
||||
exec '${NPM}' ${fe_cmd}
|
||||
" > "${frontend_log}" 2>&1 < /dev/null &
|
||||
disown
|
||||
|
||||
@@ -462,5 +455,5 @@ cat <<SUMMARY
|
||||
BACKEND_URL=http://127.0.0.1:${BACKEND_PORT} \\
|
||||
PLAYWRIGHT_BASE_URL=http://127.0.0.1:${FRONTEND_PORT} \\
|
||||
NEXT_PUBLIC_INCLUDE_DEVTOOLS=1 \\
|
||||
pnpm exec playwright test
|
||||
npm exec -- playwright test
|
||||
SUMMARY
|
||||
|
||||
@@ -6,4 +6,8 @@
|
||||
{"name": "seed-ssim-references", "description": "Run a new or updated fastvideo/tests/ssim/ test on Modal, pull generated videos, and upload them to FastVideo/ssim-reference-videos so the test has a regression baseline", "path": "seed-ssim-references/SKILL.md", "status": "draft", "trust": "low"}
|
||||
{"name": "reseed-ssim-references", "description": "Re-seed (overwrite) HF reference videos for an existing fastvideo/tests/ssim/ test and a single model id on Modal L40S. Always backs up current refs first, regenerates on Modal, pauses for the user to eyeball before-vs-after, then uploads with --force scoped to --model-id. Sister skill to seed-ssim-references; use when intentional code change has invalidated existing refs", "path": "reseed-ssim-references/SKILL.md", "status": "draft", "trust": "low"}
|
||||
{"name": "decompose-pipeline-pr", "description": "Decompose an oversized FastVideo pipeline PR into a stack of independently-reviewable PRs. Tiers the diff by blast radius (invisible / dead code / cross-cutting infra / activation), produces a branch graph and worktree bootstrap, drafts the AGENTS.md manifest, flags missing tests on cross-cutting infra changes, and extracts lessons from the PR body. Worked example: PR #1280 daVinci-MagiHuman (9.8k LOC) decomposed into 10 stacked PRs.", "path": "decompose-pipeline-pr/SKILL.md", "status": "tested", "trust": "medium"}
|
||||
{"name": "reseed-performance-baseline", "description": "Re-seed the HF performance-tracking baseline for an intentional runtime, dependency, or environment-caused benchmark shift. Use when performance CI fails because metrics such as latency, throughput, component time, or peak memory changed for an accepted reason and the rolling median baseline must be advanced by replicating one reviewed shifted source result into three success=true records, or five records when explicitly requested", "path": "reseed-performance-baseline/SKILL.md", "status": "draft", "trust": "low"}
|
||||
{"name": "add-model", "description": "Add a new model (or variant) to FastVideo: DiT + configs + pipeline + presets + registry + tests. Walks through FastVideo's single stage-based pipeline architecture with exact file paths and registration hooks.", "path": "add-model/SKILL.md", "status": "draft", "trust": "low"}
|
||||
{"name": "rlhf-training-abstractions", "description": "Use when changing FastVideo RLHF/RL training infrastructure, especially sampler, reward, scheduler trajectory, or method boundaries under fastvideo/train.", "path": "rlhf-training-abstractions/SKILL.md", "status": "draft", "trust": "low"}
|
||||
{"name": "add-rl-method", "description": "Use when adding or modifying an RL/RLHF method under fastvideo/train/methods/rl, including DiffusionNFT-like methods.", "path": "add-rl-method/SKILL.md", "status": "draft", "trust": "low"}
|
||||
{"name": "add-reward-model", "description": "Use when adding reusable reward models under fastvideo/train/methods/rl/rewards for RLHF or online RL training.", "path": "add-reward-model/SKILL.md", "status": "draft", "trust": "low"}
|
||||
|
||||
@@ -38,7 +38,7 @@ entrypoint, and applying defaults from the closest example script.
|
||||
| `finetune` (Wan T2V) | `fastvideo/training/wan_training_pipeline.py` |
|
||||
| `finetune` (Wan I2V) | `fastvideo/training/wan_i2v_training_pipeline.py` |
|
||||
| `finetune` (LTX-2) | `fastvideo/training/ltx2_training_pipeline.py` |
|
||||
| `finetune` (MatrixGame) | `fastvideo/training/matrixgame_training_pipeline.py` |
|
||||
| `finetune` (Matrix-Game 2.0) | `fastvideo/training/matrixgame2_training_pipeline.py` |
|
||||
| `distill-dmd` | `fastvideo/training/wan_distillation_pipeline.py` |
|
||||
| `self-forcing` | `fastvideo/training/wan_self_forcing_distillation_pipeline.py` |
|
||||
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
---
|
||||
name: reseed-performance-baseline
|
||||
description: Re-seed the HF performance-tracking baseline for an intentional runtime, dependency, or environment-caused benchmark shift using one or more reviewed normalized performance JSONs. Use when performance CI fails because metrics such as latency, throughput, component time, or peak memory changed for an accepted reason and the rolling median baseline in FastVideo/performance-tracking must be advanced from a consistent batch of reviewed source results. The workflow backs up existing history under /tmp, validates all source JSONs for the same (model_id, gpu_type), rejects internally inconsistent source batches, uploads one success=true reseed record per accepted source JSON, and offers to clean local temp state after a successful upload.
|
||||
---
|
||||
|
||||
# Re-seed Performance Baseline
|
||||
|
||||
## Purpose
|
||||
|
||||
Replace or advance the rolling performance baseline for a single
|
||||
`(model_id, gpu_type)` pair in the HF dataset
|
||||
`FastVideo/performance-tracking`.
|
||||
|
||||
Performance comparison uses the median of up to the last 5 successful records
|
||||
for the same model and GPU. Failed records are useful audit history, but they
|
||||
do not move the future baseline because `compare_baseline.py` loads records
|
||||
with `successful_only=True`.
|
||||
|
||||
This skill now reseeds from a reviewed batch of one or more source performance
|
||||
JSONs. It uploads one new `success=true` record per accepted source JSON; it
|
||||
does not blindly replicate one measurement into 3 or 5 records. The effective
|
||||
reseed size is therefore dynamic and equals the number of provided, validated,
|
||||
internally consistent source JSONs.
|
||||
|
||||
If the operator provides fewer than 3 records, call out that the last-5 rolling
|
||||
median may not move immediately. If the operator provides 3 consistent shifted
|
||||
records, the rolling median usually moves immediately. If the operator provides
|
||||
5 consistent shifted records, the last-5 window is effectively reset to the new
|
||||
runtime profile.
|
||||
|
||||
These records are intentional operator-approved baseline resets, not ordinary
|
||||
independent main-branch persistence. Mark them clearly with provenance fields
|
||||
so the HF history remains auditable.
|
||||
|
||||
Use this skill when a performance test fails for an intentional and reviewed
|
||||
reason, such as a torch/runtime/container upgrade that legitimately increases
|
||||
peak memory or changes timings. This is the performance equivalent of
|
||||
`reseed-ssim-references`: backup first, scope tightly, require explicit human
|
||||
approval, then upload reviewed accepted baseline records.
|
||||
|
||||
## When to use
|
||||
|
||||
- A PR or main run failed the rolling performance comparison by more than the
|
||||
allowed regression threshold, and maintainers agree the shift is caused by
|
||||
an intentional runtime, dependency, hardware image, or benchmark environment
|
||||
change rather than a FastVideo logic regression.
|
||||
- One or more shifted source result JSONs have been reviewed and accepted, and
|
||||
the operator wants to use those exact reviewed results to advance the rolling
|
||||
baseline.
|
||||
- The source batch is internally consistent: no provided source JSON regresses
|
||||
against the batch median by more than the configured tolerance.
|
||||
|
||||
## When not to use
|
||||
|
||||
- The benchmark failure might be a real code regression. Fix or investigate
|
||||
the code path first.
|
||||
- The fixed benchmark thresholds in
|
||||
`.buildkite/performance-benchmarks/tests/*.json` are too low. Those are a
|
||||
separate gate from the rolling HF baseline and may need a code review change.
|
||||
- There is no clear source run, commit, and rationale. Baseline history is a
|
||||
production signal; do not edit it without provenance.
|
||||
- The provided source JSONs disagree materially with each other. Rerun or
|
||||
investigate instead of uploading a noisy reseed batch.
|
||||
|
||||
## Inputs
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|-----------|----------|-------------|
|
||||
| `model_id` | Yes | Benchmark id, e.g. `wan-t2v-1.3b-2gpu`. This maps to the HF subdirectory after `sanitize(model_id)`. |
|
||||
| `gpu_type` | Yes | Exact GPU device string from the performance record, e.g. the L40S device name emitted by CI. Baselines are GPU-specific. |
|
||||
| `source_results` | Yes | One or more local paths or Buildkite artifact URLs for accepted shifted performance JSONs. Prefer normalized `normalized_perf_*.json` artifacts emitted by `compare_baseline.py`. Accept `source_result` as an alias only for a single JSON. |
|
||||
| `max_intra_batch_regression` | No | Maximum allowed regression of any source JSON against the source batch median. Default: `PERF_MAX_REGRESSION` if set, otherwise `0.05` (5%). |
|
||||
| `intent_rationale` | Yes | One-line explanation for why the baseline shift is legitimate. This is written into provenance and should be reused in the PR. |
|
||||
|
||||
Hardcoded defaults:
|
||||
|
||||
- HF repo: `FastVideo/performance-tracking` (`HF_REPO_ID` override is
|
||||
supported by the code, but use the default unless the user explicitly asks).
|
||||
- Local sync root: `/tmp/perf-tracking` (`PERFORMANCE_TRACKING_ROOT` override
|
||||
is supported).
|
||||
- Backup root: `/tmp/performance_reseed_backup`.
|
||||
- Download scratch root for source artifact URLs: `/tmp/performance_reseed_source`.
|
||||
- Baseline window: last 5 `success=true` records for the same
|
||||
`(model_id, gpu_type)`.
|
||||
- Reseed count: dynamic. Upload exactly one accepted seed record per validated
|
||||
source JSON.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Validate the target and source results
|
||||
|
||||
Normalize `source_results` to a list. If the user passes a single
|
||||
`source_result`, treat it as a one-element `source_results` list and report
|
||||
that a single record may not move the last-5 median immediately.
|
||||
|
||||
If any source result is a Buildkite artifact URL, download it first into a
|
||||
local scratch directory under `/tmp/performance_reseed_source/` and use the
|
||||
downloaded JSON path for the rest of the workflow. If the agent cannot access
|
||||
the artifact because Buildkite authentication is missing, ask the user to
|
||||
download the artifact manually and provide the local path.
|
||||
|
||||
Prefer the normalized Buildkite artifact emitted by `compare_baseline.py`:
|
||||
|
||||
```text
|
||||
perf_reports/results/normalized_perf_*.json
|
||||
```
|
||||
|
||||
That file is already in the HF tracking schema. Load each normalized JSON
|
||||
directly:
|
||||
|
||||
```python
|
||||
import json
|
||||
|
||||
with open(source_result, encoding="utf-8") as f:
|
||||
record = json.load(f)
|
||||
```
|
||||
|
||||
Stop if any normalized record's `model_id` or `gpu_type` does not match the
|
||||
requested `model_id` and `gpu_type`.
|
||||
|
||||
The source records may have `success: false` when they came from failed
|
||||
rolling baseline comparisons. That is expected; only the reviewed reseed
|
||||
records become new `success: true` baseline records after explicit approval.
|
||||
|
||||
Sort validated source records by their original `timestamp` ascending before
|
||||
preparing the seed records. If a source timestamp is missing or unparsable,
|
||||
preserve input order for those records and print a warning. This makes the
|
||||
fresh reseed timestamps deterministic and makes it clear which records enter
|
||||
the last-5 window when more than 5 source JSONs are provided.
|
||||
|
||||
Check that `HF_API_KEY` is exported. The sync path may be public, but the
|
||||
upload path requires write access.
|
||||
|
||||
### 1a. Check source batch consistency
|
||||
|
||||
Before syncing or preparing uploads, reject source batches that are internally
|
||||
inconsistent. Use the same metric direction as `compare_baseline.py`:
|
||||
|
||||
- Lower is better: `latency`, `memory`, `text_encoder_time_s`, `dit_time_s`,
|
||||
`vae_decode_time_s`.
|
||||
- Higher is better: `throughput`.
|
||||
|
||||
For each metric with at least two non-null source values:
|
||||
|
||||
1. Compute the source batch median.
|
||||
2. For lower-is-better metrics, compute `(source_value - batch_median) / batch_median`.
|
||||
3. For `throughput`, compute `(batch_median - source_value) / batch_median`.
|
||||
4. Stop if any source record regresses against the batch median by more than
|
||||
`max_intra_batch_regression`.
|
||||
|
||||
Default `max_intra_batch_regression` to `PERF_MAX_REGRESSION` when set,
|
||||
otherwise `0.05`. Print a table with per-source values, batch median, and
|
||||
worst intra-batch regression.
|
||||
|
||||
This check prevents uploading a mixed batch where one JSON is materially
|
||||
slower or faster than the others. If the batch fails this check, ask the user
|
||||
to provide a cleaner batch or explicitly investigate the variance. Do not
|
||||
silently drop outliers unless the user gives a concrete reviewed reason and a
|
||||
new source list.
|
||||
|
||||
### 1b. How to obtain source results from CI
|
||||
|
||||
The performance CI exports normalized source results for failed rolling
|
||||
baseline comparisons when `compare_baseline.py` ran. The preferred artifacts
|
||||
come from:
|
||||
|
||||
```text
|
||||
perf_reports/results/normalized_perf_*.json
|
||||
```
|
||||
|
||||
The normal operator flow is:
|
||||
|
||||
1. Open the failed Buildkite performance job or several reruns of the same
|
||||
benchmark after the accepted environment shift.
|
||||
2. Download the `normalized_perf_*.json` artifacts for the target benchmark.
|
||||
3. Pass all reviewed local paths or artifact URLs as `source_results`.
|
||||
|
||||
Do not scrape the Markdown performance summary to reconstruct JSON. The
|
||||
normalized JSON artifacts are the only supported source of truth for reseed
|
||||
metrics and provenance. Raw `fastvideo/tests/performance/results/perf_*.json`
|
||||
artifacts are not accepted by this skill. If no normalized JSON artifact is
|
||||
present, that run is not a valid source for baseline reseeding.
|
||||
|
||||
### 2. Sync and back up existing HF records under /tmp
|
||||
|
||||
Use `fastvideo/tests/performance/hf_store.py` helpers directly. Do **not** use
|
||||
`compare_baseline.py` as a sync shortcut; on full main runs it can persist
|
||||
records, while this step must only fetch and back up existing history.
|
||||
|
||||
The sync command pattern is:
|
||||
|
||||
```bash
|
||||
export PERFORMANCE_TRACKING_ROOT="${PERFORMANCE_TRACKING_ROOT:-/tmp/perf-tracking}"
|
||||
export HF_REPO_ID="${HF_REPO_ID:-FastVideo/performance-tracking}"
|
||||
PYTHONPATH=fastvideo/tests/performance python -c 'from hf_store import sync_from_hf; import os; sync_from_hf(os.environ["PERFORMANCE_TRACKING_ROOT"], strict=True)'
|
||||
```
|
||||
|
||||
Then back up only the sanitized model directory under `/tmp`:
|
||||
|
||||
```bash
|
||||
SHORT_COMMIT=$(git rev-parse --short=12 HEAD)
|
||||
TIMESTAMP=$(date -u +%Y%m%d_%H%M%S)
|
||||
MODEL_SAFE=$(PYTHONPATH=fastvideo/tests/performance python - <<'PY'
|
||||
from hf_store import sanitize
|
||||
print(sanitize("<model_id>"))
|
||||
PY
|
||||
)
|
||||
BACKUP_DIR="/tmp/performance_reseed_backup/${TIMESTAMP}_${SHORT_COMMIT}_${MODEL_SAFE}"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
cp -R "${PERFORMANCE_TRACKING_ROOT}/${MODEL_SAFE}" "$BACKUP_DIR/" 2>/dev/null || true
|
||||
```
|
||||
|
||||
Write provenance next to the backup:
|
||||
|
||||
```bash
|
||||
cat > "$BACKUP_DIR/PROVENANCE.txt" <<EOF
|
||||
model_id: <model_id>
|
||||
gpu_type: <gpu_type>
|
||||
source_results:
|
||||
- <source_result_1>
|
||||
- <source_result_2>
|
||||
reseed_record_count: <len(source_results)>
|
||||
max_intra_batch_regression: <threshold>
|
||||
head_commit: $(git rev-parse HEAD)
|
||||
timestamp_utc: $(date -u +%FT%TZ)
|
||||
reason: <intent_rationale>
|
||||
EOF
|
||||
```
|
||||
|
||||
If the backup has no prior records, this is not a destructive reseed; it is a
|
||||
first baseline seed. Continue, but report that baseline history was empty.
|
||||
|
||||
### 3. Compute old baseline and candidate shift
|
||||
|
||||
Load the last 5 successful records for the target:
|
||||
|
||||
```python
|
||||
from hf_store import load_records_for_model
|
||||
|
||||
records = load_records_for_model(
|
||||
"/tmp/perf-tracking",
|
||||
"<model_id>",
|
||||
"<gpu_type>",
|
||||
last_n=5,
|
||||
successful_only=True,
|
||||
)
|
||||
```
|
||||
|
||||
Print a small table showing old medians, source batch medians, candidate
|
||||
medians after appending the proposed seed records, and source batch spread for:
|
||||
|
||||
- `latency`
|
||||
- `throughput`
|
||||
- `memory`
|
||||
- `text_encoder_time_s`
|
||||
- `dit_time_s`
|
||||
- `vae_decode_time_s`
|
||||
|
||||
Also print how many successful old records exist. Make clear:
|
||||
|
||||
- 1 seed record usually does not move a last-5 median by itself.
|
||||
- 3 consistent seed records usually move the last-5 median immediately.
|
||||
- 5 consistent seed records effectively reset the last-5 window.
|
||||
- The records are intentional approved baseline resets and must be labeled
|
||||
that way.
|
||||
|
||||
### 4. Confirm intent
|
||||
|
||||
Require an explicit confirmation phrase before preparing the upload:
|
||||
|
||||
> About to RE-SEED performance baseline for `<model_id>` on `<gpu_type>`.
|
||||
> This will upload `<N>` new `success=true` records to
|
||||
> `FastVideo/performance-tracking/<sanitize(model_id)>/`, one per accepted
|
||||
> source JSON.
|
||||
>
|
||||
> Reason: `<intent_rationale>`
|
||||
> Source results: `<source_results>`
|
||||
> Reseed record count: `<N>`
|
||||
> Max intra-batch regression: `<threshold>`
|
||||
> Note: these records come from a reviewed source batch and are intended to
|
||||
> move the rolling median to the accepted runtime profile. They are not
|
||||
> ordinary main-branch persistence.
|
||||
> HEAD: `<git rev-parse --short=12 HEAD>`
|
||||
> Backup: `<BACKUP_DIR>`
|
||||
>
|
||||
> Reply `confirm performance reseed` to proceed, anything else to abort.
|
||||
|
||||
Do not continue unless the user types exactly `confirm performance reseed`.
|
||||
|
||||
### 5. Create the accepted seed records
|
||||
|
||||
Create one seed record from each normalized source result. Do not copy the
|
||||
source JSON wholesale.
|
||||
|
||||
Infer the baseline field allowlist from all existing HF records for the target
|
||||
`(model_id, gpu_type)` after syncing, including both `success=true` and
|
||||
`success=false` records. Use the union of non-provenance keys present in those
|
||||
target records, preserving only fields that also exist in the normalized
|
||||
source record or are explicitly set by the reseed workflow. Always include
|
||||
`model_id`, `timestamp`, and `success` because the upload path and baseline
|
||||
loader depend on them. Always set `timestamp` to a fresh reseed timestamp and
|
||||
`success` to `true`. Do not include unrelated source-only fields that are
|
||||
absent from existing HF records.
|
||||
|
||||
Exclude existing provenance or operator metadata from the inferred baseline
|
||||
field allowlist. At minimum, exclude keys prefixed with `baseline_reseed` and
|
||||
any fields known to be local-only audit metadata.
|
||||
|
||||
If there are no previous HF records for the target model/GPU, fall back to this
|
||||
default baseline field list:
|
||||
|
||||
- `model_id`
|
||||
- `timestamp`
|
||||
- `commit_sha`
|
||||
- `gpu_type`
|
||||
- `latency`
|
||||
- `throughput`
|
||||
- `memory`
|
||||
- `text_encoder_time_s`
|
||||
- `dit_time_s`
|
||||
- `vae_decode_time_s`
|
||||
- `success`
|
||||
|
||||
Do not upload extra fields from the source artifact.
|
||||
|
||||
Optional provenance fields are allowed and useful:
|
||||
|
||||
- `baseline_reseed: true`
|
||||
- `baseline_reseed_reason`
|
||||
- `baseline_reseed_source_result`
|
||||
- `baseline_reseed_source_timestamp`
|
||||
- `baseline_reseed_source_success`
|
||||
- `baseline_reseed_batch_size`
|
||||
- `baseline_reseed_batch_index`
|
||||
- `baseline_reseed_operator`
|
||||
- `baseline_reseed_max_intra_batch_regression`
|
||||
|
||||
Use a fresh reseed timestamp for each seed record, not the original source
|
||||
result timestamp. This is required because
|
||||
`load_records_for_model(..., last_n=5)` keeps the last records after loading
|
||||
the model directory; stale filenames/timestamps may not enter the last-5
|
||||
window and therefore may not move the median. Preserve the original source
|
||||
timestamp in `baseline_reseed_source_timestamp`.
|
||||
|
||||
Use the existing filename convention from `_write_tracking_record()`:
|
||||
`<sanitize(timestamp)>_<sanitize(commit_sha)>.json` under the sanitized model
|
||||
directory, but include a deterministic suffix such as `_reseed_01`,
|
||||
`_reseed_02`, and so on before `.json` so multiple records from the same
|
||||
batch do not overwrite each other.
|
||||
|
||||
If a source record already exists on HF with `success=false`, do not edit it
|
||||
in place unless the user explicitly asked for an audit-preserving correction.
|
||||
Prefer uploading new accepted seed records so failed history remains visible.
|
||||
|
||||
### 6. Pause before upload
|
||||
|
||||
Print:
|
||||
|
||||
- Backup directory path under `/tmp`.
|
||||
- Prepared local record paths under `PERFORMANCE_TRACKING_ROOT`.
|
||||
- HF paths that will receive the new records.
|
||||
- Old rolling medians.
|
||||
- Source batch medians, source batch spread, reseed count, and candidate
|
||||
medians.
|
||||
- Rationale.
|
||||
|
||||
Ask the user to reply exactly `upload`. Anything else aborts and leaves the
|
||||
prepared records plus backup on disk.
|
||||
|
||||
### 7. Upload only the scoped records
|
||||
|
||||
Use the shared storage helper so the path and repo type match CI:
|
||||
|
||||
```python
|
||||
from hf_store import upload_record
|
||||
|
||||
upload_record("<local_record_path>", record, strict=True)
|
||||
```
|
||||
|
||||
Run it once per prepared record. Each upload goes to:
|
||||
|
||||
```text
|
||||
FastVideo/performance-tracking/<sanitize(model_id)>/<record_filename>.json
|
||||
```
|
||||
|
||||
Never bulk upload the whole tracking root. Never modify another model's
|
||||
directory in the same operation.
|
||||
|
||||
### 8. Report outcome and offer cleanup
|
||||
|
||||
Report:
|
||||
|
||||
- Uploaded HF paths.
|
||||
- Backup directory under `/tmp`.
|
||||
- Local tracking root, usually `/tmp/perf-tracking`.
|
||||
- Old baseline window count and medians.
|
||||
- Source batch medians, source batch spread, reseed count, and candidate
|
||||
medians.
|
||||
- Expected effect based on reseed count.
|
||||
- Any separate threshold changes still needed in
|
||||
`.buildkite/performance-benchmarks/tests/*.json`.
|
||||
|
||||
Include the `intent_rationale` in the PR or follow-up comment so reviewers can
|
||||
distinguish an accepted baseline shift from a hidden regression.
|
||||
|
||||
After the upload is verified, ask whether the user wants to clear temporary
|
||||
local state. Explain what each directory is for:
|
||||
|
||||
- `PERFORMANCE_TRACKING_ROOT`, usually `/tmp/perf-tracking`: local synced
|
||||
mirror of `FastVideo/performance-tracking` plus the prepared local seed
|
||||
records used for scoped upload.
|
||||
- `/tmp/performance_reseed_backup/<...>`: local backup of the target model's
|
||||
pre-reseed HF history plus `PROVENANCE.txt`, kept so a bad reseed can be
|
||||
audited or corrected.
|
||||
- `/tmp/performance_reseed_source/<...>` when used: downloaded source JSON
|
||||
artifacts from Buildkite URLs.
|
||||
|
||||
Ask:
|
||||
|
||||
> Reseed succeeded. Do you want me to delete the local temp tracking mirror,
|
||||
> source downloads, and reseed backup under `/tmp`? These files are local
|
||||
> safety/audit artifacts only; HF already has the uploaded records.
|
||||
>
|
||||
> Reply `cleanup reseed temp` to delete them, anything else to keep them.
|
||||
|
||||
Do not delete anything unless the user replies exactly
|
||||
`cleanup reseed temp`. If cleanup is requested, remove only the specific
|
||||
directories created for this reseed. Never remove unrelated `/tmp` contents.
|
||||
|
||||
## Failure modes and handling
|
||||
|
||||
- **`HF_API_KEY` unset.** Stop before upload. Do not create an untracked
|
||||
process that appears to have reseeded but never reached HF.
|
||||
- **Source result does not match target.** Stop. The wrong benchmark or GPU
|
||||
would poison a separate baseline.
|
||||
- **Source batch is internally inconsistent.** Stop if any source regresses
|
||||
against the source batch median by more than `max_intra_batch_regression`.
|
||||
Ask for cleaner sources or a reviewed explanation before continuing.
|
||||
- **Too few source records to move the median.** Continue only after making
|
||||
clear that one or two records may not immediately move the last-5 median.
|
||||
- **The source results are noisy or suspicious.** Stop. Reseeding amplifies
|
||||
those measurements into the baseline, so they must be reviewed first.
|
||||
- **HF sync fails.** Stop for destructive reseeds. A stale or empty sync can
|
||||
make the old baseline look missing.
|
||||
- **Candidate still violates fixed thresholds.** Report that this skill only
|
||||
handles the rolling HF baseline; update benchmark JSON thresholds in code
|
||||
review if maintainers accept the new absolute limit.
|
||||
- **The user aborts at either confirmation.** Leave the backup and prepared
|
||||
records on disk. Nothing should be uploaded.
|
||||
- **The user declines cleanup.** Keep `/tmp/perf-tracking`, the source
|
||||
download directory if any, and `/tmp/performance_reseed_backup/<...>` in
|
||||
place for audit/debugging.
|
||||
- **A bad seed was uploaded.** Use the backup and HF history to identify the
|
||||
uploaded file, then remove or supersede it with an explicitly reviewed
|
||||
corrective record. Do not silently rewrite unrelated history.
|
||||
|
||||
## References
|
||||
|
||||
- `.agents/skills/reseed-ssim-references/SKILL.md` — safety pattern for
|
||||
intentional baseline replacement.
|
||||
- `fastvideo/tests/performance/compare_baseline.py` — normalization, rolling
|
||||
median comparison, and persistence rules.
|
||||
- `fastvideo/tests/performance/hf_store.py` — HF sync, record loading,
|
||||
`sanitize()`, and `upload_record()`.
|
||||
- `fastvideo/tests/performance/test_inference_performance.py` — source result
|
||||
JSON schema.
|
||||
- `.buildkite/performance-benchmarks/tests/*.json` — fixed absolute benchmark
|
||||
thresholds, separate from rolling baseline comparisons.
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-05-03 | Initial version. Sister workflow to `reseed-ssim-references`, scoped to one performance `(model_id, gpu_type)` baseline seed with backup, confirmation, provenance, and `success=true` upload. |
|
||||
| 2026-05-03 | Previous policy: replicate one approved shifted source result into 3 success records by default, or 5 only when explicitly requested. Add provenance marker for replicated-source reseeds. Superseded by the 2026-05-08 dynamic multi-source policy. |
|
||||
| 2026-05-08 | Replace fixed 3/5 replication with dynamic multi-source reseeding: upload one seed record per reviewed source JSON, validate intra-batch consistency, move backup/source scratch under `/tmp`, and ask whether to clean temp state after successful upload. |
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: rlhf-training-abstractions
|
||||
description: Use when changing FastVideo RLHF/RL training infrastructure, especially sampler, reward, scheduler trajectory, or method boundaries under fastvideo/train.
|
||||
---
|
||||
|
||||
# RLHF Training Abstractions
|
||||
|
||||
Use this skill before editing RLHF-style training code in `fastvideo/train`.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- RL methods live under `fastvideo/train/methods/rl/` and own algorithm logic: reward collection, advantage computation, policy loss, KL/reference terms, and optimizer cadence.
|
||||
- Rewards live under `fastvideo/train/methods/rl/rewards/` and must be reusable across RL methods.
|
||||
- RL methods pass decoded media to rewards; each reward decides whether to use the first frame, sampled frames, or the full video.
|
||||
- Sampling lives under `fastvideo/train/methods/rl/common/` and must use `ModelBase` primitives plus scheduler math, not model-family inference pipelines.
|
||||
- Model wrappers under `fastvideo/train/models/` own model-specific forward details.
|
||||
- Model wrappers also own model-specific latent decoding via `ModelBase.decode_latents`; RL methods should not reach into VAE normalization internals.
|
||||
- Shared RL helpers such as K-repeat prompt sampling belong under `fastvideo/train/methods/rl/common/` when they are reusable across RL methods.
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- Do not bind RL methods to inference pipeline classes such as `WanDMDPipeline`.
|
||||
- Do not hardcode timestep lists in a method when the scheduler can generate them.
|
||||
- Do not put reward-model code inside one RL method.
|
||||
- Do not make existing non-RL methods use method-managed optimization unless explicitly requested.
|
||||
|
||||
## Sampling Policy
|
||||
|
||||
- Prefer YAML-configured `method.sampling` with `scheduler`, `trajectory`, `num_steps`, `timesteps`, and `sigmas`.
|
||||
- Treat diffusers-style scheduler classes as owning both the timestep schedule and their `step()` update rule; avoid a separate `solver` field unless a new sampler truly implements solver math outside the scheduler object.
|
||||
- Missing `timesteps` means “ask the scheduler”; explicit `timesteps` or `sigmas` are overrides.
|
||||
- ODE-style trajectories should not re-noise between denoising steps.
|
||||
- SDE/re-noise behavior must be explicit in config.
|
||||
|
||||
## Validation
|
||||
|
||||
- Run focused local tests for sampler config and Trainer opt-in behavior.
|
||||
- Verify existing train methods still report `manages_optimization() == False`.
|
||||
- Keep fixed-prompt validation helpers in `fastvideo/train/methods/rl/common/validation.py` so new RL methods can reuse sharding and captions.
|
||||
- Test distributed prompt grouping helpers separately from heavyweight model loading.
|
||||
- Run `pre-commit run --files <changed paths>`; respect configured excludes.
|
||||
@@ -89,5 +89,4 @@ The following land in follow-up PRs:
|
||||
|
||||
- **MIND** metrics (depends on a separate `vipe` submodule).
|
||||
- **VBench-2.0** sibling package.
|
||||
- Native conversion of **FVD** under `fastvideo/eval/metrics/fvd/`.
|
||||
- The training-time `EvalCallback`.
|
||||
|
||||
@@ -36,7 +36,10 @@
|
||||
"thresholds": {
|
||||
"L40S": {
|
||||
"max_generation_time_s": 34.0,
|
||||
"max_peak_memory_mb": 11000.0
|
||||
"max_peak_memory_mb": 11000.0,
|
||||
"max_text_encoder_time_s": 5.0,
|
||||
"max_dit_time_s": 10.0,
|
||||
"max_vae_decode_time_s": 10.0
|
||||
},
|
||||
"default": {
|
||||
"max_generation_time_s": 120.0,
|
||||
|
||||
@@ -77,6 +77,17 @@ steps:
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "default"
|
||||
- label: ":microscope: DreamVerse App Tests"
|
||||
if: build.env("TEST_SCOPE") == "direct" && build.env("TEST_TYPE") == "dreamverse_app"
|
||||
command: "timeout 90m .buildkite/scripts/pr_test.sh"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "default"
|
||||
|
||||
# --- Full-suite-scope direct tests ---
|
||||
- label: ":bar_chart: SSIM Tests"
|
||||
@@ -206,6 +217,17 @@ steps:
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "default"
|
||||
- label: ":test_tube: Eval Metrics Tests"
|
||||
if: build.env("TEST_SCOPE") == "direct" && build.env("TEST_TYPE") == "eval"
|
||||
command: "timeout 90m .buildkite/scripts/pr_test.sh"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "default"
|
||||
|
||||
# ============================================================
|
||||
# Fastcheck: Runs on every PR (~10-15 min parallel)
|
||||
@@ -289,6 +311,16 @@ steps:
|
||||
- TEST_TYPE=unit_test
|
||||
agents:
|
||||
queue: "default"
|
||||
- path:
|
||||
- "apps/dreamverse/**"
|
||||
- "pyproject.toml"
|
||||
config:
|
||||
command: "timeout 30m .buildkite/scripts/pr_test.sh"
|
||||
label: ":microscope: DreamVerse App Tests"
|
||||
env:
|
||||
- TEST_TYPE=dreamverse_app
|
||||
agents:
|
||||
queue: "default"
|
||||
|
||||
# ============================================================
|
||||
# Full Suite: Runs when TEST_SCOPE=full
|
||||
@@ -462,3 +494,15 @@ steps:
|
||||
- TEST_TYPE=train_framework
|
||||
agents:
|
||||
queue: "default"
|
||||
- path:
|
||||
- "fastvideo/eval/**"
|
||||
- "fastvideo/tests/eval/**"
|
||||
- "pyproject.toml"
|
||||
- "docker/Dockerfile.python3.12"
|
||||
config:
|
||||
command: "timeout 90m .buildkite/scripts/pr_test.sh"
|
||||
label: ":test_tube: Eval Metrics Tests"
|
||||
env:
|
||||
- TEST_TYPE=eval
|
||||
agents:
|
||||
queue: "default"
|
||||
|
||||
@@ -76,7 +76,7 @@ EFFECTIVE_PR=${BUILDKITE_PULL_REQUEST:-false}
|
||||
if [ "$EFFECTIVE_PR" = "false" ] && [ -n "${PR_NUMBER:-}" ]; then
|
||||
EFFECTIVE_PR=$PR_NUMBER
|
||||
fi
|
||||
MODAL_ENV="BUILDKITE_REPO=$BUILDKITE_REPO BUILDKITE_COMMIT=$BUILDKITE_COMMIT BUILDKITE_PULL_REQUEST=$EFFECTIVE_PR BUILDKITE_BRANCH=${BUILDKITE_BRANCH:-} TEST_SCOPE=${TEST_SCOPE:-} IMAGE_VERSION=$IMAGE_VERSION"
|
||||
MODAL_ENV="BUILDKITE_REPO=$BUILDKITE_REPO BUILDKITE_COMMIT=$BUILDKITE_COMMIT BUILDKITE_PULL_REQUEST=$EFFECTIVE_PR BUILDKITE_BRANCH=${BUILDKITE_BRANCH:-} TEST_SCOPE=${TEST_SCOPE:-} BUILDKITE_BUILD_URL=${BUILDKITE_BUILD_URL:-} BUILDKITE_BUILD_ID=${BUILDKITE_BUILD_ID:-} BUILDKITE_JOB_ID=${BUILDKITE_JOB_ID:-} IMAGE_VERSION=$IMAGE_VERSION"
|
||||
|
||||
POST_RUN_HOOK=""
|
||||
|
||||
@@ -121,6 +121,19 @@ upload_performance_artifacts() {
|
||||
fi
|
||||
}
|
||||
|
||||
_upload_normalized_perf_results() {
|
||||
local found=0
|
||||
while IFS= read -r -d '' target; do
|
||||
found=1
|
||||
log "Found normalized performance result: $target. Uploading to Buildkite..."
|
||||
buildkite-agent artifact upload "$target"
|
||||
done < <(find "$LOCAL_DIR" -path "*/results/normalized_perf_*.json" -print0)
|
||||
|
||||
if [ "$found" -eq 0 ]; then
|
||||
log "No normalized performance result artifacts found. This is expected when the rolling performance comparison did not run."
|
||||
fi
|
||||
}
|
||||
|
||||
_cleanup_modal_volume() {
|
||||
log "Cleaning up perf_reports/ from Modal Volume..."
|
||||
if modal volume rm hf-model-weights "perf_reports/" --recursive; then
|
||||
@@ -139,6 +152,7 @@ upload_performance_artifacts() {
|
||||
_download_reports || { _cleanup_local; return 1; }
|
||||
_upload_dashboard
|
||||
_upload_perf_summary
|
||||
_upload_normalized_perf_results
|
||||
_cleanup_modal_volume
|
||||
_cleanup_local
|
||||
}
|
||||
@@ -197,10 +211,18 @@ case "$TEST_TYPE" in
|
||||
log "Running unit tests..."
|
||||
MODAL_COMMAND="$MODAL_ENV python3 -m modal run $MODAL_TEST_FILE::run_unit_test"
|
||||
;;
|
||||
"dreamverse_app")
|
||||
log "Running DreamVerse app tests..."
|
||||
MODAL_COMMAND="$MODAL_ENV python3 -m modal run $MODAL_TEST_FILE::run_dreamverse_app_tests"
|
||||
;;
|
||||
"train_framework")
|
||||
log "Running fastvideo.train framework tests..."
|
||||
MODAL_COMMAND="$MODAL_ENV HF_API_KEY=$HF_API_KEY python3 -m modal run $MODAL_TEST_FILE::run_train_framework_tests"
|
||||
;;
|
||||
"eval")
|
||||
log "Running eval metric tests..."
|
||||
MODAL_COMMAND="$MODAL_ENV HF_API_KEY=$HF_API_KEY python3 -m modal run $MODAL_TEST_FILE::run_eval_tests"
|
||||
;;
|
||||
"lora_extraction")
|
||||
log "Running LoRA extraction tests..."
|
||||
MODAL_COMMAND="$MODAL_ENV HF_API_KEY=$HF_API_KEY python3 -m modal run $MODAL_TEST_FILE::run_lora_extraction_tests"
|
||||
|
||||
+1
-11
@@ -272,7 +272,7 @@ pull_request_rules:
|
||||
remove: [needs-rebase]
|
||||
|
||||
# ============================================================
|
||||
# Auto-merge and auto-rebase
|
||||
# Auto-merge
|
||||
# ============================================================
|
||||
|
||||
- name: auto-merge when ready and all checks pass
|
||||
@@ -290,16 +290,6 @@ pull_request_rules:
|
||||
merge:
|
||||
method: squash
|
||||
|
||||
- name: auto-update when ready
|
||||
conditions:
|
||||
- label=ready
|
||||
- "#approved-reviews-by>=1"
|
||||
- -conflict
|
||||
- -closed
|
||||
- -draft
|
||||
actions:
|
||||
update: {}
|
||||
|
||||
# ============================================================
|
||||
# PR title format help
|
||||
# ============================================================
|
||||
|
||||
@@ -12,6 +12,18 @@ on:
|
||||
tag_suffix:
|
||||
required: true
|
||||
type: string
|
||||
image_name:
|
||||
required: false
|
||||
type: string
|
||||
default: fastvideo-dev
|
||||
build_args:
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
include_latest_tags:
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
@@ -67,11 +79,14 @@ jobs:
|
||||
run: |
|
||||
SHORT_SHA=$(echo ${{ github.sha }} | cut -c1-7)
|
||||
|
||||
TAGS="type=raw,value=${{ inputs.tag_suffix }}-latest"
|
||||
TAGS="${TAGS}\ntype=raw,value=${{ inputs.tag_suffix }}-sha-${SHORT_SHA}"
|
||||
|
||||
TAGS="type=raw,value=${{ inputs.tag_suffix }}-sha-${SHORT_SHA}"
|
||||
|
||||
if [[ "${{ inputs.include_latest_tags }}" == "true" ]]; then
|
||||
TAGS="type=raw,value=${{ inputs.tag_suffix }}-latest\n${TAGS}"
|
||||
fi
|
||||
|
||||
# Set Python 3.10 as the default image
|
||||
if [[ "${{ inputs.python_version }}" == "3.10" ]]; then
|
||||
if [[ "${{ inputs.include_latest_tags }}" == "true" && "${{ inputs.python_version }}" == "3.10" ]]; then
|
||||
TAGS="${TAGS}\ntype=raw,value=latest"
|
||||
fi
|
||||
|
||||
@@ -85,7 +100,7 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}/fastvideo-dev
|
||||
images: ghcr.io/${{ github.repository }}/${{ inputs.image_name }}
|
||||
tags: ${{ steps.prepare-tags.outputs.tags }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
@@ -97,10 +112,11 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: ${{ inputs.build_args }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Success message
|
||||
run: |
|
||||
echo "✅ Python ${{ inputs.python_version }} image successfully built and pushed to ghcr.io/${{ github.repository }}/fastvideo-dev:${{ inputs.tag_suffix }}-latest"
|
||||
echo "To run tests with this image, manually trigger the 'Run Tests' workflow."
|
||||
echo "✅ Python ${{ inputs.python_version }} image successfully built and pushed to ghcr.io/${{ github.repository }}/${{ inputs.image_name }}:${{ inputs.tag_suffix }}-sha-${GITHUB_SHA::7}"
|
||||
echo "To run tests with this image, manually trigger the 'Run Tests' workflow."
|
||||
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
TEST_NAME=$(echo "$COMMENT" | grep -oP '(?<=/test\s)\S+' | head -1 || true)
|
||||
|
||||
VALID="encoder vae transformer kernel unit ssim training lora-inference lora-training distillation self-forcing vsa vmoba performance api train-framework full fastcheck pre-commit"
|
||||
VALID="encoder vae transformer kernel unit dreamverse ssim training lora-inference lora-training distillation self-forcing vsa vmoba performance api train-framework eval full fastcheck pre-commit"
|
||||
if [ -z "$TEST_NAME" ] || ! echo "$VALID" | grep -qw "$TEST_NAME"; then
|
||||
echo "Unknown test: '$TEST_NAME'. Valid: $VALID"
|
||||
exit 1
|
||||
@@ -133,13 +133,13 @@ jobs:
|
||||
|
||||
declare -A MAP=(
|
||||
[encoder]=encoder [vae]=vae [transformer]=transformer
|
||||
[kernel]=kernel_tests [unit]=unit_test
|
||||
[kernel]=kernel_tests [unit]=unit_test [dreamverse]=dreamverse_app
|
||||
[ssim]=ssim [training]=training
|
||||
[lora-inference]=inference_lora [lora-training]=training_lora
|
||||
[distillation]=distillation_dmd [self-forcing]=self_forcing
|
||||
[vsa]=training_vsa [vmoba]=inference_vmoba
|
||||
[performance]=performance [api]=api_server
|
||||
[train-framework]=train_framework
|
||||
[train-framework]=train_framework [eval]=eval
|
||||
)
|
||||
|
||||
if [ "$TEST_NAME" = "full" ]; then
|
||||
|
||||
@@ -23,6 +23,11 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
dreamverse_cuda_12_9:
|
||||
description: 'Build Dreamverse CUDA 12.9 backend-only and UI images'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
|
||||
permissions:
|
||||
@@ -65,3 +70,27 @@ jobs:
|
||||
dockerfile_path: docker/Dockerfile.python3.12.cuda12.9.1
|
||||
tag_suffix: py3.12-cuda12.9.1
|
||||
secrets: inherit
|
||||
|
||||
build-dreamverse-backend-cuda-12-9:
|
||||
if: ${{ github.event.inputs.dreamverse_cuda_12_9 == 'true' }}
|
||||
uses: ./.github/workflows/_template-build-image.yml
|
||||
with:
|
||||
python_version: '3.12'
|
||||
dockerfile_path: apps/dreamverse/docker/Dockerfile
|
||||
tag_suffix: dreamverse-backend-cuda12.9.1
|
||||
image_name: dreamverse
|
||||
build_args: BUILD_DREAMVERSE_UI=0
|
||||
include_latest_tags: false
|
||||
secrets: inherit
|
||||
|
||||
build-dreamverse-ui-cuda-12-9:
|
||||
if: ${{ github.event.inputs.dreamverse_cuda_12_9 == 'true' }}
|
||||
uses: ./.github/workflows/_template-build-image.yml
|
||||
with:
|
||||
python_version: '3.12'
|
||||
dockerfile_path: apps/dreamverse/docker/Dockerfile
|
||||
tag_suffix: dreamverse-ui-cuda12.9.1
|
||||
image_name: dreamverse
|
||||
build_args: BUILD_DREAMVERSE_UI=1
|
||||
include_latest_tags: false
|
||||
secrets: inherit
|
||||
|
||||
@@ -53,23 +53,15 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-22.04]
|
||||
python-version: ['3.10', '3.11', '3.12']
|
||||
python-version: ['3.12']
|
||||
torch-cuda:
|
||||
# - torch-version: '2.5.1'
|
||||
# cuda-version: '12.4.1'
|
||||
# torch-cuda-short: 'cu124'
|
||||
# - torch-version: '2.6.0'
|
||||
# cuda-version: '12.6.3'
|
||||
# torch-cuda-short: 'cu126'
|
||||
# - torch-version: '2.7.1'
|
||||
# cuda-version: '12.8.0'
|
||||
# torch-cuda-short: 'cu128'
|
||||
# - torch-version: '2.9.1'
|
||||
# cuda-version: '12.8.0'
|
||||
# torch-cuda-short: 'cu128'
|
||||
- torch-version: '2.10.0'
|
||||
cuda-version: '12.8.0'
|
||||
torch-cuda-short: 'cu128'
|
||||
# torch 2.12 dropped cu128; cu130 (CUDA 13) is the default wheel, cu126 covers older drivers.
|
||||
- torch-version: '2.12.0'
|
||||
cuda-version: '12.6.3'
|
||||
torch-cuda-short: 'cu126'
|
||||
- torch-version: '2.12.0'
|
||||
cuda-version: '13.0.0'
|
||||
torch-cuda-short: 'cu130'
|
||||
|
||||
steps:
|
||||
- name: Free up disk space
|
||||
@@ -104,7 +96,7 @@ jobs:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install CUDA ${{ matrix.torch-cuda.cuda-version }}
|
||||
uses: Jimver/cuda-toolkit@v0.2.21
|
||||
uses: Jimver/cuda-toolkit@v0.2.35
|
||||
id: cuda-toolkit
|
||||
with:
|
||||
cuda: ${{ matrix.torch-cuda.cuda-version }}
|
||||
@@ -152,9 +144,27 @@ jobs:
|
||||
|
||||
cd fastvideo-kernel
|
||||
git submodule update --init --recursive # Ensure ThunderKittens submodule is initialized
|
||||
# Release builds are produced on GPU-less runners, so force-enable TK and target Hopper.
|
||||
export TORCH_CUDA_ARCH_LIST="9.0a"
|
||||
export CMAKE_ARGS="${CMAKE_ARGS:-} -DFASTVIDEO_KERNEL_BUILD_TK=ON -DCMAKE_CUDA_ARCHITECTURES=90a"
|
||||
# Release builds run on GPU-less runners, so force-enable TK and set the arch explicitly.
|
||||
# The CUDA 13 (cu130) leg additionally targets Blackwell sm_120a and builds the
|
||||
# attn_qat_infer FP4 inference kernels (they need CUDA Toolkit 12.8+, so the cu126 leg
|
||||
# stays Hopper-only and ships without them).
|
||||
# Cap Ninja build concurrency to fit the 16 GB standard runner. CUTLASS FP4
|
||||
# and ThunderKittens template TUs each need several GB; Ninja's default
|
||||
# (cores+2 ≈ 6 parallel jobs) exhausts RAM and the host OOM-kills the build
|
||||
# (SIGTERM / exit 143, surfaced as "runner lost communication").
|
||||
if [ "${{ matrix.torch-cuda.torch-cuda-short }}" = "cu130" ]; then
|
||||
export TORCH_CUDA_ARCH_LIST="9.0a;12.0a"
|
||||
export CMAKE_ARGS="${CMAKE_ARGS:-} -DFASTVIDEO_KERNEL_BUILD_TK=ON -DFASTVIDEO_KERNEL_BUILD_ATTN_QAT_INFER=ON -DCMAKE_CUDA_ARCHITECTURES=90a"
|
||||
# A single FP4 TU (attn_qat_infer) can use ~8-12 GB on its own, so serialize.
|
||||
export CMAKE_BUILD_PARALLEL_LEVEL=1
|
||||
else
|
||||
export TORCH_CUDA_ARCH_LIST="9.0a"
|
||||
export CMAKE_ARGS="${CMAKE_ARGS:-} -DFASTVIDEO_KERNEL_BUILD_TK=ON -DCMAKE_CUDA_ARCHITECTURES=90a"
|
||||
# No FP4 here; only the two TK TUs are heavy (single-arch) and they fit
|
||||
# side by side. Uncapped (cores+2=6) has built but occasionally OOMs when
|
||||
# the light TUs pile on; -j4 overlaps them safely (~12-14 GB peak).
|
||||
export CMAKE_BUILD_PARALLEL_LEVEL=4
|
||||
fi
|
||||
|
||||
# Build standard wheel (no local version suffix) for PyPI
|
||||
python -m build --wheel --outdir dist
|
||||
@@ -183,8 +193,8 @@ jobs:
|
||||
mv fixed_dist/*.whl dist/
|
||||
|
||||
- name: Upload wheel artifact
|
||||
# Only upload if it's the "main" CUDA version we want on PyPI
|
||||
# We upload all to artifacts for inspection/GH releases, but give them distinct artifact names
|
||||
# Upload every matrix leg as a distinct artifact (for inspection / GitHub releases).
|
||||
# The publish job below selects which CUDA build is pushed to PyPI.
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fastvideo_kernel-py${{ matrix.python-version }}-${{ matrix.torch-cuda.torch-cuda-short }}-torch${{ matrix.torch-cuda.torch-version }}
|
||||
@@ -204,13 +214,15 @@ jobs:
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Download PyPI wheels
|
||||
# Publish only the cu130 (CUDA 13) wheel to PyPI — it includes the Blackwell FP4 kernels.
|
||||
# The cu126 wheel stays available as a build artifact / GitHub-release asset.
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: fastvideo-kernel/dist/
|
||||
pattern: 'fastvideo_kernel-py*'
|
||||
pattern: 'fastvideo_kernel-py*-cu130-*'
|
||||
merge-multiple: true
|
||||
|
||||
- name: Install uv
|
||||
|
||||
+10
@@ -34,6 +34,8 @@ env
|
||||
*.log
|
||||
weights/
|
||||
logs/
|
||||
official_weights/
|
||||
converted_weights/
|
||||
|
||||
# SSIM test outputs
|
||||
fastvideo/tests/ssim/generated_videos/
|
||||
@@ -128,3 +130,11 @@ apps/dreamverse/web/.env.production.local
|
||||
.sisyphus/
|
||||
openspec/
|
||||
fastvideo/tests/ssim/reference_videos/**
|
||||
|
||||
# Editor logs and local Python version pins (accidentally committed)
|
||||
*.nvimlog
|
||||
.nvimlog
|
||||
.python-version
|
||||
|
||||
# Gradio runtime/flagging artifacts
|
||||
.gradio/
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
WRN 2026-03-26T13:46:33.469 ?.19646 server_start:193: Failed to start server: operation not permitted: /var/folders/z_/h_6myyk14d1b7z87z3vy4mjh0000gn/T/nvim.dsynkd/iSe0el/nvim.19646.0
|
||||
@@ -1 +0,0 @@
|
||||
3.12
|
||||
@@ -9,8 +9,9 @@
|
||||
**FastVideo is a unified post-training and real-time inference framework for accelerated video generation.**
|
||||
|
||||
## NEWS
|
||||
- `2026/03/17`: Release Live demo: [Into the Dreamverse: Vibe Directing in FastVideo](https://dreamverse.fastvideo.org/), check out the [Blog](https://haoailab.com/blogs/dreamverse/).
|
||||
- `2026/03/13`: Release Live demo: [Create a 5s 1080p Video in 4.5s with FastVideo on a Single GPU](https://1080p.fastvideo.org/), check out the [Blog](https://haoailab.com/blogs/fastvideo_realtime_1080p/).
|
||||
- `2026/06/23`: Release FastWan-QAD: 5s of Video generated in 1.8s E2E. [FastWan-QAD models](https://huggingface.co/FastVideo/FastWan-QAD-FP8-1.3B), check out the [Blog](https://haoailab.com/blogs/fastwan-qad/).
|
||||
- `2026/03/17`: Release demo: [Into the Dreamverse: Vibe Directing in FastVideo](), check out the [Blog](https://haoailab.com/blogs/dreamverse/).
|
||||
- `2026/03/13`: Release demo: [Create a 5s 1080p Video in 4.5s with FastVideo on a Single GPU](), check out the [Blog](https://haoailab.com/blogs/fastvideo_realtime_1080p/).
|
||||
- `2025/11/19`: Release [CausalWan2.2 I2V A14B Preview](https://huggingface.co/FastVideo/CausalWan2.2-I2V-A14B-Preview-Diffusers) models, [Blog](https://hao-ai-lab.github.io/blogs/fastvideo_causalwan_preview/) and [Inference Code!](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_self_forcing_causal_wan2_2_i2v.py).
|
||||
- `2025/08/04`: Release [FastWan](https://hao-ai-lab.github.io/FastVideo/distillation/dmd) models and [Sparse-Distillation](https://hao-ai-lab.github.io/blogs/fastvideo_post_training/).
|
||||
|
||||
@@ -42,6 +43,8 @@ FastVideo has the following features:
|
||||
- Support H100, A100, 4090
|
||||
- Support Linux, Windows, MacOS
|
||||
- See this [page](https://hao-ai-lab.github.io/FastVideo/inference/support_matrix/) for full list of supported models, hardware assumptions, and optimization compatibility.
|
||||
- Realtime video generation & editing
|
||||
- [Dreamverse](apps/dreamverse/README.md): stream and "vibe direct" video in realtime ([live demo](https://dreamverse.fastvideo.org/)), deployable on local GPU, a self-hosted B200 server, Docker, or serverless Modal
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -69,6 +72,19 @@ See below for recipes and datasets:
|
||||
| [FastWan2.1-T2V-1.3B](https://huggingface.co/FastVideo/FastWan2.1-T2V-1.3B-Diffusers) | [Recipe](https://github.com/hao-ai-lab/FastVideo/tree/main/examples/distill/Wan2.1-T2V/Wan-Syn-Data-480P) | [FastVideo Synthetic Wan2.1 480P](https://huggingface.co/datasets/FastVideo/Wan-Syn_77x448x832_600k) |
|
||||
| [FastWan2.2-TI2V-5B](https://huggingface.co/FastVideo/FastWan2.2-TI2V-5B-Diffusers) | [Recipe](https://github.com/hao-ai-lab/FastVideo/tree/main/examples/distill/Wan2.2-TI2V-5B-Diffusers/Data-free) | [FastVideo Synthetic Wan2.2 720P](https://huggingface.co/datasets/FastVideo/Wan2.2-Syn-121x704x1280_32k) |
|
||||
|
||||
## Dreamverse — Realtime Video Generation & Editing
|
||||
|
||||
[Dreamverse](apps/dreamverse/README.md) is FastVideo's realtime video generation
|
||||
and editing platform — "vibe directing" a video as it streams. It lives in the
|
||||
monorepo under [`apps/dreamverse/`](apps/dreamverse/) and ships its own backend
|
||||
(`dreamverse-server`) plus a web UI.
|
||||
|
||||
Try the [live demo](https://dreamverse.fastvideo.org/), read the
|
||||
[blog](https://haoailab.com/blogs/dreamverse/), or run it yourself. Dreamverse
|
||||
deploys on a local GPU, a self-hosted B200 server over SSH, Docker, or
|
||||
serverless [Modal](apps/dreamverse/scripts/modal/README.md) — see the
|
||||
[Dreamverse README](apps/dreamverse/README.md).
|
||||
|
||||
## Inference
|
||||
|
||||
### Generating Your First Video
|
||||
|
||||
+68
-11
@@ -2,6 +2,8 @@
|
||||
|
||||
Dreamverse is the FastVideo realtime video generation & editing platform. It lives in this monorepo under `apps/dreamverse/`.
|
||||
|
||||
**Deploy on:** [local GPU](#quick-start-local-gpu) · [self-hosted B200 (SSH)](#server-b200-deployment-ssh) · [Docker](docker/README.md) · [Modal](scripts/modal/README.md)
|
||||
|
||||
## Install Dreamverse
|
||||
|
||||
You can install Dreamverse using one of the methods below.
|
||||
@@ -43,13 +45,36 @@ See `apps/dreamverse/docker/README.md` for Docker build and run option details.
|
||||
## Optional: Building FFmpeg For Better Performance
|
||||
|
||||
For full streaming performance in a non-Docker install, build a custom FFmpeg
|
||||
binary:
|
||||
binary from a FastVideo source checkout. The command below is repo-relative,
|
||||
so run it from the repository root:
|
||||
|
||||
```bash
|
||||
bash apps/dreamverse/scripts/install_native_ffmpeg.sh
|
||||
```
|
||||
|
||||
This builds and installs into `~/opt/ffmpeg-native/` and writes
|
||||
The installer supports Linux `x86_64` and `aarch64`. It prefers conda-forge
|
||||
triplet compilers when those commands are on `PATH`, otherwise it falls back to
|
||||
system `gcc`/`g++` (plain venv). On `x86_64`, x264's hand-tuned SIMD also
|
||||
requires `nasm`; install via whichever path fits your host:
|
||||
|
||||
```bash
|
||||
sudo apt install nasm # Debian/Ubuntu
|
||||
conda install -c conda-forge nasm # inside an active conda env
|
||||
```
|
||||
|
||||
No sudo and no conda? Build `nasm` from source (~30s, installs into `$HOME`):
|
||||
|
||||
```bash
|
||||
(
|
||||
mkdir -p "$HOME/src" "$HOME/opt" && cd "$HOME/src"
|
||||
curl -fsSL -O https://www.nasm.us/pub/nasm/releasebuilds/2.16.03/nasm-2.16.03.tar.gz
|
||||
tar -xf nasm-2.16.03.tar.gz && cd nasm-2.16.03
|
||||
./configure --prefix="$HOME/opt/nasm" && make -j"$(nproc)" && make install
|
||||
)
|
||||
export PATH="$HOME/opt/nasm/bin:$PATH" # add to ~/.bashrc to persist
|
||||
```
|
||||
|
||||
The installer writes to `~/opt/ffmpeg-native/` and emits
|
||||
`apps/dreamverse/scripts/ffmpeg-env.sh`. Source it before starting the backend
|
||||
so Dreamverse uses the custom FFmpeg binary:
|
||||
|
||||
@@ -59,8 +84,7 @@ dreamverse-server
|
||||
```
|
||||
|
||||
Docker images already run this FFmpeg build during image creation and source the
|
||||
generated environment file at container startup. The installer supports Linux
|
||||
`x86_64` and `aarch64`.
|
||||
generated environment file at container startup.
|
||||
|
||||
## Launch Dreamverse
|
||||
|
||||
@@ -71,17 +95,24 @@ dreamverse-server --port 8009
|
||||
dreamverse-mock-server --port 8009
|
||||
```
|
||||
|
||||
> **Expect a slow first boot.** With `torch.compile` and startup warmup enabled
|
||||
> (the default), the backend compiles the segment 1 and segment 2 inference
|
||||
> paths before it reports ready — this can take **tens of minutes on a cold
|
||||
> cache**, regardless of how you deploy (local, server, Docker, or Modal).
|
||||
> `/healthz` responds as soon as the process is up; `/readyz` stays `503` until
|
||||
> warmup finishes. For a faster, uncompiled startup while testing, set
|
||||
> `FASTVIDEO_ENABLE_STARTUP_WARMUP=0` before starting the backend.
|
||||
|
||||
## Frontend Setup
|
||||
|
||||
Install the web dependencies once from the FastVideo checkout:
|
||||
|
||||
```bash
|
||||
cd apps/dreamverse/web
|
||||
pnpm install --frozen-lockfile
|
||||
npm ci
|
||||
```
|
||||
|
||||
The frontend package also has an npm lockfile, but the bundled launch scripts
|
||||
use `pnpm`.
|
||||
The frontend package uses `package-lock.json`; use npm for installs and scripts.
|
||||
|
||||
## Quick Start: Local GPU
|
||||
|
||||
@@ -137,11 +168,37 @@ Start the frontend:
|
||||
|
||||
```bash
|
||||
cd apps/dreamverse/web
|
||||
BACKEND_HOST=localhost BACKEND_PORT=8009 pnpm run dev
|
||||
BACKEND_HOST=localhost BACKEND_PORT=8009 npm run dev
|
||||
```
|
||||
|
||||
Open `http://localhost:5299`.
|
||||
|
||||
## Server B200 deployment (SSH)
|
||||
|
||||
Deploying on a remote GPU host (for example a B200 box) is a local install run
|
||||
over SSH, plus a few server-specific concerns. Two paths:
|
||||
|
||||
### Option A: Native (source install)
|
||||
|
||||
SSH in, then follow [Install → From source](#method-2-from-source) and
|
||||
(recommended) [Building FFmpeg](#optional-building-ffmpeg-for-better-performance),
|
||||
then start the backend as in [Quick Start: Local GPU](#quick-start-local-gpu).
|
||||
For a remote host, a few things differ from localhost:
|
||||
|
||||
- Bind all interfaces: `dreamverse-server --host 0.0.0.0 --port 8009`.
|
||||
- Point the frontend/client at the host: `BACKEND_HOST=<b200-host> BACKEND_PORT=8009 npm run dev`.
|
||||
- Keep the backend alive across SSH sessions (`tmux` / `systemd` / `nohup`).
|
||||
- Expose / firewall port `8009`, or front it with a reverse proxy + auth.
|
||||
|
||||
### Option B: Docker (on the server)
|
||||
|
||||
SSH in, then follow [Install → Using Docker](#method-3-using-docker) and the run
|
||||
steps in [`docker/README.md`](docker/README.md):
|
||||
|
||||
```bash
|
||||
CEREBRAS_API_KEY="<key>" GROQ_API_KEY="<key>" apps/dreamverse/docker/docker_run.sh
|
||||
```
|
||||
|
||||
## Quick Start: Mock Backend (For UI development)
|
||||
|
||||
The mock server emulates the Dreamverse backend protocol and streams a
|
||||
@@ -173,14 +230,14 @@ Run the frontend tests:
|
||||
|
||||
```bash
|
||||
cd apps/dreamverse/web
|
||||
pnpm test
|
||||
npm test
|
||||
```
|
||||
|
||||
Run the frontend e2e tests:
|
||||
|
||||
```bash
|
||||
cd apps/dreamverse/web
|
||||
pnpm run e2e
|
||||
npm run e2e
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
@@ -216,7 +273,7 @@ Only one GPU is used
|
||||
Frontend cannot connect to backend
|
||||
|
||||
- confirm the backend is running on `8009`; if not, point the frontend at it
|
||||
with `BACKEND_HOST=<host> BACKEND_PORT=<port> pnpm run dev`
|
||||
with `BACKEND_HOST=<host> BACKEND_PORT=<port> npm run dev`
|
||||
- confirm `http://localhost:8009/healthz` responds before starting the frontend
|
||||
- confirm `http://localhost:8009/readyz` returns `200` before clicking Generate
|
||||
- use `apps/dreamverse/scripts/smoke_local.sh` for a repeatable local startup
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
.git/
|
||||
**/.git/
|
||||
.venv/
|
||||
**/.venv/
|
||||
**/__pycache__/
|
||||
**/*.pyc
|
||||
**/*.pyo
|
||||
**/*.egg-info/
|
||||
.pytest_cache/
|
||||
**/.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.cache/
|
||||
|
||||
apps/dreamverse/web/node_modules/
|
||||
apps/dreamverse/web/.next/
|
||||
apps/dreamverse/web/out/
|
||||
apps/dreamverse/web/dist/
|
||||
apps/dreamverse/web/test-results/
|
||||
apps/dreamverse/web/playwright-report/
|
||||
|
||||
apps/dreamverse/outputs/
|
||||
apps/dreamverse/dreamverse/outputs/
|
||||
apps/dreamverse/dreamverse/prompts.local/
|
||||
apps/dreamverse/logs/
|
||||
outputs/
|
||||
logs/
|
||||
slurm-logs/
|
||||
wandb/
|
||||
|
||||
.env
|
||||
.env.*
|
||||
**/prompts.local/
|
||||
.codex/
|
||||
.agents/exploration/
|
||||
.vscode/
|
||||
.idea/
|
||||
*.log
|
||||
*.tmp
|
||||
*.pdf
|
||||
@@ -3,6 +3,7 @@ ARG CUDA_TAG=12.9.1-cudnn-devel-ubuntu22.04
|
||||
FROM nvidia/cuda:${CUDA_TAG}
|
||||
|
||||
ARG BUILD_FASTVIDEO_KERNEL_FROM_SOURCE=0
|
||||
ARG BUILD_DREAMVERSE_UI=0
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
@@ -50,6 +51,18 @@ RUN if [[ "${BUILD_FASTVIDEO_KERNEL_FROM_SOURCE}" == "1" ]]; then \
|
||||
echo "Skipping source fastvideo-kernel build; using installed fastvideo-kernel package."; \
|
||||
fi
|
||||
|
||||
RUN if [[ "${BUILD_DREAMVERSE_UI}" == "1" ]]; then \
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& cd /opt/FastVideo/apps/dreamverse/web \
|
||||
&& npm ci --ignore-scripts \
|
||||
&& NEXT_OUTPUT_EXPORT=1 npm run build \
|
||||
&& rm -rf node_modules .next; \
|
||||
else \
|
||||
echo "Skipping Dreamverse UI build; Building backend-only image."; \
|
||||
fi
|
||||
|
||||
# The monorepo ffmpeg installer force-selects conda compiler triplets for
|
||||
# local dev shells. Inside this image we explicitly opt into the system
|
||||
# gcc/g++ toolchain.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
**/.git/
|
||||
.venv/
|
||||
**/.venv/
|
||||
.*dreamverse*/
|
||||
**/__pycache__/
|
||||
**/*.pyc
|
||||
**/*.pyo
|
||||
@@ -24,6 +25,9 @@ apps/dreamverse/dreamverse/outputs/
|
||||
apps/dreamverse/dreamverse/prompts.local/
|
||||
apps/dreamverse/logs/
|
||||
outputs/
|
||||
outputs_video/
|
||||
quality_check_outputs/
|
||||
data/
|
||||
logs/
|
||||
slurm-logs/
|
||||
wandb/
|
||||
@@ -32,7 +36,9 @@ wandb/
|
||||
.env.*
|
||||
**/prompts.local/
|
||||
.codex/
|
||||
.agents/exploration/
|
||||
.agents/
|
||||
.opencode/
|
||||
.agent_tmp/
|
||||
.vscode/
|
||||
.idea/
|
||||
*.log
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Dreamverse Docker Image
|
||||
|
||||
This folder contains the backend-only Docker image for Dreamverse inside the
|
||||
FastVideo monorepo. Build commands use the FastVideo repository root as the
|
||||
Docker context, so run the helper scripts from this folder or from any path in
|
||||
the checkout.
|
||||
This folder contains the Docker image for Dreamverse inside the FastVideo
|
||||
monorepo. Build commands use the FastVideo repository root as the Docker
|
||||
context, so run the helper scripts from this folder or from any path in the
|
||||
checkout.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -17,12 +17,22 @@ The image defaults to `dreamverse:dev`. Override it with:
|
||||
DREAMVERSE_IMAGE=dreamverse:local apps/dreamverse/docker/docker_build.sh
|
||||
```
|
||||
|
||||
Backend-only remains the default image. To include the static Dreamverse UI
|
||||
served by the backend, set `BUILD_DREAMVERSE_UI=1` and choose a specific image
|
||||
tag:
|
||||
|
||||
```bash
|
||||
BUILD_DREAMVERSE_UI=1 DREAMVERSE_IMAGE=<image-tag> apps/dreamverse/docker/docker_build.sh
|
||||
```
|
||||
|
||||
Prefer SHA-specific tags for deployable images; avoid `latest`.
|
||||
|
||||
The Dockerfile builds a CUDA 12.9.1 image, installs FastVideo from this
|
||||
checkout with the `dreamverse` extra, installs the FA4
|
||||
flash-attention fork, builds native FFmpeg, and installs FlashInfer for NVFP4
|
||||
quantization.
|
||||
|
||||
FastVideo's pinned `fastvideo-kernel==0.2.6` package is installed by default.
|
||||
FastVideo's pinned `fastvideo-kernel==0.3.0` package is installed by default.
|
||||
To rebuild `fastvideo-kernel` from this checkout during the image build, set:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -4,13 +4,44 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../../.." && pwd)"
|
||||
IMAGE="${DREAMVERSE_IMAGE:-dreamverse:dev}"
|
||||
ROOT_DOCKERIGNORE="${REPO_ROOT}/.dockerignore"
|
||||
DOCKERFILE_DOCKERIGNORE="${SCRIPT_DIR}/Dockerfile.dockerignore"
|
||||
CREATED_ROOT_DOCKERIGNORE_SYMLINK=0
|
||||
|
||||
cleanup_root_dockerignore_symlink() {
|
||||
if [[ "${CREATED_ROOT_DOCKERIGNORE_SYMLINK}" == "1" && -L "${ROOT_DOCKERIGNORE}" ]] && \
|
||||
[[ "$(readlink "${ROOT_DOCKERIGNORE}")" == "${DOCKERFILE_DOCKERIGNORE}" ]]; then
|
||||
rm -- "${ROOT_DOCKERIGNORE}"
|
||||
fi
|
||||
}
|
||||
trap cleanup_root_dockerignore_symlink EXIT INT TERM
|
||||
|
||||
if [[ -z "${DOCKER_BUILDKIT:-}" ]] && docker buildx version >/dev/null 2>&1; then
|
||||
export DOCKER_BUILDKIT=1
|
||||
fi
|
||||
|
||||
build_args=()
|
||||
[[ -n "${CUDA_TAG:-}" ]] && build_args+=(--build-arg "CUDA_TAG=${CUDA_TAG}")
|
||||
[[ -n "${BUILD_FASTVIDEO_KERNEL_FROM_SOURCE:-}" ]] && \
|
||||
build_args+=(--build-arg "BUILD_FASTVIDEO_KERNEL_FROM_SOURCE=${BUILD_FASTVIDEO_KERNEL_FROM_SOURCE}")
|
||||
build_args+=(--build-arg "BUILD_DREAMVERSE_UI=${BUILD_DREAMVERSE_UI:-0}")
|
||||
|
||||
exec docker build \
|
||||
if [[ "${DOCKER_BUILDKIT:-}" == "1" ]]; then
|
||||
if [[ -L "${ROOT_DOCKERIGNORE}" ]] && \
|
||||
[[ "$(readlink "${ROOT_DOCKERIGNORE}")" == "${DOCKERFILE_DOCKERIGNORE}" ]]; then
|
||||
rm -- "${ROOT_DOCKERIGNORE}"
|
||||
printf 'Removed stale temporary root .dockerignore symlink: %s\n' "${ROOT_DOCKERIGNORE}"
|
||||
fi
|
||||
elif [[ -e "${ROOT_DOCKERIGNORE}" || -L "${ROOT_DOCKERIGNORE}" ]]; then
|
||||
printf 'Using existing root .dockerignore: %s\n' "${ROOT_DOCKERIGNORE}"
|
||||
else
|
||||
ln -s -- "${DOCKERFILE_DOCKERIGNORE}" "${ROOT_DOCKERIGNORE}"
|
||||
CREATED_ROOT_DOCKERIGNORE_SYMLINK=1
|
||||
printf 'Created temporary root .dockerignore symlink for legacy Docker builder: %s -> %s\n' \
|
||||
"${ROOT_DOCKERIGNORE}" "${DOCKERFILE_DOCKERIGNORE}"
|
||||
fi
|
||||
|
||||
docker build \
|
||||
-f "${SCRIPT_DIR}/Dockerfile" \
|
||||
-t "${IMAGE}" \
|
||||
"${build_args[@]}" \
|
||||
|
||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
_SERVER_ROOT = Path(__file__).resolve().parent
|
||||
_FASTVIDEO_DREAMVERSE_HOME = os.environ.get("FASTVIDEO_DREAMVERSE_HOME")
|
||||
_FASTVIDEO_DREAMVERSE_FRONTEND_ROOT = os.environ.get("FASTVIDEO_DREAMVERSE_FRONTEND_ROOT")
|
||||
_XDG_STATE_HOME = os.environ.get("XDG_STATE_HOME")
|
||||
_DEFAULT_STATE_ROOT = (Path(_FASTVIDEO_DREAMVERSE_HOME) if _FASTVIDEO_DREAMVERSE_HOME else
|
||||
(Path(_XDG_STATE_HOME) if _XDG_STATE_HOME else Path.home() / ".local/state") /
|
||||
@@ -16,18 +17,39 @@ _APP_ROOT = _REPO_ROOT
|
||||
|
||||
def _resolve_frontend_root() -> Path:
|
||||
for candidate in (
|
||||
Path(_FASTVIDEO_DREAMVERSE_FRONTEND_ROOT) if _FASTVIDEO_DREAMVERSE_FRONTEND_ROOT else None,
|
||||
_APP_ROOT / "web",
|
||||
_APP_ROOT / "prod-ui",
|
||||
):
|
||||
if candidate.is_dir():
|
||||
if candidate is not None and candidate.is_dir():
|
||||
return candidate
|
||||
if _FASTVIDEO_DREAMVERSE_FRONTEND_ROOT:
|
||||
return Path(_FASTVIDEO_DREAMVERSE_FRONTEND_ROOT)
|
||||
return _APP_ROOT / "web"
|
||||
|
||||
|
||||
def _resolve_frontend_static_dir_candidates() -> tuple[str, ...]:
|
||||
roots = (
|
||||
FRONTEND_ROOT,
|
||||
Path.cwd() / "apps/dreamverse/web",
|
||||
Path.cwd() / "web",
|
||||
Path("/opt/FastVideo/apps/dreamverse/web"),
|
||||
)
|
||||
candidates: list[str] = []
|
||||
seen: set[Path] = set()
|
||||
for root in roots:
|
||||
resolved_root = root.resolve(strict=False)
|
||||
if resolved_root in seen:
|
||||
continue
|
||||
seen.add(resolved_root)
|
||||
candidates.extend(str(resolved_root / dirname) for dirname in ("out", "dist"))
|
||||
return tuple(candidates)
|
||||
|
||||
|
||||
FRONTEND_ROOT = _resolve_frontend_root()
|
||||
_CLIENT_PROMPTS_ROOT = FRONTEND_ROOT / "prompts"
|
||||
_CLIENT_PROMPTS_LOCAL_ROOT = FRONTEND_ROOT / "prompts.local"
|
||||
FRONTEND_STATIC_DIR_CANDIDATES = tuple(str(FRONTEND_ROOT / dirname) for dirname in ("out", "dist"))
|
||||
FRONTEND_STATIC_DIR_CANDIDATES = _resolve_frontend_static_dir_candidates()
|
||||
|
||||
# Model registry
|
||||
MODEL_REGISTRY = {
|
||||
@@ -35,18 +57,24 @@ MODEL_REGISTRY = {
|
||||
"name": "FastLTX2",
|
||||
"model_path": "FastVideo/LTX2-Distilled-Diffusers",
|
||||
"config_model_path": "FastVideo/LTX2-Distilled-Diffusers",
|
||||
"lora_repo": "FastVideo/LTX2-OmniNFT-LoRA",
|
||||
},
|
||||
"fast-ltx23": {
|
||||
"name": "FastLTX23",
|
||||
"model_path": "FastVideo/LTX-2.3-Distilled-Diffusers",
|
||||
"config_model_path": "FastVideo/LTX-2.3-Distilled-Diffusers",
|
||||
"lora_repo": "FastVideo/LTX-2.3-OmniNFT-LoRA",
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_MODEL_ID = "fast-ltx2"
|
||||
|
||||
ACTIVE_MODEL_ID = (os.getenv("DREAMVERSE_MODEL_ID", "").strip() or DEFAULT_MODEL_ID)
|
||||
if ACTIVE_MODEL_ID not in MODEL_REGISTRY:
|
||||
ACTIVE_MODEL_ID = DEFAULT_MODEL_ID
|
||||
|
||||
# Active model configuration
|
||||
MODEL_CONFIG = MODEL_REGISTRY[DEFAULT_MODEL_ID]
|
||||
MODEL_CONFIG = MODEL_REGISTRY[ACTIVE_MODEL_ID]
|
||||
|
||||
# Generation limits
|
||||
SESSION_TIMEOUT_SECONDS = 300
|
||||
@@ -142,6 +170,76 @@ def _optional_env(*names: str) -> str | None:
|
||||
|
||||
DEVTOOLS_ENABLED = _env_bool("FASTVIDEO_ENABLE_DEVTOOLS", False)
|
||||
PROMPT_SAFETY_ENABLED = _env_bool("FASTVIDEO_ENABLE_PROMPT_SAFETY", False)
|
||||
DREAMVERSE_MAX_AUTOTUNE = _env_bool("DREAMVERSE_MAX_AUTOTUNE", True)
|
||||
DREAMVERSE_SP_SIZE = max(1, _env_int("DREAMVERSE_SP_SIZE", 1))
|
||||
|
||||
DREAMVERSE_MODEL_PATH = (os.getenv("DREAMVERSE_MODEL_PATH", "").strip() or None)
|
||||
if DREAMVERSE_MODEL_PATH:
|
||||
MODEL_CONFIG = {
|
||||
**MODEL_CONFIG,
|
||||
"model_path": DREAMVERSE_MODEL_PATH,
|
||||
"config_model_path": DREAMVERSE_MODEL_PATH,
|
||||
}
|
||||
|
||||
AVAILABLE_LORAS = {
|
||||
"pixar": {
|
||||
"repo": "vrgamedevgirl84/LTX_2.3_Pixar_Toon_Style_LoRa",
|
||||
"trigger": "P1x4r",
|
||||
"model": "fast-ltx23",
|
||||
"position": "prepend",
|
||||
"label": "Pixar Toon",
|
||||
},
|
||||
"transition": {
|
||||
"repo": "valiantcat/LTX-2.3-Transition-LORA",
|
||||
"trigger": "zhuanchang",
|
||||
"model": "fast-ltx23",
|
||||
"position": "append",
|
||||
"label": "Transition",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _active_model_key() -> str:
|
||||
return ACTIVE_MODEL_ID
|
||||
|
||||
|
||||
def _available_styles_for_active_model() -> list[str]:
|
||||
active = _active_model_key()
|
||||
return [k for k, v in AVAILABLE_LORAS.items() if v.get("model") == active]
|
||||
|
||||
|
||||
def _resolve_lora_spec(spec: str) -> str | None:
|
||||
spec = (spec or "").strip()
|
||||
if not spec:
|
||||
return None
|
||||
if spec.lower() == "omninft":
|
||||
return MODEL_CONFIG.get("lora_repo")
|
||||
if spec.lower() in AVAILABLE_LORAS:
|
||||
return AVAILABLE_LORAS[spec.lower()]["repo"]
|
||||
return spec
|
||||
|
||||
|
||||
def _parse_lora_stack(raw: str) -> list[tuple[str, float]]:
|
||||
out: list[tuple[str, float]] = []
|
||||
for item in (raw or "").split(","):
|
||||
item = item.strip()
|
||||
if not item:
|
||||
continue
|
||||
spec, _, stren = item.partition("@")
|
||||
resolved = _resolve_lora_spec(spec)
|
||||
if resolved:
|
||||
try:
|
||||
strength = float(stren) if stren.strip() else 1.0
|
||||
except ValueError:
|
||||
strength = 1.0
|
||||
out.append((resolved, strength))
|
||||
return out
|
||||
|
||||
|
||||
DREAMVERSE_LORA_PATH = _resolve_lora_spec(os.getenv("DREAMVERSE_LORA_PATH", ""))
|
||||
DREAMVERSE_LORA_NICKNAME = (os.getenv("DREAMVERSE_LORA_NICKNAME", "omninft").strip() or "omninft")
|
||||
DREAMVERSE_LORA_STRENGTH = _env_float("DREAMVERSE_LORA_STRENGTH", 1.0)
|
||||
DREAMVERSE_LORA_STACK = _parse_lora_stack(os.getenv("DREAMVERSE_LORA_STACK", ""))
|
||||
|
||||
|
||||
def _resolve_devtools_paths(
|
||||
|
||||
@@ -13,6 +13,7 @@ from multiprocessing import Process, Queue
|
||||
|
||||
from dreamverse.config import (
|
||||
DEFAULT_MODEL_ID,
|
||||
DREAMVERSE_SP_SIZE,
|
||||
MODEL_REGISTRY,
|
||||
STARTUP_WARMUP_ENABLED,
|
||||
STARTUP_WARMUP_PROMPT,
|
||||
@@ -33,6 +34,8 @@ from dreamverse.worker_ipc import (
|
||||
InitAck,
|
||||
JoinAck,
|
||||
LeaveAck,
|
||||
LoraAck,
|
||||
LoraStackPayload,
|
||||
MediaChunk,
|
||||
MediaComplete,
|
||||
MediaInit,
|
||||
@@ -80,6 +83,7 @@ class CommandType(Enum):
|
||||
USER_STEP = "user_step"
|
||||
USER_LEAVE = "user_leave"
|
||||
RELOAD_MODEL = "reload_model"
|
||||
APPLY_LORA = "apply_lora"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -282,6 +286,25 @@ def gpu_worker_process(
|
||||
message=str(e),
|
||||
))
|
||||
|
||||
elif cmd.type == CommandType.APPLY_LORA:
|
||||
try:
|
||||
assert isinstance(cmd.payload, LoraStackPayload), (f"APPLY_LORA requires LoraStackPayload, "
|
||||
f"got {type(cmd.payload).__name__}")
|
||||
trigger, position = worker.apply_lora_stack(cmd.payload.stack)
|
||||
response_queue.put(
|
||||
LoraAck(
|
||||
user_id=cmd.user_id,
|
||||
style_trigger=trigger,
|
||||
style_trigger_position=position,
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"[GPU {gpu_id}] Apply LoRA error: {e}")
|
||||
traceback.print_exc()
|
||||
response_queue.put(WorkerError(
|
||||
user_id=cmd.user_id,
|
||||
message=str(e),
|
||||
))
|
||||
|
||||
return True # Continue loop
|
||||
|
||||
if first_cmd is not None:
|
||||
@@ -359,6 +382,25 @@ def gpu_worker_process(
|
||||
message=str(e),
|
||||
))
|
||||
|
||||
elif cmd.type == CommandType.APPLY_LORA:
|
||||
try:
|
||||
assert isinstance(cmd.payload, LoraStackPayload), (f"APPLY_LORA requires LoraStackPayload, "
|
||||
f"got {type(cmd.payload).__name__}")
|
||||
trigger, position = worker.apply_lora_stack(cmd.payload.stack)
|
||||
response_queue.put(
|
||||
LoraAck(
|
||||
user_id=cmd.user_id,
|
||||
style_trigger=trigger,
|
||||
style_trigger_position=position,
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"[GPU {gpu_id}] Apply LoRA error: {e}")
|
||||
traceback.print_exc()
|
||||
response_queue.put(WorkerError(
|
||||
user_id=cmd.user_id,
|
||||
message=str(e),
|
||||
))
|
||||
|
||||
elif cmd.type in (CommandType.USER_JOIN, CommandType.USER_STEP, CommandType.USER_LEAVE):
|
||||
event_loop(first_cmd=cmd)
|
||||
break
|
||||
@@ -729,6 +771,25 @@ class GPUSlot:
|
||||
raise RuntimeError(f"Unexpected step response for {user_id[:8]}: "
|
||||
f"{type(response).__name__}")
|
||||
|
||||
async def apply_lora_stack(
|
||||
self,
|
||||
stack: list[tuple[str, float]],
|
||||
) -> LoraAck:
|
||||
"""Re-apply a runtime LoRA stack on this GPU's worker."""
|
||||
self._active = True
|
||||
user_id = "__lora__"
|
||||
payload = LoraStackPayload(stack=stack)
|
||||
response = await self._send_command_tagged(Command(CommandType.APPLY_LORA, payload=payload, user_id=user_id),
|
||||
timeout=120.0)
|
||||
match response:
|
||||
case LoraAck() as ack:
|
||||
return ack
|
||||
case WorkerError(message=msg):
|
||||
raise RuntimeError(f"Apply LoRA failed on GPU {self.gpu_id}: {msg}")
|
||||
case _:
|
||||
raise RuntimeError(f"Unexpected LoRA response on GPU {self.gpu_id}: "
|
||||
f"{type(response).__name__}")
|
||||
|
||||
async def leave_user(self, user_id: str) -> None:
|
||||
"""Remove a user from this GPU."""
|
||||
try:
|
||||
@@ -785,8 +846,16 @@ class GPUPool:
|
||||
"""Manages multiple GPU worker subprocesses."""
|
||||
|
||||
def __init__(self, gpu_ids: list[int]):
|
||||
self.gpu_ids = gpu_ids
|
||||
self.slots: dict[int, GPUSlot] = {gpu_id: GPUSlot(gpu_id, str(gpu_id)) for gpu_id in gpu_ids}
|
||||
sp_size = DREAMVERSE_SP_SIZE
|
||||
groups = [gpu_ids[i:i + sp_size] for i in range(0, len(gpu_ids), sp_size)]
|
||||
groups = [g for g in groups if len(g) == sp_size]
|
||||
if not groups:
|
||||
raise RuntimeError(f"Not enough GPUs for DREAMVERSE_SP_SIZE={sp_size}: available={gpu_ids}")
|
||||
if sp_size > 1:
|
||||
print(f"[INFO] Sequence-parallel slots (sp_size={sp_size}): " + ", ".join("{" + ",".join(map(str, g)) + "}"
|
||||
for g in groups))
|
||||
self.gpu_ids = [g[0] for g in groups]
|
||||
self.slots: dict[int, GPUSlot] = {g[0]: GPUSlot(g[0], ",".join(str(x) for x in g)) for g in groups}
|
||||
self.waiting_list: list[tuple[str, asyncio.Event, WebSocket]] = []
|
||||
self.client_gpu_map: dict[str, int] = {}
|
||||
self._pool_lock = asyncio.Lock()
|
||||
@@ -903,6 +972,17 @@ class GPUPool:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def apply_lora_stack(
|
||||
self,
|
||||
stack: list[tuple[str, float]],
|
||||
) -> dict[int, str | None]:
|
||||
"""Re-apply a runtime LoRA stack across all ready GPU workers."""
|
||||
ready_slots = [slot for slot in self.slots.values() if slot.ready]
|
||||
if not ready_slots:
|
||||
raise RuntimeError("No ready GPU workers to apply LoRA stack.")
|
||||
results = await asyncio.gather(*(slot.apply_lora_stack(stack) for slot in ready_slots))
|
||||
return {slot.gpu_id: ack.style_trigger for slot, ack in zip(ready_slots, results, strict=False)}
|
||||
|
||||
async def shutdown(self):
|
||||
"""Shutdown all GPU workers."""
|
||||
print("Shutting down GPU pool...")
|
||||
|
||||
@@ -6,18 +6,22 @@ import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, WebSocket
|
||||
from fastapi import FastAPI, HTTPException, WebSocket
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
from fastvideo.entrypoints.streaming import build_health_router
|
||||
from dreamverse.gpu_pool import GPUPool, get_available_gpus
|
||||
from dreamverse.session_logger import SessionEventLogger
|
||||
|
||||
from dreamverse.config import (
|
||||
AVAILABLE_LORAS,
|
||||
DEVTOOLS_ENABLED,
|
||||
FRONTEND_STATIC_DIR_CANDIDATES,
|
||||
PROMPT_SAFETY_ENABLED,
|
||||
SESSION_LOG_ROOT,
|
||||
_available_styles_for_active_model,
|
||||
_resolve_lora_spec,
|
||||
)
|
||||
from dreamverse.prompt_enhancer import PromptEnhancer
|
||||
from dreamverse.prompt_safety import PromptSafetyFilter
|
||||
@@ -104,6 +108,83 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
await controller.run()
|
||||
|
||||
|
||||
class LoraRequest(BaseModel):
|
||||
strength: float = 1.0
|
||||
styles: dict[str, float] = {}
|
||||
style: str = ""
|
||||
|
||||
|
||||
@app.get("/lora/options")
|
||||
async def lora_options() -> dict:
|
||||
if not DEVTOOLS_ENABLED:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
styles = _available_styles_for_active_model()
|
||||
has_base_lora = _resolve_lora_spec("omninft") is not None
|
||||
labels = {"none": "None"}
|
||||
labels.update({k: AVAILABLE_LORAS[k].get("label", k) for k in styles})
|
||||
return {
|
||||
"styles": ["none", *styles],
|
||||
"labels": labels,
|
||||
"has_base_lora": has_base_lora,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/lora")
|
||||
async def apply_lora(request: LoraRequest) -> dict:
|
||||
if not DEVTOOLS_ENABLED:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if runtime.gpu_pool is None:
|
||||
raise HTTPException(status_code=503, detail="GPU pool not ready")
|
||||
|
||||
strength = max(0.0, min(1.0, float(request.strength)))
|
||||
allowed_styles = _available_styles_for_active_model()
|
||||
|
||||
requested = dict(request.styles) if request.styles else {}
|
||||
if not requested and request.style and request.style.strip().lower() != "none":
|
||||
requested = {request.style: 1.0}
|
||||
|
||||
styles_map: dict[str, float] = {}
|
||||
for name, intensity in requested.items():
|
||||
key = str(name).strip().lower()
|
||||
if key in ("", "none"):
|
||||
continue
|
||||
if key not in allowed_styles:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown style for active model: {key}")
|
||||
value = max(0.0, min(1.0, float(intensity)))
|
||||
if value <= 0.0:
|
||||
continue
|
||||
styles_map[key] = value
|
||||
|
||||
stack: list[tuple[str, float]] = []
|
||||
if _resolve_lora_spec("omninft") is not None:
|
||||
stack.append(("omninft", strength))
|
||||
for key, intensity in styles_map.items():
|
||||
stack.append((key, intensity))
|
||||
|
||||
if not stack:
|
||||
raise HTTPException(status_code=400,
|
||||
detail="Active model has no OmniNFT LoRA and no style selected; nothing to apply.")
|
||||
|
||||
try:
|
||||
triggers = await runtime.gpu_pool.apply_lora_stack(stack)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return {
|
||||
"applied": True,
|
||||
"strength": strength,
|
||||
"styles": styles_map,
|
||||
"triggers": {
|
||||
key: AVAILABLE_LORAS[key]["trigger"]
|
||||
for key in styles_map
|
||||
},
|
||||
"gpus": {
|
||||
str(gpu_id): trigger
|
||||
for gpu_id, trigger in triggers.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Serve an exported frontend bundle when present.
|
||||
for static_dir in FRONTEND_STATIC_DIR_CANDIDATES:
|
||||
if os.path.isdir(static_dir):
|
||||
|
||||
@@ -41,7 +41,7 @@ MOCK_FPS = 24
|
||||
MOCK_DURATION_SECONDS = 5.0
|
||||
MOCK_STREAM_CHUNK_SIZE_BYTES = 256 * 1024
|
||||
MOCK_STREAM_CHUNK_DELAY_MS = 15
|
||||
MOCK_AV_MIME = 'video/mp4; codecs="avc1.42E01E"'
|
||||
MOCK_AV_MIME = 'video/mp4; codecs="avc1.42E01E,mp4a.40.2"'
|
||||
FFMPEG_BIN = shutil.which(os.getenv("FASTVIDEO_FFMPEG_BIN", "ffmpeg"))
|
||||
MOCK_SEGMENT_BYTES: bytes | None = None
|
||||
|
||||
@@ -75,9 +75,14 @@ def _build_mock_segment_bytes() -> bytes:
|
||||
"lavfi",
|
||||
"-i",
|
||||
f"testsrc2=size={MOCK_FRAME_WIDTH}x{MOCK_FRAME_HEIGHT}:rate={MOCK_FPS}",
|
||||
# Silent stereo AAC track. We don't need audible content — the FE's
|
||||
# audio-decode coverage only needs a real AAC track present
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"anullsrc=channel_layout=stereo:sample_rate=48000",
|
||||
"-t",
|
||||
f"{MOCK_DURATION_SECONDS}",
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
@@ -90,6 +95,12 @@ def _build_mock_segment_bytes() -> bytes:
|
||||
"baseline",
|
||||
"-level",
|
||||
"3.0",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-ar",
|
||||
"48000",
|
||||
"-movflags",
|
||||
"+empty_moov+default_base_moof+frag_keyframe",
|
||||
"-frag_duration",
|
||||
@@ -183,6 +194,55 @@ async def status():
|
||||
"queue_size": 0,
|
||||
"available_gpus": 1,
|
||||
"total_gpus": 1,
|
||||
"warmup_enabled": False,
|
||||
"warmup_successful_gpus": 1,
|
||||
"warmup_failed_gpus": 0,
|
||||
"gpu_status": {
|
||||
0: {
|
||||
"ready": True,
|
||||
"available": True,
|
||||
"client_count": 0,
|
||||
"current_model_id": "mock-ltx2",
|
||||
"process_alive": True,
|
||||
"warmup_enabled": False,
|
||||
"warmup_success": True,
|
||||
"warmup_error": None,
|
||||
"warmup_timings": {},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/prompt-system-config")
|
||||
async def prompt_system_config():
|
||||
return {
|
||||
"next_segment_system_prompt": "Mock next segment prompt.",
|
||||
"auto_extension_system_prompt": "Mock auto extension prompt.",
|
||||
"rewrite_window_system_prompt": "Mock rewrite window prompt.",
|
||||
"rewrite_user_system_prompt": "Mock rewrite user prompt.",
|
||||
"rewrite_model": "mock-rewrite-model",
|
||||
"rewrite_temperature": 0.0,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/curated-presets")
|
||||
async def curated_presets():
|
||||
presets = [
|
||||
{
|
||||
"id": "mock_story",
|
||||
"label": "Mock Story",
|
||||
"description": "CI-safe mock story preset.",
|
||||
"segment_prompts": [
|
||||
"A tiny robot opens a glowing door.",
|
||||
"The robot waves at a friendly moon.",
|
||||
],
|
||||
},
|
||||
]
|
||||
return {
|
||||
"presets": presets,
|
||||
"count": len(presets),
|
||||
"file_path": None,
|
||||
"fallback_file_path": None,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -124,12 +124,21 @@ def test_config_uses_local_overlay_paths_when_devtools_enabled(monkeypatch, tmp_
|
||||
assert module.CURATED_PRESETS_FALLBACK_FILE_PATH.endswith(
|
||||
"apps/dreamverse/web/prompts/selected_ltx2_continuation_story_presets.json"
|
||||
)
|
||||
assert module.FRONTEND_STATIC_DIR_CANDIDATES == (
|
||||
assert module.FRONTEND_STATIC_DIR_CANDIDATES[:2] == (
|
||||
str(module.FRONTEND_ROOT / "out"),
|
||||
str(module.FRONTEND_ROOT / "dist"),
|
||||
)
|
||||
|
||||
|
||||
def test_config_includes_docker_checkout_static_frontend_candidates(monkeypatch):
|
||||
_set_required_prompt_keys(monkeypatch)
|
||||
|
||||
module = _load_config_module()
|
||||
|
||||
assert "/opt/FastVideo/apps/dreamverse/web/out" in module.FRONTEND_STATIC_DIR_CANDIDATES
|
||||
assert "/opt/FastVideo/apps/dreamverse/web/dist" in module.FRONTEND_STATIC_DIR_CANDIDATES
|
||||
|
||||
|
||||
def test_config_enables_prompt_safety_when_requested(monkeypatch):
|
||||
_set_required_prompt_keys(monkeypatch)
|
||||
monkeypatch.setenv("FASTVIDEO_ENABLE_PROMPT_SAFETY", "true")
|
||||
|
||||
@@ -171,3 +171,69 @@ def test_mock_server_cli_updates_latency(monkeypatch):
|
||||
assert mock_server.LATENCY_MS == 321
|
||||
finally:
|
||||
mock_server.LATENCY_MS = old_latency_ms
|
||||
|
||||
|
||||
def _lora_client(monkeypatch, captured):
|
||||
server_main = _import_server_main(monkeypatch)
|
||||
monkeypatch.setattr(server_main, "DEVTOOLS_ENABLED", True)
|
||||
monkeypatch.setattr(server_main, "_available_styles_for_active_model", lambda: ["pixar", "transition"])
|
||||
monkeypatch.setattr(
|
||||
server_main,
|
||||
"_resolve_lora_spec",
|
||||
lambda spec: "FastVideo/OmniNFT" if str(spec).strip().lower() == "omninft" else spec,
|
||||
)
|
||||
|
||||
class _FakePool:
|
||||
|
||||
async def apply_lora_stack(self, stack):
|
||||
captured["stack"] = list(stack)
|
||||
return {0: "trigger"}
|
||||
|
||||
server_main.runtime.gpu_pool = _FakePool()
|
||||
return TestClient(server_main.app)
|
||||
|
||||
|
||||
def test_apply_lora_stacks_multiple_styles_with_intensities(monkeypatch):
|
||||
captured: dict = {}
|
||||
client = _lora_client(monkeypatch, captured)
|
||||
response = client.post("/lora", json={"strength": 0.8, "styles": {"pixar": 0.45, "transition": 0.5}})
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["applied"] is True
|
||||
assert body["styles"] == {"pixar": 0.45, "transition": 0.5}
|
||||
assert captured["stack"] == [("omninft", 0.8), ("pixar", 0.45), ("transition", 0.5)]
|
||||
|
||||
|
||||
def test_apply_lora_rejects_unknown_style(monkeypatch):
|
||||
captured: dict = {}
|
||||
client = _lora_client(monkeypatch, captured)
|
||||
response = client.post("/lora", json={"strength": 0.5, "styles": {"watercolor": 0.5}})
|
||||
assert response.status_code == 400
|
||||
assert "stack" not in captured
|
||||
|
||||
|
||||
def test_apply_lora_accepts_legacy_single_style(monkeypatch):
|
||||
captured: dict = {}
|
||||
client = _lora_client(monkeypatch, captured)
|
||||
response = client.post("/lora", json={"strength": 0.6, "style": "pixar"})
|
||||
assert response.status_code == 200
|
||||
assert captured["stack"] == [("omninft", 0.6), ("pixar", 1.0)]
|
||||
|
||||
|
||||
def test_apply_lora_clamps_and_drops_zero_intensity(monkeypatch):
|
||||
captured: dict = {}
|
||||
client = _lora_client(monkeypatch, captured)
|
||||
response = client.post("/lora", json={"strength": 1.5, "styles": {"pixar": 2.0, "transition": 0.0}})
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["strength"] == 1.0
|
||||
assert body["styles"] == {"pixar": 1.0}
|
||||
assert captured["stack"] == [("omninft", 1.0), ("pixar", 1.0)]
|
||||
|
||||
|
||||
def test_lora_endpoints_hidden_without_devtools(monkeypatch):
|
||||
server_main = _import_server_main(monkeypatch)
|
||||
monkeypatch.setattr(server_main, "DEVTOOLS_ENABLED", False)
|
||||
client = TestClient(server_main.app)
|
||||
assert client.get("/lora/options").status_code == 404
|
||||
assert client.post("/lora", json={"strength": 0.5}).status_code == 404
|
||||
|
||||
@@ -20,9 +20,11 @@ os.environ.setdefault("GROQ_API_KEY", "dummy")
|
||||
|
||||
import dreamverse.main as server_main # noqa: E402
|
||||
import dreamverse.runtime as runtime # noqa: E402
|
||||
from dreamverse.config import PROMPT_TIMEOUT_MS # noqa: E402
|
||||
from dreamverse.session_logger import SessionEventLogger # noqa: E402
|
||||
from dreamverse.worker_ipc import MediaChunk, MediaComplete, MediaInit # noqa: E402
|
||||
from dreamverse.session import controller as session_controller # noqa: E402
|
||||
from dreamverse.utils import PROMPT_EXTENSION_FAILURE_USER_MESSAGE # noqa: E402
|
||||
|
||||
pytestmark = pytest.mark.gpu
|
||||
|
||||
@@ -338,7 +340,7 @@ def test_session_event_logger_initializes_hostname_folder_and_utc_filename(
|
||||
logger = SessionEventLogger(tmp_path)
|
||||
assert logger.directory == tmp_path / socket.gethostname()
|
||||
assert logger.directory.is_dir()
|
||||
assert re.match(r"^\d{6}_\d{6}\.jsonl$", logger.path.name)
|
||||
assert re.match(r"^\d{6}_\d{6}_\d{6}\.jsonl$", logger.path.name)
|
||||
assert logger.path.is_file()
|
||||
|
||||
|
||||
@@ -833,7 +835,7 @@ def test_initial_custom_rollout_prompt_generates_seed_window_before_streaming():
|
||||
"rewrite_instruction": "A moonbase corridor thriller with flooding",
|
||||
"rewrite_model": "gpt-4.1-mini",
|
||||
"rewrite_temperature": 0.4,
|
||||
"timeout_ms": server_main.PROMPT_TIMEOUT_MS,
|
||||
"timeout_ms": PROMPT_TIMEOUT_MS,
|
||||
"system_prompt_override": "Session-specific rewrite prompt",
|
||||
}
|
||||
assert fake_pool._slot.calls
|
||||
@@ -1256,7 +1258,7 @@ def test_single5s_enhancement_fallback_does_not_start_generation():
|
||||
assert fallback_payloads[0]["prompt_id"] == "simple_custom_prompt"
|
||||
assert (
|
||||
fallback_payloads[0]["error"]
|
||||
== server_main.PROMPT_EXTENSION_FAILURE_USER_MESSAGE
|
||||
== PROMPT_EXTENSION_FAILURE_USER_MESSAGE
|
||||
)
|
||||
assert fallback_payloads[0]["source"] == "user_enhancement_failed"
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ module import time.
|
||||
# mypy: ignore-errors
|
||||
import gc
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
@@ -20,11 +21,19 @@ import numpy as np
|
||||
import torch
|
||||
|
||||
from dreamverse.config import (
|
||||
AVAILABLE_LORAS,
|
||||
FRAME_HEIGHT,
|
||||
FRAME_WIDTH,
|
||||
MODEL_CONFIG,
|
||||
NUM_FRAMES,
|
||||
NUM_INFERENCE_STEPS,
|
||||
DREAMVERSE_MAX_AUTOTUNE,
|
||||
DREAMVERSE_SP_SIZE,
|
||||
DREAMVERSE_LORA_PATH,
|
||||
DREAMVERSE_LORA_NICKNAME,
|
||||
DREAMVERSE_LORA_STRENGTH,
|
||||
DREAMVERSE_LORA_STACK,
|
||||
_resolve_lora_spec,
|
||||
)
|
||||
|
||||
# Multi-frame decoded continuation defaults from
|
||||
@@ -62,6 +71,15 @@ DEFAULT_LTX2_AUDIO_HOP_LENGTH = 160
|
||||
DEFAULT_LTX2_AUDIO_DOWNSAMPLE = 4
|
||||
|
||||
|
||||
def _reset_lora_registry(worker) -> dict:
|
||||
pipeline = getattr(worker, "pipeline", None)
|
||||
if pipeline is None:
|
||||
return {"status": "no_pipeline"}
|
||||
pipeline.cur_adapter_name = ""
|
||||
pipeline.cur_adapter_strength = 1.0
|
||||
return {"status": "lora_registry_reset"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepResult:
|
||||
"""Output of one generation step.
|
||||
@@ -199,6 +217,8 @@ class VideoGenerationWorker:
|
||||
self.continuation = ContinuationState()
|
||||
self.audio_encoder_module = None
|
||||
self.audio_processor_module = None
|
||||
self.active_style_prepend: list[str] = []
|
||||
self.active_style_append: list[str] = []
|
||||
|
||||
def _gpu_mem(self) -> str:
|
||||
a = torch.cuda.memory_allocated() / 1024**3
|
||||
@@ -257,6 +277,7 @@ class VideoGenerationWorker:
|
||||
or self.current_model_config["model_path"])
|
||||
|
||||
enable_compile = os.getenv("ENABLE_TORCH_COMPILE", "1") == "1"
|
||||
compile_mode = "max-autotune-no-cudagraphs" if DREAMVERSE_MAX_AUTOTUNE else None
|
||||
|
||||
components = ComponentConfig(
|
||||
config_root=config_model_path,
|
||||
@@ -269,7 +290,7 @@ class VideoGenerationWorker:
|
||||
generator_config = GeneratorConfig(
|
||||
model_path=model_root,
|
||||
engine=EngineConfig(
|
||||
num_gpus=1,
|
||||
num_gpus=DREAMVERSE_SP_SIZE,
|
||||
offload=OffloadConfig(
|
||||
dit=False,
|
||||
dit_layerwise=False,
|
||||
@@ -280,9 +301,10 @@ class VideoGenerationWorker:
|
||||
compile=CompileConfig(
|
||||
enabled=enable_compile,
|
||||
text_encoder_enabled=enable_compile,
|
||||
vae_enabled=enable_compile,
|
||||
backend="inductor",
|
||||
fullgraph=True,
|
||||
mode="max-autotune-no-cudagraphs",
|
||||
mode=compile_mode,
|
||||
dynamic=False,
|
||||
),
|
||||
use_fsdp_inference=False,
|
||||
@@ -304,9 +326,88 @@ class VideoGenerationWorker:
|
||||
|
||||
self.generator = VideoGenerator.from_pretrained(config=generator_config)
|
||||
print(f"[GPU {self.gpu_id}] After model load: {self._gpu_mem()}")
|
||||
|
||||
lora_stack = DREAMVERSE_LORA_STACK or ([(DREAMVERSE_LORA_PATH,
|
||||
DREAMVERSE_LORA_STRENGTH)] if DREAMVERSE_LORA_PATH else [])
|
||||
for i, (lora_path, lora_strength) in enumerate(lora_stack):
|
||||
nickname = DREAMVERSE_LORA_NICKNAME if i == 0 else f"{DREAMVERSE_LORA_NICKNAME}_{i}"
|
||||
print(f"[GPU {self.gpu_id}] Applying LoRA '{nickname}' from {lora_path} "
|
||||
f"@{lora_strength} accumulate={i > 0}")
|
||||
self.generator.set_lora_adapter(
|
||||
lora_nickname=nickname,
|
||||
lora_path=lora_path,
|
||||
strength=lora_strength,
|
||||
accumulate=(i > 0),
|
||||
)
|
||||
if lora_stack:
|
||||
print(f"[GPU {self.gpu_id}] LoRA stack applied ({len(lora_stack)})")
|
||||
|
||||
self._load_audio_encoder(model_root)
|
||||
print(f"[GPU {self.gpu_id}] LTX2 model loaded (warmup pending)")
|
||||
|
||||
def apply_lora_stack(
|
||||
self,
|
||||
stack: list[tuple[str, float]],
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Re-apply a runtime LoRA stack and update the active style trigger."""
|
||||
if self.generator is None:
|
||||
raise RuntimeError("Generator not initialized; cannot apply LoRA stack.")
|
||||
|
||||
try:
|
||||
self.generator.unmerge_lora_weights()
|
||||
except Exception as exc:
|
||||
print(f"[GPU {self.gpu_id}] unmerge_lora_weights skipped: {exc}")
|
||||
|
||||
try:
|
||||
self.generator.executor.collective_rpc(_reset_lora_registry)
|
||||
except Exception as exc:
|
||||
print(f"[GPU {self.gpu_id}] lora registry reset skipped: {exc}")
|
||||
|
||||
resolved_stack: list[tuple[str, float]] = []
|
||||
for spec, strength in stack:
|
||||
resolved = _resolve_lora_spec(spec)
|
||||
if resolved:
|
||||
resolved_stack.append((resolved, float(strength)))
|
||||
|
||||
for i, (lora_path, lora_strength) in enumerate(resolved_stack):
|
||||
nickname = DREAMVERSE_LORA_NICKNAME if i == 0 else f"{DREAMVERSE_LORA_NICKNAME}_{i}"
|
||||
print(f"[GPU {self.gpu_id}] Re-applying LoRA '{nickname}' from {lora_path} "
|
||||
f"@{lora_strength} accumulate={i > 0}")
|
||||
self.generator.set_lora_adapter(
|
||||
lora_nickname=nickname,
|
||||
lora_path=lora_path,
|
||||
strength=lora_strength,
|
||||
accumulate=(i > 0),
|
||||
)
|
||||
print(f"[GPU {self.gpu_id}] Runtime LoRA stack applied ({len(resolved_stack)})")
|
||||
|
||||
prepend: list[str] = []
|
||||
append: list[str] = []
|
||||
for spec, _ in stack:
|
||||
key = (spec or "").strip().lower()
|
||||
if key in AVAILABLE_LORAS:
|
||||
trigger = AVAILABLE_LORAS[key]["trigger"]
|
||||
if AVAILABLE_LORAS[key].get("position", "append") == "prepend":
|
||||
prepend.append(trigger)
|
||||
else:
|
||||
append.append(trigger)
|
||||
self.active_style_prepend = prepend
|
||||
self.active_style_append = append
|
||||
combined = " ".join(prepend + append) or None
|
||||
return combined, None
|
||||
|
||||
def _inject_style_trigger(self, prompt: str) -> str:
|
||||
result = prompt
|
||||
for trigger in self.active_style_prepend:
|
||||
trigger = (trigger or "").strip()
|
||||
if trigger and not re.search(rf"\b{re.escape(trigger)}\b", result, re.IGNORECASE):
|
||||
result = f"{trigger} {result}".strip()
|
||||
for trigger in self.active_style_append:
|
||||
trigger = (trigger or "").strip()
|
||||
if trigger and not re.search(rf"\b{re.escape(trigger)}\b", result, re.IGNORECASE):
|
||||
result = f"{result} {trigger}".strip()
|
||||
return result
|
||||
|
||||
def _load_audio_encoder(self, model_root: str) -> None:
|
||||
if not ENABLE_AUDIO_RE_ENCODE:
|
||||
return
|
||||
@@ -375,6 +476,8 @@ class VideoGenerationWorker:
|
||||
"""Execute one generation step; snapshot state for the next segment."""
|
||||
timings: dict = {}
|
||||
|
||||
prompt = self._inject_style_trigger(prompt)
|
||||
|
||||
request_kwargs = dict(
|
||||
prompt=prompt,
|
||||
negative_prompt="",
|
||||
|
||||
@@ -48,6 +48,13 @@ class ReloadAck:
|
||||
user_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoraAck:
|
||||
user_id: str | None
|
||||
style_trigger: str | None
|
||||
style_trigger_position: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WarmupComplete:
|
||||
user_id: str | None
|
||||
@@ -117,6 +124,7 @@ WorkerEvent = (StepComplete
|
||||
| JoinAck
|
||||
| LeaveAck
|
||||
| ReloadAck
|
||||
| LoraAck
|
||||
| WarmupComplete
|
||||
| MediaInit
|
||||
| MediaChunk
|
||||
@@ -154,4 +162,9 @@ class ReloadModelPayload:
|
||||
model_config: dict
|
||||
|
||||
|
||||
CommandPayload = UserStepPayload | WarmupPayload | ReloadModelPayload
|
||||
@dataclass(frozen=True)
|
||||
class LoraStackPayload:
|
||||
stack: list[tuple[str, float]]
|
||||
|
||||
|
||||
CommandPayload = (UserStepPayload | WarmupPayload | ReloadModelPayload | LoraStackPayload)
|
||||
|
||||
@@ -32,14 +32,21 @@
|
||||
# FFMPEG_NATIVE_CC explicit C compiler command for native builds
|
||||
# FFMPEG_NATIVE_CXX explicit C++ compiler command for native builds
|
||||
#
|
||||
# Toolchain selection: CC / CXX / AS are pinned to the conda-forge
|
||||
# triplet matching `uname -m`. Inherited values are intentionally
|
||||
# ignored — conda envs that have BOTH `gcc_linux-64` and
|
||||
# `gcc_linux-aarch64` installed export the cross-compiler triplet on
|
||||
# every `conda activate` (the `aarch64` activation script sorts later
|
||||
# and wins), which silently breaks x264's compiler probe on the
|
||||
# opposite host. If you genuinely need a non-host toolchain, set
|
||||
# FFMPEG_NATIVE_CC and/or FFMPEG_NATIVE_CXX explicitly.
|
||||
# Toolchain selection (precedence, highest first):
|
||||
# 1. FFMPEG_NATIVE_CC / FFMPEG_NATIVE_CXX, if set — explicit override.
|
||||
# 2. The conda-forge triplet matching `uname -m`
|
||||
# (`x86_64-conda-linux-gnu-cc` or `aarch64-conda-linux-gnu-cc`),
|
||||
# if present on PATH. Pinning the triplet sidesteps a bug where
|
||||
# conda envs with BOTH `gcc_linux-64` and `gcc_linux-aarch64`
|
||||
# installed export the cross-compiler triplet on every
|
||||
# `conda activate` (the `aarch64` activation script sorts later
|
||||
# and wins), silently breaking x264's compiler probe on the
|
||||
# opposite host.
|
||||
# 3. System `gcc` / `g++` — fallback so the script also works in a
|
||||
# plain venv or bare shell with no conda toolchain installed.
|
||||
# Inherited bare CC / CXX from the caller environment are ignored in
|
||||
# cases (2) and (3); use FFMPEG_NATIVE_CC/CXX to inject a non-host
|
||||
# toolchain.
|
||||
set -euo pipefail
|
||||
|
||||
# ─── Defaults (override via env) ──────────────────────────────────────────
|
||||
@@ -61,8 +68,8 @@ MAKE_JOBS="${MAKE_JOBS:-$(( NPROC < 16 ? NPROC : 16 ))}"
|
||||
ARCH="$(uname -m)"
|
||||
case "$ARCH" in
|
||||
x86_64)
|
||||
DEFAULT_CC=x86_64-conda-linux-gnu-cc
|
||||
DEFAULT_CXX=x86_64-conda-linux-gnu-c++
|
||||
CONDA_CC=x86_64-conda-linux-gnu-cc
|
||||
CONDA_CXX=x86_64-conda-linux-gnu-c++
|
||||
AS=nasm
|
||||
X264_CFLAGS="-O3 -march=native -mtune=native -fPIC -flto"
|
||||
X264_LDFLAGS="-flto -fuse-linker-plugin"
|
||||
@@ -70,8 +77,8 @@ case "$ARCH" in
|
||||
FFMPEG_LDFLAGS="-flto -Wl,-rpath,$INSTALL_PREFIX/lib"
|
||||
;;
|
||||
aarch64)
|
||||
DEFAULT_CC=aarch64-conda-linux-gnu-cc
|
||||
DEFAULT_CXX=aarch64-conda-linux-gnu-c++
|
||||
CONDA_CC=aarch64-conda-linux-gnu-cc
|
||||
CONDA_CXX=aarch64-conda-linux-gnu-c++
|
||||
unset AS # GNU as on ARM
|
||||
X264_CFLAGS="-O3 -mcpu=native -fPIC -flto"
|
||||
X264_LDFLAGS="-flto -fuse-linker-plugin"
|
||||
@@ -84,8 +91,27 @@ case "$ARCH" in
|
||||
;;
|
||||
esac
|
||||
|
||||
# Prefer the conda-forge triplet when it's actually on PATH; otherwise fall
|
||||
# back to system gcc/g++ so a plain venv works too. FFMPEG_NATIVE_CC/CXX
|
||||
# overrides both.
|
||||
if command -v -- "$CONDA_CC" >/dev/null 2>&1 \
|
||||
&& command -v -- "$CONDA_CXX" >/dev/null 2>&1; then
|
||||
DEFAULT_CC="$CONDA_CC"
|
||||
DEFAULT_CXX="$CONDA_CXX"
|
||||
default_source="conda-forge ($ARCH triplet)"
|
||||
else
|
||||
DEFAULT_CC=gcc
|
||||
DEFAULT_CXX=g++
|
||||
default_source="system gcc/g++"
|
||||
fi
|
||||
|
||||
CC="${FFMPEG_NATIVE_CC:-$DEFAULT_CC}"
|
||||
CXX="${FFMPEG_NATIVE_CXX:-$DEFAULT_CXX}"
|
||||
if [[ -n "${FFMPEG_NATIVE_CC:-}" || -n "${FFMPEG_NATIVE_CXX:-}" ]]; then
|
||||
toolchain_source="FFMPEG_NATIVE_CC/CXX override"
|
||||
else
|
||||
toolchain_source="$default_source"
|
||||
fi
|
||||
|
||||
require_compiler() {
|
||||
local name="$1" compiler="$2"
|
||||
@@ -95,6 +121,8 @@ require_compiler() {
|
||||
fi
|
||||
if ! command -v -- "$compiler" >/dev/null 2>&1; then
|
||||
echo "[install_native_ffmpeg] $name is unavailable: $compiler" >&2
|
||||
echo "[install_native_ffmpeg] install a compiler, or set FFMPEG_NATIVE_CC/FFMPEG_NATIVE_CXX" \
|
||||
"to compiler commands on PATH." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! "$compiler" --version >/dev/null 2>&1; then
|
||||
@@ -107,7 +135,7 @@ require_compiler CC "$CC"
|
||||
require_compiler CXX "$CXX"
|
||||
export CC CXX
|
||||
[[ -n "${AS:-}" ]] && export AS
|
||||
echo "[install_native_ffmpeg] toolchain: CC=$CC CXX=$CXX AS=${AS:-<gnu-as>} (uname -m=$ARCH)"
|
||||
echo "[install_native_ffmpeg] toolchain: CC=$CC CXX=$CXX AS=${AS:-<gnu-as>} (uname -m=$ARCH, source: $toolchain_source)"
|
||||
|
||||
# ─── Step 0: probe required tools ─────────────────────────────────────────
|
||||
required=("$CC" "$CXX" make pkg-config git)
|
||||
@@ -118,7 +146,12 @@ for cmd in "${required[@]}"; do
|
||||
done
|
||||
if (( ${#missing[@]} > 0 )); then
|
||||
echo "[install_native_ffmpeg] missing required tools: ${missing[*]}" >&2
|
||||
echo "[install_native_ffmpeg] install them, or set FFMPEG_NATIVE_CC/FFMPEG_NATIVE_CXX explicitly." >&2
|
||||
echo "[install_native_ffmpeg] install the missing tools. FFMPEG_NATIVE_CC/FFMPEG_NATIVE_CXX" \
|
||||
"only override compiler selection; they do not provide make, pkg-config, git, or nasm." >&2
|
||||
if [[ " ${missing[*]} " == *" nasm "* ]]; then
|
||||
echo "[install_native_ffmpeg] nasm is required on x86_64 for x264 SIMD; install nasm" \
|
||||
"(for example via apt or conda-forge) and retry." >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ NO_BROWSER=1 apps/dreamverse/scripts/launch/launch_demo.sh
|
||||
`launch_backend_dreamverse.sh` starts the full Dreamverse backend path used by
|
||||
the web app.
|
||||
|
||||
`launch_frontend.sh` starts the Next.js frontend and installs `pnpm`
|
||||
`launch_frontend.sh` starts the Next.js frontend and installs npm
|
||||
dependencies when `node_modules/` is missing.
|
||||
|
||||
`launch_backend_fastvideo.sh` starts the typed `fastvideo serve --config` path
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
# FRONTEND_MODE=dev bash launch_frontend.sh # plain dev (5299, no devtools)
|
||||
# FRONTEND_MODE=single5s bash launch_frontend.sh # single-5s product mode
|
||||
#
|
||||
# The script ``cd``'s into ``web`` and shells out to pnpm. It runs
|
||||
# ``pnpm install --frozen-lockfile`` only when ``node_modules/`` is missing so repeat
|
||||
# The script ``cd``'s into ``web`` and shells out to npm. It runs
|
||||
# ``npm ci`` only when ``node_modules/`` is missing so repeat
|
||||
# launches are fast.
|
||||
|
||||
set -euo pipefail
|
||||
@@ -34,21 +34,21 @@ fi
|
||||
cd "${WEB_ROOT}"
|
||||
|
||||
if [[ ! -d node_modules ]]; then
|
||||
echo "[launch-demo] node_modules missing — running pnpm install --frozen-lockfile"
|
||||
pnpm install --frozen-lockfile
|
||||
echo "[launch-demo] node_modules missing — running npm ci"
|
||||
npm ci
|
||||
fi
|
||||
|
||||
case "${FRONTEND_MODE}" in
|
||||
devtools)
|
||||
echo "[launch-demo] starting Next.js dev:devtools (port 5274)"
|
||||
exec pnpm run dev:devtools -- "$@"
|
||||
exec npm run dev:devtools -- "$@"
|
||||
;;
|
||||
dev)
|
||||
echo "[launch-demo] starting Next.js dev (port 5299)"
|
||||
exec pnpm run dev -- "$@"
|
||||
exec npm run dev -- "$@"
|
||||
;;
|
||||
single5s)
|
||||
echo "[launch-demo] starting Next.js dev:single5s (port 5274)"
|
||||
exec pnpm run dev:single5s -- "$@"
|
||||
exec npm run dev:single5s -- "$@"
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# Dreamverse Modal deployment
|
||||
|
||||
This directory contains the Modal wrapper for running the registry-built
|
||||
Dreamverse Docker image on a Modal B200 container. The wrapper pulls a prebuilt Docker image and exposes the dreamverse application running inside.
|
||||
|
||||
|
||||
## 1. Install Modal CLI
|
||||
|
||||
Install and configure the Modal CLI for the target workspace/profile:
|
||||
|
||||
```bash
|
||||
pip install modal
|
||||
modal token set --token-id <token-id> --token-secret <token-secret> --profile=<profile-name>
|
||||
modal profile activate <profile-name>
|
||||
```
|
||||
|
||||
## 2. Create the API key secret
|
||||
|
||||
Dreamverse needs several API keys for prompt-rewriter LLMs and model access in the Modal secret named
|
||||
`dreamverse-api-keys`. Create or replace it with placeholders like this:
|
||||
|
||||
```bash
|
||||
modal secret create dreamverse-api-keys \
|
||||
CEREBRAS_API_KEY=<your-cerebras-key> \
|
||||
GROQ_API_KEY=<your-groq-key> \
|
||||
HF_TOKEN=<your-huggingface-token> \
|
||||
--force
|
||||
```
|
||||
|
||||
## 3. Deploy with Docker image
|
||||
|
||||
`DREAMVERSE_IMAGE` is required at deploy time:
|
||||
|
||||
```bash
|
||||
DREAMVERSE_IMAGE=ghcr.io/<org>/<repo>/dreamverse:<tag> \
|
||||
modal deploy apps/dreamverse/scripts/modal/modal_app.py
|
||||
```
|
||||
|
||||
Use a SHA-specific tag, not `latest`. Use a `dreamverse-backend-cuda12.9.1-sha-*`
|
||||
tag for backend-only deploys, or a `dreamverse-ui-cuda12.9.1-sha-*` tag for an
|
||||
image that includes the static UI served by the backend.
|
||||
|
||||
For local image build details, see
|
||||
`apps/dreamverse/docker/README.md` and `apps/dreamverse/docker/docker_build.sh`.
|
||||
|
||||
|
||||
## 4. Validate the deployment
|
||||
|
||||
Set the URL returned by `modal deploy`, then probe the backend:
|
||||
|
||||
```bash
|
||||
URL=https://<workspace>--dreamverse-b200-serve.modal.run
|
||||
curl -fsS --max-time 300 "$URL/healthz"
|
||||
curl -fsS --max-time 300 "$URL/status"
|
||||
curl -fsS --max-time 300 "$URL/readyz"
|
||||
```
|
||||
|
||||
Check logs with function and container IDs so you can confirm only one container
|
||||
is active:
|
||||
|
||||
```bash
|
||||
modal app logs dreamverse-b200 \
|
||||
--tail 200 \
|
||||
--timestamps \
|
||||
--show-function-id \
|
||||
--show-container-id
|
||||
```
|
||||
|
||||
Useful Modal commands:
|
||||
|
||||
```bash
|
||||
modal app list
|
||||
modal app list --json
|
||||
modal billing report --for today --resolution h
|
||||
modal app stop dreamverse-b200
|
||||
```
|
||||
|
||||
## Runtime details
|
||||
|
||||
### Volumes and assumed locations
|
||||
|
||||
`modal_app.py` creates the named volumes if they do not exist and mounts them at
|
||||
the locations expected by the image:
|
||||
|
||||
| Modal volume | Container path | Environment variable | Purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| `dreamverse-hf-cache` | `/root/.cache/huggingface` | `HF_HOME` | Hugging Face model/cache data |
|
||||
| `dreamverse-state` | `/var/lib/dreamverse` | `FASTVIDEO_DREAMVERSE_HOME` | Dreamverse outputs, session logs, and runtime state |
|
||||
|
||||
### Useful dev env vars
|
||||
|
||||
Most deploys only need the Modal secret and required `DREAMVERSE_IMAGE`.
|
||||
`DREAMVERSE_IMAGE` is read from your local shell at deploy time; the runtime
|
||||
knobs below live in the image or `modal_app.py` unless you intentionally change them:
|
||||
|
||||
- `DREAMVERSE_IMAGE`: deploy a prebuilt SHA-specific image tag without editing `modal_app.py`.
|
||||
- `FASTVIDEO_ENABLE_DEVTOOLS`: enable Dreamverse devtools behavior.
|
||||
- `FASTVIDEO_PROMPT_PROVIDER` / `FASTVIDEO_PROMPT_*_MODEL`: try prompt-rewriter provider or model choices.
|
||||
- `FASTVIDEO_ENABLE_STARTUP_WARMUP`: trade slower startup for a warmer first request.
|
||||
- `DREAMVERSE_MAX_AUTOTUNE`: enable or disable PyTorch Inductor max-autotune for the compiled Dreamverse runtime.
|
||||
|
||||
The Modal wrapper defaults to torch compile with Inductor max-autotune enabled
|
||||
for the fastest generation after startup warmup. If you need shorter
|
||||
compile/warmup time, disable max-autotune via:
|
||||
|
||||
```bash
|
||||
DREAMVERSE_IMAGE=ghcr.io/<org>/<repo>/dreamverse:<tag> \
|
||||
DREAMVERSE_MAX_AUTOTUNE=0 \
|
||||
modal deploy apps/dreamverse/scripts/modal/modal_app.py
|
||||
```
|
||||
|
||||
### Autoscaling and cost safety
|
||||
|
||||
The current deployed script keeps exactly one B200 container warm by setting
|
||||
`min_containers=1` and `max_containers=1`. `min_containers=1` prevents Modal
|
||||
from scaling the deployment down to zero after idle periods, which avoids paying
|
||||
the expensive torch-compile/startup-warmup cost again on the next request.
|
||||
`max_containers=1` caps concurrency so concurrent requests queue onto the warm
|
||||
container instead of spawning additional B200 containers and multiplying cost.
|
||||
|
||||
### Common gotchas
|
||||
|
||||
- Modal profile/workspace matters: create the secret and deploy with the same active profile.
|
||||
- `modal secret create ... --force` replaces the existing `dreamverse-api-keys` secret.
|
||||
- Use the exact URL printed by `modal deploy`; the placeholder URL above is only an example shape.
|
||||
- B200 cold starts and first model downloads can make `/readyz` slow. Check logs before redeploying.
|
||||
- `modal app stop dreamverse-b200` stops the app; it is not a read-only inspection command.
|
||||
@@ -0,0 +1,77 @@
|
||||
# pyright: reportAttributeAccessIssue=false
|
||||
"""Modal deployment entrypoint for the Dreamverse B200 backend."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import modal
|
||||
|
||||
IMAGE = os.environ.get("DREAMVERSE_IMAGE")
|
||||
if not IMAGE:
|
||||
raise RuntimeError(
|
||||
"DREAMVERSE_IMAGE is required. Set it to a published SHA-specific Dreamverse image, "
|
||||
"for example a dreamverse-backend-cuda12.9.1-sha-* tag or a "
|
||||
"dreamverse-ui-cuda12.9.1-sha-* tag if serving the static UI."
|
||||
)
|
||||
|
||||
# ``@modal.web_server`` invokes ``serve()`` directly and bypasses the image
|
||||
# ENTRYPOINT (``docker/docker_entrypoint.sh``). That entrypoint normally
|
||||
# ``:?``-validates these secret entries and ``source``-s ``ffmpeg-env.sh``;
|
||||
# neither runs on Modal, so we replicate both here.
|
||||
_REQUIRED_SECRET_KEYS: tuple[str, ...] = ("CEREBRAS_API_KEY", "GROQ_API_KEY")
|
||||
|
||||
image = modal.Image.from_registry(IMAGE)
|
||||
image = image.env({
|
||||
"DREAMVERSE_IMAGE": IMAGE,
|
||||
"HF_HOME": "/root/.cache/huggingface",
|
||||
"FASTVIDEO_DREAMVERSE_HOME": "/var/lib/dreamverse",
|
||||
"FASTVIDEO_ENABLE_STARTUP_WARMUP": "1",
|
||||
"FASTVIDEO_GPU_COUNT": "1",
|
||||
"ENABLE_TORCH_COMPILE": "1",
|
||||
"DREAMVERSE_MAX_AUTOTUNE": os.environ.get("DREAMVERSE_MAX_AUTOTUNE", "1"),
|
||||
"STREAM_MODE": "av_fmp4",
|
||||
# Native ffmpeg is built into ``/opt/ffmpeg-native`` by the Dockerfile but
|
||||
# is not on the image's ``PATH``. Without these, ``av_streaming``'s
|
||||
# ``shutil.which("ffmpeg")`` returns ``None`` and ``av_fmp4`` muxing has
|
||||
# no encoder. ``ffmpeg-env.sh`` would normally set them.
|
||||
"FASTVIDEO_FFMPEG_BIN": "/opt/ffmpeg-native/bin/ffmpeg",
|
||||
"FASTVIDEO_VIDEO_CODEC": "libx264",
|
||||
})
|
||||
|
||||
app = modal.App("dreamverse-b200")
|
||||
|
||||
hf_cache = modal.Volume.from_name("dreamverse-hf-cache", create_if_missing=True)
|
||||
dreamverse_state = modal.Volume.from_name("dreamverse-state", create_if_missing=True)
|
||||
|
||||
|
||||
@app.function(
|
||||
image=image,
|
||||
gpu="B200",
|
||||
cpu=16,
|
||||
memory=65536,
|
||||
timeout=7200,
|
||||
startup_timeout=4800,
|
||||
min_containers=1,
|
||||
max_containers=1,
|
||||
secrets=[modal.Secret.from_name("dreamverse-api-keys")],
|
||||
volumes={
|
||||
"/root/.cache/huggingface": hf_cache,
|
||||
"/var/lib/dreamverse": dreamverse_state,
|
||||
},
|
||||
)
|
||||
@modal.web_server(8009, startup_timeout=4800)
|
||||
def serve():
|
||||
# ``or ""`` collapses ``None`` (unset) into an empty string, ``.strip()``
|
||||
# collapses whitespace-only values (e.g. ``" "``) — both should be
|
||||
# treated as missing.
|
||||
missing = [
|
||||
k for k in _REQUIRED_SECRET_KEYS
|
||||
if not (os.environ.get(k) or "").strip()
|
||||
]
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
"dreamverse-api-keys secret is missing required entries: "
|
||||
f"{', '.join(missing)}. Add them with `modal secret create "
|
||||
"dreamverse-api-keys ... --force` and redeploy "
|
||||
"(see apps/dreamverse/scripts/modal/README.md).")
|
||||
subprocess.Popen(["dreamverse-server", "--host", "0.0.0.0", "--port", "8009"])
|
||||
@@ -87,9 +87,9 @@ if [[ "${started_backend}" == "1" ]]; then
|
||||
echo "Backend log: ${BACKEND_LOG_PATH}"
|
||||
echo "Backend is still running so you can launch the frontend:"
|
||||
echo " cd ${ROOT_DIR}/web"
|
||||
echo " BACKEND_HOST=${HOST} BACKEND_PORT=${PORT} pnpm run dev"
|
||||
echo " BACKEND_HOST=${HOST} BACKEND_PORT=${PORT} npm run dev"
|
||||
else
|
||||
echo "You can now launch the frontend:"
|
||||
echo " cd ${ROOT_DIR}/web"
|
||||
echo " BACKEND_HOST=${HOST} BACKEND_PORT=${PORT} pnpm run dev"
|
||||
echo " BACKEND_HOST=${HOST} BACKEND_PORT=${PORT} npm run dev"
|
||||
fi
|
||||
|
||||
@@ -11,7 +11,7 @@ test.describe('backend health', () => {
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const body = await response.json();
|
||||
expect(body.status).toBe('ok');
|
||||
expect(body.service).toBe('ltx2-streaming-backend');
|
||||
expect(['ltx2-streaming-backend', 'ltx2-streaming-mock-server']).toContain(body.service);
|
||||
});
|
||||
|
||||
test('readyz reports gpu pool state', async ({ request }) => {
|
||||
|
||||
@@ -18,7 +18,7 @@ import { test, expect, type WebSocket as PWWebSocket } from '@playwright/test';
|
||||
* PLAYWRIGHT_BASE_URL=http://127.0.0.1:5274 \
|
||||
* NEXT_PUBLIC_INCLUDE_DEVTOOLS=1 \
|
||||
* PLAYWRIGHT_LONG_RUNNING=1 \
|
||||
* pnpm exec playwright test e2e/long-running-segments.spec.ts
|
||||
* npm exec -- playwright test e2e/long-running-segments.spec.ts
|
||||
*
|
||||
* The full run takes ~7-9 minutes on a B200: ~3-4min for torch.compile
|
||||
* max-autotune to warm both DiT + text-encoder graphs, then ~30s for
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { stat } from 'node:fs/promises';
|
||||
|
||||
import { test, expect, type WebSocket as PWWebSocket } from '@playwright/test';
|
||||
|
||||
test.describe('mock-backed generation smoke', () => {
|
||||
test('accepts a custom prompt and enters the streaming state', async ({ page, request }) => {
|
||||
const health = await request.get('/healthz');
|
||||
const body = health.ok() ? await health.json() : {};
|
||||
test.skip(
|
||||
body.service !== 'ltx2-streaming-mock-server',
|
||||
'Mock-backed smoke requires dreamverse.mock_server on BACKEND_PORT (default 8009).',
|
||||
);
|
||||
|
||||
const wsEventTypes: string[] = [];
|
||||
|
||||
page.on('websocket', (ws: PWWebSocket) => {
|
||||
ws.on('framereceived', ({ payload }) => {
|
||||
if (typeof payload !== 'string') return;
|
||||
try {
|
||||
const parsed = JSON.parse(payload);
|
||||
if (typeof parsed?.type === 'string') wsEventTypes.push(parsed.type);
|
||||
} catch {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByRole('img', { name: 'FastVideo' })).toBeVisible();
|
||||
|
||||
const continuation = page.getByLabel('Continuation prompt');
|
||||
await expect(continuation).toBeVisible();
|
||||
await continuation.fill('A glass whale swims above a neon forest');
|
||||
|
||||
await page.getByRole('button', { name: /^generate$/i }).click();
|
||||
|
||||
await expect(continuation).toBeDisabled({ timeout: 30_000 });
|
||||
await expect(continuation).toHaveAttribute('placeholder', /generating video/i);
|
||||
await expect(page.getByRole('button', { name: /^leave$/i })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.locator('video').first()).toHaveCount(1);
|
||||
|
||||
await expect.poll(() => wsEventTypes).toEqual(
|
||||
expect.arrayContaining(['ltx2_stream_start', 'ltx2_segment_start', 'media_init']),
|
||||
);
|
||||
});
|
||||
|
||||
test('streams, plays, and surfaces a downloadable clip', async ({ page, request }) => {
|
||||
const health = await request.get('/healthz');
|
||||
const body = health.ok() ? await health.json() : {};
|
||||
test.skip(
|
||||
body.service !== 'ltx2-streaming-mock-server',
|
||||
'Mock-backed playback assertions require dreamverse.mock_server on BACKEND_PORT (default 8009).',
|
||||
);
|
||||
|
||||
await page.addInitScript(() => {
|
||||
(window as unknown as { __sharedFiles: unknown }).__sharedFiles = null;
|
||||
const stub = async (data: { files?: File[] }) => {
|
||||
const files = Array.isArray(data?.files) ? data.files : [];
|
||||
(window as unknown as { __sharedFiles: unknown }).__sharedFiles = files.map((f) => ({
|
||||
name: f.name,
|
||||
type: f.type,
|
||||
size: f.size,
|
||||
}));
|
||||
};
|
||||
Object.defineProperty(Navigator.prototype, 'share', {
|
||||
value: stub,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(Navigator.prototype, 'canShare', {
|
||||
value: (data: { files?: File[] }) => Array.isArray(data?.files),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const continuation = page.getByLabel('Continuation prompt');
|
||||
await expect(continuation).toBeVisible();
|
||||
await continuation.fill('A glass whale swims above a neon forest');
|
||||
await page.getByRole('button', { name: /^generate$/i }).click();
|
||||
|
||||
const liveVideo = page.locator('video:not(.hidden)');
|
||||
await expect(liveVideo).toHaveCount(1);
|
||||
|
||||
await test.step('MSE pipeline attaches the live <video>', async () => {
|
||||
// Chromium/Firefox attach via blob: URL; Safari uses srcObject with ManagedMediaSource.
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
liveVideo.evaluate(
|
||||
(v: HTMLVideoElement) =>
|
||||
v.src.startsWith('blob:') || v.srcObject !== null,
|
||||
),
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
await test.step('SourceBuffer accepts fMP4 and decoder produces frames', async () => {
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
liveVideo.evaluate((v: HTMLVideoElement) =>
|
||||
v.buffered.length > 0 ? v.buffered.end(0) : 0,
|
||||
),
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// readyState >= 3 = HAVE_FUTURE_DATA.
|
||||
await expect
|
||||
.poll(
|
||||
async () => liveVideo.evaluate((v: HTMLVideoElement) => v.readyState),
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
await test.step('<video> playback advances past the first second', async () => {
|
||||
await liveVideo.evaluate(async (v: HTMLVideoElement) => {
|
||||
if (v.paused) {
|
||||
try {
|
||||
await v.play();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => liveVideo.evaluate((v: HTMLVideoElement) => v.currentTime),
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
liveVideo.evaluate((v: HTMLVideoElement) =>
|
||||
v.ended ? v.duration : v.currentTime,
|
||||
),
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBeGreaterThanOrEqual(1.0);
|
||||
});
|
||||
|
||||
await test.step('the AAC audio track is demuxed and decoded', async () => {
|
||||
// The mock emits AAC (mp4a.40.2), matching the real backend
|
||||
// (av_streaming.py). Proves the FE decoded the audio track.
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
liveVideo.evaluate((el: HTMLVideoElement) => {
|
||||
const v = el as HTMLVideoElement & {
|
||||
audioTracks?: { length: number };
|
||||
mozHasAudio?: boolean;
|
||||
webkitAudioDecodedByteCount?: number;
|
||||
};
|
||||
return (
|
||||
(v.audioTracks?.length ?? 0) > 0 ||
|
||||
v.mozHasAudio === true ||
|
||||
(v.webkitAudioDecodedByteCount ?? 0) > 0
|
||||
);
|
||||
}),
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
await test.step('completed clip surfaces a working download', async () => {
|
||||
const downloadButton = page.getByRole('button', { name: /download video|share video/i });
|
||||
await expect(downloadButton).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const buttonLabel = (await downloadButton.getAttribute('aria-label')) ?? '';
|
||||
const isShareFlow = /share video/i.test(buttonLabel);
|
||||
|
||||
if (isShareFlow) {
|
||||
await downloadButton.click();
|
||||
await expect
|
||||
.poll(
|
||||
async () => page.evaluate(() => (window as { __sharedFiles?: unknown }).__sharedFiles),
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.not.toBeNull();
|
||||
const shared = (await page.evaluate(
|
||||
() => (window as { __sharedFiles?: Array<{ name: string; type: string; size: number }> }).__sharedFiles,
|
||||
)) ?? [];
|
||||
expect(shared).toHaveLength(1);
|
||||
expect(shared[0].name).toMatch(/\.(mp4|webm)$/);
|
||||
expect(shared[0].size).toBeGreaterThan(0);
|
||||
} else {
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await downloadButton.click();
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toMatch(/\.(mp4|webm)$/);
|
||||
|
||||
const savedPath = await download.path();
|
||||
expect(savedPath).not.toBeNull();
|
||||
const { size } = await stat(savedPath!);
|
||||
expect(size).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('project history sidebar lists the current session', async () => {
|
||||
const sidebar = page.getByRole('complementary', { name: 'Project history' });
|
||||
await page.getByRole('button', { name: 'Toggle sidebar' }).click();
|
||||
|
||||
await expect(sidebar).toBeInViewport();
|
||||
|
||||
await expect(sidebar.getByText('Current', { exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByText('Active', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
const lastError = await liveVideo.evaluate((v: HTMLVideoElement) =>
|
||||
v.error ? `${v.error.code}: ${v.error.message ?? ''}` : null,
|
||||
);
|
||||
expect(lastError).toBeNull();
|
||||
});
|
||||
|
||||
test('starts a new project and switches back to the prior session', async ({ page, request }) => {
|
||||
const health = await request.get('/healthz');
|
||||
const body = health.ok() ? await health.json() : {};
|
||||
test.skip(
|
||||
body.service !== 'ltx2-streaming-mock-server',
|
||||
'Mock-backed project lifecycle assertions require dreamverse.mock_server on BACKEND_PORT (default 8009).',
|
||||
);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await test.step('complete a generation so there is a project to save', async () => {
|
||||
const continuation = page.getByLabel('Continuation prompt');
|
||||
await expect(continuation).toBeVisible();
|
||||
await continuation.fill('Aurora over a frozen lake');
|
||||
await page.getByRole('button', { name: /^generate$/i }).click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: /download video|share video/i }),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
});
|
||||
|
||||
const sidebar = page.getByRole('complementary', { name: 'Project history' });
|
||||
|
||||
await test.step('"New project" closes the sidebar and resets the composer', async () => {
|
||||
await page.getByRole('button', { name: 'Toggle sidebar' }).click();
|
||||
await expect(sidebar).toBeInViewport();
|
||||
await sidebar.getByRole('button', { name: /^new project$/i }).click();
|
||||
await expect(sidebar).not.toBeInViewport();
|
||||
await expect(page.getByRole('button', { name: /^generate$/i })).toBeVisible({ timeout: 30_000 });
|
||||
const continuation = page.getByLabel('Continuation prompt');
|
||||
await expect(continuation).toBeEnabled();
|
||||
await expect(continuation).toHaveValue('');
|
||||
});
|
||||
|
||||
await test.step('the prior session appears under timeline', async () => {
|
||||
await page.getByRole('button', { name: 'Toggle sidebar' }).click();
|
||||
await expect(sidebar).toBeInViewport();
|
||||
await expect(sidebar.getByText('Previous', { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(sidebar.getByText(/^(just now|\d+m ago)$/).first()).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('clicking the prior session enters viewing mode', async () => {
|
||||
const priorRow = sidebar.locator('div[role="button"]').filter({ hasText: /just now|\d+m ago/ }).first();
|
||||
await priorRow.click();
|
||||
await expect(sidebar).not.toBeInViewport();
|
||||
await expect(page.locator('video[autoplay][loop]')).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test('saved projects persist across a page reload', async ({ page, request }) => {
|
||||
const health = await request.get('/healthz');
|
||||
const body = health.ok() ? await health.json() : {};
|
||||
test.skip(
|
||||
body.service !== 'ltx2-streaming-mock-server',
|
||||
'Mock-backed persistence assertions require dreamverse.mock_server on BACKEND_PORT (default 8009).',
|
||||
);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await test.step('complete a generation', async () => {
|
||||
const continuation = page.getByLabel('Continuation prompt');
|
||||
await expect(continuation).toBeVisible();
|
||||
await continuation.fill('Aurora over a frozen lake');
|
||||
await page.getByRole('button', { name: /^generate$/i }).click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: /download video|share video/i }),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
});
|
||||
|
||||
const sidebar = page.getByRole('complementary', { name: 'Project history' });
|
||||
|
||||
await test.step('persist via "New project" and confirm it lands under Previous', async () => {
|
||||
await page.getByRole('button', { name: 'Toggle sidebar' }).click();
|
||||
await expect(sidebar).toBeInViewport();
|
||||
await sidebar.getByRole('button', { name: /^new project$/i }).click();
|
||||
await expect(page.getByRole('button', { name: /^generate$/i })).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: 'Toggle sidebar' }).click();
|
||||
await expect(sidebar).toBeInViewport();
|
||||
await expect(sidebar.getByText('Previous', { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(sidebar.getByText(/^(just now|\d+m ago)$/).first()).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('after page reload, the prior project is still in Previous', async () => {
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: 'Toggle sidebar' }).click();
|
||||
await expect(sidebar).toBeInViewport();
|
||||
await expect(sidebar.getByText('Previous', { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(sidebar.getByText(/^(just now|\d+m ago)$/).first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,10 +6,13 @@ const backendHost = process.env.BACKEND_HOST || '127.0.0.1';
|
||||
const backendPort = Number(process.env.BACKEND_PORT) || 8009;
|
||||
const backendUrl = `http://${backendHost}:${backendPort}`;
|
||||
const configDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const staticExport = process.env.NEXT_OUTPUT_EXPORT === '1';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
...(staticExport ? { output: 'export' as const } : {}),
|
||||
...(staticExport ? { images: { unoptimized: true } } : {}),
|
||||
outputFileTracingRoot: path.join(configDir, '..', '..', '..'),
|
||||
async rewrites() {
|
||||
...(staticExport ? {} : { async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/ws',
|
||||
@@ -47,8 +50,16 @@ const nextConfig: NextConfig = {
|
||||
source: '/curated-presets/:path*',
|
||||
destination: `${backendUrl}/curated-presets/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/lora',
|
||||
destination: `${backendUrl}/lora`,
|
||||
},
|
||||
{
|
||||
source: '/lora/:path*',
|
||||
destination: `${backendUrl}/lora/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
} }),
|
||||
webpack: (config) => {
|
||||
config.module.rules.push({
|
||||
test: /\.jsonl$/,
|
||||
|
||||
Generated
+11669
-11443
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,7 @@
|
||||
"geist": "^1.7.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"mp4box": "^2.3.0",
|
||||
"next": "^15.3.3",
|
||||
"next": "15.5.18",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
@@ -59,9 +59,16 @@
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"jsdom": "^26.1.0",
|
||||
"mock-socket": "^9.3.1",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss": "8.5.10",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "0.25.12",
|
||||
"picomatch": "4.0.4",
|
||||
"postcss": "8.5.10",
|
||||
"rollup": "4.59.0",
|
||||
"vite": "7.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const backendHost = process.env.BACKEND_HOST || '127.0.0.1';
|
||||
const backendPort = process.env.BACKEND_PORT || '8009';
|
||||
|
||||
/**
|
||||
* Playwright config for Dreamverse end-to-end tests.
|
||||
*
|
||||
* Tests assume the Dreamverse Python server is reachable at
|
||||
* BACKEND_HOST:BACKEND_PORT (default 127.0.0.1:8009) and the Next.js frontend
|
||||
* runs on port 5299. The webServer block boots `pnpm run dev` if no
|
||||
* server is already listening so tests work both locally and in CI.
|
||||
* runs on port 5299. The webServer block boots the mock backend and
|
||||
* `npm run dev` if no server is already listening so tests work both
|
||||
* locally and in CI.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
@@ -26,23 +30,34 @@ export default defineConfig({
|
||||
viewport: { width: 1280, height: 720 },
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
video: process.env.CI ? 'retain-on-failure' : 'on',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
|
||||
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
||||
{ name: 'msedge', use: { ...devices['Desktop Edge'], channel: 'msedge' } },
|
||||
{ name: 'mobile-safari', use: { ...devices['iPhone 14'] } },
|
||||
{ name: 'mobile-chromium', use: { ...devices['Pixel 7'] } },
|
||||
],
|
||||
webServer: process.env.PLAYWRIGHT_SKIP_WEBSERVER
|
||||
? undefined
|
||||
: {
|
||||
command: 'pnpm run dev',
|
||||
url: 'http://127.0.0.1:5299',
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
BACKEND_HOST: process.env.BACKEND_HOST || '127.0.0.1',
|
||||
BACKEND_PORT: process.env.BACKEND_PORT || '8009',
|
||||
: [
|
||||
{
|
||||
command: `PYTHONPATH=.. python3 -m uvicorn dreamverse.mock_server:app --host ${backendHost} --port ${backendPort}`,
|
||||
url: `http://${backendHost}:${backendPort}/healthz`,
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000,
|
||||
},
|
||||
},
|
||||
{
|
||||
command: 'npm run dev',
|
||||
url: 'http://127.0.0.1:5299',
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
BACKEND_HOST: backendHost,
|
||||
BACKEND_PORT: backendPort,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
Generated
-5199
File diff suppressed because it is too large
Load Diff
@@ -2489,6 +2489,8 @@ export default function Page() {
|
||||
if (devtoolsMode) {
|
||||
return (
|
||||
<DevtoolsShell
|
||||
videoRef={videoRefCallback}
|
||||
archivedPlaybackRef={archivedPlaybackRefCallback}
|
||||
connected={connected as boolean}
|
||||
gpuAssigned={gpuAssigned as boolean}
|
||||
sessionStarted={sessionStarted as boolean}
|
||||
|
||||
@@ -161,6 +161,7 @@ export default function VideoPlayer({
|
||||
}}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
aria-label={canShare ? "Share video" : "Download video"}
|
||||
className="absolute top-3 left-3 z-10 cursor-pointer bg-slate-800/50 text-white/90 shadow-md backdrop-blur-sm transition-all border-white/30 hover:bg-slate-800/85 hover:border-white/50 hover:text-white hover:scale-105"
|
||||
>
|
||||
{canShare ? <Share className="size-5" /> : <Download className="size-5" />}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import LoraControls from '@/components/devtools/LoraControls';
|
||||
|
||||
interface DevtoolsDrawerProps {
|
||||
editableMode?: boolean;
|
||||
@@ -126,6 +127,8 @@ export default function DevtoolsDrawer({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<LoraControls />
|
||||
|
||||
{/* Prompt window memory */}
|
||||
<details className="group overflow-hidden rounded-2xl border border-border bg-card shadow-sm" open>
|
||||
<summary className="flex cursor-pointer list-none items-start justify-between gap-4 px-5 py-4">
|
||||
|
||||
@@ -113,6 +113,8 @@ interface DevtoolsShellProps {
|
||||
formatTime?: (seconds: number) => string;
|
||||
formatDurationMs?: (durationMs: number) => string;
|
||||
onPlaying?: () => void;
|
||||
videoRef?: React.RefCallback<HTMLVideoElement>;
|
||||
archivedPlaybackRef?: React.RefCallback<HTMLVideoElement>;
|
||||
}
|
||||
|
||||
export default function DevtoolsShell({
|
||||
@@ -209,6 +211,8 @@ export default function DevtoolsShell({
|
||||
formatTime = (seconds) => `${seconds}`,
|
||||
formatDurationMs = (durationMs) => `${durationMs}`,
|
||||
onPlaying = () => {},
|
||||
videoRef,
|
||||
archivedPlaybackRef,
|
||||
}: DevtoolsShellProps) {
|
||||
return (
|
||||
<WorkspaceShell
|
||||
@@ -235,6 +239,8 @@ export default function DevtoolsShell({
|
||||
workspace={
|
||||
<div className="flex flex-col gap-4">
|
||||
<VideoPlayer
|
||||
videoRef={videoRef}
|
||||
archivedPlaybackRef={archivedPlaybackRef}
|
||||
activeClip={activeClip}
|
||||
sessionStarted={sessionStarted}
|
||||
avPlaybackStarted={avPlaybackStarted}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
const STYLE_LABELS: Record<string, string> = {
|
||||
none: 'None',
|
||||
pixar: 'Pixar Toon',
|
||||
transition: 'Transition',
|
||||
};
|
||||
|
||||
export default function LoraControls() {
|
||||
const [styleKeys, setStyleKeys] = useState<string[]>([]);
|
||||
const [labels, setLabels] = useState<Record<string, string>>(STYLE_LABELS);
|
||||
const [strength, setStrength] = useState(0.8);
|
||||
const [enabled, setEnabled] = useState<Record<string, boolean>>({});
|
||||
const [intensity, setIntensity] = useState<Record<string, number>>({});
|
||||
const [status, setStatus] = useState('idle');
|
||||
|
||||
const strengthRef = useRef(0.8);
|
||||
const enabledRef = useRef<Record<string, boolean>>({});
|
||||
const intensityRef = useRef<Record<string, number>>({});
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/lora/options')
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((data) => {
|
||||
if (data && Array.isArray(data.styles)) {
|
||||
const keys = data.styles.filter((s: string) => s !== 'none');
|
||||
setStyleKeys(keys);
|
||||
const initIntensity: Record<string, number> = {};
|
||||
keys.forEach((k: string) => {
|
||||
initIntensity[k] = 1.0;
|
||||
});
|
||||
setIntensity(initIntensity);
|
||||
intensityRef.current = initIntensity;
|
||||
}
|
||||
if (data && data.labels && typeof data.labels === 'object') {
|
||||
setLabels((prev) => ({ ...prev, ...data.labels }));
|
||||
}
|
||||
})
|
||||
.catch(() => setStatus('options unavailable'));
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const applyNow = () => {
|
||||
const reqId = ++requestIdRef.current;
|
||||
const stylesPayload: Record<string, number> = {};
|
||||
for (const key of Object.keys(enabledRef.current)) {
|
||||
if (enabledRef.current[key]) {
|
||||
stylesPayload[key] = intensityRef.current[key] ?? 1.0;
|
||||
}
|
||||
}
|
||||
setStatus('applying…');
|
||||
fetch('/lora', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ strength: strengthRef.current, styles: stylesPayload }),
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (reqId !== requestIdRef.current) return;
|
||||
const active = Object.keys(data?.styles ?? {});
|
||||
const desc = active.length
|
||||
? active.map((k) => `${labels[k] ?? k}@${data.styles[k]}`).join(' + ')
|
||||
: 'no style';
|
||||
setStatus(`applied OmniNFT@${data.strength} + ${desc}`);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (reqId !== requestIdRef.current) return;
|
||||
setStatus(`error: ${String(error).slice(0, 120)}`);
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleApply = () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
debounceRef.current = setTimeout(applyNow, 250);
|
||||
};
|
||||
|
||||
return (
|
||||
<details
|
||||
className="group overflow-hidden rounded-2xl border border-border bg-card shadow-sm xl:col-span-2"
|
||||
open
|
||||
>
|
||||
<summary className="flex cursor-pointer list-none items-start justify-between gap-4 px-5 py-4">
|
||||
<div className="space-y-1">
|
||||
<span className="block text-lg font-semibold text-foreground">
|
||||
LoRA stack
|
||||
</span>
|
||||
<span className="block text-sm leading-6 text-muted-foreground">
|
||||
OmniNFT strength + stackable style adapters, each with its own intensity (live).
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="secondary">{status}</Badge>
|
||||
</summary>
|
||||
<div className="space-y-5 border-t border-border px-5 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lora-strength">
|
||||
OmniNFT strength · {strength.toFixed(2)}
|
||||
</Label>
|
||||
<input
|
||||
id="lora-strength"
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={strength}
|
||||
onChange={(event) => {
|
||||
const next = Number(event.target.value);
|
||||
setStrength(next);
|
||||
strengthRef.current = next;
|
||||
scheduleApply();
|
||||
}}
|
||||
className="w-full accent-sky-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{styleKeys.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No style adapters for the active model.
|
||||
</p>
|
||||
) : (
|
||||
styleKeys.map((key) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm font-medium text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!enabled[key]}
|
||||
onChange={(event) => {
|
||||
const next = { ...enabledRef.current, [key]: event.target.checked };
|
||||
enabledRef.current = next;
|
||||
setEnabled(next);
|
||||
scheduleApply();
|
||||
}}
|
||||
className="accent-sky-400"
|
||||
/>
|
||||
{labels[key] ?? STYLE_LABELS[key] ?? key}
|
||||
</label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{(intensity[key] ?? 1).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={intensity[key] ?? 1}
|
||||
disabled={!enabled[key]}
|
||||
onChange={(event) => {
|
||||
const next = { ...intensityRef.current, [key]: Number(event.target.value) };
|
||||
intensityRef.current = next;
|
||||
setIntensity(next);
|
||||
scheduleApply();
|
||||
}}
|
||||
className="w-full accent-sky-400 disabled:opacity-40"
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
# FVD (Fréchet Video Distance) Benchmark
|
||||
|
||||
Evaluate generated video quality using FVD with the I3D feature extractor.
|
||||
|
||||
## Quick Start
|
||||
|
||||
**Run the benchmark:**
|
||||
|
||||
```bash
|
||||
bash benchmarks/scripts/run.sh
|
||||
```
|
||||
|
||||
That's it! The script auto-installs dependencies and runs the benchmark.
|
||||
|
||||
**To customize:** Edit `benchmarks/fvd/run_fvd.py` to change:
|
||||
- Video paths (`real_dir`, `gen_dir`)
|
||||
- Number of videos, frames, sampling strategy
|
||||
- Device, batch size, caching, etc.
|
||||
|
||||
## Advanced Usage (CLI)
|
||||
|
||||
For more control without editing Python files, use the CLI.
|
||||
|
||||
**First-time setup** (one-time per pod/environment):
|
||||
|
||||
```bash
|
||||
bash benchmarks/scripts/setup_fvd.sh
|
||||
```
|
||||
|
||||
Then run any configuration you want:
|
||||
|
||||
```bash
|
||||
# Custom configuration
|
||||
python -m benchmarks.fvd.cli \
|
||||
--real-path data/real/ \
|
||||
--gen-path outputs/gen/ \
|
||||
--num-videos 1024 \
|
||||
--num-frames 32 \
|
||||
--clip-strategy random \
|
||||
--batch-size 32 \
|
||||
--seed 42 \
|
||||
--extractor clip
|
||||
```
|
||||
|
||||
**Standard protocols:**
|
||||
|
||||
```bash
|
||||
# Use predefined protocols
|
||||
python -m benchmarks.fvd.cli \
|
||||
--real-path data/real/ \
|
||||
--gen-path outputs/gen/ \
|
||||
--protocol fvd2048_16f # or fvd2048_128f, quick_test, etc.
|
||||
```
|
||||
|
||||
This would use i3d model by default as the feature extractor
|
||||
|
||||
**Feature caching** (speed up repeated evaluations):
|
||||
|
||||
```bash
|
||||
python -m benchmarks.fvd.cli \
|
||||
--real-path data/real/ \
|
||||
--gen-path outputs/gen/ \
|
||||
--protocol fvd2048_16f \
|
||||
--cache-real-features fvd-cache/extractor_name # Directory path (will save/load fvd-cache/extractor_name/extractor-name_real_features.pkl)
|
||||
```
|
||||
|
||||
Run `python -m benchmarks.fvd.cli --help` for all options.
|
||||
|
||||
## Available Protocols
|
||||
|
||||
- `fvd2048_16f` - Standard (2048 videos, 16 frames)
|
||||
- `fvd2048_128f` - Long videos (128 frames)
|
||||
- `fvd2048_128f_subsample8` - Subsampled long videos
|
||||
- `quick_test` - Fast testing (10 videos)
|
||||
|
||||
## Configuration Options
|
||||
|
||||
Key options in `FVDConfig`:
|
||||
|
||||
```python
|
||||
num_videos=2048, # Videos to evaluate
|
||||
num_frames_per_clip=16, # Frames per clip
|
||||
clip_strategy='beginning', # beginning|random|uniform|middle|sliding
|
||||
frame_stride=1, # Frame subsampling
|
||||
batch_size=32, # GPU batch size
|
||||
device='cuda', # cuda|cpu
|
||||
cache_real_features=None, # Cache path for speed
|
||||
seed=42, # Reproducibility
|
||||
extractor='i3d', # i3d|clip|videomae
|
||||
```
|
||||
|
||||
## Programmatic Usage
|
||||
|
||||
```python
|
||||
from benchmarks.fvd import compute_fvd_with_config, FVDConfig
|
||||
|
||||
config = FVDConfig.fvd2048_16f() # or custom config
|
||||
results = compute_fvd_with_config('data/real/', 'outputs/gen/', config)
|
||||
print(f"FVD: {results['fvd']:.2f}")
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Requires minimum 10 frames per clip
|
||||
- Supports both video files (.mp4, .avi, etc.) and frame directories
|
||||
- `--cache-real-features` expects a **directory path** (e.g., `cache/real`), it will automatically create/load `real_features.pkl` inside that directory
|
||||
@@ -1,37 +0,0 @@
|
||||
"""
|
||||
FastVideo Frechet Video Distance (FVD) Benchmark Module.
|
||||
>>> from fastvideo.benchmarks.fvd import compute_fvd_with_config, FVDConfig
|
||||
>>> config = FVDConfig.fvd2048_16f() # Standard protocol
|
||||
>>> results = compute_fvd_with_config('data/real/', 'outputs/gen/', config)
|
||||
>>> print(f"FVD: {results['fvd']:.2f}")
|
||||
"""
|
||||
|
||||
from .fvd import (
|
||||
compute_fvd,
|
||||
compute_fvd_with_config,
|
||||
compute_frechet_distance,
|
||||
compute_statistics,
|
||||
FVDConfig,
|
||||
)
|
||||
from .feature_extractors import (BaseFeatureExtractor, I3DFeatureExtractor, load_extractor)
|
||||
from .video_utils import (
|
||||
load_video_auto,
|
||||
sample_clips_from_video,
|
||||
load_video_clips_streaming,
|
||||
ClipSamplingStrategy,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'compute_fvd',
|
||||
'compute_fvd_with_config',
|
||||
'compute_frechet_distance',
|
||||
'compute_statistics',
|
||||
'FVDConfig',
|
||||
'BaseFeatureExtractor',
|
||||
'I3DFeatureExtractor',
|
||||
'load_extractor',
|
||||
'load_video_auto',
|
||||
'sample_clips_from_video',
|
||||
'load_video_clips_streaming',
|
||||
'ClipSamplingStrategy',
|
||||
]
|
||||
@@ -1,77 +0,0 @@
|
||||
import argparse
|
||||
import sys
|
||||
import traceback
|
||||
from .fvd import compute_fvd_with_config, FVDConfig
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description='Compute Fréchet Video Distance (FVD)')
|
||||
|
||||
# Required arguments
|
||||
parser.add_argument('--real-path', type=str, required=True, help='Path to real videos')
|
||||
parser.add_argument('--gen-path', type=str, required=True, help='Path to generated videos')
|
||||
|
||||
# Extractor selection
|
||||
parser.add_argument('--extractor',
|
||||
type=str,
|
||||
default='i3d',
|
||||
choices=['i3d', 'clip', 'videomae'],
|
||||
help='Feature extractor model to use (default: i3d)')
|
||||
|
||||
# Standard args
|
||||
parser.add_argument('--seed', type=int, default=None, help='Random seed for reproducibility')
|
||||
parser.add_argument('--protocol',
|
||||
type=str,
|
||||
default=None,
|
||||
choices=['fvd2048_16f', 'fvd2048_128f', 'quick_test'],
|
||||
help='Use standard protocol (overrides other settings)')
|
||||
parser.add_argument('--num-videos', type=int, default=2048, help='Number of videos to use')
|
||||
parser.add_argument('--num-frames', type=int, default=16, help='Number of frames per clip')
|
||||
parser.add_argument('--clip-strategy', type=str, default='beginning', help='Clip sampling strategy')
|
||||
parser.add_argument('--batch-size', type=int, default=32, help='Batch size for feature extraction')
|
||||
parser.add_argument('--device', type=str, default='cuda', help='Device to use (cuda or cpu)')
|
||||
parser.add_argument('--cache-real-features', type=str, default=None, help='Path to cache real video features')
|
||||
parser.add_argument('--quiet', action='store_true', help='Suppress progress output')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Create config
|
||||
if args.protocol:
|
||||
protocol_map = {
|
||||
'fvd2048_16f': FVDConfig.fvd2048_16f,
|
||||
'fvd2048_128f': FVDConfig.fvd2048_128f,
|
||||
'quick_test': FVDConfig.quick_test,
|
||||
}
|
||||
config = protocol_map[args.protocol]()
|
||||
# Apply overrides
|
||||
config.device = args.device
|
||||
config.cache_real_features = args.cache_real_features
|
||||
config.extractor_model = args.extractor # Apply extractor arg
|
||||
else:
|
||||
config = FVDConfig(
|
||||
num_videos=args.num_videos,
|
||||
num_frames_per_clip=args.num_frames,
|
||||
extractor_model=args.extractor, # Apply extractor arg
|
||||
clip_strategy=args.clip_strategy,
|
||||
batch_size=args.batch_size,
|
||||
device=args.device,
|
||||
cache_real_features=args.cache_real_features,
|
||||
seed=args.seed)
|
||||
|
||||
try:
|
||||
_ = compute_fvd_with_config(
|
||||
args.real_path, # noqa: F841
|
||||
args.gen_path,
|
||||
config,
|
||||
verbose=not args.quiet)
|
||||
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -1,232 +0,0 @@
|
||||
"""
|
||||
Pluggable Feature Extractors for FVD Computation.
|
||||
Supports I3D (standard), CLIP, and VideoMAE via a common interface.
|
||||
"""
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
from abc import ABC, abstractmethod
|
||||
from huggingface_hub import hf_hub_download
|
||||
from tqdm import tqdm
|
||||
|
||||
try:
|
||||
from transformers import CLIPModel, CLIPProcessor, VideoMAEModel
|
||||
TRANSFORMERS_AVAILABLE = True
|
||||
except ImportError:
|
||||
TRANSFORMERS_AVAILABLE = False
|
||||
|
||||
|
||||
class BaseFeatureExtractor(ABC, nn.Module):
|
||||
"""Abstract base class for all video feature extractors."""
|
||||
|
||||
def __init__(self, device: str = 'cuda'):
|
||||
super().__init__()
|
||||
self.device = torch.device(device if torch.cuda.is_available() else 'cpu')
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def feature_dim(self) -> int:
|
||||
"""Dimension of the output feature vector."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def preprocess(self, videos: torch.Tensor) -> torch.Tensor:
|
||||
"""
|
||||
Args:
|
||||
videos: [B, T, C, H, W] in [0, 255] range.
|
||||
Returns:
|
||||
Preprocessed tensor ready for the model.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def extract_features_batch(self, videos: torch.Tensor) -> torch.Tensor:
|
||||
"""
|
||||
Extract features for a single batch.
|
||||
Args:
|
||||
videos: [B, T, C, H, W] (raw input)
|
||||
Returns:
|
||||
Features: [B, feature_dim]
|
||||
"""
|
||||
pass
|
||||
|
||||
@torch.no_grad()
|
||||
def extract_features(self, videos: torch.Tensor, batch_size: int = 32, verbose: bool = True) -> torch.Tensor:
|
||||
"""
|
||||
Extract features for a large tensor of videos by batching.
|
||||
"""
|
||||
N = len(videos)
|
||||
all_features = []
|
||||
|
||||
iterator = range(0, N, batch_size)
|
||||
if verbose:
|
||||
iterator = tqdm(iterator, desc=f"Extracting features ({self.__class__.__name__})")
|
||||
|
||||
for i in iterator:
|
||||
batch = videos[i:i + batch_size].to(self.device)
|
||||
features = self.extract_features_batch(batch)
|
||||
all_features.append(features.cpu())
|
||||
|
||||
return torch.cat(all_features, dim=0)
|
||||
|
||||
|
||||
# 1. I3D Extractor (The Standard FVD Metric)
|
||||
class I3DFeatureExtractor(BaseFeatureExtractor):
|
||||
REPO_ID = 'flateon/FVD-I3D-torchscript'
|
||||
MODEL_FILENAME = 'i3d_torchscript.pt'
|
||||
|
||||
def __init__(self, device: str = 'cuda', cache_dir: str | None = None):
|
||||
super().__init__(device)
|
||||
self.cache_dir = cache_dir
|
||||
self.model = self._load_model()
|
||||
self.model.eval()
|
||||
self.model.to(self.device)
|
||||
|
||||
@property
|
||||
def feature_dim(self) -> int:
|
||||
return 400
|
||||
|
||||
def _load_model(self) -> torch.nn.Module:
|
||||
try:
|
||||
model_path = hf_hub_download(repo_id=self.REPO_ID, filename=self.MODEL_FILENAME, cache_dir=self.cache_dir)
|
||||
return torch.jit.load(model_path, map_location=self.device)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to load I3D model: {e}") from e
|
||||
|
||||
def preprocess(self, videos: torch.Tensor) -> torch.Tensor:
|
||||
"""Standard I3D preprocessing: Resize to 224, Norm to [-1, 1]."""
|
||||
B, T, C, H, W = videos.shape
|
||||
|
||||
if T < 10:
|
||||
raise ValueError(f"I3D requires at least 10 frames, got {T}")
|
||||
|
||||
# Normalize to [0, 1]
|
||||
if videos.max() > 1.0:
|
||||
videos = videos / 255.0
|
||||
|
||||
# Scale to [-1, 1]
|
||||
videos = videos * 2.0 - 1.0
|
||||
|
||||
# Resize to 224x224
|
||||
if H != 224 or W != 224:
|
||||
videos = videos.reshape(B * T, C, H, W)
|
||||
videos = F.interpolate(videos, size=(224, 224), mode='bilinear', align_corners=False)
|
||||
videos = videos.reshape(B, T, C, 224, 224)
|
||||
|
||||
# [B, T, C, H, W] -> [B, C, T, H, W]
|
||||
return videos.permute(0, 2, 1, 3, 4).contiguous()
|
||||
|
||||
def extract_features_batch(self, videos: torch.Tensor) -> torch.Tensor:
|
||||
batch = self.preprocess(videos)
|
||||
# TorchScript I3D returns raw logits when return_features=True
|
||||
return self.model(batch, rescale=False, resize=False, return_features=True)
|
||||
|
||||
|
||||
# 2. CLIP Extractor (Semantic/Content Quality)
|
||||
class CLIPFeatureExtractor(BaseFeatureExtractor):
|
||||
|
||||
def __init__(self, device: str = 'cuda', model_name: str = "openai/clip-vit-base-patch32"):
|
||||
if not TRANSFORMERS_AVAILABLE:
|
||||
raise ImportError("Please install transformers: uv pip install transformers")
|
||||
super().__init__(device)
|
||||
self.processor = CLIPProcessor.from_pretrained(model_name)
|
||||
self.model = CLIPModel.from_pretrained(model_name).to(self.device)
|
||||
self.model.eval()
|
||||
self._feature_dim = self.model.config.projection_dim
|
||||
|
||||
@property
|
||||
def feature_dim(self) -> int:
|
||||
return self._feature_dim
|
||||
|
||||
def preprocess(self, videos: torch.Tensor) -> torch.Tensor:
|
||||
# Ensure values are [0, 255]
|
||||
if videos.max() <= 1.0:
|
||||
videos = videos * 255.0
|
||||
|
||||
return videos.to(torch.uint8)
|
||||
|
||||
def extract_features_batch(self, videos: torch.Tensor) -> torch.Tensor:
|
||||
# Input: [B, T, C, H, W]
|
||||
B, T, C, H, W = videos.shape
|
||||
videos = self.preprocess(videos)
|
||||
|
||||
# Flatten B*T to treat frames as images
|
||||
images = videos.view(B * T, C, H, W)
|
||||
|
||||
# HF Processor
|
||||
inputs = self.processor(images=images, return_tensors="pt", padding=True)
|
||||
inputs = {k: v.to(self.device) for k, v in inputs.items()}
|
||||
|
||||
# Extract features [B*T, Dim]
|
||||
outputs = self.model.get_image_features(**inputs)
|
||||
|
||||
# Reshape [B, T, Dim] and Average Pooling over time
|
||||
outputs = outputs.view(B, T, -1)
|
||||
return outputs.mean(dim=1)
|
||||
|
||||
|
||||
# 3. VideoMAE Extractor (Structure/Motion Quality)
|
||||
class VideoMAEFeatureExtractor(BaseFeatureExtractor):
|
||||
|
||||
def __init__(self, device: str = 'cuda', model_name: str = "MCG-NJU/videomae-base"):
|
||||
if not TRANSFORMERS_AVAILABLE:
|
||||
raise ImportError("Please install transformers: uv pip install transformers")
|
||||
super().__init__(device)
|
||||
self.model = VideoMAEModel.from_pretrained(model_name).to(self.device)
|
||||
self.model.eval()
|
||||
|
||||
self.register_buffer('mean', torch.tensor([0.485, 0.456, 0.406], device=self.device).view(1, 1, 3, 1, 1))
|
||||
self.register_buffer('std', torch.tensor([0.229, 0.224, 0.225], device=self.device).view(1, 1, 3, 1, 1))
|
||||
|
||||
@property
|
||||
def feature_dim(self) -> int:
|
||||
return self.model.config.hidden_size
|
||||
|
||||
def preprocess(self, videos: torch.Tensor) -> torch.Tensor:
|
||||
"""
|
||||
Efficient GPU-based preprocessing.
|
||||
Input: [B, T, C, H, W] in range [0, 255]
|
||||
"""
|
||||
B, T, C, H, W = videos.shape
|
||||
|
||||
# 1. Resize to 224x224
|
||||
if H != 224 or W != 224:
|
||||
videos = videos.view(B * T, C, H, W)
|
||||
videos = F.interpolate(videos, size=(224, 224), mode='bilinear', align_corners=False)
|
||||
videos = videos.view(B, T, C, 224, 224)
|
||||
|
||||
# 2. Normalize to [0, 1]
|
||||
if videos.dtype != torch.float32:
|
||||
videos = videos.float()
|
||||
|
||||
if videos.max() > 1.0:
|
||||
videos = videos / 255.0
|
||||
|
||||
# 3. Apply ImageNet Mean/Std
|
||||
return (videos - self.mean) / self.std
|
||||
|
||||
def extract_features_batch(self, videos: torch.Tensor) -> torch.Tensor:
|
||||
# Input: [B, T, C, H, W]
|
||||
|
||||
# Fast GPU Preprocessing
|
||||
pixel_values = self.preprocess(videos)
|
||||
|
||||
# Forward pass
|
||||
outputs = self.model(pixel_values)
|
||||
|
||||
# Global Average Pooling of last hidden state [B, T_patches, 768] -> [B, 768]
|
||||
return outputs.last_hidden_state.mean(dim=1)
|
||||
|
||||
|
||||
# Factory
|
||||
def load_extractor(name: str, device: str = 'cuda') -> BaseFeatureExtractor:
|
||||
name = name.lower()
|
||||
if name == 'i3d':
|
||||
return I3DFeatureExtractor(device)
|
||||
elif name == 'clip':
|
||||
return CLIPFeatureExtractor(device)
|
||||
elif name == 'videomae':
|
||||
return VideoMAEFeatureExtractor(device)
|
||||
else:
|
||||
raise ValueError(f"Unknown extractor: {name}. Options: i3d, clip, videomae")
|
||||
@@ -1,384 +0,0 @@
|
||||
import numpy as np
|
||||
import scipy.linalg
|
||||
import torch
|
||||
from pathlib import Path
|
||||
from collections.abc import Iterator
|
||||
import pickle
|
||||
from dataclasses import dataclass, field
|
||||
from .feature_extractors import BaseFeatureExtractor, load_extractor
|
||||
from .video_utils import ClipSamplingStrategy, load_video_clips_streaming
|
||||
|
||||
|
||||
def compute_statistics(features: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Compute mean and covariance."""
|
||||
mu = np.mean(features, axis=0)
|
||||
sigma = np.cov(features, rowvar=False)
|
||||
return mu, sigma
|
||||
|
||||
|
||||
def compute_frechet_distance(mu1: np.ndarray,
|
||||
sigma1: np.ndarray,
|
||||
mu2: np.ndarray,
|
||||
sigma2: np.ndarray,
|
||||
eps: float = 1e-6) -> float:
|
||||
"""
|
||||
Compute Fréchet distance between two Gaussians.
|
||||
"""
|
||||
sigma1 = sigma1 + eps * np.eye(sigma1.shape[0])
|
||||
sigma2 = sigma2 + eps * np.eye(sigma2.shape[0])
|
||||
|
||||
diff = mu1 - mu2
|
||||
mean_distance = np.sum(diff**2)
|
||||
|
||||
trace_sum = np.trace(sigma1 + sigma2)
|
||||
|
||||
covmean = scipy.linalg.sqrtm(sigma1 @ sigma2)
|
||||
|
||||
if np.iscomplexobj(covmean):
|
||||
if not np.allclose(np.diagonal(covmean).imag, 0, atol=1e-3):
|
||||
print(f"Warning: Imaginary component: {np.max(np.abs(covmean.imag))}")
|
||||
covmean = covmean.real
|
||||
|
||||
trace_product = np.trace(covmean)
|
||||
|
||||
fvd = mean_distance + trace_sum - 2 * trace_product
|
||||
|
||||
return float(fvd)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FVDConfig:
|
||||
# default configuration for FVD computation:
|
||||
|
||||
# Video selection
|
||||
num_videos: int = 2048
|
||||
|
||||
# Feature Extractor Selection
|
||||
extractor_model: str = 'i3d' # Options: 'i3d', 'clip', 'videomae'
|
||||
|
||||
# Clip sampling
|
||||
num_frames_per_clip: int = 16
|
||||
num_clips_per_video: int = 1
|
||||
clip_strategy: str | ClipSamplingStrategy = 'beginning'
|
||||
|
||||
# Temporal subsampling
|
||||
frame_stride: int = 1 # 1=no subsampling, 2=every 2nd, 8=every 8th
|
||||
temporal_stride: int = 1 # For sliding window clips
|
||||
|
||||
# Data processing
|
||||
video_extensions: list[str] = field(default_factory=lambda: ['.mp4', '.avi', '.mov', '.mkv'])
|
||||
support_frame_dirs: bool = True
|
||||
|
||||
# Computation
|
||||
batch_size: int = 32
|
||||
device: str = 'cuda'
|
||||
|
||||
use_streaming: bool = True
|
||||
resize_before_extraction: bool = True
|
||||
|
||||
# Caching
|
||||
cache_real_features: str | None = None
|
||||
i3d_model_path: str | None = None
|
||||
|
||||
# Reproducibility
|
||||
seed: int | None = None
|
||||
|
||||
@classmethod
|
||||
def fvd2048_16f(cls) -> 'FVDConfig':
|
||||
"""Standard FVD protocol: 2048 videos, 16 frames, beginning clip."""
|
||||
return cls(num_videos=2048, num_frames_per_clip=16, clip_strategy='beginning', use_streaming=True)
|
||||
|
||||
@classmethod
|
||||
def fvd2048_128f(cls) -> 'FVDConfig':
|
||||
"""Long video protocol: 2048 videos, 128 frames."""
|
||||
return cls(num_videos=2048, num_frames_per_clip=128, clip_strategy='beginning', use_streaming=True)
|
||||
|
||||
@classmethod
|
||||
def quick_test(cls) -> 'FVDConfig':
|
||||
"""Quick test config: 100 videos, 16 frames."""
|
||||
return cls(num_videos=100, num_frames_per_clip=16, clip_strategy='beginning')
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Export config to dict for logging"""
|
||||
d = self.__dict__.copy()
|
||||
d['clip_strategy'] = str(self.clip_strategy)
|
||||
return d
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Human-readable protocol name"""
|
||||
desc = f"FVD_{self.extractor_model.upper()}_{self.num_videos}_{self.num_frames_per_clip}f"
|
||||
if self.frame_stride > 1:
|
||||
desc += f"_subsample{self.frame_stride}"
|
||||
if self.num_clips_per_video > 1:
|
||||
desc += f"_{self.num_clips_per_video}clips"
|
||||
if self.clip_strategy != 'beginning':
|
||||
desc += f"_{self.clip_strategy}"
|
||||
return desc
|
||||
|
||||
|
||||
def extract_features_streaming(video_generator: Iterator[torch.Tensor],
|
||||
extractor: BaseFeatureExtractor,
|
||||
batch_size: int = 32,
|
||||
max_clips: int | None = None,
|
||||
verbose: bool = True) -> np.ndarray:
|
||||
"""
|
||||
Extract features from a video clip generator using streaming.
|
||||
"""
|
||||
all_features = []
|
||||
batch = []
|
||||
|
||||
if verbose:
|
||||
print(f"Extracting features with batch_size={batch_size}...")
|
||||
|
||||
with torch.no_grad():
|
||||
for clip_count, clip in enumerate(video_generator):
|
||||
batch.append(clip)
|
||||
|
||||
# Process batch when full
|
||||
if len(batch) == batch_size:
|
||||
batch_tensor = torch.stack(batch).to(extractor.device)
|
||||
features = extractor.extract_features_batch(batch_tensor)
|
||||
|
||||
all_features.append(features.detach().cpu().numpy())
|
||||
batch = []
|
||||
|
||||
if verbose and clip_count % (batch_size * 10) == 0:
|
||||
print(f"Processed {clip_count} clips...")
|
||||
|
||||
if max_clips is not None and clip_count >= max_clips:
|
||||
break
|
||||
|
||||
# Process remaining clips
|
||||
if len(batch) > 0:
|
||||
batch_tensor = torch.stack(batch).to(extractor.device)
|
||||
features = extractor.extract_features_batch(batch_tensor)
|
||||
all_features.append(features.detach().cpu().numpy())
|
||||
|
||||
if len(all_features) == 0:
|
||||
raise RuntimeError("No features extracted - check video loading")
|
||||
|
||||
features = np.concatenate(all_features, axis=0)
|
||||
|
||||
if verbose:
|
||||
print(f"Extracted {len(features)} feature vectors")
|
||||
|
||||
return features
|
||||
|
||||
|
||||
def load_or_compute_features(videos: str | Path | torch.Tensor,
|
||||
extractor: BaseFeatureExtractor,
|
||||
config: FVDConfig,
|
||||
cache_path: str | None = None,
|
||||
cache_name: str = "real_features") -> np.ndarray:
|
||||
"""Load features from cache or compute (with streaming support)"""
|
||||
|
||||
if cache_path is not None:
|
||||
script_dir = Path(__file__).parent
|
||||
cache_dir = script_dir / cache_path
|
||||
cache_file = cache_dir / f"{config.extractor_model}_{cache_name}.pkl"
|
||||
|
||||
if cache_file.exists():
|
||||
print(f"Loading cached features from {cache_file}")
|
||||
with open(cache_file, 'rb') as f:
|
||||
features = pickle.load(f)
|
||||
|
||||
# Validate and limit based on config
|
||||
max_features = config.num_videos * config.num_clips_per_video
|
||||
|
||||
if len(features) < max_features:
|
||||
print(f"WARNING: Cache has {len(features)} features but need {max_features}")
|
||||
print("Cached features insufficient - will recompute...")
|
||||
elif len(features) > max_features:
|
||||
print(f"Using {max_features} features from cache (truncated from {len(features)})")
|
||||
features = features[:max_features]
|
||||
return features
|
||||
else:
|
||||
print(f"Using all {len(features)} cached features")
|
||||
return features
|
||||
|
||||
print("Computing features from scratch...")
|
||||
|
||||
if isinstance(videos, (str | Path)):
|
||||
target_size = (224, 224) if config.resize_before_extraction else None
|
||||
|
||||
video_generator = load_video_clips_streaming(videos,
|
||||
num_frames=config.num_frames_per_clip,
|
||||
max_videos=config.num_videos,
|
||||
clip_strategy=config.clip_strategy,
|
||||
frame_stride=config.frame_stride,
|
||||
num_clips_per_video=config.num_clips_per_video,
|
||||
video_extensions=config.video_extensions,
|
||||
support_frame_dirs=config.support_frame_dirs,
|
||||
target_size=target_size,
|
||||
verbose=True)
|
||||
|
||||
max_clips = config.num_videos * config.num_clips_per_video
|
||||
features = extract_features_streaming(video_generator,
|
||||
extractor,
|
||||
batch_size=config.batch_size,
|
||||
max_clips=max_clips,
|
||||
verbose=True)
|
||||
else:
|
||||
print(f"Extracting features from {len(videos)} video tensors...")
|
||||
features = extractor.extract_features(videos, batch_size=config.batch_size, verbose=True)
|
||||
features = features.numpy()
|
||||
|
||||
# Validate feature count
|
||||
expected_count = config.num_videos * config.num_clips_per_video
|
||||
if len(features) < expected_count:
|
||||
raise ValueError(f"ERROR: Only extracted {len(features)} features, but need {expected_count}!\n"
|
||||
f"Found fewer videos than expected. Check your video directory.")
|
||||
elif len(features) > expected_count:
|
||||
print(f"Truncating {len(features)} features to {expected_count}")
|
||||
features = features[:expected_count]
|
||||
|
||||
# Cache features if requested
|
||||
if cache_path is not None:
|
||||
script_dir = Path(__file__).parent
|
||||
cache_dir = script_dir / cache_path
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
cache_file = cache_dir / f"{config.extractor_model}_{cache_name}.pkl"
|
||||
print(f"Caching features to {cache_file}")
|
||||
with open(cache_file, 'wb') as f:
|
||||
pickle.dump(features, f)
|
||||
|
||||
return features
|
||||
|
||||
|
||||
def compute_fvd_with_config(real_videos: str | Path | torch.Tensor,
|
||||
gen_videos: str | Path | torch.Tensor,
|
||||
config: FVDConfig,
|
||||
verbose: bool = True) -> dict:
|
||||
"""
|
||||
Compute FVD using a standardized configuration.
|
||||
|
||||
This is the recommended way to compute FVD for reproducibility.
|
||||
|
||||
Args:
|
||||
real_videos: Path or tensors
|
||||
gen_videos: Path or tensors
|
||||
config: FVDConfig specifying protocol
|
||||
verbose: Print progress
|
||||
|
||||
Returns:
|
||||
results: Dictionary with:
|
||||
- 'fvd': FVD score (float)
|
||||
- 'protocol': Protocol name (str)
|
||||
- 'model': Feature extractor model name (str)
|
||||
- 'config': Configuration dict
|
||||
|
||||
Example:
|
||||
>>> config = FVDConfig.fvd2048_16f()
|
||||
>>> results = compute_fvd_with_config('data/real/', 'outputs/gen/', config)
|
||||
>>> print(f"FVD: {results['fvd']:.2f}")
|
||||
"""
|
||||
|
||||
# Seed for reproducibility
|
||||
if config.seed is not None:
|
||||
import random as _rnd
|
||||
_rnd.seed(config.seed)
|
||||
np.random.seed(config.seed)
|
||||
torch.manual_seed(config.seed)
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.manual_seed_all(config.seed)
|
||||
|
||||
if verbose:
|
||||
print("=" * 70)
|
||||
print(f"Computing FVD with protocol: {config}")
|
||||
print(f"Model: {config.extractor_model.upper()}")
|
||||
print("=" * 70)
|
||||
print("\nConfiguration:")
|
||||
for key, value in config.to_dict().items():
|
||||
print(f" {key}: {value}")
|
||||
print()
|
||||
|
||||
# Initialize Extractor using Factory
|
||||
if verbose:
|
||||
print(f"\nInitializing {config.extractor_model.upper()} model on {config.device}...")
|
||||
|
||||
extractor = load_extractor(config.extractor_model, device=config.device)
|
||||
|
||||
# Extract features
|
||||
if verbose:
|
||||
print(f"\n{'='*70}")
|
||||
print("Extracting REAL video features...")
|
||||
print(f"{'='*70}")
|
||||
|
||||
real_features = load_or_compute_features(videos=real_videos,
|
||||
extractor=extractor,
|
||||
config=config,
|
||||
cache_path=config.cache_real_features,
|
||||
cache_name="real_features")
|
||||
|
||||
if verbose:
|
||||
print(f"\n{'='*70}")
|
||||
print("Extracting GENERATED video features...")
|
||||
print(f"{'='*70}")
|
||||
|
||||
gen_features = load_or_compute_features(videos=gen_videos,
|
||||
extractor=extractor,
|
||||
config=config,
|
||||
cache_path=None,
|
||||
cache_name="gen_features")
|
||||
|
||||
if verbose:
|
||||
print(f"\nReal videos/clips: {len(real_features)}")
|
||||
print(f"Generated videos/clips: {len(gen_features)}")
|
||||
print(f"\n{'='*70}")
|
||||
print("Computing statistics...")
|
||||
print(f"{'='*70}")
|
||||
|
||||
mu_real, sigma_real = compute_statistics(real_features)
|
||||
mu_gen, sigma_gen = compute_statistics(gen_features)
|
||||
|
||||
if verbose:
|
||||
print(f"\n{'='*70}")
|
||||
print("Computing Fréchet distance...")
|
||||
print(f"{'='*70}")
|
||||
|
||||
fvd = compute_frechet_distance(mu_real, sigma_real, mu_gen, sigma_gen)
|
||||
|
||||
if verbose:
|
||||
print(f"\n{'='*70}")
|
||||
print(f"FVD Score ({config.extractor_model.upper()}): {fvd:.4f}")
|
||||
print(f"Protocol: {config}")
|
||||
print(f"{'='*70}\n")
|
||||
|
||||
results = {
|
||||
'fvd': fvd,
|
||||
'protocol': str(config),
|
||||
'model': config.extractor_model,
|
||||
'config': config.to_dict(),
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def compute_fvd(real_videos: str | Path | torch.Tensor,
|
||||
gen_videos: str | Path | torch.Tensor,
|
||||
num_frames: int = 16,
|
||||
batch_size: int = 32,
|
||||
device: str = 'cuda',
|
||||
num_videos: int | None = 2048,
|
||||
cache_real_features: str | None = None,
|
||||
i3d_model_path: str | None = None,
|
||||
seed: int | None = None,
|
||||
verbose: bool = True) -> float:
|
||||
"""
|
||||
Backward compatibility wrapper for computing FVD (defaults to I3D).
|
||||
"""
|
||||
num_videos = num_videos if num_videos is not None else 2048
|
||||
|
||||
config = FVDConfig(
|
||||
num_videos=num_videos,
|
||||
num_frames_per_clip=num_frames,
|
||||
extractor_model='i3d', # Default to I3D
|
||||
batch_size=batch_size,
|
||||
device=device,
|
||||
cache_real_features=cache_real_features,
|
||||
i3d_model_path=i3d_model_path,
|
||||
seed=seed,
|
||||
)
|
||||
|
||||
result = compute_fvd_with_config(real_videos, gen_videos, config, verbose)
|
||||
return result['fvd']
|
||||
@@ -1,124 +0,0 @@
|
||||
"""I3D Feature Extractor for FVD Computation"""
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
from pathlib import Path
|
||||
from huggingface_hub import hf_hub_download
|
||||
from tqdm import tqdm
|
||||
from contextlib import suppress
|
||||
|
||||
|
||||
class I3DFeatureExtractor(nn.Module):
|
||||
"""
|
||||
I3D feature extractor for FVD computation.
|
||||
Extracts 400-dimensional features from videos using I3D model
|
||||
trained on Kinetics-400.
|
||||
"""
|
||||
|
||||
REPO_ID = 'flateon/FVD-I3D-torchscript'
|
||||
MODEL_FILENAME = 'i3d_torchscript.pt'
|
||||
|
||||
def __init__(self, device: str = 'cuda', cache_dir: str | Path | None = None):
|
||||
super().__init__()
|
||||
|
||||
self.device_str = device
|
||||
if device == 'cuda' and not torch.cuda.is_available():
|
||||
print("Warning: CUDA requested but not available – falling back to CPU")
|
||||
self.device = torch.device('cpu')
|
||||
else:
|
||||
self.device = torch.device(device)
|
||||
|
||||
self.cache_dir: str | None
|
||||
if cache_dir is not None:
|
||||
self.cache_dir = str(Path(cache_dir).resolve())
|
||||
else:
|
||||
self.cache_dir = None # Use HF default cache
|
||||
|
||||
self.model = self._load_model()
|
||||
self.model.eval()
|
||||
|
||||
with suppress(Exception):
|
||||
self.model.to(self.device)
|
||||
|
||||
def _load_model(self) -> torch.nn.Module:
|
||||
"""Download and load I3D TorchScript model from Hugging Face Hub."""
|
||||
print(f"Loading I3D model from Hugging Face Hub ({self.REPO_ID})...")
|
||||
|
||||
try:
|
||||
# Download model from Hugging Face Hub
|
||||
model_path = hf_hub_download(repo_id=self.REPO_ID, filename=self.MODEL_FILENAME, cache_dir=self.cache_dir)
|
||||
|
||||
# Load directly to chosen device
|
||||
model = torch.jit.load(model_path, map_location=self.device)
|
||||
print("I3D model loaded successfully")
|
||||
return model
|
||||
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to load I3D model from Hugging Face Hub. Error: {e}\n"
|
||||
f"Ensure you have internet connection and huggingface_hub installed:\n"
|
||||
f"uv pip install huggingface_hub") from e
|
||||
|
||||
def preprocess(self, videos: torch.Tensor) -> torch.Tensor:
|
||||
"""
|
||||
Preprocess videos for I3D.
|
||||
|
||||
Args:
|
||||
videos: [B, T, C, H, W], values in [0, 255]
|
||||
|
||||
Returns:
|
||||
Preprocessed videos [B, C, T, 224, 224] (normalized and resized)
|
||||
"""
|
||||
B, T, C, H, W = videos.shape
|
||||
|
||||
if T < 10:
|
||||
raise ValueError(f"I3D requires at least 10 frames, got {T}")
|
||||
|
||||
# Normalize to [0, 1] if needed
|
||||
if videos.max() > 1.0:
|
||||
videos = videos / 255.0
|
||||
|
||||
# Resize to 224x224 if needed
|
||||
if H != 224 or W != 224:
|
||||
videos = videos.reshape(B * T, C, H, W)
|
||||
videos = F.interpolate(videos, size=(224, 224), mode='bilinear', align_corners=False)
|
||||
videos = videos.reshape(B, T, C, 224, 224)
|
||||
|
||||
# Convert to [B, C, T, H, W] format
|
||||
videos = videos.permute(0, 2, 1, 3, 4).contiguous()
|
||||
|
||||
return videos
|
||||
|
||||
@torch.no_grad()
|
||||
def extract_features(self, videos: torch.Tensor, batch_size: int = 32, verbose: bool = True) -> torch.Tensor:
|
||||
"""
|
||||
Extract I3D features
|
||||
|
||||
Args:
|
||||
videos: [N, T, C, H, W], values in [0, 255]
|
||||
batch_size: Batch size for processing
|
||||
verbose: Show progress bar
|
||||
|
||||
Returns:
|
||||
Features [N, 400]
|
||||
"""
|
||||
N = len(videos)
|
||||
all_features = []
|
||||
|
||||
iterator = range(0, N, batch_size)
|
||||
if verbose:
|
||||
iterator = tqdm(iterator, desc="Extracting I3D features")
|
||||
|
||||
for i in iterator:
|
||||
batch = videos[i:i + batch_size].to(self.device)
|
||||
batch = self.preprocess(batch) # Now returns [B, C, T, H, W]
|
||||
|
||||
# Use the HF model without rescale/resize (we handle it in preprocess)
|
||||
features = self.model(batch, rescale=False, resize=False, return_features=True)
|
||||
|
||||
all_features.append(features.cpu())
|
||||
|
||||
return torch.cat(all_features, dim=0)
|
||||
|
||||
def __call__(self, videos: torch.Tensor, batch_size: int = 32) -> torch.Tensor:
|
||||
return self.extract_features(videos, batch_size=batch_size)
|
||||
@@ -1,51 +0,0 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
root_dir = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(root_dir))
|
||||
|
||||
from benchmarks.fvd.fvd import FVDConfig, compute_fvd_with_config # noqa: E402
|
||||
|
||||
|
||||
def main() -> None:
|
||||
script_dir = Path(__file__).parent.resolve()
|
||||
|
||||
# Define directories
|
||||
real_dir = "benchmarks/data/real_videos"
|
||||
gen_dir = "benchmarks/data/generated_videos"
|
||||
|
||||
# Compare all 3 models
|
||||
models_to_test = ['i3d', 'clip', 'videomae']
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("STARTING COMPARISON BENCHMARK")
|
||||
print(f"{'='*60}")
|
||||
|
||||
for model_name in models_to_test:
|
||||
print(f"\n>>> Running evaluation with {model_name.upper()}...")
|
||||
|
||||
try:
|
||||
cfg = FVDConfig(
|
||||
num_videos=650,
|
||||
num_frames_per_clip=16,
|
||||
extractor_model=model_name,
|
||||
clip_strategy='beginning',
|
||||
device='cuda',
|
||||
seed=42,
|
||||
# Use separate cache folders for each model to avoid conflicts
|
||||
cache_real_features=str(script_dir / f'fvd-cache/{model_name}'),
|
||||
)
|
||||
|
||||
results = compute_fvd_with_config(real_dir, gen_dir, cfg, verbose=False)
|
||||
print(f"FVD: {results['fvd']}\nModel: {results['model']}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"{model_name.upper()} Failed: {e}")
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("BENCHMARK COMPLETE")
|
||||
print(f"{'='*60}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,89 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import random
|
||||
from fvd import compute_fvd_with_config, FVDConfig
|
||||
|
||||
script_path = Path(__file__).resolve()
|
||||
fastvideo_root = script_path.parent.parent.parent
|
||||
sys.path.insert(0, str(fastvideo_root))
|
||||
|
||||
|
||||
def split_videos(video_dir: Path, n_per_subset: int = 128, seed: int = 42):
|
||||
subset_a = video_dir.parent / 'bair_full_subset_A'
|
||||
subset_b = video_dir.parent / 'bair_full_subset_B'
|
||||
|
||||
if subset_a.exists():
|
||||
shutil.rmtree(subset_a)
|
||||
if subset_b.exists():
|
||||
shutil.rmtree(subset_b)
|
||||
|
||||
subset_a.mkdir(parents=True)
|
||||
subset_b.mkdir(parents=True)
|
||||
|
||||
videos = sorted(video_dir.glob('*.mp4'))
|
||||
|
||||
random.seed(seed)
|
||||
shuffled = list(videos)
|
||||
random.shuffle(shuffled)
|
||||
|
||||
needed = n_per_subset * 2
|
||||
if len(shuffled) > needed:
|
||||
shuffled = shuffled[:needed]
|
||||
|
||||
mid = len(shuffled) // 2
|
||||
|
||||
print(f"\nSplitting {len(shuffled)} BAIR FULL videos:")
|
||||
print(f" Subset A: {mid} videos")
|
||||
print(f" Subset B: {len(shuffled) - mid} videos")
|
||||
|
||||
for v in shuffled[:mid]:
|
||||
shutil.copy2(v, subset_a / v.name)
|
||||
|
||||
for v in shuffled[mid:]:
|
||||
shutil.copy2(v, subset_b / v.name)
|
||||
|
||||
return subset_a, subset_b, mid
|
||||
|
||||
|
||||
def validate_fvd(subset_a: Path, subset_b: Path, num_videos: int):
|
||||
config = FVDConfig(num_videos=num_videos,
|
||||
num_frames_per_clip=16,
|
||||
clip_strategy='beginning',
|
||||
batch_size=8,
|
||||
device='cuda',
|
||||
seed=42)
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("TEST 1: Identity Test")
|
||||
print("=" * 70)
|
||||
|
||||
result1 = compute_fvd_with_config(real_videos=str(subset_a), gen_videos=str(subset_a), config=config, verbose=False)
|
||||
fvd_identity = result1['fvd']
|
||||
print(f"\nIdentity FVD: {fvd_identity:.2f}")
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("TEST 2: Real vs Real")
|
||||
print("=" * 70)
|
||||
|
||||
result2 = compute_fvd_with_config(real_videos=str(subset_a), gen_videos=str(subset_b), config=config, verbose=False)
|
||||
fvd_real = result2['fvd']
|
||||
print(f"\nReal vs Real FVD: {fvd_real:.2f}")
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("RESULTS")
|
||||
print("=" * 70)
|
||||
print(f"Identity: {fvd_identity:.2f}")
|
||||
print(f"Real vs Real: {fvd_real:.2f}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
bair_dir = Path('benchmarks/data/bair_full_videos')
|
||||
|
||||
subset_a, subset_b, count = split_videos(bair_dir, n_per_subset=128, seed=42)
|
||||
validate_fvd(subset_a, subset_b, count)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,460 +0,0 @@
|
||||
import torch
|
||||
import cv2
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
from collections.abc import Iterator
|
||||
from tqdm import tqdm
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ClipSamplingStrategy(Enum):
|
||||
"""Clip sampling strategies for FVD evaluation."""
|
||||
BEGINNING = 'beginning' # Take first N frames (most common)
|
||||
RANDOM = 'random' # Random N consecutive frames
|
||||
UNIFORM = 'uniform' # Uniformly spaced frames across video
|
||||
MIDDLE = 'middle' # Middle N frames
|
||||
SLIDING = 'sliding' # Multiple sliding windows
|
||||
ALL = 'all' # All possible clips
|
||||
|
||||
|
||||
def _load_video_cv2(video_path: str | Path,
|
||||
num_frames: int | None = 16,
|
||||
sample_strategy: str = 'uniform') -> torch.Tensor:
|
||||
"""
|
||||
Load video from video file using OpenCV.
|
||||
|
||||
Args:
|
||||
video_path: Path to video file (MP4, AVI, MOV, MKV)
|
||||
num_frames: Number of frames to extract
|
||||
sample_strategy: 'uniform' or 'random'
|
||||
|
||||
Returns:
|
||||
video: [T, C, H, W]
|
||||
"""
|
||||
video_path = str(video_path)
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
|
||||
if not cap.isOpened():
|
||||
raise RuntimeError(f"Cannot open video: {video_path}")
|
||||
|
||||
frames = []
|
||||
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
|
||||
if num_frames is None:
|
||||
# Read all available frames
|
||||
while True:
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
break
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
frames.append(frame)
|
||||
|
||||
cap.release()
|
||||
if len(frames) == 0:
|
||||
raise RuntimeError(f"Video has 0 frames: {video_path}")
|
||||
|
||||
frames = np.stack(frames) # [T, H, W, C]
|
||||
frames = torch.from_numpy(frames).permute(0, 3, 1, 2).float() # [T, C, H, W]
|
||||
return frames
|
||||
|
||||
if total_frames == 0:
|
||||
raise RuntimeError(f"Video has 0 frames: {video_path}")
|
||||
|
||||
# Determine frame indices for sampling
|
||||
if total_frames < num_frames:
|
||||
frame_indices = list(range(total_frames)) + [total_frames - 1] * (num_frames - total_frames)
|
||||
elif sample_strategy == 'uniform':
|
||||
frame_indices = np.linspace(0, total_frames - 1, num_frames, dtype=int).tolist()
|
||||
elif sample_strategy == 'random':
|
||||
frame_indices = sorted(np.random.choice(total_frames, num_frames, replace=False))
|
||||
else:
|
||||
raise ValueError(f"Unknown sample_strategy: {sample_strategy}")
|
||||
|
||||
# Extract frames
|
||||
for idx in frame_indices:
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
|
||||
ret, frame = cap.read()
|
||||
|
||||
if not ret:
|
||||
if len(frames) > 0:
|
||||
frames.append(frames[-1].copy())
|
||||
else:
|
||||
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
frames.append(np.zeros((h, w, 3), dtype=np.uint8))
|
||||
continue
|
||||
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
frames.append(frame)
|
||||
|
||||
cap.release()
|
||||
|
||||
frames = np.stack(frames) # [T, H, W, C]
|
||||
frames = torch.from_numpy(frames).permute(0, 3, 1, 2).float() # [T, C, H, W]
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
def _load_video_from_frames(frame_dir: str | Path,
|
||||
num_frames: int | None = 16,
|
||||
sample_strategy: str = 'uniform',
|
||||
frame_extensions: list[str] | None = None) -> torch.Tensor:
|
||||
"""
|
||||
Load video from directory of frame images.
|
||||
|
||||
Args:
|
||||
frame_dir: Directory containing frames
|
||||
num_frames: Number of frames to sample
|
||||
sample_strategy: 'uniform' or 'random'
|
||||
frame_extensions: Image file extensions to look for
|
||||
|
||||
Returns:
|
||||
video: [T, C, H, W]
|
||||
"""
|
||||
if frame_extensions is None:
|
||||
frame_extensions = ['.jpg', '.png', '.jpeg', '.bmp']
|
||||
|
||||
frame_dir = Path(frame_dir)
|
||||
|
||||
if not frame_dir.exists():
|
||||
raise FileNotFoundError(f"Frame directory not found: {frame_dir}")
|
||||
|
||||
# Find all frames
|
||||
frame_files: list[Path] = []
|
||||
for ext in frame_extensions:
|
||||
frame_files.extend(frame_dir.glob(f"*{ext}"))
|
||||
|
||||
if len(frame_files) == 0:
|
||||
raise ValueError(f"No frames found in {frame_dir} with extensions {frame_extensions}")
|
||||
|
||||
frame_files = sorted(frame_files, key=lambda x: x.name)
|
||||
total_frames = len(frame_files)
|
||||
|
||||
# Determine frame indices
|
||||
if num_frames is None:
|
||||
frame_indices = list(range(total_frames))
|
||||
else:
|
||||
if total_frames < num_frames:
|
||||
frame_indices = list(range(total_frames)) + [total_frames - 1] * (num_frames - total_frames)
|
||||
elif sample_strategy == 'uniform':
|
||||
frame_indices = np.linspace(0, total_frames - 1, num_frames, dtype=int).tolist()
|
||||
elif sample_strategy == 'random':
|
||||
frame_indices = sorted(np.random.choice(total_frames, num_frames, replace=False))
|
||||
else:
|
||||
raise ValueError(f"Unknown sample_strategy: {sample_strategy}")
|
||||
|
||||
# Load frames
|
||||
frames = []
|
||||
for idx in frame_indices:
|
||||
frame_path = frame_files[idx]
|
||||
frame = cv2.imread(str(frame_path))
|
||||
|
||||
if frame is None:
|
||||
raise RuntimeError(f"Failed to load frame: {frame_path}")
|
||||
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
frames.append(frame)
|
||||
|
||||
# Stack and convert to tensor
|
||||
frames = np.stack(frames) # [T, H, W, C]
|
||||
frames = torch.from_numpy(frames).permute(0, 3, 1, 2).float() # [T, C, H, W]
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
def _detect_video_format(path: str | Path) -> str:
|
||||
"""
|
||||
Detect if path is a video file or frame directory.
|
||||
|
||||
Returns:
|
||||
'video_file', 'frame_directory', or 'unknown'
|
||||
"""
|
||||
path = Path(path)
|
||||
|
||||
if path.is_file():
|
||||
return 'video_file'
|
||||
elif path.is_dir():
|
||||
# Check if contains image files
|
||||
image_extensions = ['.jpg', '.jpeg', '.png', '.bmp']
|
||||
for ext in image_extensions:
|
||||
if list(path.glob(f"*{ext}")):
|
||||
return 'frame_directory'
|
||||
return 'unknown'
|
||||
else:
|
||||
raise ValueError(f"Path does not exist: {path}")
|
||||
|
||||
|
||||
def load_video_auto(video_path: str | Path,
|
||||
num_frames: int | None = 16,
|
||||
sample_strategy: str = 'uniform') -> torch.Tensor:
|
||||
"""
|
||||
Automatically detect format and load video.
|
||||
|
||||
Supports:
|
||||
- Video files (MP4, AVI, MOV, MKV)
|
||||
- Frame directories (JPG, PNG)
|
||||
|
||||
Args:
|
||||
video_path: Path to video file or frame directory
|
||||
num_frames: Number of frames to extract
|
||||
sample_strategy: 'uniform' or 'random'
|
||||
|
||||
Returns:
|
||||
video: [T, C, H, W]
|
||||
"""
|
||||
format_type = _detect_video_format(video_path)
|
||||
|
||||
if format_type == 'video_file':
|
||||
return _load_video_cv2(video_path, num_frames, sample_strategy)
|
||||
elif format_type == 'frame_directory':
|
||||
return _load_video_from_frames(video_path, num_frames, sample_strategy)
|
||||
else:
|
||||
raise ValueError(f"Unknown video format at {video_path}")
|
||||
|
||||
|
||||
def sample_clips_from_video(video: torch.Tensor,
|
||||
num_frames_per_clip: int = 16,
|
||||
num_clips: int = 1,
|
||||
strategy: str | ClipSamplingStrategy = ClipSamplingStrategy.BEGINNING,
|
||||
frame_stride: int = 1,
|
||||
temporal_stride: int = 1) -> list[torch.Tensor]:
|
||||
"""
|
||||
Sample clips from a video with various strategies.
|
||||
|
||||
Args:
|
||||
video: [T, C, H, W] full video
|
||||
num_frames_per_clip: Frames per clip
|
||||
num_clips: Number of clips to extract
|
||||
strategy: ClipSamplingStrategy or string ('beginning', 'random', etc.)
|
||||
frame_stride: Skip frames (FPS control: 1=all, 2=every 2nd, 8=every 8th)
|
||||
temporal_stride: Stride between clips for sliding window
|
||||
|
||||
Returns:
|
||||
List of clips, each [num_frames_per_clip, C, H, W]
|
||||
|
||||
Examples:
|
||||
>>> # Beginning clip (most common for FVD)
|
||||
>>> clips = sample_clips_from_video(video, 16, strategy='beginning')
|
||||
|
||||
>>> # Multiple random clips
|
||||
>>> clips = sample_clips_from_video(video, 16, num_clips=4, strategy='random')
|
||||
|
||||
>>> # Subsample FPS by 2x (every 2nd frame)
|
||||
>>> clips = sample_clips_from_video(video, 16, frame_stride=2)
|
||||
|
||||
>>> # Sliding window with overlap
|
||||
>>> clips = sample_clips_from_video(video, 16, strategy='sliding', temporal_stride=8)
|
||||
"""
|
||||
# Convert string to enum if needed
|
||||
if isinstance(strategy, str):
|
||||
strategy = ClipSamplingStrategy(strategy)
|
||||
|
||||
T, C, H, W = video.shape
|
||||
|
||||
# Apply frame stride (FPS subsampling)
|
||||
if frame_stride > 1:
|
||||
video = video[::frame_stride]
|
||||
T = len(video)
|
||||
|
||||
effective_clip_length = num_frames_per_clip
|
||||
|
||||
# Handle videos shorter than clip length
|
||||
if effective_clip_length > T:
|
||||
pad_length = effective_clip_length - T
|
||||
last_frame = video[-1:].repeat(pad_length, 1, 1, 1)
|
||||
video = torch.cat([video, last_frame], dim=0)
|
||||
T = len(video)
|
||||
|
||||
clips = []
|
||||
|
||||
if strategy == ClipSamplingStrategy.BEGINNING:
|
||||
# Take first clip (most common for FVD evaluation)
|
||||
clip = video[:effective_clip_length]
|
||||
clips.append(clip)
|
||||
|
||||
elif strategy == ClipSamplingStrategy.MIDDLE:
|
||||
# Take middle clip
|
||||
start = (T - effective_clip_length) // 2
|
||||
clip = video[start:start + effective_clip_length]
|
||||
clips.append(clip)
|
||||
|
||||
elif strategy == ClipSamplingStrategy.RANDOM:
|
||||
# Sample N random clips
|
||||
for _ in range(num_clips):
|
||||
start = 0 if effective_clip_length == T else np.random.randint(0, T - effective_clip_length + 1)
|
||||
clip = video[start:start + effective_clip_length]
|
||||
clips.append(clip)
|
||||
|
||||
elif strategy == ClipSamplingStrategy.UNIFORM:
|
||||
# Uniformly spaced clips
|
||||
if num_clips == 1:
|
||||
# Single clip from middle
|
||||
start = (T - effective_clip_length) // 2
|
||||
clip = video[start:start + effective_clip_length]
|
||||
clips.append(clip)
|
||||
else:
|
||||
# Multiple uniformly spaced clips
|
||||
step = (T - effective_clip_length) / (num_clips - 1) if num_clips > 1 else 0
|
||||
for i in range(num_clips):
|
||||
start = int(i * step)
|
||||
start = min(start, T - effective_clip_length)
|
||||
clip = video[start:start + effective_clip_length]
|
||||
clips.append(clip)
|
||||
|
||||
elif strategy == ClipSamplingStrategy.SLIDING:
|
||||
# Sliding window with stride
|
||||
for start in range(0, T - effective_clip_length + 1, temporal_stride):
|
||||
clip = video[start:start + effective_clip_length]
|
||||
clips.append(clip)
|
||||
if len(clips) >= num_clips:
|
||||
break
|
||||
|
||||
elif strategy == ClipSamplingStrategy.ALL:
|
||||
# All possible clips (overlapping)
|
||||
for start in range(T - effective_clip_length + 1):
|
||||
clip = video[start:start + effective_clip_length]
|
||||
clips.append(clip)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown strategy: {strategy}")
|
||||
|
||||
return clips
|
||||
|
||||
|
||||
def load_video_clips_streaming(directory: str | Path,
|
||||
num_frames: int = 16,
|
||||
max_videos: int | None = None,
|
||||
clip_strategy: str
|
||||
| ClipSamplingStrategy = 'beginning',
|
||||
frame_stride: int = 1,
|
||||
num_clips_per_video: int = 1,
|
||||
video_extensions: list[str] | None = None,
|
||||
support_frame_dirs: bool = True,
|
||||
target_size: tuple[int, int] | None = (224, 224),
|
||||
verbose: bool = True) -> Iterator[torch.Tensor]:
|
||||
"""
|
||||
This generator yields clips one-by-one instead of loading all videos into RAM.
|
||||
Perfect for large datasets where memory is limited.
|
||||
|
||||
Args:
|
||||
directory: Path to directory with videos
|
||||
num_frames: Frames per clip
|
||||
max_videos: Max videos to load
|
||||
clip_strategy: 'beginning', 'random', 'uniform', etc.
|
||||
frame_stride: Frame skip (1=all, 2=every 2nd, 8=every 8th)
|
||||
num_clips_per_video: Number of clips per video
|
||||
video_extensions: Video file extensions
|
||||
support_frame_dirs: Also load frame directories
|
||||
target_size: Resize clips to (H, W). If None, keep original size.
|
||||
verbose: Show progress
|
||||
|
||||
Yields:
|
||||
clip: [T, C, H, W] individual clips
|
||||
|
||||
Example:
|
||||
>>> for clip in load_video_clips_streaming('data/videos/', num_frames=16):
|
||||
>>> features = model.extract_features(clip.unsqueeze(0))
|
||||
>>> # Process one clip at a time - low memory usage!
|
||||
"""
|
||||
if video_extensions is None:
|
||||
video_extensions = ['.mp4', '.avi', '.mov', '.mkv']
|
||||
|
||||
directory = Path(directory)
|
||||
|
||||
if not directory.exists():
|
||||
raise FileNotFoundError(f"Directory not found: {directory}")
|
||||
|
||||
# Find video paths
|
||||
video_paths: list[Path] = []
|
||||
|
||||
# Find video files
|
||||
for ext in video_extensions:
|
||||
video_paths.extend(directory.glob(f"**/*{ext}"))
|
||||
|
||||
# Find frame directories if enabled
|
||||
if support_frame_dirs:
|
||||
for subdir in directory.iterdir():
|
||||
if subdir.is_dir():
|
||||
# Check if it contains frames
|
||||
image_extensions = ['.jpg', '.jpeg', '.png', '.bmp']
|
||||
for ext in image_extensions:
|
||||
if list(subdir.glob(f"*{ext}")):
|
||||
video_paths.append(subdir)
|
||||
break
|
||||
|
||||
if len(video_paths) == 0:
|
||||
raise ValueError(f"No videos found in {directory}")
|
||||
|
||||
video_paths = sorted(video_paths)
|
||||
|
||||
if max_videos is not None:
|
||||
video_paths = video_paths[:max_videos]
|
||||
|
||||
if verbose:
|
||||
print(f"Found {len(video_paths)} videos in {directory}")
|
||||
if num_clips_per_video > 1:
|
||||
print(f"Extracting {num_clips_per_video} clips per video...")
|
||||
if frame_stride > 1:
|
||||
print(f"Subsampling frames with stride {frame_stride}...")
|
||||
if target_size:
|
||||
print(f"Resizing clips to {target_size}...")
|
||||
|
||||
# Track statistics
|
||||
failed_count = 0
|
||||
total_clips = 0
|
||||
|
||||
iterator = tqdm(video_paths, desc="Loading videos") if verbose else video_paths
|
||||
|
||||
for video_path in iterator:
|
||||
try:
|
||||
# Load full video
|
||||
video = load_video_auto(video_path, num_frames=None, sample_strategy='uniform')
|
||||
|
||||
# Sample clips from video
|
||||
clips = sample_clips_from_video(video,
|
||||
num_frames_per_clip=num_frames,
|
||||
num_clips=num_clips_per_video,
|
||||
strategy=clip_strategy,
|
||||
frame_stride=frame_stride)
|
||||
|
||||
if target_size is not None:
|
||||
resized_clips = []
|
||||
for clip in clips:
|
||||
T, C, H, W = clip.shape
|
||||
if target_size != (H, W):
|
||||
# Resize to target size
|
||||
clip = clip.contiguous() # Fix non-contiguous tensors first
|
||||
clip_flat = clip.view(T * C, H, W).unsqueeze(0) # [1, T*C, H, W]
|
||||
clip_resized = torch.nn.functional.interpolate(clip_flat,
|
||||
size=target_size,
|
||||
mode='bilinear',
|
||||
align_corners=False)
|
||||
clip = clip_resized.squeeze(0).view(T, C, target_size[0],
|
||||
target_size[1]) # Back to [T, C, H, W]
|
||||
resized_clips.append(clip)
|
||||
clips = resized_clips
|
||||
|
||||
# Yield clips one by one
|
||||
for clip in clips:
|
||||
yield clip
|
||||
total_clips += 1
|
||||
|
||||
# Free memory
|
||||
del video, clips
|
||||
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
if verbose:
|
||||
print(f"\nWarning: Failed to load {video_path}: {e}")
|
||||
continue
|
||||
|
||||
# Validate
|
||||
if total_clips == 0:
|
||||
raise RuntimeError(f"Failed to load any videos from {directory}")
|
||||
|
||||
failure_rate = failed_count / len(video_paths)
|
||||
if failure_rate > 0.1: # More than 10% failed
|
||||
print(f"\nWARNING: {failure_rate:.1%} of videos failed to load ({failed_count}/{len(video_paths)})")
|
||||
|
||||
if verbose:
|
||||
print(f"\nSuccessfully loaded {total_clips} clips from {len(video_paths) - failed_count} videos")
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 1. Install missing dependency
|
||||
uv pip install -q opencv-python-headless transformers huggingface_hub
|
||||
|
||||
# 2. Run FVD script
|
||||
python benchmarks/fvd/run_fvd.py
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 1. Install missing dependency
|
||||
uv pip install -q opencv-python-headless
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
# FastVideo (trackwan_bidir) env — CORRECTED build. CUDA 12.9 container, aarch64 + GB200 (sm_100a).
|
||||
# Ordering fix: install repo-pinned torch (2.11.0 cu128) FIRST, build the local Blackwell kernel
|
||||
# against it, then core deps (relaxed kernel pin so the local 0.3.0 satisfies), then FA4.
|
||||
set +e
|
||||
say(){ echo; echo "==================== $* ===================="; }
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
export CUDA_HOME=${CUDA_HOME:-/usr/local/cuda}
|
||||
export PATH=$CUDA_HOME/bin:/mnt/lustre/vlm-s4duan/bin:$PATH
|
||||
export LD_LIBRARY_PATH=$CUDA_HOME/lib64:$LD_LIBRARY_PATH
|
||||
export UV_CACHE_DIR=/mnt/lustre/vlm-s4duan/.uv/cache
|
||||
export UV_PYTHON_INSTALL_DIR=/mnt/lustre/vlm-s4duan/.uv/python
|
||||
export TORCH_CUDA_ARCH_LIST=10.0a
|
||||
export MAX_JOBS=${MAX_JOBS:-64}
|
||||
cd /mnt/lustre/vlm-s4duan/FastVideo || { echo NO_REPO; exit 1; }
|
||||
|
||||
say "ENSURE toolchain"
|
||||
if ! command -v g++ >/dev/null || ! command -v cmake >/dev/null || ! command -v ninja >/dev/null; then
|
||||
apt-get update -y >/tmp/apt.log 2>&1 && apt-get install -y --no-install-recommends g++ gcc make cmake ninja-build git ca-certificates >>/tmp/apt.log 2>&1 && echo "apt ok" || { echo APT_FAIL; tail -20 /tmp/apt.log; }
|
||||
fi
|
||||
nvcc --version | tail -2
|
||||
|
||||
say "FRESH venv (python 3.12 on Lustre)"
|
||||
rm -rf .venv
|
||||
uv venv --python 3.12 --seed .venv || exit 1
|
||||
source .venv/bin/activate
|
||||
python --version
|
||||
|
||||
say "STAGE 1: torch 2.11.0 cu128 (repo pin, Blackwell-capable)"
|
||||
uv pip install --torch-backend=cu128 torch==2.11.0 torchvision torchaudio 2>&1 | tail -15
|
||||
python -c "import torch;print('TORCH',torch.__version__,torch.version.cuda,'avail',torch.cuda.is_available())" || echo TORCH_FAIL
|
||||
|
||||
say "STAGE 2: submodules + build local fastvideo-kernel (sm_100a) against torch 2.11"
|
||||
git submodule update --init --recursive fastvideo-kernel/include/cutlass fastvideo-kernel/include/tk 2>&1 | tail -5
|
||||
( cd fastvideo-kernel && TORCH_CUDA_ARCH_LIST=10.0a bash ./build.sh ) 2>&1 | tail -20
|
||||
echo "KERNEL_EXIT=$?"
|
||||
python -c "import fastvideo_kernel;print('kernel import OK')" || echo KERNEL_IMPORT_FAIL
|
||||
|
||||
say "STAGE 3: core deps uv pip install -e .[dev] (local kernel already satisfies relaxed pin)"
|
||||
uv pip install -e ".[dev]" 2>&1 | tail -40
|
||||
echo "CORE_EXIT=$?"
|
||||
python -c "import fastvideo;print('fastvideo import OK')" || echo FASTVIDEO_IMPORT_FAIL
|
||||
|
||||
say "STAGE 4: FA4 — OFFICIAL Dao-AILab flash_attn/cute (NOT the XOR-op fork; fork is stale/broken)"
|
||||
# Package name is flash-attn-4; pass the bare git URL (name-qualified spec is rejected).
|
||||
# --prerelease allow lets it pull the matching nvidia-cutlass-dsl==4.6.0.dev0 + quack>=0.5.3.
|
||||
uv pip install --prerelease allow \
|
||||
"git+https://github.com/Dao-AILab/flash-attention.git#subdirectory=flash_attn/cute" 2>&1 | tail -25
|
||||
python -c "from flash_attn.cute.interface import _flash_attn_fwd,_flash_attn_bwd; print('FA4_CUTE_OK')" 2>&1
|
||||
|
||||
say "VERIFY"
|
||||
python - <<'PY'
|
||||
import importlib, torch
|
||||
print("torch", torch.__version__, torch.version.cuda, "cuda_avail", torch.cuda.is_available())
|
||||
if torch.cuda.is_available():
|
||||
print("device0", torch.cuda.get_device_name(0), "cc", torch.cuda.get_device_capability(0))
|
||||
x = torch.randn(2048, 2048, device="cuda", dtype=torch.bfloat16)
|
||||
y = (x @ x); torch.cuda.synchronize()
|
||||
print("bf16 matmul on GPU OK", tuple(y.shape))
|
||||
for m in ["fastvideo", "fastvideo_kernel", "flash_attn"]:
|
||||
try: importlib.import_module(m); print("import OK:", m)
|
||||
except Exception as e: print("import FAIL:", m, repr(e)[:180])
|
||||
try:
|
||||
from flash_attn.cute.interface import _flash_attn_fwd; print("FA4 flash_attn.cute.interface OK")
|
||||
except Exception as e:
|
||||
print("FA4 cute interface:", repr(e)[:180])
|
||||
PY
|
||||
|
||||
say "DONE build_env.sh (v2)"
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
set +e
|
||||
export HF_HOME=/mnt/lustre/vlm-s4duan/.hf
|
||||
export TOKENIZERS_PARALLELISM=false
|
||||
cd /mnt/lustre/vlm-s4duan/FastVideo || exit 1
|
||||
source .venv/bin/activate
|
||||
|
||||
BASE=/mnt/lustre/vlm-s4duan/models/Wan2.1-Fun-1.3B-InP-Diffusers
|
||||
OUT=/mnt/lustre/vlm-s4duan/models/trackwan_1.3b_init
|
||||
mkdir -p /mnt/lustre/vlm-s4duan/models
|
||||
|
||||
echo "==================== download Fun-InP diffusers base ===================="
|
||||
python - <<PY
|
||||
from huggingface_hub import snapshot_download
|
||||
p = snapshot_download("weizhou03/Wan2.1-Fun-1.3B-InP-Diffusers", local_dir="$BASE")
|
||||
print("downloaded to", p)
|
||||
PY
|
||||
echo "DL_EXIT=$?"
|
||||
|
||||
echo "==================== base transformer sanity (I2V => in_channels 36) ===================="
|
||||
python - <<PY
|
||||
import json
|
||||
c = json.load(open("$BASE/transformer/config.json"))
|
||||
print("in_channels:", c.get("in_channels"), "image_dim:", c.get("image_dim"), "num_layers:", c.get("num_layers"), "hidden:", c.get("hidden_size") or c.get("dim"))
|
||||
PY
|
||||
|
||||
echo "==================== convert_trackwan_init.py ===================="
|
||||
python data_pipeline/convert_trackwan_init.py --base "$BASE" --out "$OUT"
|
||||
echo "CONVERT_EXIT=$?"
|
||||
|
||||
echo "==================== trackwan init result ===================="
|
||||
ls -la "$OUT" 2>&1 | head -25
|
||||
python - <<PY
|
||||
import json
|
||||
c = json.load(open("$OUT/transformer/config.json"))
|
||||
print("trackwan in_channels:", c.get("in_channels"), "| has track_config:", "track_config" in c)
|
||||
PY
|
||||
echo "==================== DONE build_trackwan_init ===================="
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
# Phase 0, step 0 — carve a SMALL overfit dataset out of the 720p openvid parquets.
|
||||
# Symlinks a few data_chunk parquets into a dedicated dir; the map-style loader walks it for
|
||||
# *.parquet, so a couple of chunks (~32 clips each) is a good overfit set. Non-destructive
|
||||
# (symlinks only). Re-run to rebuild; it clears the stale map_style_cache.
|
||||
set -uo pipefail
|
||||
|
||||
SRC_ROOT=${SRC_ROOT:-/home/hal-shared/motionstream/data/openvid-wantrack-parquets}
|
||||
SRC_SHARD=${SRC_SHARD:-shard000}
|
||||
N_CHUNKS=${N_CHUNKS:-2} # ~32 clips/chunk -> ~64 clips
|
||||
OUT=${OUT:-/home/hal-kevin/data/motion-stream-test/overfit_subset_720p/combined_parquet_dataset}
|
||||
|
||||
src_worker="$SRC_ROOT/$SRC_SHARD/combined_parquet_dataset/worker_0"
|
||||
[ -d "$src_worker" ] || { echo "[subset] source not found: $src_worker" >&2; exit 1; }
|
||||
|
||||
dst_worker="$OUT/worker_0"
|
||||
rm -rf "$OUT" # drop old subset + its map_style_cache
|
||||
mkdir -p "$dst_worker"
|
||||
|
||||
n=0
|
||||
for f in $(ls "$src_worker"/data_chunk_*.parquet | sort -V | head -n "$N_CHUNKS"); do
|
||||
ln -s "$(readlink -f "$f")" "$dst_worker/$(basename "$f")"
|
||||
n=$((n + 1))
|
||||
done
|
||||
|
||||
echo "[subset] linked $n parquet chunk(s) from $SRC_SHARD -> $OUT"
|
||||
python - "$OUT" <<'PY'
|
||||
import glob, sys, pyarrow.parquet as pq
|
||||
fs = glob.glob(f"{sys.argv[1]}/**/*.parquet", recursive=True)
|
||||
rows = sum(pq.ParquetFile(f).metadata.num_rows for f in fs)
|
||||
print(f"[subset] {len(fs)} file(s), {rows} clips total")
|
||||
PY
|
||||
echo "[subset] point the overfit config data_path at: $OUT"
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
# Phase 0, step 1 — build the d64 + bias 14B WanTrack init for the overfit.
|
||||
# CPU only, needs ~62GB RAM (loads the 14B base) -> run on a COMPUTE node, not the login node.
|
||||
# pretrained channels are preserved; the added track slot is ZERO-init (--pe-init zero, matching
|
||||
# upstream's trackwan_14b_i2v_d64_zero_init_bias) and the track_encoder gets default init WITH bias
|
||||
# (--use-bias-defaults), matching TRACKWAN_TRACK_BIAS=1 at train time.
|
||||
set -uo pipefail
|
||||
cd ~/FastVideo
|
||||
|
||||
BASE=${BASE:-/home/hal-kevin/models/Wan2.1-I2V-14B-720P-Diffusers}
|
||||
OUT=${OUT:-/home/hal-kevin/models/trackwan_14b_i2v_d64_bias_init}
|
||||
ID_DIM=${ID_DIM:-64}
|
||||
PE_INIT=${PE_INIT:-zero}
|
||||
|
||||
python data_pipeline/convert_trackwan_init_v2.py \
|
||||
--base "$BASE" \
|
||||
--out "$OUT" \
|
||||
--id-dim "$ID_DIM" \
|
||||
--pe-init "$PE_INIT" \
|
||||
--use-bias-defaults
|
||||
|
||||
echo "[init] built $OUT (id_dim=$ID_DIM, pe-init=$PE_INIT, bias=on)"
|
||||
echo "[init] expect: 'added 4 track_encoder tensors' (2 weights + 2 bias)"
|
||||
python -c "
|
||||
from safetensors import safe_open
|
||||
ks=[k for k in safe_open('$OUT/transformer/diffusion_pytorch_model.safetensors','pt').keys() if 'track_encoder' in k]
|
||||
import json; c=json.load(open('$OUT/transformer/config.json'))
|
||||
print('[init] in_channels', c['in_channels'], '| track_config.id_dim', c['track_config']['id_dim'])
|
||||
print('[init] track_encoder keys:', sorted(ks))
|
||||
"
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/bin/bash
|
||||
# Phase 0, step 2 — overfit the track pathway. 14B/720p, d64+bias, sparse, HEAD TRAINABLE.
|
||||
# Run the SAME command on BOTH racks (2x4 GB200). MASTER_ADDR = rack0 host on both; NODE_RANK
|
||||
# differs (0 on rack0, 1 on rack1). STAGE=A uses fixed track IDs (lock onto the pattern), STAGE=B
|
||||
# uses random IDs; run A first, then B (B resumes A's checkpoint via resume_from_checkpoint: latest).
|
||||
#
|
||||
# rack0: MASTER_ADDR=hpc-rack-1-6 NODE_RANK=0 STAGE=A bash data_pipeline/720_stage_1/02_run_overfit.sh
|
||||
# rack1: MASTER_ADDR=hpc-rack-1-6 NODE_RANK=1 STAGE=A bash data_pipeline/720_stage_1/02_run_overfit.sh
|
||||
# Extra args pass through to the trainer, e.g. ... STAGE=B bash 02_run_overfit.sh --training.loop.max_train_steps 1600
|
||||
set -uo pipefail
|
||||
cd ~/FastVideo
|
||||
|
||||
: "${MASTER_ADDR:?set MASTER_ADDR to rack-0 hostname (reachable from both racks)}"
|
||||
: "${NODE_RANK:?set NODE_RANK: 0 on the master rack, 1 on the other}"
|
||||
STAGE=${STAGE:-A}
|
||||
MASTER_PORT=${MASTER_PORT:-29502}
|
||||
CFG=data_pipeline/720_stage_1/finetune_wantrack_overfit_14b_720p_d64_bias.yaml
|
||||
|
||||
# Stage A = deterministic track IDs (fixed sampling); Stage B = random IDs.
|
||||
# B writes to its OWN output dir (keeps A's dir pristine, A/B checkpoints separated) — mirrors
|
||||
# upstream chain_stepAB.sh + run_stepB_seed.sh. Seed B's dir once with A's final checkpoint via
|
||||
# 02b_seed_stageB.sh BEFORE launching B, so resume_from_checkpoint: latest picks up A's weights.
|
||||
OUT_A=/home/hal-kevin/data/motion-stream-test/overfit_14b_720p_d64_bias_out
|
||||
OUT_B=/home/hal-kevin/data/motion-stream-test/overfit_14b_720p_d64_bias_stageB_out
|
||||
case "$STAGE" in
|
||||
A) FIXED=1; OUT_DIR=$OUT_A ;;
|
||||
B) FIXED=0; OUT_DIR=$OUT_B ;;
|
||||
*) echo "STAGE must be A or B (got '$STAGE')" >&2; exit 1 ;;
|
||||
esac
|
||||
if [ "$STAGE" = B ] && [ ! -d "$OUT_B" ]; then
|
||||
echo "[overfit] ERROR: STAGE=B but $OUT_B does not exist." >&2
|
||||
echo " Seed it first (run ONCE): bash data_pipeline/720_stage_1/02b_seed_stageB.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Pin NCCL to the four active 400G InfiniBand HCAs (keep off the 200G Ethernet port).
|
||||
export NCCL_IB_HCA=mlx5_0,mlx5_1,mlx5_3,mlx5_4
|
||||
# export NCCL_DEBUG=INFO # uncomment on the FIRST launch to confirm NET/IB, then re-comment.
|
||||
|
||||
FASTVIDEO_FA4=1 \
|
||||
TRACKWAN_TRACK_BIAS=1 WANTRACK_FREEZE_HEAD=0 \
|
||||
WANTRACK_AUG=1 WANTRACK_SPARSE=1 WANTRACK_EXTRA_RANDOM=20 \
|
||||
WANTRACK_PMASK=0 WANTRACK_MASK_CHUNK=0 \
|
||||
WANTRACK_IMAGE_COND=0 \
|
||||
WANTRACK_FIXED_SAMPLE=${FIXED} \
|
||||
torchrun \
|
||||
--nnodes=2 --nproc_per_node=4 --node_rank=${NODE_RANK} \
|
||||
--rdzv_id=overfit_14b_720p --rdzv_backend=c10d --rdzv_endpoint=${MASTER_ADDR}:${MASTER_PORT} \
|
||||
--log-dir ~/FastVideo/torchrun_logs \
|
||||
-m fastvideo.train.entrypoint.train \
|
||||
--config ${CFG} \
|
||||
--training.checkpoint.output_dir ${OUT_DIR} \
|
||||
--training.tracker.run_name overfit-14b-720p-d64-bias-stage${STAGE} \
|
||||
--training.checkpoint.resume_from_checkpoint latest \
|
||||
"$@" \
|
||||
2>&1 | tee -a data_pipeline/720_stage_1/overfit_node${NODE_RANK}_stage${STAGE}.log
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
# Seed Stage B's output dir with Stage A's FINAL checkpoint, so B runs in its own dir while its
|
||||
# config still uses resume_from_checkpoint: latest (which is what makes crash-restarts safe).
|
||||
# Mirrors upstream examples/train/run_stepB_seed.sh.
|
||||
#
|
||||
# Run this ONCE (not per-rack) on the shared filesystem, AFTER Stage A finishes, BEFORE launching
|
||||
# Stage B. Hardlinked (cp -al), not copied: a 14B training-state checkpoint is 100s of GB, and both
|
||||
# dirs are on the same filesystem (/home), so hardlinks are instant and use no extra space.
|
||||
#
|
||||
# bash data_pipeline/720_stage_1/02b_seed_stageB.sh
|
||||
# # then, on BOTH racks:
|
||||
# MASTER_ADDR=hpc-rack-1-6 NODE_RANK=0 STAGE=B bash data_pipeline/720_stage_1/02_run_overfit.sh \
|
||||
# --training.loop.max_train_steps 3000
|
||||
set -uo pipefail
|
||||
|
||||
OUT_A=${OUT_A:-/home/hal-kevin/data/motion-stream-test/overfit_14b_720p_d64_bias_out}
|
||||
OUT_B=${OUT_B:-/home/hal-kevin/data/motion-stream-test/overfit_14b_720p_d64_bias_stageB_out}
|
||||
|
||||
# Pick A's latest COMPLETE checkpoint (dcp/.metadata present) — refuse to seed off a partial save.
|
||||
STEP=${STEP:-}
|
||||
if [ -z "$STEP" ]; then
|
||||
for d in $(for c in "$OUT_A"/checkpoint-*; do n=${c##*checkpoint-}; echo "$n"; done | sort -rn); do
|
||||
[ -f "$OUT_A/checkpoint-$d/dcp/.metadata" ] && { STEP=$d; break; }
|
||||
done
|
||||
fi
|
||||
[ -n "$STEP" ] || { echo "[B-seed] no complete checkpoint found under $OUT_A" >&2; exit 1; }
|
||||
|
||||
SRC="$OUT_A/checkpoint-$STEP"
|
||||
DST="$OUT_B/checkpoint-$STEP"
|
||||
[ -f "$SRC/dcp/.metadata" ] || { echo "[B-seed] $SRC incomplete (no dcp/.metadata) — refusing" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$OUT_B"
|
||||
if [ -e "$DST" ]; then
|
||||
echo "[B-seed] $DST already exists — leaving it alone"
|
||||
else
|
||||
echo "[B-seed] hardlinking $SRC -> $DST"
|
||||
cp -al "$SRC" "$DST"
|
||||
fi
|
||||
echo "[B-seed] Stage B dir now seeded at step $STEP:"
|
||||
for c in "$OUT_B"/checkpoint-*; do echo " $c"; done
|
||||
echo "[B-seed] Launch B with --training.loop.max_train_steps > $STEP (e.g. $((STEP + 1000)))."
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
# Phase 0, step 3 — export the overfit DCP checkpoint to a diffusers model dir.
|
||||
# The Phase-1 merge (convert_trackwan_init_v2.py --track-src/--pe-src) reads this diffusers dir.
|
||||
# Only 1 GPU needed (DCP reshards automatically). Run on a compute node.
|
||||
set -uo pipefail
|
||||
cd ~/FastVideo
|
||||
|
||||
# --checkpoint accepts an output_dir (auto-picks the latest checkpoint-<step>), a specific
|
||||
# checkpoint-<step> dir, or its dcp/ subdir.
|
||||
# Default = the Stage-B dir (the FINAL overfit; B refines A and is what the merge lifts). Override
|
||||
# CKPT to the Stage-A dir only if you deliberately want to export A.
|
||||
CKPT=${CKPT:-/home/hal-kevin/data/motion-stream-test/overfit_14b_720p_d64_bias_stageB_out}
|
||||
OUT=${OUT:-/home/hal-kevin/models/overfit14b_export}
|
||||
CFG=${CFG:-data_pipeline/720_stage_1/finetune_wantrack_overfit_14b_720p_d64_bias.yaml}
|
||||
|
||||
# TRACKWAN_TRACK_BIAS=1 MUST match training: it toggles bias on track_encoder.{proj,temporal_conv}
|
||||
# at build time (track_encoder.py:73). The overfit trained with bias=1, so the checkpoint carries
|
||||
# those bias params; building bias-less here fails to load them ("track_encoder.proj.bias not found").
|
||||
TRACKWAN_TRACK_BIAS=1 \
|
||||
python -m fastvideo.train.entrypoint.dcp_to_diffusers \
|
||||
--checkpoint "$CKPT" \
|
||||
--output-dir "$OUT" \
|
||||
--config "$CFG" \
|
||||
--role student \
|
||||
--overwrite \
|
||||
|| { echo "[export] FAILED (see traceback above) -- nothing written" >&2; exit 1; }
|
||||
|
||||
# Guard against a silent partial write (the entrypoint can exit 0 yet write no weights).
|
||||
ls "$OUT"/transformer/*.safetensors >/dev/null 2>&1 \
|
||||
|| { echo "[export] ERROR: no transformer/*.safetensors under $OUT -- export did not complete" >&2; exit 1; }
|
||||
|
||||
echo "[export] wrote diffusers model -> $OUT"
|
||||
echo "[export] next (Phase 1 merge):"
|
||||
echo " python data_pipeline/convert_trackwan_init_v2.py \\"
|
||||
echo " --base /home/hal-kevin/models/Wan2.1-I2V-14B-720P-Diffusers \\"
|
||||
echo " --out /home/hal-kevin/models/trackwan_14b_i2v_d64_merged_from_overfit_bias \\"
|
||||
echo " --id-dim 64 --pe-init random \\"
|
||||
echo " --track-src $OUT/transformer --pe-src $OUT/transformer"
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
# Step 04 — MERGE. Graft the overfit's co-adapted track pathway (track_encoder + patch-embed track
|
||||
# slot [36:52]) onto a PRISTINE 14B base, discarding the overfit's base degradation. CPU only,
|
||||
# ~62GB RAM -> run on a compute node. Needs 03_export.sh to have produced the overfit diffusers dir.
|
||||
set -uo pipefail
|
||||
cd ~/FastVideo
|
||||
|
||||
BASE=${BASE:-/home/hal-kevin/models/Wan2.1-I2V-14B-720P-Diffusers}
|
||||
SRC=${SRC:-/home/hal-kevin/models/overfit14b_export} # produced by 03_export.sh
|
||||
OUT=${OUT:-/home/hal-kevin/models/trackwan_14b_i2v_d64_merged_from_overfit_bias}
|
||||
ID_DIM=${ID_DIM:-64}
|
||||
|
||||
[ -d "$SRC/transformer" ] || { echo "[merge] $SRC/transformer not found — run 03_export.sh first" >&2; exit 1; }
|
||||
|
||||
# --track-src + --pe-src from the SAME export = the merge: encoder AND its co-adapted track slot
|
||||
# lifted together (bias copied through); everything else comes from the pristine --base.
|
||||
python data_pipeline/convert_trackwan_init_v2.py \
|
||||
--base "$BASE" \
|
||||
--out "$OUT" \
|
||||
--id-dim "$ID_DIM" --pe-init random \
|
||||
--track-src "$SRC/transformer" \
|
||||
--pe-src "$SRC/transformer"
|
||||
|
||||
echo "[merge] built merged init -> $OUT"
|
||||
python -c "
|
||||
import json; c=json.load(open('$OUT/transformer/config.json'))
|
||||
print('[merge] in_channels', c['in_channels'], '| id_dim', c['track_config']['id_dim'])
|
||||
"
|
||||
echo "[merge] next: 05_run_openvid_stage1.sh (init_from defaults to this dir)"
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
# Step 05 — OpenVid Stage 1 (the big run, ~10 days). 14B/720p, merged init, HEAD FROZEN, sparse.
|
||||
# Run the SAME command on BOTH racks. MASTER_ADDR = rack0 host on both; NODE_RANK 0 on rack0, 1 on rack1.
|
||||
# rack0: MASTER_ADDR=hpc-rack-1-6 NODE_RANK=0 bash data_pipeline/720_stage_1/05_run_openvid_stage1.sh
|
||||
# rack1: MASTER_ADDR=hpc-rack-1-6 NODE_RANK=1 bash data_pipeline/720_stage_1/05_run_openvid_stage1.sh
|
||||
# resume_from_checkpoint: latest -> relaunch both to continue after any interruption.
|
||||
set -uo pipefail
|
||||
cd ~/FastVideo
|
||||
|
||||
: "${MASTER_ADDR:?set MASTER_ADDR to rack-0 hostname (reachable from both racks)}"
|
||||
: "${NODE_RANK:?set NODE_RANK: 0 on the master rack, 1 on the other}"
|
||||
MASTER_PORT=${MASTER_PORT:-29503}
|
||||
NNODES=${NNODES:-8} # 8 nodes x 4 GB200 = 32 GPUs (matches num_gpus:32 / replicate 8 in the config)
|
||||
GPUS_PER_NODE=${GPUS_PER_NODE:-4}
|
||||
CFG=data_pipeline/720_stage_1/finetune_wantrack_openvid_stage1_14b_720p_d64_bias.yaml
|
||||
|
||||
export NCCL_IB_HCA=mlx5_0,mlx5_1,mlx5_3,mlx5_4
|
||||
# export NCCL_DEBUG=INFO # uncomment on the FIRST launch to confirm NET/IB, then re-comment.
|
||||
|
||||
# Stage 1: sparse conditioning, HEAD FROZEN (train the DiT to use the merged track pathway), no masking.
|
||||
# Every WANTRACK_/TRACKWAN_ knob is set EXPLICITLY (no reliance on code defaults) to match upstream
|
||||
# stage-1 (D) and to avoid inheriting the overfit launcher's opposite settings (IMAGE_COND=0, FIXED_SAMPLE=1).
|
||||
FASTVIDEO_FA4=1 \
|
||||
TRACKWAN_TRACK_BIAS=1 \
|
||||
WANTRACK_FREEZE_HEAD=1 \
|
||||
WANTRACK_IMAGE_COND=1 \
|
||||
WANTRACK_AUG=1 \
|
||||
WANTRACK_SPARSE=1 WANTRACK_EXTRA_RANDOM=20 WANTRACK_EXTRA_MODE=random \
|
||||
WANTRACK_FIXED_SAMPLE=0 \
|
||||
WANTRACK_PMASK=0 WANTRACK_MASK_CHUNK=0 \
|
||||
WANTRACK_TRACK_DROP=0 WANTRACK_MOTION_DROP=0 WANTRACK_TEXT_DROP=0 \
|
||||
torchrun \
|
||||
--nnodes=${NNODES} --nproc_per_node=${GPUS_PER_NODE} --node_rank=${NODE_RANK} \
|
||||
--rdzv_id=openvid_stage1_14b_720p --rdzv_backend=c10d --rdzv_endpoint=${MASTER_ADDR}:${MASTER_PORT} \
|
||||
--log-dir ~/FastVideo/torchrun_logs \
|
||||
-m fastvideo.train.entrypoint.train \
|
||||
--config ${CFG} \
|
||||
--training.distributed.num_gpus $((NNODES * GPUS_PER_NODE)) \
|
||||
--training.checkpoint.resume_from_checkpoint latest \
|
||||
"$@" \
|
||||
2>&1 | tee -a data_pipeline/720_stage_1/openvid_stage1_node${NODE_RANK}.log
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/bin/bash
|
||||
# Step 05 (2-RACK variant) — OpenVid Stage 1 on 2 racks (2x4 = 8 GB200) instead of 8 nodes.
|
||||
#
|
||||
# SAME effective global batch as the 8-node 05: grad_accum is bumped 4x (4 -> 16) to compensate for
|
||||
# 4x fewer GPUs, so the optimization is equivalent (see note below) — only the wall-clock is ~4x
|
||||
# (~48 days vs ~12). Run the SAME command on BOTH racks; MASTER_ADDR = rack0 host on both,
|
||||
# NODE_RANK 0 on rack0, 1 on rack1.
|
||||
# rack0: MASTER_ADDR=hpc-rack-1-6 NODE_RANK=0 bash data_pipeline/720_stage_1/05b_run_openvid_stage1_2rack.sh
|
||||
# rack1: MASTER_ADDR=hpc-rack-1-6 NODE_RANK=1 bash data_pipeline/720_stage_1/05b_run_openvid_stage1_2rack.sh
|
||||
# resume_from_checkpoint: latest -> relaunch both to continue after any interruption.
|
||||
set -uo pipefail
|
||||
cd ~/FastVideo
|
||||
|
||||
: "${MASTER_ADDR:?set MASTER_ADDR to rack-0 hostname (reachable from both racks)}"
|
||||
: "${NODE_RANK:?set NODE_RANK: 0 on the master rack, 1 on the other}"
|
||||
MASTER_PORT=${MASTER_PORT:-29503}
|
||||
NNODES=${NNODES:-2} # 2 racks x 4 GB200 = 8 GPUs
|
||||
GPUS_PER_NODE=${GPUS_PER_NODE:-4}
|
||||
CFG=data_pipeline/720_stage_1/finetune_wantrack_openvid_stage1_14b_720p_d64_bias.yaml
|
||||
# bf16 parquets (complete 259k set, ~4.7TB). Loader honors the per-field _dtype; same clips/order
|
||||
# as fp32 (so val_sample_indices are unchanged). Override to the fp32 set if ever needed:
|
||||
# DATA_PATH=/home/hal-shared/motionstream/data/openvid-wantrack-parquets
|
||||
DATA_PATH=${DATA_PATH:-/home/hal-shared/motionstream/data/openvid-wantrack-parquets-bf16}
|
||||
|
||||
# --- batch math: hold the effective global batch EQUAL to the 8-node 05 ------------------
|
||||
# 8-node config: replicate 8 x shard 4 = 32 GPUs, grad_accum 4.
|
||||
# 2 racks: replicate 2 x shard 4 = 8 GPUs, grad_accum 16.
|
||||
# 4x fewer GPUs x 4x grad_accum = same effective batch under EITHER counting convention
|
||||
# (num_gpus- or replicate_dim-based both scale by 4). Model still shards across 4 GPUs
|
||||
# (shard_dim 4) as in the 8-node run, so per-GPU memory is unchanged (no OOM risk from this).
|
||||
HSDP_REPLICATE=${HSDP_REPLICATE:-2}
|
||||
HSDP_SHARD=${HSDP_SHARD:-4}
|
||||
GRAD_ACCUM=${GRAD_ACCUM:-16}
|
||||
|
||||
export NCCL_IB_HCA=mlx5_0,mlx5_1,mlx5_3,mlx5_4
|
||||
# export NCCL_DEBUG=INFO # uncomment on the FIRST launch to confirm NET/IB, then re-comment.
|
||||
|
||||
# Stage 1 env — identical to 05 (every knob explicit): sparse, HEAD FROZEN, CLIP on, no masking.
|
||||
FASTVIDEO_FA4=1 \
|
||||
TRACKWAN_TRACK_BIAS=1 \
|
||||
WANTRACK_FREEZE_HEAD=1 \
|
||||
WANTRACK_IMAGE_COND=1 \
|
||||
WANTRACK_AUG=1 \
|
||||
WANTRACK_SPARSE=1 WANTRACK_EXTRA_RANDOM=20 WANTRACK_EXTRA_MODE=random \
|
||||
WANTRACK_FIXED_SAMPLE=0 \
|
||||
WANTRACK_PMASK=0 WANTRACK_MASK_CHUNK=0 \
|
||||
WANTRACK_TRACK_DROP=0 WANTRACK_MOTION_DROP=0 WANTRACK_TEXT_DROP=0 \
|
||||
torchrun \
|
||||
--nnodes=${NNODES} --nproc_per_node=${GPUS_PER_NODE} --node_rank=${NODE_RANK} \
|
||||
--rdzv_id=openvid_stage1_14b_720p_2rack --rdzv_backend=c10d --rdzv_endpoint=${MASTER_ADDR}:${MASTER_PORT} \
|
||||
--log-dir ~/FastVideo/torchrun_logs \
|
||||
-m fastvideo.train.entrypoint.train \
|
||||
--config ${CFG} \
|
||||
--training.data.data_path ${DATA_PATH} \
|
||||
--training.distributed.num_gpus $((NNODES * GPUS_PER_NODE)) \
|
||||
--training.distributed.hsdp_replicate_dim ${HSDP_REPLICATE} \
|
||||
--training.distributed.hsdp_shard_dim ${HSDP_SHARD} \
|
||||
--training.loop.gradient_accumulation_steps ${GRAD_ACCUM} \
|
||||
--training.checkpoint.resume_from_checkpoint latest \
|
||||
--training.checkpoint.training_state_checkpointing_steps 20 \
|
||||
--training.checkpoint.checkpoints_total_limit 50 \
|
||||
--callbacks.track_validation.validate_at_start false \
|
||||
--callbacks.track_validation.every_steps 100 \
|
||||
--callbacks.track_validation.val_sample_indices "[1660, 1888]" \
|
||||
"$@" \
|
||||
2>&1 | tee -a data_pipeline/720_stage_1/openvid_stage1_2rack_node${NODE_RANK}.log
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
# Step 06 — export the OpenVid Stage-1 DCP checkpoint to a diffusers dir. Stage 2 (07) inits its
|
||||
# WEIGHTS from this export (init_from, fresh optimizer/step), so it must be a diffusers model dir,
|
||||
# not a DCP resume. Only 1 GPU needed. Run on a compute node.
|
||||
set -uo pipefail
|
||||
cd ~/FastVideo
|
||||
|
||||
CKPT=${CKPT:-/home/hal-kevin/data/motion-stream-test/openvid_stage1_14b_720p_out} # auto-picks latest
|
||||
OUT=${OUT:-/home/hal-kevin/models/openvid_stage1_14b_export}
|
||||
CFG=${CFG:-data_pipeline/720_stage_1/finetune_wantrack_openvid_stage1_14b_720p_d64_bias.yaml}
|
||||
|
||||
python -m fastvideo.train.entrypoint.dcp_to_diffusers \
|
||||
--checkpoint "$CKPT" \
|
||||
--output-dir "$OUT" \
|
||||
--config "$CFG" \
|
||||
--role student \
|
||||
--overwrite
|
||||
|
||||
echo "[export] stage-1 teacher -> $OUT"
|
||||
echo "[export] next: 07_run_synth_stage2.sh (init_from defaults to this dir)"
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
# Step 07 — Stage 2 (robustness, ~1 day). 14B/720p, inits from exported Stage-1 teacher, HEAD FROZEN,
|
||||
# sparse + heavy masking/dropout. Produces the FINAL bidir teacher. Run on BOTH racks (same command,
|
||||
# NODE_RANK 0/1, MASTER_ADDR = rack0).
|
||||
# rack0: MASTER_ADDR=hpc-rack-1-6 NODE_RANK=0 bash data_pipeline/720_stage_1/07_run_synth_stage2.sh
|
||||
# rack1: MASTER_ADDR=hpc-rack-1-6 NODE_RANK=1 bash data_pipeline/720_stage_1/07_run_synth_stage2.sh
|
||||
set -uo pipefail
|
||||
cd ~/FastVideo
|
||||
|
||||
: "${MASTER_ADDR:?set MASTER_ADDR to rack-0 hostname (reachable from both racks)}"
|
||||
: "${NODE_RANK:?set NODE_RANK: 0 on the master rack, 1 on the other}"
|
||||
MASTER_PORT=${MASTER_PORT:-29504}
|
||||
NNODES=${NNODES:-8} # 8 nodes x 4 GB200 = 32 GPUs (matches num_gpus:32 / replicate 8 in the config)
|
||||
GPUS_PER_NODE=${GPUS_PER_NODE:-4}
|
||||
CFG=data_pipeline/720_stage_1/finetune_wantrack_synth_stage2_14b_720p_d64_bias.yaml
|
||||
|
||||
export NCCL_IB_HCA=mlx5_0,mlx5_1,mlx5_3,mlx5_4
|
||||
# export NCCL_DEBUG=INFO # first launch only.
|
||||
|
||||
# Stage 2: sparse + robustness masking/dropout, HEAD still FROZEN.
|
||||
# Every WANTRACK_/TRACKWAN_ knob is set EXPLICITLY (no code defaults) to match upstream stage-2 (E):
|
||||
# same as stage-1 but with masking (PMASK=0.2/CHUNK=8) and dropout (TRACK=0.5, MOTION=0.3) ON.
|
||||
FASTVIDEO_FA4=1 \
|
||||
TRACKWAN_TRACK_BIAS=1 \
|
||||
WANTRACK_FREEZE_HEAD=1 \
|
||||
WANTRACK_IMAGE_COND=1 \
|
||||
WANTRACK_AUG=1 \
|
||||
WANTRACK_SPARSE=1 WANTRACK_EXTRA_RANDOM=20 WANTRACK_EXTRA_MODE=random \
|
||||
WANTRACK_FIXED_SAMPLE=0 \
|
||||
WANTRACK_PMASK=0.2 WANTRACK_MASK_CHUNK=8 \
|
||||
WANTRACK_TRACK_DROP=0.5 WANTRACK_MOTION_DROP=0.3 WANTRACK_TEXT_DROP=0 \
|
||||
torchrun \
|
||||
--nnodes=${NNODES} --nproc_per_node=${GPUS_PER_NODE} --node_rank=${NODE_RANK} \
|
||||
--rdzv_id=synth_stage2_14b_720p --rdzv_backend=c10d --rdzv_endpoint=${MASTER_ADDR}:${MASTER_PORT} \
|
||||
--log-dir ~/FastVideo/torchrun_logs \
|
||||
-m fastvideo.train.entrypoint.train \
|
||||
--config ${CFG} \
|
||||
--training.distributed.num_gpus $((NNODES * GPUS_PER_NODE)) \
|
||||
--training.checkpoint.resume_from_checkpoint latest \
|
||||
"$@" \
|
||||
2>&1 | tee -a data_pipeline/720_stage_1/synth_stage2_node${NODE_RANK}.log
|
||||
@@ -0,0 +1,83 @@
|
||||
# Stage 1 — Bidirectional 14B/720p track teacher (full pipeline)
|
||||
|
||||
The complete bidirectional teacher: overfit the track pathway → merge it into a pristine 14B
|
||||
base → OpenVid stage-1 (frozen head) → synth stage-2 (robustness). The final teacher then seeds
|
||||
the causal (Self-Forcing) student. Full rationale: `../notes/trackwan_14b_720p_teacher_merge_plan.md`.
|
||||
|
||||
Recipe: **d64 + bias, sparse conditioning, flow_shift 6, 720p.** Hardware: 2×4 GB200, 400G IB,
|
||||
manual 2-node `torchrun` (rack0 = `hpc-rack-1-6` = NODE_RANK 0; rack1 = `hpc-rack-1-8` = NODE_RANK 1;
|
||||
`MASTER_ADDR` = rack0 on both).
|
||||
|
||||
## Files / run order
|
||||
|
||||
| Step | Script | What | Where |
|
||||
|---|---|---|---|
|
||||
| 0 | `00_make_overfit_subset.sh` | carve a small overfit set (symlinks ~2 parquet chunks) | login/compute |
|
||||
| 1 | `01_build_init.sh` | build `trackwan_14b_i2v_d64_bias_init` | compute (CPU, ~62GB RAM) |
|
||||
| 2 | `02_run_overfit.sh` | overfit track pathway, head trainable (Stage A→B) | **both racks** |
|
||||
| 3 | `03_export.sh` | overfit DCP → diffusers | compute (1 GPU) |
|
||||
| 4 | `04_merge.sh` | graft pathway into pristine 14B base → merged init | compute (CPU, ~62GB) |
|
||||
| 5 | `05_run_openvid_stage1.sh` | OpenVid stage-1, **head frozen** (~10 days) | **both racks** |
|
||||
| 6 | `06_export_stage1.sh` | stage-1 DCP → diffusers | compute (1 GPU) |
|
||||
| 7 | `07_run_synth_stage2.sh` | stage-2 robustness, head frozen (~1 day) → **final teacher** | **both racks** |
|
||||
|
||||
Configs (referenced by the scripts): `finetune_wantrack_overfit_14b_720p_d64_bias.yaml`,
|
||||
`finetune_wantrack_openvid_stage1_14b_720p_d64_bias.yaml`,
|
||||
`finetune_wantrack_synth_stage2_14b_720p_d64_bias.yaml`.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# 0-1: data + init
|
||||
bash data_pipeline/720_stage_1/00_make_overfit_subset.sh
|
||||
bash data_pipeline/720_stage_1/01_build_init.sh # compute node
|
||||
|
||||
# 2: overfit — both racks, Stage A (fixed IDs) then Stage B (random IDs, resumes A)
|
||||
MASTER_ADDR=hpc-rack-1-6 NODE_RANK=0 STAGE=A bash data_pipeline/720_stage_1/02_run_overfit.sh # rack0
|
||||
MASTER_ADDR=hpc-rack-1-6 NODE_RANK=1 STAGE=A bash data_pipeline/720_stage_1/02_run_overfit.sh # rack1
|
||||
# ...when track-following is clear in validation, stop and run Stage B (bump steps as needed):
|
||||
MASTER_ADDR=hpc-rack-1-6 NODE_RANK=0 STAGE=B bash data_pipeline/720_stage_1/02_run_overfit.sh --training.loop.max_train_steps 1600
|
||||
MASTER_ADDR=hpc-rack-1-6 NODE_RANK=1 STAGE=B bash data_pipeline/720_stage_1/02_run_overfit.sh --training.loop.max_train_steps 1600
|
||||
|
||||
# 3-4: export + merge
|
||||
bash data_pipeline/720_stage_1/03_export.sh # compute node
|
||||
bash data_pipeline/720_stage_1/04_merge.sh # compute node
|
||||
|
||||
# 5: OpenVid stage-1 — both racks (~10 days; resume by relaunching)
|
||||
MASTER_ADDR=hpc-rack-1-6 NODE_RANK=0 bash data_pipeline/720_stage_1/05_run_openvid_stage1.sh # rack0
|
||||
MASTER_ADDR=hpc-rack-1-6 NODE_RANK=1 bash data_pipeline/720_stage_1/05_run_openvid_stage1.sh # rack1
|
||||
|
||||
# 6-7: export stage-1 + synth stage-2 -> final teacher
|
||||
bash data_pipeline/720_stage_1/06_export_stage1.sh # compute node
|
||||
MASTER_ADDR=hpc-rack-1-6 NODE_RANK=0 bash data_pipeline/720_stage_1/07_run_synth_stage2.sh # rack0
|
||||
MASTER_ADDR=hpc-rack-1-6 NODE_RANK=1 bash data_pipeline/720_stage_1/07_run_synth_stage2.sh # rack1
|
||||
```
|
||||
|
||||
## Notes & decisions baked in
|
||||
|
||||
- **Topology / batch:** stage-1 and stage-2 target the **upstream recipe on 8 nodes × 4 GB200 = 32
|
||||
GPUs** — `num_gpus 32, hsdp_replicate_dim 8, hsdp_shard_dim 4, grad_accum 4 → global bs 128`.
|
||||
Stage-1 = 4,800 steps = 2.4 epochs over 259k ≈ **~12 days**; stage-2 = 600 steps ≈ **~1.5 days**.
|
||||
(On only 8 GPUs / 2 nodes this recipe is ~49 days — if you drop back, set `NNODES=2`,
|
||||
`hsdp_replicate_dim 2`, `grad_accum 2` → global bs 16, and use ~8000 stage-1 steps for a ~10-day
|
||||
half-epoch.) The overfit (steps 0–3) stays on **2 nodes** — it's ~64 clips, so 32 GPUs / bs 128
|
||||
would exceed the dataset; leave it at `NNODES=2` there.
|
||||
- **Launching the 8-node stages (05, 07):** the scripts take `NNODES` (default 8) and pass a matching
|
||||
`--training.distributed.num_gpus`. But manually running one command on each of 8 nodes (NODE_RANK
|
||||
0…7) is impractical — **use a SLURM launcher** (`srun` spans all nodes with `--node-rank=$SLURM_PROCID`,
|
||||
as in `examples/train/run_slurm.sh` / `run_wan14b_held.sh`). The overfit (02) is fine to launch
|
||||
manually on 2 nodes.
|
||||
- **Env knobs** live in the launch scripts: overfit = `FREEZE_HEAD=0`; stage-1/2 = `FREEZE_HEAD=1`;
|
||||
stage-2 adds `TRACK_DROP=0.5 MOTION_DROP=0.3 PMASK=0.2 MASK_CHUNK=8`. `TRACKWAN_TRACK_BIAS=1` and
|
||||
`WANTRACK_SPARSE=1 EXTRA_RANDOM=20` throughout.
|
||||
- **Stage-2 data** defaults to the openvid parquets + masking (you have no synth generated). Swap
|
||||
`data_path` to a synth set if you build one; the robustness comes from the masking either way.
|
||||
- **`dit_precision`:** fp32 master (default) for the real teacher stages; upstream uses bf16 — flip
|
||||
only if memory-blocked.
|
||||
- **Monitor track-following, not loss** — the `track_validation` with-track vs no-track/adversarial
|
||||
deltas. Gate the overfit (step 2) on these before merging, and watch stage-1's first validation
|
||||
(step 2000) before committing the full ~10 days. Check efficiency with `python scripts/mfu_estimate.py`.
|
||||
- **Not runnable end-to-end yet:** steps 4–7 chain via default paths (04 reads 03's export, 05 reads
|
||||
04's merge, etc.), so they're correct now but only *run* once the prior step's output exists.
|
||||
- **Two things to smoke-test once:** `dcp_to_diffusers` on a real checkpoint (steps 3 & 6), and that
|
||||
the overfit actually learns track-following on ~64 clips (if weak: `N_CHUNKS=4` in step 0, or more steps).
|
||||
@@ -0,0 +1,86 @@
|
||||
# Step 05 config — OpenVid Stage 1. 14B/720p, merged d64+bias init, HEAD FROZEN, sparse.
|
||||
# The big run: the DiT learns to USE the frozen (merged) track pathway on the full 720p openvid set.
|
||||
#
|
||||
# BATCH: upstream recipe = global bs 128 on 32 GPUs (8 nodes x 4). This config targets that: 4800
|
||||
# steps x 128 = 614k samples = 2.4 epochs over 259k, ~12 days on 32 GPUs. (On only 8 GPUs, bs 128
|
||||
# would be grad_accum 16 = ~49 days; if you drop back to 8 nodes/GPUs, use grad_accum 2 = global
|
||||
# bs 16 and ~8000 steps for a ~10-day half-epoch instead.)
|
||||
models:
|
||||
student:
|
||||
_target_: fastvideo.train.models.wantrack.wantrack.WanTrackModel
|
||||
init_from: /home/hal-kevin/models/trackwan_14b_i2v_d64_merged_from_overfit_bias # from 04_merge.sh
|
||||
trainable: true
|
||||
|
||||
method:
|
||||
_target_: fastvideo.train.methods.fine_tuning.finetune.FineTuneMethod
|
||||
|
||||
training:
|
||||
model_path: /home/hal-kevin/models/trackwan_14b_i2v_d64_merged_from_overfit_bias
|
||||
# dit_precision: fp32 master (default) for the real teacher (standard MFU / stable updates at lr 1e-5).
|
||||
# upstream sets bf16; flip only if memory-blocked (see notes/trackwan_14b_720p_teacher_merge_plan.md).
|
||||
|
||||
distributed:
|
||||
num_gpus: 32
|
||||
sp_size: 1
|
||||
tp_size: 1
|
||||
hsdp_replicate_dim: 8 # across the 8 nodes
|
||||
hsdp_shard_dim: 4 # within each node (over NVLink)
|
||||
|
||||
data:
|
||||
data_path: /home/hal-shared/motionstream/data/openvid-wantrack-parquets # full 259k, 720p
|
||||
dataloader_num_workers: 2
|
||||
train_batch_size: 1
|
||||
training_cfg_rate: 0.0
|
||||
seed: 1000
|
||||
num_latent_t: 31
|
||||
num_height: 720
|
||||
num_width: 1280
|
||||
num_frames: 121
|
||||
|
||||
optimizer:
|
||||
learning_rate: 1.0e-5 # MotionStream teacher stage-1
|
||||
betas: [0.9, 0.999]
|
||||
weight_decay: 0.01
|
||||
lr_scheduler: constant
|
||||
lr_warmup_steps: 0
|
||||
|
||||
loop:
|
||||
max_train_steps: 4800 # upstream stage-1: 2.4 epochs over 259k at bs128; ~12 days on 32 GPUs; resumable
|
||||
gradient_accumulation_steps: 4 # 1 x 32 GPU x 4 = global batch 128
|
||||
|
||||
checkpoint:
|
||||
output_dir: /home/hal-kevin/data/motion-stream-test/openvid_stage1_14b_720p_out
|
||||
training_state_checkpointing_steps: 200
|
||||
checkpoints_total_limit: 5
|
||||
resume_from_checkpoint: latest
|
||||
|
||||
tracker:
|
||||
trackers: [wandb]
|
||||
project_name: wantrack-bidir
|
||||
run_name: openvid-stage1-14b-720p-d64-bias
|
||||
entity: s4duan-uc-san-diego
|
||||
|
||||
model:
|
||||
enable_gradient_checkpointing_type: full
|
||||
|
||||
callbacks:
|
||||
grad_clip:
|
||||
_target_: fastvideo.train.callbacks.grad_clip.GradNormClipCallback
|
||||
max_grad_norm: 1.0
|
||||
track_validation:
|
||||
_target_: fastvideo.train.callbacks.track_validation.TrackValidationCallback
|
||||
every_steps: 2000 # 14B/720p validation is a multi-hour job; keep it rare
|
||||
num_val_samples: 1
|
||||
val_sample_indices: [28]
|
||||
num_inference_steps: 20
|
||||
guidance_scale: 3.0
|
||||
motion_guidance_scale: 1.5
|
||||
fps: 24
|
||||
grid_stride: 3
|
||||
tail: 12
|
||||
include_heldout: false
|
||||
validate_at_start: false
|
||||
seed: 1000
|
||||
|
||||
pipeline:
|
||||
flow_shift: 6
|
||||
@@ -0,0 +1,85 @@
|
||||
# Phase 0 overfit — 14B/720p, d64 + bias, sparse conditioning, HEAD TRAINABLE.
|
||||
# Goal: co-adapt the track pathway (track_encoder + patch-embed track slot) on a small subset so
|
||||
# the model follows tracks. The base is DISCARDED by the Phase-1 merge (only the track pathway is
|
||||
# lifted), so dit_precision: bf16 here is fine + faster. Sparse/bias/freeze knobs come from the
|
||||
# launch script (02_run_overfit.sh), not this file.
|
||||
models:
|
||||
student:
|
||||
_target_: fastvideo.train.models.wantrack.wantrack.WanTrackModel
|
||||
init_from: /home/hal-kevin/models/trackwan_14b_i2v_d64_bias_init
|
||||
trainable: true
|
||||
|
||||
method:
|
||||
_target_: fastvideo.train.methods.fine_tuning.finetune.FineTuneMethod
|
||||
|
||||
training:
|
||||
model_path: /home/hal-kevin/models/trackwan_14b_i2v_d64_bias_init
|
||||
dit_precision: bf16 # overfit base is thrown away by the merge; bf16 for speed
|
||||
|
||||
distributed:
|
||||
num_gpus: 8
|
||||
sp_size: 1
|
||||
tp_size: 1
|
||||
hsdp_replicate_dim: 2 # across the 2 racks
|
||||
hsdp_shard_dim: 4 # within each rack
|
||||
|
||||
data:
|
||||
data_path: /home/hal-kevin/data/motion-stream-synth/preprocessed_i2v_track/combined_parquet_dataset
|
||||
dataloader_num_workers: 2
|
||||
train_batch_size: 1
|
||||
training_cfg_rate: 0.0
|
||||
seed: 1000
|
||||
num_latent_t: 31
|
||||
num_height: 720
|
||||
num_width: 1280
|
||||
num_frames: 121
|
||||
|
||||
optimizer:
|
||||
learning_rate: 1.0e-4 # upstream overfit LR (synth_sparse_*_14b), constant
|
||||
betas: [0.9, 0.999]
|
||||
weight_decay: 0.01
|
||||
lr_scheduler: constant
|
||||
lr_warmup_steps: 0
|
||||
|
||||
loop:
|
||||
max_train_steps: 2000 # upstream overfit steps (per stage); stop earlier if tracks lock in
|
||||
gradient_accumulation_steps: 1 # 1 x 8 GPU x 1 = global batch 8 (fine on a tiny subset)
|
||||
|
||||
checkpoint:
|
||||
output_dir: /home/hal-kevin/data/motion-stream-test/overfit_14b_720p_d64_bias_out
|
||||
training_state_checkpointing_steps: 100
|
||||
checkpoints_total_limit: 30 # keep effectively all (20 ckpts @ every-100 for 2000 steps) so you can export the BEST, not just the latest
|
||||
resume_from_checkpoint: latest
|
||||
|
||||
tracker:
|
||||
trackers: [wandb]
|
||||
project_name: wantrack-bidir
|
||||
run_name: overfit-14b-720p-d64-bias
|
||||
entity: s4duan-uc-san-diego
|
||||
|
||||
model:
|
||||
enable_gradient_checkpointing_type: full
|
||||
|
||||
callbacks:
|
||||
grad_clip:
|
||||
_target_: fastvideo.train.callbacks.grad_clip.GradNormClipCallback
|
||||
max_grad_norm: 1.0
|
||||
track_validation:
|
||||
_target_: fastvideo.train.callbacks.track_validation.TrackValidationCallback
|
||||
# Validate on the overfit clips themselves — you want to SEE track-following emerge. The
|
||||
# with-track vs no-track/adversarial deltas are the signal to stop the overfit.
|
||||
every_steps: 200
|
||||
num_val_samples: 2
|
||||
val_sample_indices: [0, 10]
|
||||
num_inference_steps: 20
|
||||
guidance_scale: 3.0
|
||||
motion_guidance_scale: 1.5
|
||||
fps: 24
|
||||
grid_stride: 3
|
||||
tail: 12
|
||||
include_heldout: false
|
||||
validate_at_start: false
|
||||
seed: 1000
|
||||
|
||||
pipeline:
|
||||
flow_shift: 6
|
||||
@@ -0,0 +1,84 @@
|
||||
# Step 07 config — Stage 2 (robustness). 14B/720p, inits from the exported Stage-1 teacher, HEAD
|
||||
# still FROZEN, sparse + heavy masking/dropout. Short finetune (lr 1e-6) -> the FINAL teacher.
|
||||
#
|
||||
# DATA: upstream uses a synthetic set here for controlled motion. You don't have synth generated,
|
||||
# so this defaults to the SAME 720p openvid parquets + the stage-2 masking env (the robustness comes
|
||||
# from the masking, applied on whatever data). Swap data_path to a synth set if you generate one
|
||||
# (data_pipeline/gen_synth_worker.py). Masking knobs are set in 07_run_synth_stage2.sh, not here.
|
||||
models:
|
||||
student:
|
||||
_target_: fastvideo.train.models.wantrack.wantrack.WanTrackModel
|
||||
init_from: /home/hal-kevin/models/openvid_stage1_14b_export # from 06_export_stage1.sh
|
||||
trainable: true
|
||||
|
||||
method:
|
||||
_target_: fastvideo.train.methods.fine_tuning.finetune.FineTuneMethod
|
||||
|
||||
training:
|
||||
model_path: /home/hal-kevin/models/openvid_stage1_14b_export
|
||||
|
||||
distributed:
|
||||
num_gpus: 32
|
||||
sp_size: 1
|
||||
tp_size: 1
|
||||
hsdp_replicate_dim: 8 # across the 8 nodes
|
||||
hsdp_shard_dim: 4 # within each node
|
||||
|
||||
data:
|
||||
data_path: /home/hal-shared/motionstream/data/openvid-wantrack-parquets # swap to synth if available
|
||||
dataloader_num_workers: 2
|
||||
train_batch_size: 1
|
||||
training_cfg_rate: 0.0
|
||||
seed: 1000
|
||||
num_latent_t: 31
|
||||
num_height: 720
|
||||
num_width: 1280
|
||||
num_frames: 121
|
||||
|
||||
optimizer:
|
||||
learning_rate: 1.0e-6 # low LR, short robustness finetune
|
||||
betas: [0.9, 0.999]
|
||||
weight_decay: 0.01
|
||||
lr_scheduler: constant
|
||||
lr_warmup_steps: 0
|
||||
|
||||
loop:
|
||||
max_train_steps: 600 # upstream stage-2; ~1.5 days on 32 GPUs at global bs 128
|
||||
gradient_accumulation_steps: 4 # 1 x 32 GPU x 4 = global batch 128
|
||||
|
||||
checkpoint:
|
||||
output_dir: /home/hal-kevin/data/motion-stream-test/synth_stage2_14b_720p_out
|
||||
training_state_checkpointing_steps: 100
|
||||
checkpoints_total_limit: 3
|
||||
resume_from_checkpoint: latest
|
||||
|
||||
tracker:
|
||||
trackers: [wandb]
|
||||
project_name: wantrack-bidir
|
||||
run_name: synth-stage2-14b-720p-d64-bias
|
||||
entity: s4duan-uc-san-diego
|
||||
|
||||
model:
|
||||
enable_gradient_checkpointing_type: full
|
||||
|
||||
callbacks:
|
||||
grad_clip:
|
||||
_target_: fastvideo.train.callbacks.grad_clip.GradNormClipCallback
|
||||
max_grad_norm: 1.0
|
||||
track_validation:
|
||||
_target_: fastvideo.train.callbacks.track_validation.TrackValidationCallback
|
||||
every_steps: 300
|
||||
num_val_samples: 1
|
||||
val_sample_indices: [28]
|
||||
num_inference_steps: 20
|
||||
guidance_scale: 3.0
|
||||
motion_guidance_scale: 1.5
|
||||
fps: 24
|
||||
grid_stride: 3
|
||||
tail: 12
|
||||
include_heldout: false
|
||||
validate_at_start: false
|
||||
seed: 1000
|
||||
|
||||
pipeline:
|
||||
flow_shift: 6
|
||||
@@ -0,0 +1,111 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Fill in real captions on a videos2caption.json built from OpenVid clips.
|
||||
|
||||
The OpenVid-WanTrack shards ship mp4s only -- no metadata -- so a manifest built by
|
||||
scanning the clip directory has empty ``cap`` fields. Stage 5 would then encode empty
|
||||
strings through T5, giving every clip an identical null text embedding: text conditioning
|
||||
(and the joint text+motion CFG that depends on a meaningful conditional/null contrast)
|
||||
would be silently dead.
|
||||
|
||||
OpenVid-1M's caption CSV keys on exactly the same filenames (``---_iRTHryQ_13_0to241.mp4``),
|
||||
so the join is 1:1 on basename -- no id parsing required.
|
||||
|
||||
python data_pipeline/add_captions.py --manifest <root>/videos2caption.json
|
||||
|
||||
The CSV is downloaded once from the Hub (~300 MB) and cached; pass --captions-csv to use
|
||||
a local copy. Clips with no caption keep "" and are counted, never silently dropped.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CAPTION_REPO = "nkp37/OpenVid-1M"
|
||||
CAPTION_FILES = ["data/train/OpenVid-1M.csv", "data/train/OpenVidHD.csv"]
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p.add_argument("--manifest", type=Path, required=True, help="videos2caption.json to patch in place.")
|
||||
p.add_argument("--captions-csv", type=Path, nargs="*", default=None,
|
||||
help="Local caption CSV(s). Default: download+cache from the Hub.")
|
||||
p.add_argument("--cache-dir", type=Path, default=Path.home() / ".cache/openvid_captions",
|
||||
help="Where downloaded caption CSVs are cached.")
|
||||
p.add_argument("--min-coverage", type=float, default=0.9,
|
||||
help="Fail if fewer than this fraction of clips get a caption (0 = never fail).")
|
||||
p.add_argument("--dry-run", action="store_true", help="Report coverage without writing.")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def caption_paths(args: argparse.Namespace) -> list[Path]:
|
||||
if args.captions_csv:
|
||||
return list(args.captions_csv)
|
||||
from huggingface_hub import hf_hub_download
|
||||
args.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
out = []
|
||||
for f in CAPTION_FILES:
|
||||
try:
|
||||
out.append(Path(hf_hub_download(CAPTION_REPO, f, repo_type="dataset",
|
||||
cache_dir=str(args.cache_dir))))
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[caption] warn: could not fetch {f} ({e})", flush=True)
|
||||
if not out:
|
||||
sys.exit("[caption] ERROR: no caption CSV available")
|
||||
return out
|
||||
|
||||
|
||||
def load_captions(paths: list[Path]) -> dict[str, str]:
|
||||
"""basename -> caption. Later files do not overwrite earlier hits."""
|
||||
caps: dict[str, str] = {}
|
||||
csv.field_size_limit(10 * 1024 * 1024) # captions can be long
|
||||
for p in paths:
|
||||
n0 = len(caps)
|
||||
with p.open(newline="", encoding="utf-8", errors="replace") as fh:
|
||||
for row in csv.DictReader(fh):
|
||||
key, cap = row.get("video"), row.get("caption")
|
||||
if key and cap and key not in caps:
|
||||
caps[key] = cap.strip()
|
||||
print(f"[caption] {p.name}: +{len(caps) - n0} captions (total {len(caps)})", flush=True)
|
||||
return caps
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
items = json.loads(args.manifest.read_text())
|
||||
caps = load_captions(caption_paths(args))
|
||||
|
||||
hit = 0
|
||||
missing: list[str] = []
|
||||
for it in items:
|
||||
name = Path(it.get("path", "")).name
|
||||
cap = caps.get(name)
|
||||
if cap:
|
||||
it["cap"] = [cap]
|
||||
hit += 1
|
||||
else:
|
||||
it.setdefault("cap", [""])
|
||||
missing.append(name)
|
||||
|
||||
cov = hit / max(len(items), 1)
|
||||
print(f"[caption] matched {hit}/{len(items)} clips ({cov*100:.1f}%)", flush=True)
|
||||
if missing:
|
||||
print(f"[caption] first few unmatched: {missing[:3]}", flush=True)
|
||||
|
||||
if cov < args.min_coverage:
|
||||
sys.exit(f"[caption] ERROR: coverage {cov*100:.1f}% < required {args.min_coverage*100:.0f}%. "
|
||||
"Refusing to write -- training on empty captions silently breaks text conditioning.")
|
||||
|
||||
if args.dry_run:
|
||||
print("[caption] dry run, manifest not written", flush=True)
|
||||
return
|
||||
tmp = args.manifest.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(items, indent=2))
|
||||
tmp.replace(args.manifest)
|
||||
print(f"[caption] wrote {args.manifest}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/bin/bash
|
||||
# Benchmark Stage 3 (extract_tracks) + Stage 4 (segment_tracks) end-to-end on the
|
||||
# motion-stream-test source videos, writing ALL outputs to a separate bench dir so
|
||||
# the real dataset is never touched.
|
||||
#
|
||||
# Usage:
|
||||
# bash data_pipeline/benchmark_tracks.sh # 50 videos, 4 GPUs
|
||||
# GPUS=0 bash data_pipeline/benchmark_tracks.sh # single GPU
|
||||
# LIMIT=5 bash data_pipeline/benchmark_tracks.sh # smoke run
|
||||
# VIZ=1 bash data_pipeline/benchmark_tracks.sh # include viz mp4 rendering
|
||||
# FUSED=1 bash data_pipeline/benchmark_tracks.sh # fused stage 3+4 (extract --segment)
|
||||
#
|
||||
# Results append to $OUT_DIR/benchmark_results.txt (tagged with git commit); each
|
||||
# entry records gpus/videos/viz/fused so runs stay comparable.
|
||||
set -euo pipefail
|
||||
|
||||
SRC_VIDEOS=${SRC_VIDEOS:-/home/hal-kevin/data/motion-stream-test/videos}
|
||||
SRC_MANIFEST=${SRC_MANIFEST:-/home/hal-kevin/data/motion-stream-test/videos2caption.json}
|
||||
OUT_DIR=${OUT_DIR:-/home/hal-kevin/data/motion-stream-qtest}
|
||||
GPUS=${GPUS:-0,1,2,3}
|
||||
LIMIT=${LIMIT:-}
|
||||
VIZ=${VIZ:-0} # 0 = lean run (production-like for large-scale), 1 = render overlay mp4s
|
||||
FUSED=${FUSED:-0} # 1 = single fused pass (extract_tracks --segment); stage 4 not run
|
||||
AMP=${AMP:-0} # 1 = bf16 autocast for CoTracker (--amp)
|
||||
COMPILE=${COMPILE:-0} # 1 = torch.compile the main CoTracker pass (--compile)
|
||||
OVERRIDE_EVERY=${OVERRIDE_EVERY:-3} # --vis-override-every (2 rides the entry-sweep mask cache in fused mode)
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
IFS=',' read -ra GPU_ARR <<< "$GPUS"
|
||||
WORLD_SIZE=${#GPU_ARR[@]}
|
||||
MANIFEST=bench_manifest.json
|
||||
LOG="$OUT_DIR/benchmark.log"
|
||||
RESULTS="$OUT_DIR/benchmark_results.txt"
|
||||
LIMIT_ARGS=()
|
||||
[[ -n "$LIMIT" ]] && LIMIT_ARGS=(--limit "$LIMIT")
|
||||
VIZ_ARGS=()
|
||||
[[ "$VIZ" == "1" ]] && VIZ_ARGS=(--viz --viz-dir "$OUT_DIR/bench_viz")
|
||||
FUSED_ARGS=()
|
||||
if [[ "$FUSED" == "1" ]]; then
|
||||
FUSED_ARGS=(--segment --vis-override-every "$OVERRIDE_EVERY")
|
||||
[[ "$VIZ" == "1" ]] && FUSED_ARGS+=("${VIZ_ARGS[@]}")
|
||||
fi
|
||||
SPEED_ARGS=()
|
||||
[[ "$AMP" == "1" ]] && SPEED_ARGS+=(--amp)
|
||||
if [[ "$COMPILE" == "1" ]]; then
|
||||
SPEED_ARGS+=(--compile)
|
||||
# Persist compile artifacts in $HOME (default /tmp/torchinductor_* is wiped by
|
||||
# reboots/tmp-cleaners), so repeat runs start warm instead of recompiling.
|
||||
export TORCHINDUCTOR_CACHE_DIR=${TORCHINDUCTOR_CACHE_DIR:-$HOME/.cache/torchinductor}
|
||||
export TRITON_CACHE_DIR=${TRITON_CACHE_DIR:-$HOME/.cache/triton}
|
||||
fi
|
||||
|
||||
# --- setup: symlink source videos, copy manifest without points_path -------------
|
||||
mkdir -p "$OUT_DIR"
|
||||
ln -sfn "$SRC_VIDEOS" "$OUT_DIR/bench_videos"
|
||||
python - "$SRC_MANIFEST" "$OUT_DIR/$MANIFEST" <<'PY'
|
||||
import json, sys
|
||||
items = json.load(open(sys.argv[1]))
|
||||
for it in items:
|
||||
it.pop("points_path", None) # stage 3 re-patches these to the bench tracks dir
|
||||
json.dump(items, open(sys.argv[2], "w"), indent=2)
|
||||
print(f"[bench] manifest: {len(items)} items -> {sys.argv[2]}")
|
||||
PY
|
||||
rm -rf "$OUT_DIR/bench_tracks"
|
||||
[[ "$VIZ" == "1" ]] && rm -rf "$OUT_DIR/bench_viz"
|
||||
: > "$LOG"
|
||||
|
||||
N_VIDEOS=$(ls "$OUT_DIR"/bench_videos/*.mp4 | wc -l)
|
||||
[[ -n "$LIMIT" ]] && N_VIDEOS=$LIMIT
|
||||
COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)$(git diff --quiet 2>/dev/null || echo -dirty)
|
||||
echo "[bench] commit=$COMMIT gpus=$GPUS videos=$N_VIDEOS log=$LOG"
|
||||
|
||||
wait_workers() { # wait_workers <stage-name> <pid...>
|
||||
local stage=$1 fail=0 pid
|
||||
shift
|
||||
for pid in "$@"; do
|
||||
wait "$pid" || fail=$((fail + 1))
|
||||
done
|
||||
[[ $fail -gt 0 ]] && echo "[bench] WARNING: $fail $stage worker(s) failed — check $LOG"
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- stage 3: extract tracks ------------------------------------------------------
|
||||
t0=$(date +%s)
|
||||
PIDS=()
|
||||
for i in "${!GPU_ARR[@]}"; do
|
||||
CUDA_VISIBLE_DEVICES=${GPU_ARR[$i]} python -u data_pipeline/extract_tracks.py \
|
||||
--data-dir "$OUT_DIR" \
|
||||
--videos-subdir bench_videos \
|
||||
--out-subdir bench_tracks \
|
||||
--manifest "$MANIFEST" \
|
||||
--grid-size 50 \
|
||||
--device cuda \
|
||||
--detect-entries \
|
||||
--sam-conf 0.75 --sam-iou 0.9 --sam-imgsz 1024 \
|
||||
--entry-sample-every 2 --entry-min-area 0.001 --entry-new-area 0.5 \
|
||||
"${FUSED_ARGS[@]}" \
|
||||
"${SPEED_ARGS[@]}" \
|
||||
--force \
|
||||
--rank "$i" --world-size "$WORLD_SIZE" \
|
||||
"${LIMIT_ARGS[@]}" \
|
||||
>> "$LOG" 2>&1 &
|
||||
PIDS+=($!)
|
||||
done
|
||||
wait_workers "stage-3" "${PIDS[@]}"
|
||||
t1=$(date +%s)
|
||||
S3=$((t1 - t0)); [[ $S3 -eq 0 ]] && S3=1
|
||||
N_NPZ=$(ls "$OUT_DIR"/bench_tracks/*.npz 2>/dev/null | wc -l || true)
|
||||
echo "[bench] stage 3: ${S3}s for $N_NPZ npz"
|
||||
[[ "$N_NPZ" -eq "$N_VIDEOS" ]] || echo "[bench] WARNING: expected $N_VIDEOS npz — check $LOG"
|
||||
|
||||
# --- stage 4: segment tracks (skipped when FUSED=1: stage 3 already segmented) -----
|
||||
S4=0
|
||||
if [[ "$FUSED" != "1" ]]; then
|
||||
t2=$(date +%s)
|
||||
PIDS=()
|
||||
for i in "${!GPU_ARR[@]}"; do
|
||||
CUDA_VISIBLE_DEVICES=${GPU_ARR[$i]} python -u data_pipeline/segment_tracks.py \
|
||||
--data-dir "$OUT_DIR" \
|
||||
--videos-subdir bench_videos \
|
||||
--manifest "$MANIFEST" \
|
||||
--conf 0.75 --iou 0.9 --imgsz 1024 \
|
||||
--vis-override-every "$OVERRIDE_EVERY" \
|
||||
"${VIZ_ARGS[@]}" \
|
||||
--force \
|
||||
--rank "$i" --world-size "$WORLD_SIZE" \
|
||||
"${LIMIT_ARGS[@]}" \
|
||||
>> "$LOG" 2>&1 &
|
||||
PIDS+=($!)
|
||||
done
|
||||
wait_workers "stage-4" "${PIDS[@]}"
|
||||
t3=$(date +%s)
|
||||
S4=$((t3 - t2)); [[ $S4 -eq 0 ]] && S4=1
|
||||
echo "[bench] stage 4: ${S4}s"
|
||||
fi
|
||||
|
||||
# --- summary ----------------------------------------------------------------------
|
||||
{
|
||||
echo "=== $(date -u '+%Y-%m-%d %H:%M:%S') UTC commit=$COMMIT gpus=$GPUS videos=$N_VIDEOS viz=$VIZ fused=$FUSED amp=$AMP compile=$COMPILE override=$OVERRIDE_EVERY ==="
|
||||
awk -v s3="$S3" -v s4="$S4" -v w="$WORLD_SIZE" -v n="$N_VIDEOS" -v fused="$FUSED" 'BEGIN {
|
||||
label = (fused == "1") ? "stage 3+4 (fused):" : "stage 3 (extract):"
|
||||
printf "%s %5ds total %6.1fs/video/worker %5.1f videos/min\n", label, s3, s3*w/n, 60*n/s3
|
||||
if (fused != "1")
|
||||
printf "stage 4 (segment): %5ds total %6.1fs/video/worker %5.1f videos/min\n", s4, s4*w/n, 60*n/s4
|
||||
printf "end-to-end: %5ds total\n", s3+s4
|
||||
}'
|
||||
} | tee -a "$RESULTS"
|
||||
echo "[bench] results appended to $RESULTS"
|
||||
@@ -0,0 +1,53 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Build videos2caption.json + merge.txt for the final track dataset.
|
||||
|
||||
Pairs each 121f/720p clip (--clips-dir) with its CoTracker npz (--tracks-dir) and
|
||||
caption (--captions JSON {video.mp4: caption}). Output is exactly the manifest
|
||||
`fastvideo/pipelines/preprocess/v1_preprocess.py --preprocess_task i2v_track` consumes
|
||||
(same format used for the overfit run), so the dataset is training-ready.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse, json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--clips-dir", required=True)
|
||||
ap.add_argument("--tracks-dir", required=True)
|
||||
ap.add_argument("--captions", required=True, help="JSON {video.mp4: caption}")
|
||||
ap.add_argument("--out-dir", required=True)
|
||||
ap.add_argument("--fps", type=int, default=24)
|
||||
ap.add_argument("--num-frames", type=int, default=121)
|
||||
ap.add_argument("--height", type=int, default=720)
|
||||
ap.add_argument("--width", type=int, default=1280)
|
||||
a = ap.parse_args()
|
||||
|
||||
caps = json.load(open(a.captions))
|
||||
tracks = {p.stem: p for p in Path(a.tracks_dir).glob("*.npz")}
|
||||
items, missing_track, missing_cap = [], 0, 0
|
||||
for clip in sorted(Path(a.clips_dir).glob("*.mp4")):
|
||||
tr = tracks.get(clip.stem)
|
||||
if tr is None:
|
||||
missing_track += 1; continue
|
||||
cap = caps.get(clip.name, "")
|
||||
if not cap:
|
||||
missing_cap += 1
|
||||
items.append({
|
||||
"path": clip.name,
|
||||
"cap": [cap],
|
||||
"points_path": str(tr.resolve()),
|
||||
"fps": float(a.fps),
|
||||
"num_frames": a.num_frames,
|
||||
"resolution": {"width": a.width, "height": a.height},
|
||||
})
|
||||
outd = Path(a.out_dir); outd.mkdir(parents=True, exist_ok=True)
|
||||
(outd / "videos2caption.json").write_text(json.dumps(items, indent=2))
|
||||
(outd / "merge.txt").write_text(f"{a.clips_dir},{outd / 'videos2caption.json'}")
|
||||
print(f"[manifest] {len(items)} clips paired (clip+track+caption); "
|
||||
f"skipped {missing_track} clips w/o track, {missing_cap} w/o caption")
|
||||
print(f"[manifest] wrote {outd}/videos2caption.json + merge.txt")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CFG-ablation eval on a trackwan checkpoint.
|
||||
|
||||
Reproduces ``fastvideo/train/callbacks/track_validation.py::_sample`` denoise
|
||||
formula EXACTLY, but with configurable ``w_text`` and ``w_motion`` so we can
|
||||
isolate which CFG branch is causing artifacts.
|
||||
|
||||
Runs each val sample under N configs; uploads all videos to one wandb run for
|
||||
side-by-side visual comparison.
|
||||
|
||||
Usage (single-GPU, on any held alloc):
|
||||
python data_pipeline/cfg_ablation.py \
|
||||
--model-dir /mnt/lustre/vlm-s4duan/exports/synth_stage2_paperLR_ckpt400 \
|
||||
--yaml examples/train/scenario/worldmodel/finetune_wantrack_synth_stage2_paperLR.yaml \
|
||||
--val-parquet /mnt/lustre/vlm-s4duan/val_examples_mixed/combined_parquet_dataset \
|
||||
--wandb-run-name cfg_abl_paperLR_ckpt400
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import imageio.v2 as imageio
|
||||
import numpy as np
|
||||
import pyarrow.parquet as pq
|
||||
import torch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
|
||||
import trackwan_infer as twi # noqa: E402
|
||||
from fastvideo.forward_context import set_forward_context # noqa: E402
|
||||
from fastvideo.models.schedulers.scheduling_flow_match_euler_discrete import ( # noqa: E402
|
||||
FlowMatchEulerDiscreteScheduler,
|
||||
)
|
||||
|
||||
|
||||
def load_val_samples(parquet_dir: str, text_len: int) -> list:
|
||||
from fastvideo.dataset.dataloader.schema import pyarrow_schema_i2v_track
|
||||
from fastvideo.dataset.utils import collate_rows_from_parquet_schema
|
||||
fs = sorted(glob.glob(os.path.join(parquet_dir, "**", "*.parquet"), recursive=True))
|
||||
if not fs:
|
||||
raise FileNotFoundError(parquet_dir)
|
||||
rows = []
|
||||
for f in fs:
|
||||
rows.extend(pq.read_table(f).to_pylist())
|
||||
batch = collate_rows_from_parquet_schema(rows, pyarrow_schema_i2v_track,
|
||||
text_padding_length=int(text_len), cfg_rate=0.0)
|
||||
infos = batch.get("info_list") or [{} for _ in rows]
|
||||
return [{
|
||||
"id": str(infos[i].get("id", f"clip{i:03d}")) if i < len(infos) else f"clip{i:03d}",
|
||||
"text_embedding": batch["text_embedding"][i:i + 1].clone(),
|
||||
"text_attention_mask": batch["text_attention_mask"][i:i + 1].clone(),
|
||||
"first_frame_latent": batch["first_frame_latent"][i:i + 1].clone(),
|
||||
"clip_feature": batch["clip_feature"][i:i + 1].clone(),
|
||||
"vae_latent": batch["vae_latent"][i:i + 1].clone(),
|
||||
"track_points": batch["track_points"][i:i + 1].clone(),
|
||||
"track_visibility": batch["track_visibility"][i:i + 1].clone(),
|
||||
"object_ids": batch["object_ids"][i:i + 1].clone() if "object_ids" in batch else None,
|
||||
"track_weights": batch["track_weights"][i:i + 1].clone() if "track_weights" in batch else None,
|
||||
"caption": str(infos[i].get("caption", "") if i < len(infos) else ""),
|
||||
} for i in range(len(rows))]
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def generate_with_cfg(model, sample: dict, *, w_text: float, w_motion: float,
|
||||
num_steps: int, seed: int,
|
||||
null_text: torch.Tensor | None = None,
|
||||
null_text_mask: torch.Tensor | None = None,
|
||||
no_track: bool = False) -> np.ndarray:
|
||||
"""Reproduce track_validation._sample denoise EXACTLY, w/ configurable CFG.
|
||||
|
||||
If ``null_text`` is provided, use it as the ∅ text (canonical Wan/T5 convention
|
||||
= UMT5-encoded empty string). Otherwise fall back to zeros_like(txt).
|
||||
If ``no_track`` is True, pass tp=None/tv=None to every forward (matches the
|
||||
val callback's `track_val/no_track` panel).
|
||||
"""
|
||||
device = model.device
|
||||
dtype = torch.bfloat16
|
||||
|
||||
ff = sample["first_frame_latent"].to(device, dtype)
|
||||
txt = sample["text_embedding"].to(device, dtype)
|
||||
mask = sample["text_attention_mask"].to(device, dtype)
|
||||
clip = sample["clip_feature"].to(device, dtype)
|
||||
if no_track:
|
||||
tp = None
|
||||
tv = None
|
||||
else:
|
||||
# CRITICAL: sparse-sample tracks the same way training + track_validation do.
|
||||
# Feeding the raw 2500 tracks is out-of-distribution and produces melted output.
|
||||
tp_raw = sample["track_points"].float()
|
||||
tv_raw = sample["track_visibility"].float()
|
||||
oid = sample.get("object_ids")
|
||||
tw = sample.get("track_weights")
|
||||
aug_gen = torch.Generator(device=device).manual_seed(int(seed))
|
||||
tp_s, tv_s = model._augment_tracks(tp_raw, tv_raw, aug_gen, object_ids=oid, track_weights=tw)
|
||||
if tp_s is None or tv_s is None: # motion_drop fired -> fall back to raw
|
||||
tp_s, tv_s = tp_raw, tv_raw
|
||||
tp = tp_s.to(device, dtype)
|
||||
tv = tv_s.to(device, dtype)
|
||||
cond20 = model._build_i2v_cond_concat(ff)
|
||||
|
||||
_, _, T, H, W = ff.shape
|
||||
gen = torch.Generator(device="cpu").manual_seed(int(seed))
|
||||
latents = torch.randn((1, 16, T, H, W), generator=gen, dtype=torch.float32).to(device)
|
||||
sched = FlowMatchEulerDiscreteScheduler(shift=float(model.timestep_shift))
|
||||
sched.set_timesteps(int(num_steps), device=device)
|
||||
|
||||
do_text_cfg = (w_text != 1.0)
|
||||
do_motion_cfg = (w_motion != 1.0) and not no_track # no tracks -> motion CFG is a no-op
|
||||
|
||||
def _fwd(text_e, tp_e, tv_e, mi, ts_):
|
||||
with torch.autocast(device.type, dtype=dtype), \
|
||||
set_forward_context(current_timestep=ts_, attn_metadata=None):
|
||||
return model.transformer(hidden_states=mi, encoder_hidden_states=text_e,
|
||||
encoder_attention_mask=mask, timestep=ts_,
|
||||
encoder_hidden_states_image=clip,
|
||||
track_points=tp_e, track_visibility=tv_e,
|
||||
return_dict=False)
|
||||
|
||||
if do_text_cfg:
|
||||
if null_text is not None:
|
||||
# Broadcast the [seq_len, dim] null embedding to match txt shape [B, seq_len, dim]
|
||||
nl = null_text.to(device, dtype)
|
||||
if nl.dim() == 2:
|
||||
nl = nl.unsqueeze(0).expand_as(txt).contiguous()
|
||||
txt_null = nl
|
||||
else:
|
||||
txt_null = torch.zeros_like(txt)
|
||||
else:
|
||||
txt_null = None
|
||||
|
||||
for tt in sched.timesteps:
|
||||
mi = torch.cat([latents.to(dtype), cond20], dim=1)
|
||||
ts_ = tt.reshape(1).to(device, dtype)
|
||||
v_full = _fwd(txt, tp, tv, mi, ts_)
|
||||
if not do_text_cfg and not do_motion_cfg:
|
||||
v = v_full
|
||||
elif do_text_cfg and not do_motion_cfg:
|
||||
v_no_text = _fwd(txt_null, tp, tv, mi, ts_)
|
||||
v = v_no_text + w_text * (v_full - v_no_text)
|
||||
elif do_motion_cfg and not do_text_cfg:
|
||||
v_no_motion = _fwd(txt, None, None, mi, ts_)
|
||||
v = v_no_motion + w_motion * (v_full - v_no_motion)
|
||||
else:
|
||||
# joint text+motion CFG (matches track_validation._sample)
|
||||
v_no_text = _fwd(txt_null, tp, tv, mi, ts_)
|
||||
v_no_motion = _fwd(txt, None, None, mi, ts_)
|
||||
alpha = w_text / max(w_text + w_motion, 1e-6)
|
||||
v_base = alpha * v_no_text + (1.0 - alpha) * v_no_motion
|
||||
v = v_base + w_text * (v_full - v_no_text) + w_motion * (v_full - v_no_motion)
|
||||
latents = sched.step(v.float(), tt, latents.float(), return_dict=False)[0]
|
||||
|
||||
return twi.decode_to_pixels(model, latents)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--model-dir", required=True)
|
||||
p.add_argument("--yaml", required=True)
|
||||
p.add_argument("--val-parquet", required=True)
|
||||
p.add_argument("--wandb-project", default="wantrack-bidir")
|
||||
p.add_argument("--wandb-run-name", required=True)
|
||||
p.add_argument("--out-dir", default="/mnt/lustre/vlm-s4duan/cfg_ablation")
|
||||
p.add_argument("--steps", type=int, default=30)
|
||||
p.add_argument("--seed", type=int, default=1000)
|
||||
p.add_argument("--fps", type=int, default=24)
|
||||
p.add_argument("--configs", default="3.0,1.5;1.0,1.5;1.0,1.0",
|
||||
help="';'-sep list of 'w_text,w_motion' pairs")
|
||||
p.add_argument("--null-text-pt",
|
||||
help="Path to precomputed UMT5(``'') embedding .pt file. If given, this is "
|
||||
"used as the ∅ text branch instead of zeros_like(txt).")
|
||||
p.add_argument("--no-track", action="store_true",
|
||||
help="Force track_points=None throughout denoise. Reproduces the val "
|
||||
"callback's `track_val/no_track` panel.")
|
||||
args = p.parse_args()
|
||||
|
||||
null_text = None
|
||||
null_text_mask = None
|
||||
if args.null_text_pt:
|
||||
blob = torch.load(args.null_text_pt, map_location="cpu")
|
||||
null_text = blob["embedding"]
|
||||
null_text_mask = blob.get("attention_mask")
|
||||
print(f"[abl] loaded null-text embedding: shape={list(null_text.shape)} "
|
||||
f"norm={null_text.norm().item():.3f}", flush=True)
|
||||
|
||||
import wandb
|
||||
Path(args.out_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"[abl] loading model {args.model_dir}", flush=True)
|
||||
model, tc = twi.load_trackwan(args.model_dir, args.yaml)
|
||||
text_len = int(getattr(tc.data, "text_padding_length", 256))
|
||||
|
||||
print(f"[abl] loading val samples from {args.val_parquet}", flush=True)
|
||||
samples = load_val_samples(args.val_parquet, text_len)
|
||||
print(f"[abl] {len(samples)} val samples", flush=True)
|
||||
|
||||
configs = [tuple(float(x) for x in pair.split(","))
|
||||
for pair in args.configs.split(";")]
|
||||
print(f"[abl] configs: {configs}", flush=True)
|
||||
|
||||
run = wandb.init(project=args.wandb_project, name=args.wandb_run_name,
|
||||
config={"model_dir": args.model_dir, "steps": args.steps,
|
||||
"configs": [list(c) for c in configs]})
|
||||
|
||||
for i, s in enumerate(samples):
|
||||
sid = s["id"] or f"clip{i:03d}"
|
||||
print(f"[abl] === sample {i}: {sid} ===", flush=True)
|
||||
wandb_log = {"sample": i, "caption": s["caption"][:120]}
|
||||
for wt, wm in configs:
|
||||
t0 = time.time()
|
||||
frames = generate_with_cfg(model, s, w_text=wt, w_motion=wm,
|
||||
num_steps=args.steps, seed=args.seed + i,
|
||||
null_text=null_text, null_text_mask=null_text_mask,
|
||||
no_track=args.no_track)
|
||||
dt = time.time() - t0
|
||||
tag = f"wt{wt}_wm{wm}".replace(".", "p")
|
||||
fn = Path(args.out_dir) / f"{sid}_{tag}.mp4"
|
||||
imageio.mimsave(str(fn), frames, fps=args.fps, macro_block_size=1)
|
||||
wandb_log[f"gen_{tag}"] = wandb.Video(str(fn), fps=args.fps, format="mp4")
|
||||
print(f"[abl] {tag}: {dt:.1f}s -> {fn.name}", flush=True)
|
||||
wandb.log(wandb_log)
|
||||
|
||||
# also render GT reference once
|
||||
for i, s in enumerate(samples):
|
||||
try:
|
||||
ref = twi.decode_reference(model, s["vae_latent"].to(model.device, torch.bfloat16))
|
||||
fn = Path(args.out_dir) / f"{s['id']}_gt.mp4"
|
||||
imageio.mimsave(str(fn), ref, fps=args.fps, macro_block_size=1)
|
||||
wandb.log({"sample": i, "reference_gt": wandb.Video(str(fn), fps=args.fps, format="mp4")})
|
||||
except Exception as e:
|
||||
print(f"[abl] gt render failed for sample {i}: {e}", flush=True)
|
||||
|
||||
wandb.finish()
|
||||
print("[abl] DONE", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,94 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Report the WanTrack "gate" — patch_embedding.weight[:, 36:] — from a DCP checkpoint.
|
||||
|
||||
The overfit stages (A/B) exist to grow this slot from EXACTLY ZERO up to roughly the scale the
|
||||
successful 1.3B merged init reached (std ~0.011), co-adapting it with track_encoder. If it is
|
||||
still ~0 there is no bootstrap happening and step C would merge nothing; if it explodes past the
|
||||
pretrained channel scale (~0.0124) the track pathway is overwhelming the image prior.
|
||||
|
||||
Loads ONLY the tensors of interest (DCP resharding is per-tensor), so it costs ~MBs, not the
|
||||
~200GB a full checkpoint load would.
|
||||
|
||||
Usage: python data_pipeline/check_gate_growth.py <checkpoint-dir> [more dirs...]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
import torch.distributed.checkpoint as dcp
|
||||
from torch.distributed.checkpoint import FileSystemReader
|
||||
|
||||
BASE_IN = 36
|
||||
# NOTE: the live module nests the conv, so a TRAINED checkpoint stores
|
||||
# 'patch_embedding.proj.weight', while the converter writes 'patch_embedding.weight' into the
|
||||
# init safetensors. Try both — silently matching neither is how the earlier LWLR experiment
|
||||
# ended up measuring nothing.
|
||||
PE_KEYS = ["patch_embedding.proj.weight", "patch_embedding.weight"]
|
||||
KEYS = PE_KEYS + [
|
||||
"track_encoder.proj.weight",
|
||||
"track_encoder.temporal_conv.weight",
|
||||
]
|
||||
|
||||
|
||||
def _find(md_keys, want: str) -> str | None:
|
||||
"""DCP nests model tensors under a role prefix (e.g. 'student.'); match on the suffix."""
|
||||
exact = [k for k in md_keys if k == want]
|
||||
if exact:
|
||||
return exact[0]
|
||||
cands = [k for k in md_keys if k.endswith(want)]
|
||||
return cands[0] if cands else None
|
||||
|
||||
|
||||
def report(ckpt: Path) -> None:
|
||||
dcp_dir = ckpt / "dcp" if (ckpt / "dcp").is_dir() else ckpt
|
||||
reader = FileSystemReader(str(dcp_dir))
|
||||
md = reader.read_metadata()
|
||||
planned = md.state_dict_metadata
|
||||
sd = {}
|
||||
resolved = {}
|
||||
for want in KEYS:
|
||||
k = _find(list(planned.keys()), want)
|
||||
if k is None:
|
||||
continue
|
||||
meta = planned[k]
|
||||
sd[k] = torch.empty(tuple(meta.size), dtype=meta.properties.dtype)
|
||||
resolved[want] = k
|
||||
if not sd:
|
||||
print(f"{ckpt.name}: none of {KEYS} found in checkpoint metadata")
|
||||
return
|
||||
dcp.load(sd, checkpoint_id=str(dcp_dir))
|
||||
|
||||
out = [f"{ckpt.name:>18s}"]
|
||||
pe_k = next((resolved[k] for k in PE_KEYS if k in resolved), None)
|
||||
if pe_k is None:
|
||||
out.append("!! patch_embedding NOT FOUND — cannot report gate")
|
||||
else:
|
||||
pe = sd[pe_k].float()
|
||||
out.append(f"gate[:, {BASE_IN}:] std={pe[:, BASE_IN:].std():.6f}")
|
||||
out.append(f"pretrained std={pe[:, :BASE_IN].std():.6f}")
|
||||
for w in ("track_encoder.proj.weight", "track_encoder.temporal_conv.weight"):
|
||||
if w in resolved:
|
||||
out.append(f"{w.split('.')[1]}_norm={sd[resolved[w]].float().norm():.4f}")
|
||||
print(" ".join(out))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
for key, default in [("RANK", "0"), ("LOCAL_RANK", "0"), ("WORLD_SIZE", "1"),
|
||||
("MASTER_ADDR", "127.0.0.1"), ("MASTER_PORT", "29555")]:
|
||||
os.environ.setdefault(key, default)
|
||||
print(f"reference: 1.3B merged-from-overfit gate std = 0.011418 (grown from 0.000000)")
|
||||
for p in sys.argv[1:]:
|
||||
try:
|
||||
report(Path(p))
|
||||
except Exception as e: # keep going across checkpoints
|
||||
print(f"{Path(p).name}: ERROR {type(e).__name__}: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user