Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,33 @@ The orchestrator runs on any cheap VPS. The GPU backend is a separate service th
| GCP (a2/a3) | A100, H100 | ~$2-$4/hr |
| Self-hosted 8× MI300X | MI300X (192GB each) | ~$16-$24/hr (node) |

## Deploy the GPU Backend

Video generation needs a GPU backend — the open-source Wan server in
[`src/gpu_backend/`](src/gpu_backend/). It exposes one base64 HTTP contract
(`POST /generate/t2v|i2v|flf2v`, `GET /health`) that the pipeline consumes,
whether it runs on Modal, RunPod, a self-hosted box, or your own cloud.

> A provider token or API key **is not enough on its own** — the backend has to
> be *deployed* into your account first. That deploy is what produces the
> endpoint URL your jobs route to.

```bash
pip install "flow[gpu]" modal && modal token new # one-time Modal auth

# Deploy the backend as a NAMED instance (deploy several — A100 pool, H100 pool…)
flow deploy modal --name flow-gpu-a100 --gpu A100-80GB
# → prints the base URL, e.g. https://<workspace>--flow-gpu-a100.modal.run
```

Put that URL in `[gpu_backend].url`. Defaults live in the `[deploy]` section of
your config; CLI flags override them. `flow deploy aws` / `flow deploy gcp` print
the concrete manual steps (first-class automation is on the roadmap — they never
fake a deployment).

**Self-hosted / RunPod:** run the same FastAPI app directly
(`uvicorn gpu_backend.server:app`, GPU host) and point `[gpu_backend].url` at it.

## Text-to-Speech & Voice Cloning

Narration is produced by a pluggable TTS layer. Pick a provider in config:
Expand Down
16 changes: 15 additions & 1 deletion config/config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,25 @@ model = "gpt-4o-mini"
[gpu_backend]
# Supported: modal, runpod, self-hosted
provider = "modal"
url = "" # URL of your GPU backend inference server
url = "" # base URL of your deployed GPU backend (e.g. https://you--flow-gpu-backend.modal.run)
api_key = ""
resolution = "480p" # 480p or 720p
clip_duration = 5 # seconds per clip

# `flow deploy` defaults — deploy the GPU backend as a NAMED instance.
# A token alone can't generate; you must deploy the open-source backend into
# your account first, which yields the `url` above. Deploy several named
# instances (e.g. an A100 pool and an H100 pool) and point `url` at one.
# flow deploy modal --name flow-gpu-a100 --gpu A100-80GB
[deploy]
provider = "modal" # modal (automated) | aws | gcp (scaffolded)
name = "flow-gpu-backend" # instance name -> Modal app + subdomain label
gpu = "A100-80GB" # e.g. A100-80GB, H100
model_t2v = "Wan-AI/Wan2.2-T2V-A14B-Diffusers"
model_i2v = "Wan-AI/Wan2.2-I2V-A14B-Diffusers"
region = "" # AWS/GCP only
scaledown_window = 300 # seconds idle before Modal scales to zero

