diff --git a/.github/workflows/_template-build-image.yml b/.github/workflows/_template-build-image.yml index f0c0961b4..cdf6fa16d 100644 --- a/.github/workflows/_template-build-image.yml +++ b/.github/workflows/_template-build-image.yml @@ -16,6 +16,14 @@ on: 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: @@ -71,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 @@ -101,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 }}/${{ inputs.image_name }}:${{ inputs.tag_suffix }}-latest" + 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." diff --git a/.github/workflows/infra-build-image.yml b/.github/workflows/infra-build-image.yml index 43f81d35e..6ed3f42e9 100644 --- a/.github/workflows/infra-build-image.yml +++ b/.github/workflows/infra-build-image.yml @@ -24,7 +24,7 @@ on: default: false type: boolean dreamverse_cuda_12_9: - description: 'Build Dreamverse CUDA 12.9 image' + description: 'Build Dreamverse CUDA 12.9 backend-only and UI images' required: false default: false type: boolean @@ -71,12 +71,26 @@ jobs: tag_suffix: py3.12-cuda12.9.1 secrets: inherit - build-dreamverse-cuda-12-9: + 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-cuda12.9.1 + 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 diff --git a/apps/dreamverse/docker/Dockerfile b/apps/dreamverse/docker/Dockerfile index ead8433bf..80c37f32b 100644 --- a/apps/dreamverse/docker/Dockerfile +++ b/apps/dreamverse/docker/Dockerfile @@ -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,19 @@ 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/* \ + && corepack enable \ + && cd /opt/FastVideo/apps/dreamverse/web \ + && pnpm install --frozen-lockfile --ignore-scripts \ + && NEXT_OUTPUT_EXPORT=1 pnpm 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. diff --git a/apps/dreamverse/docker/README.md b/apps/dreamverse/docker/README.md index 9b7879ce3..70ad5520f 100644 --- a/apps/dreamverse/docker/README.md +++ b/apps/dreamverse/docker/README.md @@ -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,6 +17,16 @@ 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= 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 diff --git a/apps/dreamverse/docker/docker_build.sh b/apps/dreamverse/docker/docker_build.sh index 5c3ae5c23..7eb742de7 100755 --- a/apps/dreamverse/docker/docker_build.sh +++ b/apps/dreamverse/docker/docker_build.sh @@ -24,6 +24,7 @@ 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}") if [[ "${DOCKER_BUILDKIT:-}" == "1" ]]; then if [[ -L "${ROOT_DOCKERIGNORE}" ]] && \ diff --git a/apps/dreamverse/dreamverse/config.py b/apps/dreamverse/dreamverse/config.py index 7d77a53df..ba74ec6dc 100644 --- a/apps/dreamverse/dreamverse/config.py +++ b/apps/dreamverse/dreamverse/config.py @@ -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 = { diff --git a/apps/dreamverse/dreamverse/tests/test_config.py b/apps/dreamverse/dreamverse/tests/test_config.py index 6a06ccffd..99a7a0893 100644 --- a/apps/dreamverse/dreamverse/tests/test_config.py +++ b/apps/dreamverse/dreamverse/tests/test_config.py @@ -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") diff --git a/apps/dreamverse/scripts/modal/README.md b/apps/dreamverse/scripts/modal/README.md new file mode 100644 index 000000000..38aff4f83 --- /dev/null +++ b/apps/dreamverse/scripts/modal/README.md @@ -0,0 +1,113 @@ +# 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-secret --profile= +modal profile activate +``` + +## 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= \ + GROQ_API_KEY= \ + HF_TOKEN= \ + --force +``` + +## 3. Deploy with Docker image + +`DREAMVERSE_IMAGE` is required at deploy time: + +```bash +DREAMVERSE_IMAGE=ghcr.io///dreamverse: \ + 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://--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. + +### Autoscaling and cost safety + +The current deployed script is capped with `max_containers=1`. If uncapped, concurrent requests could +spawn multiple containers, while `max_containers=1` queued requests onto one +container instead of multiplying B200 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. diff --git a/apps/dreamverse/scripts/modal/modal_app.py b/apps/dreamverse/scripts/modal/modal_app.py new file mode 100644 index 000000000..15469d33f --- /dev/null +++ b/apps/dreamverse/scripts/modal/modal_app.py @@ -0,0 +1,49 @@ +# 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." + ) + +image = modal.Image.from_registry(IMAGE) +image = image.env({ + "HF_HOME": "/root/.cache/huggingface", + "FASTVIDEO_DREAMVERSE_HOME": "/var/lib/dreamverse", + "FASTVIDEO_ENABLE_STARTUP_WARMUP": "0", + "FASTVIDEO_GPU_COUNT": "1", + "ENABLE_TORCH_COMPILE": "0", + "STREAM_MODE": "av_fmp4", +}) + +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, + 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(): + subprocess.Popen(["dreamverse-server", "--host", "0.0.0.0", "--port", "8009"]) diff --git a/apps/dreamverse/web/next.config.ts b/apps/dreamverse/web/next.config.ts index 55477c0a8..53388f7a7 100644 --- a/apps/dreamverse/web/next.config.ts +++ b/apps/dreamverse/web/next.config.ts @@ -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', @@ -48,7 +51,7 @@ const nextConfig: NextConfig = { destination: `${backendUrl}/curated-presets/:path*`, }, ]; - }, + } }), webpack: (config) => { config.module.rules.push({ test: /\.jsonl$/,