[tts]
# Text-to-speech provider. Built-ins: edge (free, online, no cloning),
# miso (MisoTTS — natural voice + one-shot cloning, needs a GPU).
Expand Down
60 changes: 60 additions & 0 deletions src/flow/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,5 +99,65 @@ def mcp(
run(host=host, port=port)


@app.command()
def deploy(
provider: str = typer.Argument(
None, help="Target provider: modal | aws | gcp (default from config)"
),
name: str = typer.Option(None, help="Instance name — deploy several, named"),
gpu: str = typer.Option(None, help="GPU type, e.g. A100-80GB / H100"),
model_t2v: str = typer.Option(None, help="T2V model id"),
model_i2v: str = typer.Option(None, help="I2V model id"),
region: str = typer.Option(None, help="Region (AWS/GCP)"),
config_path: str = typer.Option("config/config.toml", help="Path to config file"),
) -> None:
"""Deploy the GPU backend to a provider as a named compute instance.

A token alone can't generate — this deploys the open-source Wan backend into
your account and reports the endpoint URL to register. CLI flags override the
deploy section of your config file.
"""
from flow.config import load_config
from flow.deploy import DeploySpec, available_providers, get_deployer

d = load_config(config_path).deploy
provider = provider or d.provider
spec = DeploySpec(
name=name or d.name,
gpu=gpu or d.gpu,
model_t2v=model_t2v or d.model_t2v,
model_i2v=model_i2v or d.model_i2v,
region=region or d.region,
scaledown_window=d.scaledown_window,
)

try:
deployer = get_deployer(provider)
except ValueError:
console.print(
f"[red]Unknown provider '{provider}'[/] — "
f"available: {', '.join(available_providers())}"
)
raise typer.Exit(1) from None

console.print(
f"[bold green]Flow[/] — deploying '{spec.name}' to "
f"[cyan]{provider}[/] (gpu={spec.gpu})"
)
result = deployer.deploy(spec)

if result.status == "deployed":
console.print(f"[bold green]✓[/] deployed [bold]{result.name}[/]")
if result.endpoint_url:
console.print(f" endpoint: [cyan]{result.endpoint_url}[/]")
console.print(f" {result.detail}")
elif result.status == "manual_required":
console.print(f"[yellow]⚠ manual steps required[/] for {provider}:")
console.print(f" {result.detail}")
else:
console.print(f"[red]✗ deploy failed:[/] {result.detail}")
raise typer.Exit(1)


if __name__ == "__main__":
app()
13 changes: 13 additions & 0 deletions src/flow/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@ class BillingConfig(BaseModel):
can_generate: bool = True


class DeployConfig(BaseModel):
"""Defaults for ``flow deploy`` (deploy the GPU backend as a named instance)."""

provider: str = "modal" # modal, aws, gcp
name: str = "flow-gpu-backend" # instance name (deploy several, named)
gpu: str = "A100-80GB"
model_t2v: str = "Wan-AI/Wan2.2-T2V-A14B-Diffusers"
model_i2v: str = "Wan-AI/Wan2.2-I2V-A14B-Diffusers"
region: str = "" # AWS/GCP
scaledown_window: int = 300 # seconds idle before Modal scales to zero


class Config(BaseModel):
llm: LLMConfig = LLMConfig()
gpu_backend: GPUBackendConfig = GPUBackendConfig()
Expand All @@ -84,6 +96,7 @@ class Config(BaseModel):
agent: AgentConfig = AgentConfig()
mcp: MCPConfig = MCPConfig()
billing: BillingConfig = BillingConfig()
deploy: DeployConfig = DeployConfig()
output_dir: str = "storage/outputs"
aspect_ratio: str = "9:16" # 9:16, 16:9
generation_mode: str = "sequential" # sequential, parallel_flf2v, pipelined_flf2v
Expand Down
20 changes: 20 additions & 0 deletions src/flow/deploy/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Deployers for the GPU backend (Modal automated; AWS/GCP scaffolded)."""

from __future__ import annotations

from flow.deploy import cloud, modal_deploy # noqa: F401 — register deployers
from flow.deploy.base import (
Deployer,
DeployResult,
DeploySpec,
available_providers,
get_deployer,
)

__all__ = [
"DeploySpec",
"DeployResult",
"Deployer",
"get_deployer",
"available_providers",
]
74 changes: 74 additions & 0 deletions src/flow/deploy/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Deployers — push the GPU backend to a provider as a NAMED instance.

A token/API key alone can't generate video: the open-source GPU backend
(``gpu_backend/modal_server.py``) must be *deployed* into the target account,
which produces the thing jobs actually route to — an **endpoint URL**. These
deployers do that deployment, parameterized by a :class:`DeploySpec` (from CLI
args and/or the config file), so you can stand up several named instances
(e.g. an A100 pool and an H100 pool) and register their URLs.
"""

from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass, field


@dataclass
class DeploySpec:
"""What to deploy, and how. Defaults mirror the config example."""

name: str = "flow-gpu-backend"
gpu: str = "A100-80GB"
model_t2v: str = "Wan-AI/Wan2.2-T2V-A14B-Diffusers"
model_i2v: str = "Wan-AI/Wan2.2-I2V-A14B-Diffusers"
region: str = ""
scaledown_window: int = 300
extra: dict = field(default_factory=dict)


@dataclass
class DeployResult:
"""Outcome of a deploy. ``status`` is honest about what happened."""

name: str
provider: str
status: str # "deployed" | "manual_required" | "failed"
endpoint_url: str = ""
detail: str = ""

@property
def ok(self) -> bool:
return self.status == "deployed"


class Deployer(ABC):
"""Interface for provider deployers."""

provider: str = "base"

@abstractmethod
def deploy(self, spec: DeploySpec) -> DeployResult:
"""Deploy the GPU backend; return where it landed (or honest guidance)."""
...


_REGISTRY: dict[str, type[Deployer]] = {}


def register(cls: type[Deployer]) -> type[Deployer]:
_REGISTRY[cls.provider] = cls
return cls


def get_deployer(provider: str) -> Deployer:
cls = _REGISTRY.get(provider)
if cls is None:
raise ValueError(
f"unknown provider {provider!r}; available: {sorted(_REGISTRY)}"
)
return cls()


def available_providers() -> list[str]:
return sorted(_REGISTRY)
48 changes: 48 additions & 0 deletions src/flow/deploy/cloud.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""AWS and GCP deployers — honest scaffolds.

These are **not** automated yet, and they do not pretend to be: they return a
``manual_required`` result with the concrete steps to stand up the GPU backend
on that cloud. First-class AWS/GCP automation is on the roadmap. No fake
success.
"""

from __future__ import annotations

from flow.deploy.base import Deployer, DeployResult, DeploySpec, register

_BASE64_CONTRACT = (
"exposing POST /generate/t2v|i2v|flf2v and GET /health (base64 contract), "
"then register the endpoint URL as a named compute instance"
)


@register
class AWSDeployer(Deployer):
provider = "aws"

def deploy(self, spec: DeploySpec) -> DeployResult:
return DeployResult(
spec.name, self.provider, "manual_required",
detail=(
"AWS deploy is not automated yet. Manual path: build the GPU "
"backend image (Dockerfile.gpu), push to ECR, and deploy as a "
"SageMaker async endpoint or an ECS/EKS GPU service "
f"{_BASE64_CONTRACT}. Region: " + (spec.region or "us-east-1") + "."
),
)


@register
class GCPDeployer(Deployer):
provider = "gcp"

def deploy(self, spec: DeploySpec) -> DeployResult:
return DeployResult(
spec.name, self.provider, "manual_required",
detail=(
"GCP deploy is not automated yet. Manual path: build the GPU "
"backend image (Dockerfile.gpu), push to Artifact Registry, and "
"deploy to Cloud Run (GPU) or GKE "
f"{_BASE64_CONTRACT}. Region: " + (spec.region or "us-central1") + "."
),
)
77 changes: 77 additions & 0 deletions src/flow/deploy/modal_deploy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Modal deployer — runs ``modal deploy`` on the GPU backend as a named app.

Real and automated: validates the modal CLI, passes the spec through as env
(read by ``gpu_backend/modal_server.py`` at deploy time), runs the deploy, and
reports the resulting ``*.modal.run`` base URL.
"""

from __future__ import annotations

import os
import re
import shutil
import subprocess
from pathlib import Path

from flow.deploy.base import Deployer, DeployResult, DeploySpec, register

_MODAL_URL = re.compile(r"https://[^\s\"']+\.modal\.run")


@register
class ModalDeployer(Deployer):
provider = "modal"

def _server_path(self) -> Path:
"""Locate the packaged GPU backend module (works installed or in-repo)."""
import gpu_backend

return Path(gpu_backend.__file__).parent / "modal_server.py"

def deploy(self, spec: DeploySpec) -> DeployResult:
if shutil.which("modal") is None:
return DeployResult(
spec.name, self.provider, "failed",
detail="modal CLI not found. Install + auth: "
"`pip install modal && modal token new`.",
)
server = self._server_path()
if not server.exists():
return DeployResult(
spec.name, self.provider, "failed",
detail=f"GPU backend module not found at {server}",
)

env = {
**os.environ,
"FLOW_GPU_APP_NAME": spec.name,
"FLOW_GPU_TYPE": spec.gpu,
"FLOW_MODEL_T2V": spec.model_t2v,
"FLOW_MODEL_I2V": spec.model_i2v,
"FLOW_GPU_SCALEDOWN": str(spec.scaledown_window),
}
try:
proc = subprocess.run(
["modal", "deploy", str(server)],
env=env, capture_output=True, text=True, timeout=1800,
)
except (subprocess.TimeoutExpired, OSError) as err:
return DeployResult(spec.name, self.provider, "failed", detail=str(err))

combined = (proc.stdout or "") + (proc.stderr or "")
if proc.returncode != 0:
return DeployResult(
spec.name, self.provider, "failed",
detail=combined.strip()[-800:] or "modal deploy failed",
)

urls = _MODAL_URL.findall(combined)
url = urls[0] if urls else ""
return DeployResult(
spec.name, self.provider, "deployed", endpoint_url=url,
detail=(
"Deployed. Register this base URL as a named compute instance."
if url else
"Deployed; run `modal app list` to find the base URL."
),
)
Loading
Loading