From bab9415b2db64c04177707b724d7fae4f7828513 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 9 Nov 2026 08:13:02 +0000 Subject: [PATCH] feat(video): Wan 2.1 1.3B on 8 GiB cards and on CPU; model tags; v0.2.86 - GPU: wan2.1-t2v-1.3b in the video manifest (8 GiB floor, full_vram_gib 20). On a smaller card the runner encodes the prompt on the CPU and uses model CPU offload + VAE tiling. Rows carry their own job defaults; Wan frame counts must be 4k+1. - Nodes advertise c0mpute:video:model: for what their card holds; the /v1/videos gateway requires the model's tag, rejects unknown models and applies the model's defaults. - CPU: video_cpu.rs serves wan2.1-t2v-1.3b text-to-video through the pinned stable-diffusion.cpp build (-M vid_gen) with SHA-pinned Apache-2.0 GGUF weights (5.43 GB) and ffmpeg. Opt-in: `c0mpute video setup --cpu` or [video] cpu = true / C0MPUTE_VIDEO_CPU=1. Capped at 33 frames, 480x272, 20 steps; gated per offer on RAM, load and threads. - Video joins the default worker roles (subscribes only where it can serve). - /plugins shows a model's CPU option. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/video-runner.yml | 2 +- Cargo.lock | 32 +- Cargo.toml | 2 +- apps/tui/package.json | 2 +- apps/web/package.json | 2 +- apps/web/src/app/plugins/page.tsx | 5 + .../web/src/app/releases/latest.json/route.ts | 2 +- apps/web/src/lib/plugins.ts | 5 + node/crates/c0mpute-cli/src/main.rs | 54 +- node/crates/c0mpute-core/src/capabilities.rs | 7 +- node/crates/c0mpute-core/src/config.rs | 78 +++ node/crates/c0mpute-core/src/diffusion.rs | 82 ++- node/crates/c0mpute-core/src/lib.rs | 1 + .../crates/c0mpute-core/src/speech_gateway.rs | 20 +- node/crates/c0mpute-core/src/supervisor.rs | 63 ++ node/crates/c0mpute-core/src/video.rs | 201 +++++- node/crates/c0mpute-core/src/video_cpu.rs | 603 ++++++++++++++++++ node/crates/c0mpute-core/tests/video_e2e.rs | 2 +- package.json | 2 +- packages/shared/package.json | 2 +- plugins/ad-generator-agent/module.toml | 2 +- plugins/ads-manager/module.toml | 2 +- plugins/coinpay/module.toml | 2 +- plugins/crawl/module.toml | 2 +- plugins/ctv-ads/module.toml | 2 +- plugins/diffusion/module.toml | 2 +- plugins/embed/module.toml | 2 +- plugins/gameserver/module.toml | 2 +- plugins/hosting/module.toml | 2 +- plugins/infernet/module.toml | 2 +- plugins/live-stream/module.toml | 2 +- plugins/music/module.toml | 2 +- plugins/ocr/module.toml | 2 +- plugins/podcast-ads/module.toml | 2 +- plugins/podcasting/module.toml | 2 +- plugins/podcasts/module.toml | 2 +- plugins/render/module.toml | 2 +- plugins/secure-chat/module.toml | 2 +- plugins/storage/module.toml | 2 +- plugins/train/module.toml | 2 +- plugins/transcode/module.toml | 2 +- plugins/tts/module.toml | 2 +- plugins/video/module.toml | 39 +- plugins/video/runner/Dockerfile | 2 +- plugins/video/runner/README.md | 31 +- plugins/video/runner/runner.py | 95 ++- plugins/video/runner/test_runner.py | 42 +- plugins/webhook/module.toml | 2 +- plugins/whisper/module.toml | 2 +- plugins/workflow-step/module.toml | 2 +- 50 files changed, 1320 insertions(+), 106 deletions(-) create mode 100644 node/crates/c0mpute-core/src/video_cpu.rs diff --git a/.github/workflows/video-runner.yml b/.github/workflows/video-runner.yml index 76a8b244..1614835b 100644 --- a/.github/workflows/video-runner.yml +++ b/.github/workflows/video-runner.yml @@ -105,7 +105,7 @@ jobs: # Links the package to this public repo (and its README/license). labels: | org.opencontainers.image.source=https://github.com/${{ github.repository }} - org.opencontainers.image.description=c0mpute video runner (Wan 2.2, LTX-2.3) + org.opencontainers.image.description=c0mpute video runner (Wan 2.2 / 2.1, LTX-2.3) org.opencontainers.image.licenses=MIT # The context is plugins/video so module.toml — the runner's model # registry — is part of the image. diff --git a/Cargo.lock b/Cargo.lock index 01f363ac..5c41e898 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -478,7 +478,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "c0mpute-api" -version = "0.2.85" +version = "0.2.86" dependencies = [ "anyhow", "c0mpute-proto", @@ -493,7 +493,7 @@ dependencies = [ [[package]] name = "c0mpute-bench" -version = "0.2.85" +version = "0.2.86" dependencies = [ "anyhow", "blake3", @@ -504,7 +504,7 @@ dependencies = [ [[package]] name = "c0mpute-cli" -version = "0.2.85" +version = "0.2.86" dependencies = [ "anyhow", "axum", @@ -534,7 +534,7 @@ dependencies = [ [[package]] name = "c0mpute-core" -version = "0.2.85" +version = "0.2.86" dependencies = [ "aes-gcm", "anyhow", @@ -580,7 +580,7 @@ dependencies = [ [[package]] name = "c0mpute-doctor" -version = "0.2.85" +version = "0.2.86" dependencies = [ "anyhow", "serde", @@ -592,7 +592,7 @@ dependencies = [ [[package]] name = "c0mpute-envelope" -version = "0.2.85" +version = "0.2.86" dependencies = [ "blake3", "bs58", @@ -605,7 +605,7 @@ dependencies = [ [[package]] name = "c0mpute-gateway" -version = "0.2.85" +version = "0.2.86" dependencies = [ "anyhow", "axum", @@ -627,7 +627,7 @@ dependencies = [ [[package]] name = "c0mpute-market" -version = "0.2.85" +version = "0.2.86" dependencies = [ "c0mpute-envelope", "serde", @@ -637,7 +637,7 @@ dependencies = [ [[package]] name = "c0mpute-net" -version = "0.2.85" +version = "0.2.86" dependencies = [ "anyhow", "async-trait", @@ -654,7 +654,7 @@ dependencies = [ [[package]] name = "c0mpute-placement" -version = "0.2.85" +version = "0.2.86" dependencies = [ "anyhow", "async-trait", @@ -675,7 +675,7 @@ dependencies = [ [[package]] name = "c0mpute-proto" -version = "0.2.85" +version = "0.2.86" dependencies = [ "blake3", "hex", @@ -687,7 +687,7 @@ dependencies = [ [[package]] name = "c0mpute-secure-chat" -version = "0.2.85" +version = "0.2.86" dependencies = [ "aes-gcm", "anyhow", @@ -710,7 +710,7 @@ dependencies = [ [[package]] name = "c0mpute-store" -version = "0.2.85" +version = "0.2.86" dependencies = [ "anyhow", "blake3", @@ -728,7 +728,7 @@ dependencies = [ [[package]] name = "c0mpute-transcode" -version = "0.2.85" +version = "0.2.86" dependencies = [ "anyhow", "c0mpute-proto", @@ -741,7 +741,7 @@ dependencies = [ [[package]] name = "c0mpute-update" -version = "0.2.85" +version = "0.2.86" dependencies = [ "anyhow", "hex", @@ -755,7 +755,7 @@ dependencies = [ [[package]] name = "c0mpute-verify" -version = "0.2.85" +version = "0.2.86" dependencies = [ "anyhow", "blake3", diff --git a/Cargo.toml b/Cargo.toml index b2799924..7c9b6403 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ members = [ ] [workspace.package] -version = "0.2.85" +version = "0.2.86" edition = "2024" license = "MIT" repository = "https://github.com/profullstack/c0mpute" diff --git a/apps/tui/package.json b/apps/tui/package.json index e4e434c0..7989837f 100644 --- a/apps/tui/package.json +++ b/apps/tui/package.json @@ -1,6 +1,6 @@ { "name": "@c0mpute/tui", - "version": "0.2.85", + "version": "0.2.86", "private": true, "type": "module", "bin": { diff --git a/apps/web/package.json b/apps/web/package.json index a3ebf163..5d8f186d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "c0mpute", - "version": "0.2.85", + "version": "0.2.86", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/app/plugins/page.tsx b/apps/web/src/app/plugins/page.tsx index 1dc50997..91d7d9b8 100644 --- a/apps/web/src/app/plugins/page.tsx +++ b/apps/web/src/app/plugins/page.tsx @@ -274,5 +274,10 @@ function modelSpec(model: ModelOption): string[] { model.vram_gib ? `${model.vram_gib} GiB VRAM` : undefined, model.resolution, model.audio ? "video + audio" : undefined, + typeof model.cpu === "string" && model.cpu.length > 0 + ? `or CPU: ${model.cpu}` + : model.cpu === true + ? "or CPU (slow, batch)" + : undefined, ].filter((v): v is string => typeof v === "string" && v.length > 0); } diff --git a/apps/web/src/app/releases/latest.json/route.ts b/apps/web/src/app/releases/latest.json/route.ts index 296d44ba..76e69cdc 100644 --- a/apps/web/src/app/releases/latest.json/route.ts +++ b/apps/web/src/app/releases/latest.json/route.ts @@ -28,7 +28,7 @@ export const dynamic = "force-dynamic"; * this in sync with the workspace version in `Cargo.toml` on each release so a * freshly built CLI reports "already latest" rather than a phantom upgrade. */ -const FALLBACK_VERSION = "0.2.85"; +const FALLBACK_VERSION = "0.2.86"; const FALLBACK_MIN_REQUIRED = "0.0.1"; type Channel = "stable" | "beta" | "nightly"; diff --git a/apps/web/src/lib/plugins.ts b/apps/web/src/lib/plugins.ts index 1fc20167..9f60858d 100644 --- a/apps/web/src/lib/plugins.ts +++ b/apps/web/src/lib/plugins.ts @@ -73,6 +73,11 @@ export interface ModelOption { audio?: boolean; /** Few-step distilled checkpoint (fixed schedule, no CFG). */ distilled?: boolean; + /** + * CPU-only nodes can serve it too; the string says what that takes and + * how slow it is (CPU video is batch, minutes per clip). + */ + cpu?: string | boolean; } /** diff --git a/node/crates/c0mpute-cli/src/main.rs b/node/crates/c0mpute-cli/src/main.rs index 1f7003fb..50032872 100644 --- a/node/crates/c0mpute-cli/src/main.rs +++ b/node/crates/c0mpute-cli/src/main.rs @@ -564,6 +564,10 @@ enum VideoCmd { /// Build from source without trying the registry first. #[arg(long)] build: bool, + /// No GPU: install CPU text-to-video instead (Wan 2.1 1.3B on + /// stable-diffusion.cpp, ~5.4 GB, slow batch clips). Opts this node in. + #[arg(long)] + cpu: bool, }, /// Report whether this node can serve video, and why not if it cannot. Check { @@ -1658,6 +1662,7 @@ async fn run_worker(cmd: WorkerCmd, config_path: &std::path::Path) -> Result<()> Role::WorkflowStep, Role::Diffusion, Role::Music, + Role::Video, Role::Crawl, Role::Hosting, Role::Ocr, @@ -2484,7 +2489,27 @@ async fn run_video(cmd: VideoCmd) -> Result<()> { use c0mpute_core::video; match cmd { - VideoCmd::Setup { build } => { + VideoCmd::Setup { cpu: true, .. } => { + use c0mpute_core::video_cpu; + println!( + "installing CPU video: stable-diffusion.cpp + Wan 2.1 T2V 1.3B ({:.1} GB of weights)", + video_cpu::download_bytes() as f64 / 1e9 + ); + video_cpu::install().await?; + println!("installed under {}", c0mpute_core::diffusion::diffusion_root().display()); + if c0mpute_core::supervisor::which_ffmpeg().is_none() { + println!("ffmpeg is not on PATH: install it, or this node cannot encode clips"); + } + println!( + "this node now serves {} text-to-video on CPU (at most {} frames of 480x272, {} steps; many minutes per clip)", + video_cpu::MODEL_ID, + video_cpu::MAX_FRAMES, + video_cpu::MAX_STEPS + ); + println!("start (or restart) a worker with the video role: `c0mpute worker start`"); + Ok(()) + } + VideoCmd::Setup { build, .. } => { let how = video::setup(build, &std::env::temp_dir(), &|line: &str| println!("{line}")).await?; println!("runner image ready as {} ({how:?})", video::RUNNER_IMAGE); match video::readiness(video::RUNNER_IMAGE) { @@ -2505,9 +2530,32 @@ async fn run_video(cmd: VideoCmd) -> Result<()> { } VideoCmd::Check { selftest } => { match video::readiness(video::RUNNER_IMAGE) { - None => println!("ready: GPU, container runtime and runner image are all present"), + None => { + println!("ready: GPU, container runtime and runner image are all present"); + match video::nvidia_vram_mib() { + Some(mib) => println!( + "card: {mib} MiB; models it can run: {}", + video::models_for_vram(mib).join(", ") + ), + None => println!("card memory unknown (nvidia-smi reported none)"), + } + } Some(not_ready) => { - println!("not ready: {}", not_ready.reason()); + println!("GPU video not ready: {}", not_ready.reason()); + let cfg = Config::load_or_default(&config::default_config_path()).unwrap_or_default(); + if c0mpute_core::video_cpu::installed() { + let serving = if c0mpute_core::video_cpu::can_serve(&cfg.video) { + "yes" + } else { + "no (disabled, busy, low RAM, small CPU budget or no ffmpeg)" + }; + println!( + "CPU video: installed ({}); can take a clip right now: {serving}", + c0mpute_core::video_cpu::MODEL_ID + ); + } else { + println!("CPU video: not installed (`c0mpute video setup --cpu` opts in)"); + } return Ok(()); } } diff --git a/node/crates/c0mpute-core/src/capabilities.rs b/node/crates/c0mpute-core/src/capabilities.rs index 9a3133ab..3e3c47db 100644 --- a/node/crates/c0mpute-core/src/capabilities.rs +++ b/node/crates/c0mpute-core/src/capabilities.rs @@ -130,8 +130,13 @@ pub fn role_tags(config: &Config) -> Vec { // Same rule as TTS: advertise only what this box can do now. // A node without the GPU, the runtime or the image would win // auctions and fail them. + // One model tag per model the card can hold. With no GPU, + // CPU video (opt-in) advertises the small Wan model while + // the box is idle with free RAM, re-checked each tick. if crate::video::readiness_cached(crate::video::RUNNER_IMAGE).is_none() { - tags.push(crate::video::ROLE_TAG.to_string()); + tags.extend(crate::video::gpu_tags()); + } else if crate::video_cpu::can_serve(&config.video) { + tags.extend(crate::video_cpu::tags()); } } Role::Diffusion => { diff --git a/node/crates/c0mpute-core/src/config.rs b/node/crates/c0mpute-core/src/config.rs index 6b9519ec..7abc9898 100644 --- a/node/crates/c0mpute-core/src/config.rs +++ b/node/crates/c0mpute-core/src/config.rs @@ -47,6 +47,10 @@ pub struct Config { /// Music generation (`music.txt2music`, ACE-Step 1.5) provisioning and gates. #[serde(default)] pub music: MusicConfig, + /// Video generation. The GPU path needs nothing here; this is the CPU + /// path (Wan 2.1 1.3B through stable-diffusion.cpp, opt-in). + #[serde(default)] + pub video: VideoConfig, /// Static-site hosting: how much this node holds for the network. #[serde(default)] pub hosting: HostingConfig, @@ -390,6 +394,76 @@ impl MusicConfig { } } +/// CPU video (`video_cpu`): Wan 2.1 T2V 1.3B on stable-diffusion.cpp. +/// +/// Opt-in, because a 33-frame clip holds 16 threads for many minutes: +/// - `cpu` unset (the default): serve only if an operator installed it with +/// `c0mpute video setup --cpu`; never download anything on its own. +/// - `cpu = true` (or `C0MPUTE_VIDEO_CPU=1`): install in the background on a +/// box that passes the gates, then serve. +/// - `cpu = false` (or `C0MPUTE_VIDEO_CPU=0`): never serve, even if installed. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct VideoConfig { + #[serde(default)] + pub cpu: Option, + #[serde(default = "default_video_min_ram_gb")] + pub min_ram_gb: u64, + #[serde(default = "default_video_min_free_ram_gb")] + pub min_free_ram_gb: u64, + #[serde(default = "default_video_min_free_disk_gb")] + pub min_free_disk_gb: u64, + /// Smallest per-job CPU thread budget worth serving clips with. + #[serde(default = "default_video_min_cpu_threads")] + pub min_cpu_threads: usize, + /// Busiest the box may be (1-minute load per logical CPU) and still take a clip. + #[serde(default = "default_diffusion_max_load_per_cpu")] + pub max_load_per_cpu: f64, +} + +impl Default for VideoConfig { + fn default() -> Self { + Self { + cpu: None, + min_ram_gb: default_video_min_ram_gb(), + min_free_ram_gb: default_video_min_free_ram_gb(), + min_free_disk_gb: default_video_min_free_disk_gb(), + min_cpu_threads: default_video_min_cpu_threads(), + max_load_per_cpu: default_diffusion_max_load_per_cpu(), + } + } +} + +fn default_video_min_ram_gb() -> u64 { + 16 +} +fn default_video_min_free_ram_gb() -> u64 { + // sd-cli holds ~1.8 GB RSS with the encoder and VAE streamed from disk + // (cgroup peak 4.8 GB with page cache; dev2, 2026-11-09). The headroom + // is for the host: a clip runs for ~25 minutes, and on a box that dips + // low in that window an OOM guard kills it. + 8 +} +fn default_video_min_free_disk_gb() -> u64 { + // 5.4 GB of weights on top of the sd.cpp runtime, plus frames. + 12 +} +fn default_video_min_cpu_threads() -> usize { + 16 +} + +impl VideoConfig { + /// `Some(true)`: install and serve; `Some(false)`: never; `None`: serve + /// only what an operator installed by hand. The environment wins. + pub fn cpu_setting(&self) -> Option { + match std::env::var("C0MPUTE_VIDEO_CPU") { + Ok(value) if !value.trim().is_empty() => { + Some(auto_install_enabled_with(true, Some(value))) + } + _ => self.cpu, + } + } +} + /// CPU text-to-speech provisioning. /// /// Kokoro is small enough — an int8 ONNX export of hexgrad/Kokoro-82M, about @@ -460,6 +534,9 @@ impl Default for Config { Role::WorkflowStep, Role::Diffusion, Role::Music, + // Subscribes only where it can serve: a ready GPU runner, or + // CPU video the operator opted into. + Role::Video, Role::Crawl, Role::Hosting, Role::Ocr, @@ -478,6 +555,7 @@ impl Default for Config { speech: SpeechConfig::default(), diffusion: DiffusionConfig::default(), music: MusicConfig::default(), + video: VideoConfig::default(), hosting: HostingConfig::default(), ads: AdsConfig::default(), embed: EmbedConfig::default(), diff --git a/node/crates/c0mpute-core/src/diffusion.rs b/node/crates/c0mpute-core/src/diffusion.rs index f7ec26ff..576f77db 100644 --- a/node/crates/c0mpute-core/src/diffusion.rs +++ b/node/crates/c0mpute-core/src/diffusion.rs @@ -91,15 +91,23 @@ pub fn diffusion_root() -> PathBuf { .join("diffusion") } -fn bin_dir() -> PathBuf { +pub(crate) fn bin_dir() -> PathBuf { diffusion_root().join("bin") } -fn models_dir() -> PathBuf { +pub(crate) fn models_dir() -> PathBuf { diffusion_root().join("models") } -fn sd_cli() -> PathBuf { +pub(crate) fn sd_cli() -> PathBuf { bin_dir().join("sd-cli") } +/// Written once the sd.cpp runtime alone (no weights) is installed and runs. +/// CPU video shares the runtime with images, so either can install it. +fn runtime_marker() -> PathBuf { + diffusion_root().join(".runtime-ready") +} +fn runtime_stamp() -> String { + format!("sdcpp={SDCPP_RELEASE}\n") +} fn ready_marker() -> PathBuf { diffusion_root().join(".ready") } @@ -107,7 +115,7 @@ fn retry_marker() -> PathBuf { diffusion_root().join(".retry-after") } -fn platform_supported() -> bool { +pub(crate) fn platform_supported() -> bool { cfg!(all(target_os = "linux", target_arch = "x86_64")) } @@ -133,7 +141,7 @@ pub fn memory() -> MemorySnapshot { } } -const GIB: u64 = 1024 * 1024 * 1024; +pub(crate) const GIB: u64 = 1024 * 1024 * 1024; /// Pure decision: can a box with this memory take a diffusion job right now? pub fn has_headroom_with(config: &crate::config::DiffusionConfig, mem: MemorySnapshot) -> bool { @@ -171,15 +179,15 @@ pub fn idle_enough_with(load_1m: f64, cpus: usize, max_per_cpu: f64) -> bool { cpus > 0 && load_1m.is_finite() && load_1m < cpus as f64 * max_per_cpu } -fn load_avg_1m() -> f64 { +pub(crate) fn load_avg_1m() -> f64 { sysinfo::System::load_average().one } -fn logical_cpus() -> usize { +pub(crate) fn logical_cpus() -> usize { std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1) } -fn now_secs() -> u64 { +pub(crate) fn now_secs() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|elapsed| elapsed.as_secs()) @@ -193,7 +201,7 @@ fn in_install_backoff() -> bool { .is_some_and(|retry_after| now_secs() < retry_after) } -fn free_disk_bytes(root: &Path) -> u64 { +pub(crate) fn free_disk_bytes(root: &Path) -> u64 { let disks = sysinfo::Disks::new_with_refreshed_list(); let mut best: Option<(usize, u64)> = None; let mut largest = 0u64; @@ -284,10 +292,34 @@ pub async fn install() -> Result<()> { if !platform_supported() { bail!("diffusion has a CPU build for Linux x86_64 only"); } - let root = diffusion_root(); tokio::fs::create_dir_all(models_dir()).await?; let _ = tokio::fs::remove_file(ready_marker()).await; + install_runtime().await?; + + for weight in &WEIGHTS { + download_verified_streaming(weight.url, &models_dir().join(weight.name), weight.sha256) + .await + .with_context(|| format!("download {}", weight.name))?; + } + tokio::fs::write(ready_marker(), ready_stamp()).await?; + Ok(()) +} + +/// The pinned sd.cpp runtime alone: download, verify, extract, self-test. +/// Skipped when the runtime marker already names this release. +pub(crate) async fn install_runtime() -> Result<()> { + if !platform_supported() { + bail!("stable-diffusion.cpp has a CPU build for Linux x86_64 only"); + } + if sd_cli().is_file() + && std::fs::read_to_string(runtime_marker()).is_ok_and(|s| s == runtime_stamp()) + { + return Ok(()); + } + let root = diffusion_root(); + tokio::fs::create_dir_all(&root).await?; + let _ = tokio::fs::remove_file(runtime_marker()).await; let zip_path = root.join("sdcpp.zip"); download_verified_streaming(SDCPP_ZIP_URL, &zip_path, SDCPP_ZIP_SHA256).await?; let staging = root.join("bin.staging"); @@ -303,12 +335,6 @@ pub async fn install() -> Result<()> { tokio::fs::rename(&staging, bin_dir()).await?; let _ = tokio::fs::remove_file(&zip_path).await; - for weight in &WEIGHTS { - download_verified_streaming(weight.url, &models_dir().join(weight.name), weight.sha256) - .await - .with_context(|| format!("download {}", weight.name))?; - } - // The binary links glibc and libstdc++ from the host. Running it once is // the only honest check that this box can load it. let output = Command::new(sd_cli()) @@ -328,7 +354,7 @@ pub async fn install() -> Result<()> { .collect::() ); } - tokio::fs::write(ready_marker(), ready_stamp()).await?; + tokio::fs::write(runtime_marker(), runtime_stamp()).await?; Ok(()) } @@ -364,7 +390,7 @@ fn extract_zip(zip_path: &Path, dest: &Path) -> Result<()> { /// Stream a large file to disk while hashing it; never holds it in memory. /// A file already present with the right hash is kept. -async fn download_verified_streaming(url: &str, path: &Path, expected_sha256: &str) -> Result<()> { +pub(crate) async fn download_verified_streaming(url: &str, path: &Path, expected_sha256: &str) -> Result<()> { if path.is_file() && sha256_file(path).await? == expected_sha256 { return Ok(()); } @@ -527,16 +553,19 @@ pub async fn generate(spec: &Txt2ImgSpec, output: &Path) -> Result<()> { Ok(()) } -/// One image at a time per node: a job holds ~9 GB and every core, so a second -/// concurrent job would only swap. -fn node_slot() -> Arc { +/// One sd-cli job at a time per node: a job holds ~4-9 GB and every core, so a +/// second concurrent job would only swap. +/// +/// Shared with CPU video (`video_cpu`): both are sd-cli, and an image and a +/// clip at once on one box would only make both miss their deadlines. +pub(crate) fn node_slot() -> Arc { static SLOT: std::sync::OnceLock> = std::sync::OnceLock::new(); SLOT.get_or_init(|| Arc::new(Semaphore::new(1))).clone() } /// `/proc//stat` -> (state, parent pid). The command name is in /// parentheses and may contain spaces, so parse after the last `)`. -fn parse_stat(stat: &str) -> Option<(char, u32)> { +pub(crate) fn parse_stat(stat: &str) -> Option<(char, u32)> { let rest = stat.get(stat.rfind(')')? + 1..)?.trim_start(); let mut fields = rest.split_whitespace(); let state = fields.next()?.chars().next()?; @@ -551,7 +580,16 @@ fn parse_stat(stat: &str) -> Option<(char, u32)> { /// keeps ~6-9 GB and every core busy, so the node stops bidding (free-RAM /// gate) or runs the next image at half speed. Called once when the runner /// starts, before it takes any job, so every sd-cli child is such an orphan. +/// +/// Both sd-cli runners (images, and CPU video in `video_cpu`) call this, but +/// only the first call in a process does anything: the second runner can +/// start minutes later, after its own install, when the first may already be +/// rendering a live job. pub fn kill_orphaned_renders() -> usize { + static DONE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + if DONE.swap(true, std::sync::atomic::Ordering::SeqCst) { + return 0; + } if !cfg!(target_os = "linux") { return 0; } diff --git a/node/crates/c0mpute-core/src/lib.rs b/node/crates/c0mpute-core/src/lib.rs index 49e81ed8..6f12afab 100644 --- a/node/crates/c0mpute-core/src/lib.rs +++ b/node/crates/c0mpute-core/src/lib.rs @@ -48,6 +48,7 @@ pub mod temporal; pub mod text; pub mod train; pub mod video; +pub mod video_cpu; pub mod webhook; pub mod workflow; diff --git a/node/crates/c0mpute-core/src/speech_gateway.rs b/node/crates/c0mpute-core/src/speech_gateway.rs index f68413f8..5acb0b01 100644 --- a/node/crates/c0mpute-core/src/speech_gateway.rs +++ b/node/crates/c0mpute-core/src/speech_gateway.rs @@ -935,7 +935,7 @@ fn audio_format(headers: &HeaderMap) -> ApiResult<&'static str> { /// `POST /v1/images`: CPU txt2img on a c0mpute worker (`diffusion.txt2img`). /// Body: `{prompt, width?, height?, steps?, seed?}` (see `diffusion::Txt2ImgSpec`). /// Returns `image/png`. Expect ~90-150 s. -/// `POST /v1/videos`: a video job (Wan 2.2 / LTX-2.3) as a JSON body of +/// `POST /v1/videos`: a video job (Wan 2.2 / 2.1, LTX-2.3) as a JSON body of /// [`crate::video::VideoJobInline`] fields. Send `image_base64` for /// image-to-video; this gateway stores and serves the image to the worker /// itself. Answers `video/mp4`. @@ -950,11 +950,23 @@ struct VideoRequest { async fn generate_video( State(state): State, headers: HeaderMap, - Json(input): Json, + Json(mut raw): Json, ) -> ApiResult { use base64::Engine; authenticate(&state, &headers)?; + // A buyer who names only the model gets that model's own defaults (the + // small Wan 2.1 model is 33 frames of 480x272, not 81 of 1280x704). + crate::video::apply_model_defaults(&mut raw); + let input: VideoRequest = serde_json::from_value(raw) + .map_err(|error| ApiError(StatusCode::BAD_REQUEST, format!("invalid video request: {error}")))?; let mut job = input.job; + if crate::video::manifest_model(&job.model).is_none() { + let known: Vec<&str> = crate::video::manifest_models().iter().map(|m| m.id.as_str()).collect(); + return Err(ApiError( + StatusCode::BAD_REQUEST, + format!("unknown video model {}; have {}", job.model, known.join(", ")), + )); + } let workload = match input.image_base64.as_deref() { Some(b64) => { let bytes = base64::engine::general_purpose::STANDARD @@ -1002,7 +1014,9 @@ async fn generate_video( &state, workload, spec, - vec![crate::video::ROLE_TAG.to_string()], + // The model tag: only a node that can run this model (a big enough + // card, or CPU video for the small one) is eligible. + vec![crate::video::ROLE_TAG.to_string(), crate::video::model_tag(&job.model)], crate::video::MAX_RESULT_BYTES, ) .await?; diff --git a/node/crates/c0mpute-core/src/supervisor.rs b/node/crates/c0mpute-core/src/supervisor.rs index 7a05684a..a88efab0 100644 --- a/node/crates/c0mpute-core/src/supervisor.rs +++ b/node/crates/c0mpute-core/src/supervisor.rs @@ -410,6 +410,69 @@ impl Supervisor { }); continue; } + // Video on a box with no usable GPU: CPU text-to-video (Wan 2.1 + // 1.3B on stable-diffusion.cpp) if the operator opted in. Same + // shape as CPU images: provision in the background, subscribe + // once it works, re-check RAM and load on every offer. + if workload_type == crate::video::WORKLOAD_TXT2VID + && crate::video::readiness(crate::video::RUNNER_IMAGE).is_some() + { + let net = net.clone(); + let store = self.store.clone(); + let cache_root = cache_root.clone(); + let config = self.config.clone(); + tokio::spawn(async move { + if !crate::video_cpu::ensure(&config.video).await { + info!( + "no GPU video and CPU video is not enabled; not subscribing to video.txt2vid \ + (`c0mpute video setup --cpu` opts in)" + ); + return; + } + let runner_tx = crate::video_cpu::spawn_cpu_video_runner( + net.clone(), + store, + cache_root, + load.clone(), + ); + info!( + workload_type = crate::video::WORKLOAD_TXT2VID, + model = crate::video_cpu::MODEL_ID, + "subscribing to job topic (CPU video)" + ); + let gate = config.video.clone(); + let filter: dispatch::OfferFilter = std::sync::Arc::new(move |offer| { + let fits = offer + .spec_inline + .as_ref() + .and_then(|spec| { + serde_json::from_value::(spec.clone()).ok() + }) + .is_some_and(|job| { + crate::video_cpu::fits(&job, crate::video::WORKLOAD_TXT2VID).is_ok() + }); + fits && crate::video_cpu::can_serve(&gate) + }); + // The tag set is fixed at subscribe time and the box may + // have been busy then; the filter is the live gate. + let mut tags = capabilities::tags_from_config(&config); + for tag in crate::video_cpu::tags() { + if !tags.contains(&tag) { + tags.push(tag); + } + } + dispatch::run_worker_subscriber_with_load( + net, + crate::video::WORKLOAD_TXT2VID.to_string(), + tags, + Some(runner_tx), + Some(filter), + Some(config.pricing.clone()), + Some(load), + ); + }); + continue; + } info!(%workload_type, "subscribing to job topic"); let runner_tx = match workload_type.as_str() { "ffmpeg.transcode" => { diff --git a/node/crates/c0mpute-core/src/video.rs b/node/crates/c0mpute-core/src/video.rs index 26f55bd6..9a818e22 100644 --- a/node/crates/c0mpute-core/src/video.rs +++ b/node/crates/c0mpute-core/src/video.rs @@ -1,4 +1,6 @@ -//! `video.txt2vid` / `video.img2vid` — Wan 2.2 video generation. +//! `video.txt2vid` / `video.img2vid` — Wan 2.2 / 2.1 and LTX-2.3 video +//! generation on an NVIDIA GPU. (CPU-only nodes serve the small Wan 2.1 +//! model through stable-diffusion.cpp instead: see `video_cpu`.) //! //! This is the first *container* workload: unlike transcode, TTS and STT, //! the work happens in `ghcr.io/profullstack/c0mpute-video-runner` rather than in this @@ -40,9 +42,147 @@ pub const WORKLOAD_TXT2VID: &str = "video.txt2vid"; pub const WORKLOAD_IMG2VID: &str = "video.img2vid"; /// Advertised only when the container runtime, the image and an NVIDIA GPU -/// are all present — see [`readiness`]. +/// are all present — see [`readiness`] — or when CPU video is installed +/// (`video_cpu`). pub const ROLE_TAG: &str = "c0mpute:role:video"; +/// One tag per model a node can actually run: `c0mpute:video:model:`. +/// The gateway requires the tag of the model a job names, so a job for a +/// 48 GiB model never goes to an 8 GiB card or a CPU node. +pub const MODEL_TAG_PREFIX: &str = "c0mpute:video:model:"; + +pub fn model_tag(model: &str) -> String { + format!("{MODEL_TAG_PREFIX}{model}") +} + +/// The plugin manifest this binary was built with: the model table the +/// runner image reads too (a unit test keeps the image pin and this file in +/// step, so they describe the same runner). +const MANIFEST: &str = include_str!("../../../../plugins/video/module.toml"); + +/// One row of `[module.models]`, as far as the node needs it. +#[derive(Clone, Debug, PartialEq)] +pub struct ManifestModel { + pub id: String, + /// VRAM floor (GiB) at the stated precision, offload included. + pub vram_gib: u64, + pub workloads: Vec, + /// The model's own defaults for fields a job leaves out. + pub defaults: Vec<(String, u32)>, + /// A CPU-only node can serve it too (`video_cpu`). + pub cpu: bool, +} + +/// Job fields a manifest row may give its own default for. +pub const DEFAULTABLE: [&str; 5] = ["frames", "fps", "steps", "width", "height"]; + +/// The models in the manifest, parsed once. +pub fn manifest_models() -> &'static [ManifestModel] { + static MODELS: std::sync::OnceLock> = std::sync::OnceLock::new(); + MODELS.get_or_init(|| parse_manifest_models(MANIFEST)) +} + +pub fn parse_manifest_models(text: &str) -> Vec { + let Ok(manifest) = toml::from_str::(text) else { + return Vec::new(); + }; + let Some(table) = manifest + .get("module") + .and_then(|m| m.get("models")) + .and_then(|m| m.as_table()) + else { + return Vec::new(); + }; + let mut models: Vec = table + .iter() + .map(|(id, row)| ManifestModel { + id: id.clone(), + vram_gib: row.get("vram_gib").and_then(|v| v.as_integer()).unwrap_or(0).max(0) as u64, + workloads: row + .get("workloads") + .and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|w| w.as_str().map(str::to_string)).collect()) + .unwrap_or_default(), + defaults: DEFAULTABLE + .iter() + .filter_map(|k| { + row.get(*k) + .and_then(|v| v.as_integer()) + .and_then(|v| u32::try_from(v).ok()) + .map(|v| (k.to_string(), v)) + }) + .collect(), + cpu: row.get("cpu").is_some_and(|v| v.as_bool().unwrap_or(true)), + }) + .collect(); + models.sort_by(|a, b| a.id.cmp(&b.id)); + models +} + +pub fn manifest_model(id: &str) -> Option<&'static ManifestModel> { + manifest_models().iter().find(|m| m.id == id) +} + +/// Fill the fields a request left out with the named model's own defaults +/// (Wan 2.1 1.3B: 33 frames of 480x272 at 16 fps), so a buyer who only +/// names the small model does not get the 1280x704, 81-frame defaults sized +/// for the big ones. Fields the request set are never touched. +pub fn apply_model_defaults(request: &mut serde_json::Value) { + let Some(model) = request + .get("model") + .and_then(|m| m.as_str()) + .and_then(manifest_model) + else { + return; + }; + let Some(obj) = request.as_object_mut() else { + return; + }; + for (key, value) in &model.defaults { + if obj.get(key).is_none_or(|v| v.is_null()) { + obj.insert(key.clone(), serde_json::json!(value)); + } + } +} + +/// Models a card with `vram_mib` of memory can run, by the manifest's VRAM +/// floors. 512 MiB of slack: an "8 GB" card reports a little under 8192. +pub fn models_for_vram(vram_mib: u64) -> Vec<&'static str> { + manifest_models() + .iter() + .filter(|m| m.vram_gib > 0 && m.vram_gib * 1024 <= vram_mib + 512) + .map(|m| m.id.as_str()) + .collect() +} + +/// Largest card's memory per `nvidia-smi`, in MiB. +pub fn nvidia_vram_mib() -> Option { + let out = std::process::Command::new("nvidia-smi") + .args(["--query-gpu=memory.total", "--format=csv,noheader,nounits"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + parse_vram_mib(&String::from_utf8_lossy(&out.stdout)) +} + +pub fn parse_vram_mib(text: &str) -> Option { + text.lines().filter_map(|l| l.trim().parse::().ok()).max() +} + +/// Tags a GPU video node advertises: the role, plus one per model its card +/// can hold. Probed once (a card does not change size under a running node). +pub fn gpu_tags() -> Vec { + static VRAM: std::sync::OnceLock> = std::sync::OnceLock::new(); + let vram = *VRAM.get_or_init(nvidia_vram_mib); + let mut tags = vec![ROLE_TAG.to_string()]; + if let Some(mib) = vram { + tags.extend(models_for_vram(mib).into_iter().map(model_tag)); + } + tags +} + /// Where `.github/workflows/video-runner.yml` publishes the runner. pub const RUNNER_REPOSITORY: &str = "ghcr.io/profullstack/c0mpute-video-runner"; @@ -185,6 +325,13 @@ impl VideoJobInline { if !(1..=MAX_FRAMES).contains(&self.frames) { return Err(format!("frames must be 1..{MAX_FRAMES}")); } + if self.frames % 4 != 1 { + // Every model here compresses time by 4 (LTX-2 by 8, so 8k+1 is + // also 4k+1). Any other count is silently rounded by the model + // and the clip then fails the runner's exact frame check after + // the worker has paid for it. + return Err("frames must be 4k+1 (e.g. 33, 81, 121)".into()); + } if !(1..=60).contains(&self.fps) { return Err("fps must be 1..60".into()); } @@ -843,6 +990,56 @@ mod tests { ); } + #[test] + fn frame_counts_must_be_4k_plus_1() { + let mut j = job(); + j.frames = 80; + assert!(j.validate(WORKLOAD_TXT2VID).is_err()); + j.frames = 33; + assert!(j.validate(WORKLOAD_TXT2VID).is_ok()); + } + + #[test] + fn the_manifest_lists_the_small_wan_model_for_8_gib_and_cpu() { + let small = manifest_model("wan2.1-t2v-1.3b").expect("wan2.1-t2v-1.3b in module.toml"); + assert_eq!(small.vram_gib, 8); + assert!(small.cpu); + assert_eq!(small.workloads, vec![WORKLOAD_TXT2VID.to_string()]); + assert!(!manifest_model("wan2.2-ti2v-5b").unwrap().cpu); + assert_eq!(manifest_models().len(), 5); + } + + #[test] + fn a_card_advertises_only_the_models_it_can_hold() { + // An 8 GB card (nvidia-smi says 8192, some say a little less). + assert_eq!(models_for_vram(8192), vec!["wan2.1-t2v-1.3b"]); + assert_eq!(models_for_vram(7982), vec!["wan2.1-t2v-1.3b"]); + assert!(models_for_vram(6144).is_empty()); + let big = models_for_vram(24 * 1024); + assert!(big.contains(&"wan2.2-ti2v-5b") && big.contains(&"wan2.1-t2v-1.3b")); + assert!(!big.contains(&"wan2.2-t2v-a14b")); + assert_eq!(models_for_vram(80 * 1024).len(), 5); + assert_eq!(parse_vram_mib("8192\n24576\n"), Some(24576)); + assert_eq!(parse_vram_mib("No devices were found"), None); + assert_eq!(model_tag("wan2.1-t2v-1.3b"), "c0mpute:video:model:wan2.1-t2v-1.3b"); + } + + #[test] + fn omitted_fields_take_the_named_models_defaults() { + let mut req = serde_json::json!({ "model": "wan2.1-t2v-1.3b", "prompt": "p", "steps": 8 }); + apply_model_defaults(&mut req); + let inline: VideoJobInline = serde_json::from_value(req).unwrap(); + assert_eq!((inline.frames, inline.fps, inline.width, inline.height), (33, 16, 480, 272)); + assert_eq!(inline.steps, 8, "a field the buyer set is kept"); + assert!(inline.validate(WORKLOAD_TXT2VID).is_ok()); + + // A model without its own defaults keeps the serde ones. + let mut req = serde_json::json!({ "model": "wan2.2-ti2v-5b", "prompt": "p" }); + apply_model_defaults(&mut req); + let inline: VideoJobInline = serde_json::from_value(req).unwrap(); + assert_eq!((inline.frames, inline.width), (81, 1280)); + } + #[test] fn weights_live_under_the_cache_root() { let dir = weights_dir(Path::new("/var/c0mpute")); diff --git a/node/crates/c0mpute-core/src/video_cpu.rs b/node/crates/c0mpute-core/src/video_cpu.rs new file mode 100644 index 00000000..1e4b6df8 --- /dev/null +++ b/node/crates/c0mpute-core/src/video_cpu.rs @@ -0,0 +1,603 @@ +//! `video.txt2vid` on CPU: stable-diffusion.cpp + Wan 2.1 T2V 1.3B. +//! +//! The GPU path (`video`) runs a diffusers container and needs an NVIDIA +//! card. Most of the network has none, so a CPU-only node can serve the one +//! model small enough to be worth it on CPU, through the same pinned sd.cpp +//! build that serves CPU images (`diffusion`), in `-M vid_gen` mode: +//! +//! - `sd-cli`: shared with `diffusion` (one install, one runtime marker); +//! - three Apache-2.0 weight files, pinned to a repo commit and SHA-256: +//! Wan2.1-T2V-1.3B Q8_0 (off-grid-ai, converted with sd.cpp itself), +//! UMT5-XXL encoder Q4_K_M (city96) and the Wan 2.1 VAE (Comfy-Org). +//! 5.43 GB in all. +//! - ffmpeg on PATH: sd-cli writes PNG frames, ffmpeg makes the H.264 MP4 +//! every video buyer gets. +//! +//! Slow and batch: a 33-frame 480x272 clip at 20 steps is 20-30 minutes of 16 +//! threads (52-93 s per sampling step on dev2 under load, 2026-11-09). So it +//! is opt-in (see +//! `config::VideoConfig`), capped at that size, and gated per offer on free +//! RAM and load exactly like CPU images. One sd-cli job at a time per node, +//! images and clips together (`diffusion::node_slot`). + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Instant; + +use anyhow::{Context, Result, bail}; +use c0mpute_net::Libp2pNetwork; +use c0mpute_net::topics::JobStatus; +use c0mpute_store::ChunkStore; +use tokio::process::Command; +use tokio::sync::mpsc; +use tracing::{info, warn}; + +use crate::config::VideoConfig; +use crate::diffusion::{self, GIB}; +use crate::runner::{AcceptedJob, WorkerLoad, cleanup_job_dir, publish_receipt, safe_job_dir}; +use crate::video::{self, VideoJobInline, WORKLOAD_TXT2VID}; + +/// The one model a CPU node serves. +pub const MODEL_ID: &str = "wan2.1-t2v-1.3b"; + +/// Advertised beside the role and model tags, so a buyer (and /api/nodes) +/// can tell a CPU clip from a GPU one. +pub const CPU_TAG: &str = "c0mpute:video:cpu"; + +/// Caps: the defaults are the ceiling. Time grows with frames x pixels x +/// steps, and this size already takes 20-30 minutes of 16 threads; anything +/// bigger would blow the 45-minute job timeout on a slower box. +pub const MAX_FRAMES: u32 = 33; +pub const MAX_PIXELS: u32 = 480 * 272; +pub const MAX_STEPS: u32 = 20; + +const INSTALL_RETRY_BACKOFF_SECS: u64 = 6 * 60 * 60; + +/// Wan's own recommended negative prompt (from the model card and sd.cpp's +/// docs/wan.md). It is in Chinese because that is what the model was +/// trained to read; it lists the usual artifacts (overexposure, static +/// frames, blur, subtitles, extra fingers, malformed limbs, crowds). +const DEFAULT_NEGATIVE: &str = "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走"; + +struct WeightFile { + name: &'static str, + url: &'static str, + sha256: &'static str, + bytes: u64, +} + +const WEIGHTS: [WeightFile; 3] = [ + WeightFile { + name: "Wan2.1-T2V-1.3B-Q8_0.gguf", + url: "https://huggingface.co/off-grid-ai/Wan2.1-T2V-1.3B-GGUF/resolve/1ba777b2426d5717dd0fddb1100078604a9d4667/Wan2.1-T2V-1.3B-Q8_0.gguf", + sha256: "de0eb6b6b75abe801b59d7b2004ecc0ca424d4ed0eeb9eed34f0d8aaf321f888", + bytes: 1_519_435_360, + }, + WeightFile { + name: "umt5-xxl-encoder-Q4_K_M.gguf", + url: "https://huggingface.co/city96/umt5-xxl-encoder-gguf/resolve/b535255bee98c2b0a59ea7c0ae2dcd0c6657b3b7/umt5-xxl-encoder-Q4_K_M.gguf", + sha256: "17cf97a5bbbc60a646d6105b832b6f657ce904a8a1ad970e4b59df0c67584a40", + bytes: 3_655_145_312, + }, + WeightFile { + name: "wan_2.1_vae.safetensors", + url: "https://huggingface.co/Comfy-Org/Wan-2.1/resolve/123acf1cc74bccbb9bfff8ac1ee72edc08c2341d/split_files/vae/wan_2.1_vae.safetensors", + sha256: "2fc39d31359a4b0a64f55876d8ff7fa8d780956ae2cb13463b0223e15148976b", + bytes: 253_815_318, + }, +]; + +/// Total download for the weights (the runtime is shared with images). +pub fn download_bytes() -> u64 { + WEIGHTS.iter().map(|w| w.bytes).sum() +} + +fn ready_marker() -> PathBuf { + diffusion::diffusion_root().join(".video-ready") +} +fn retry_marker() -> PathBuf { + diffusion::diffusion_root().join(".video-retry-after") +} + +/// What the ready marker must say. Changing a pin changes it, so nodes +/// re-provision instead of running stale files. +fn ready_stamp() -> String { + let hashes: Vec<&str> = WEIGHTS.iter().map(|w| &w.sha256[..12]).collect(); + format!("video={MODEL_ID} {}\n", hashes.join(",")) +} + +/// Weights and runtime are in place. (Serving also needs ffmpeg and the +/// operator's consent; see [`can_serve`].) +pub fn installed() -> bool { + diffusion::platform_supported() + && diffusion::sd_cli().is_file() + && std::fs::read_to_string(ready_marker()).is_ok_and(|s| s == ready_stamp()) +} + +fn ffmpeg_present() -> bool { + crate::supervisor::which_ffmpeg().is_some() +} + +/// Pure decision: does a box with this memory have room for a clip now? +pub fn has_headroom_with(config: &VideoConfig, mem: diffusion::MemorySnapshot) -> bool { + mem.total >= config.min_ram_gb.saturating_mul(GIB) + && mem.available >= config.min_free_ram_gb.saturating_mul(GIB) +} + +/// Pure decision: is this per-job thread budget enough for a clip in time? +pub fn fast_enough_with(config: &VideoConfig, job_threads: usize) -> bool { + job_threads >= config.min_cpu_threads +} + +fn fast_enough(config: &VideoConfig) -> bool { + fast_enough_with(config, crate::resources::cpu_threads_per_job()) +} + +/// Allowed, installed, ffmpeg present, and idle with free memory right now. +/// Called on every advertise tick and every offer. +pub fn can_serve(config: &VideoConfig) -> bool { + config.cpu_setting() != Some(false) + && installed() + && ffmpeg_present() + && fast_enough(config) + && has_headroom_with(config, diffusion::memory()) + && diffusion::idle_enough_with( + diffusion::load_avg_1m(), + diffusion::logical_cpus(), + config.max_load_per_cpu, + ) + && diffusion::node_slot().available_permits() > 0 +} + +/// Tags a CPU video node advertises while it can take a clip. +pub fn tags() -> Vec { + vec![ + video::ROLE_TAG.to_string(), + video::model_tag(MODEL_ID), + CPU_TAG.to_string(), + ] +} + +/// Whether a job fits what a CPU node will do: the small model, text to +/// video, within the CPU caps. On top of [`VideoJobInline::validate`]. +pub fn fits(job: &VideoJobInline, workload: &str) -> std::result::Result<(), String> { + job.validate(workload)?; + if workload != WORKLOAD_TXT2VID { + return Err("CPU video is text-to-video only".into()); + } + if job.model != MODEL_ID { + return Err(format!("CPU video serves {MODEL_ID} only")); + } + if job.frames > MAX_FRAMES { + return Err(format!("CPU video: frames must be at most {MAX_FRAMES}")); + } + if job.width.saturating_mul(job.height) > MAX_PIXELS { + return Err(format!("CPU video: width*height must not exceed {MAX_PIXELS} (480x272)")); + } + if job.steps > MAX_STEPS { + return Err(format!("CPU video: steps must be at most {MAX_STEPS}")); + } + Ok(()) +} + +fn in_install_backoff() -> bool { + std::fs::read_to_string(retry_marker()) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .is_some_and(|retry_after| diffusion::now_secs() < retry_after) +} + +/// Make sure this node can serve CPU video if it should. Installs only with +/// `cpu = true` / `C0MPUTE_VIDEO_CPU=1`; with the default it only reports +/// whether an operator already ran `c0mpute video setup --cpu`. Never fatal. +pub async fn ensure(config: &VideoConfig) -> bool { + let setting = config.cpu_setting(); + if setting == Some(false) || !diffusion::platform_supported() { + return false; + } + if !fast_enough(config) { + info!( + job_threads = crate::resources::cpu_threads_per_job(), + required = config.min_cpu_threads, + "skipping CPU video: CPU budget too small for a clip in time" + ); + return false; + } + if installed() { + return true; + } + if setting != Some(true) { + tracing::debug!("CPU video not installed and not enabled; `c0mpute video setup --cpu` opts in"); + return false; + } + let mem = diffusion::memory(); + if mem.total < config.min_ram_gb.saturating_mul(GIB) { + info!(total_gb = mem.total / GIB, required_gb = config.min_ram_gb, "skipping CPU video install: not enough RAM"); + return false; + } + let root = diffusion::diffusion_root(); + let free = diffusion::free_disk_bytes(&root); + if free < config.min_free_disk_gb.saturating_mul(GIB) { + info!(free_gb = free / GIB, required_gb = config.min_free_disk_gb, "skipping CPU video install: not enough free disk"); + return false; + } + if in_install_backoff() { + tracing::debug!("CPU video install failed recently; waiting out the backoff"); + return false; + } + info!( + gb = download_bytes() / 1_000_000_000, + "installing Wan 2.1 1.3B for stable-diffusion.cpp so this node can serve video.txt2vid on CPU" + ); + match install().await { + Ok(()) => { + let _ = tokio::fs::remove_file(retry_marker()).await; + info!("CPU video ready"); + true + } + Err(error) => { + let _ = tokio::fs::create_dir_all(&root).await; + let retry_after = diffusion::now_secs().saturating_add(INSTALL_RETRY_BACKOFF_SECS); + let _ = tokio::fs::write(retry_marker(), retry_after.to_string()).await; + warn!(err = %format!("{error:#}"), "could not install CPU video; this node will not serve clips"); + false + } + } +} + +/// Download, verify and self-test. Public for `c0mpute video setup --cpu`. +pub async fn install() -> Result<()> { + if !diffusion::platform_supported() { + bail!("CPU video has a build for Linux x86_64 only"); + } + let _ = tokio::fs::remove_file(ready_marker()).await; + tokio::fs::create_dir_all(diffusion::models_dir()).await?; + diffusion::install_runtime().await?; + for weight in &WEIGHTS { + diffusion::download_verified_streaming( + weight.url, + &diffusion::models_dir().join(weight.name), + weight.sha256, + ) + .await + .with_context(|| format!("download {}", weight.name))?; + } + // The pinned build must know the video mode, or every job would fail. + let output = Command::new(diffusion::sd_cli()) + .arg("--help") + .env("LD_LIBRARY_PATH", diffusion::bin_dir()) + .output() + .await + .context("run sd-cli self-test")?; + if !String::from_utf8_lossy(&output.stdout).contains("vid_gen") { + bail!("sd-cli has no vid_gen mode (exit {:?})", output.status.code()); + } + tokio::fs::write(ready_marker(), ready_stamp()).await?; + Ok(()) +} + +/// The sd-cli argv for a clip (everything after the binary). Pure, so the +/// contract with sd.cpp is tested without the binary. +pub fn sd_args(job: &VideoJobInline, models: &Path, frames_pattern: &Path, threads: usize, seed: u32) -> Vec { + let prompt = job.prompt.trim(); + // sd-cli reads the next argv as the prompt, but keep a leading dash from + // ever looking like a flag. + let prompt = if prompt.starts_with('-') { format!(" {prompt}") } else { prompt.to_string() }; + let negative = match job.negative_prompt.trim() { + "" => DEFAULT_NEGATIVE.to_string(), + n if n.starts_with('-') => format!(" {n}"), + n => n.to_string(), + }; + let path = |name: &str| models.join(name).display().to_string(); + vec![ + "-M".into(), + "vid_gen".into(), + "--diffusion-model".into(), + path(WEIGHTS[0].name), + "--t5xxl".into(), + path(WEIGHTS[1].name), + "--vae".into(), + path(WEIGHTS[2].name), + "--cfg-scale".into(), + job.guidance_scale.unwrap_or(6.0).to_string(), + "--sampling-method".into(), + "euler".into(), + "--flow-shift".into(), + "3.0".into(), + "--steps".into(), + job.steps.to_string(), + "-W".into(), + job.width.to_string(), + "-H".into(), + job.height.to_string(), + "--video-frames".into(), + job.frames.to_string(), + "--seed".into(), + seed.to_string(), + // Flash attention in the DiT only (this build's spelling; the old + // --diffusion-fa is deprecated) and a tiled VAE decode, the memory + // peak on CPU. + "--flash-attn".into(), + "off,diffusion=on".into(), + "--vae-tiling".into(), + // The 3.7 GB text encoder runs once per clip and the VAE once at + // the end, so they stream from disk; the DiT, used every step, stays + // in RAM. Measured on dev2: 5.3 GB RSS with everything in RAM (and + // earlyoom killed it twice on the busy host), 1.8 GB like this at the + // same speed. All-disk was also 1.8 GB but over twice as slow. + "--params-backend".into(), + "te=disk,vae=disk".into(), + "-t".into(), + threads.max(1).to_string(), + "-o".into(), + frames_pattern.display().to_string(), + "-p".into(), + prompt, + "-n".into(), + negative, + ] +} + +/// The ffmpeg argv: PNG frames -> H.264 MP4 that plays in a browser. +pub fn ffmpeg_args(frames_pattern: &Path, fps: u32, frames: u32, out: &Path) -> Vec { + vec![ + "-y".into(), + "-hide_banner".into(), + "-loglevel".into(), + "error".into(), + "-framerate".into(), + fps.to_string(), + "-i".into(), + frames_pattern.display().to_string(), + "-frames:v".into(), + frames.to_string(), + "-c:v".into(), + "libx264".into(), + "-preset".into(), + "medium".into(), + "-crf".into(), + "18".into(), + "-pix_fmt".into(), + "yuv420p".into(), + "-movflags".into(), + "+faststart".into(), + out.display().to_string(), + ] +} + +fn stderr_tail(stderr: &[u8]) -> String { + let text = String::from_utf8_lossy(stderr); + let lines: Vec<&str> = text.lines().rev().take(5).collect(); + lines.into_iter().rev().collect::>().join(" | ") +} + +/// Render one clip into `job_dir/out.mp4` and return its path. +pub async fn generate(job: &VideoJobInline, job_dir: &Path) -> Result { + fits(job, WORKLOAD_TXT2VID).map_err(anyhow::Error::msg)?; + if !installed() { + bail!("CPU video is not installed"); + } + let ffmpeg = crate::supervisor::which_ffmpeg().context("CPU video needs ffmpeg on PATH")?; + let frame_dir = job_dir.join("frames"); + tokio::fs::create_dir_all(&frame_dir).await?; + let pattern = frame_dir.join("frame_%05d.png"); + let seed = job.seed.map(|s| (s & 0xffff_ffff) as u32).unwrap_or_else(rand::random::); + + let mut command = Command::new(diffusion::sd_cli()); + let threads = crate::resources::apply_job_limits(&mut command); + command + .env("LD_LIBRARY_PATH", diffusion::bin_dir()) + .args(sd_args(job, &diffusion::models_dir(), &pattern, threads, seed)) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + let child = command.spawn().context("start sd-cli")?; + let result = tokio::time::timeout(video::JOB_TIMEOUT, child.wait_with_output()) + .await + .context("sd-cli timed out")??; + if !result.status.success() { + bail!("sd-cli failed (exit {:?}): {}", result.status.code(), stderr_tail(&result.stderr)); + } + + // sd-cli numbers from 0 when the pattern has %d; ffmpeg starts there too. + let mut written = 0u32; + let mut entries = tokio::fs::read_dir(&frame_dir).await?; + while let Some(entry) = entries.next_entry().await? { + if entry.file_name().to_string_lossy().ends_with(".png") { + written += 1; + } + } + if written < job.frames { + bail!("sd-cli wrote {written} frames, asked for {}", job.frames); + } + + let out = job_dir.join("out.mp4"); + let encoded = Command::new(ffmpeg) + .args(ffmpeg_args(&pattern, job.fps, job.frames, &out)) + .stdin(std::process::Stdio::null()) + .output() + .await + .context("run ffmpeg")?; + if !encoded.status.success() { + bail!("ffmpeg encode failed: {}", stderr_tail(&encoded.stderr)); + } + // The frames are many times the clip; only out.mp4 leaves this dir. + let _ = tokio::fs::remove_dir_all(&frame_dir).await; + Ok(out) +} + +pub fn spawn_cpu_video_runner( + net: Arc, + store: ChunkStore, + cache_root: PathBuf, + load: WorkerLoad, +) -> mpsc::Sender { + diffusion::kill_orphaned_renders(); + let (tx, mut rx) = mpsc::channel::(4); + tokio::spawn(async move { + while let Some(job) = rx.recv().await { + let Ok(_compute_slot) = load.compute_slots().acquire_owned().await else { + return; + }; + let Ok(_node_slot) = diffusion::node_slot().acquire_owned().await else { + return; + }; + let started = Instant::now(); + let job_id = job.accept.job_id.clone(); + let result = run_job(&net, &store, &cache_root, &job).await; + cleanup_job_dir(&cache_root, &job_id).await; + match result { + Ok(hash) => { + load.record_job(started.elapsed()); + info!(%job_id, %hash, secs = started.elapsed().as_secs(), "cpu video runner: job complete"); + publish_receipt(&net, &job.accept, Some(hash), JobStatus::Completed).await; + } + Err(error) => { + warn!(%job_id, err = %format!("{error:#}"), "cpu video runner: job failed"); + publish_receipt(&net, &job.accept, None, JobStatus::Failed).await; + } + } + } + }); + tx +} + +async fn run_job(net: &Libp2pNetwork, store: &ChunkStore, cache_root: &Path, job: &AcceptedJob) -> Result { + let spec = job.accept.spec_inline.as_ref().context("accept missing spec_inline")?; + let inline: VideoJobInline = serde_json::from_value(spec.clone()).context("decode video job")?; + fits(&inline, WORKLOAD_TXT2VID).map_err(anyhow::Error::msg)?; + let job_dir = safe_job_dir(cache_root, &job.accept.job_id)?; + tokio::fs::create_dir_all(&job_dir).await?; + let out = generate(&inline, &job_dir).await?; + let len = tokio::fs::metadata(&out).await?.len(); + if len == 0 || len > video::MAX_RESULT_BYTES as u64 { + bail!("video result is empty or exceeds the 256 MiB result limit"); + } + let mut head = [0u8; 16]; + { + use tokio::io::AsyncReadExt; + let mut f = tokio::fs::File::open(&out).await?; + let n = f.read(&mut head).await?; + if !video::is_mp4(&head[..n]) { + bail!("video result is not an MP4"); + } + } + crate::pieces::publish_result_file(net, store, &out, video::MAX_RESULT_BYTES as u64).await +} + +#[cfg(test)] +mod tests { + use super::*; + + fn small() -> VideoJobInline { + let mut req = serde_json::json!({ "model": MODEL_ID, "prompt": "a fox in the snow", "seed": 42 }); + video::apply_model_defaults(&mut req); + serde_json::from_value(req).unwrap() + } + + #[test] + fn the_models_defaults_fit_the_cpu_caps() { + let job = small(); + assert_eq!((job.frames, job.width, job.height, job.steps, job.fps), (33, 480, 272, 20, 16)); + assert!(fits(&job, WORKLOAD_TXT2VID).is_ok()); + // Portrait at the same area is fine too. + let mut portrait = small(); + (portrait.width, portrait.height) = (272, 480); + assert!(fits(&portrait, WORKLOAD_TXT2VID).is_ok()); + } + + #[test] + fn cpu_refuses_what_it_cannot_finish() { + let mut j = small(); + j.model = "wan2.2-ti2v-5b".into(); + assert!(fits(&j, WORKLOAD_TXT2VID).is_err(), "only the small model"); + let mut j = small(); + j.frames = 81; + assert!(fits(&j, WORKLOAD_TXT2VID).is_err()); + let mut j = small(); + (j.width, j.height) = (832, 480); + assert!(fits(&j, WORKLOAD_TXT2VID).is_err()); + let mut j = small(); + j.steps = 30; + assert!(fits(&j, WORKLOAD_TXT2VID).is_err()); + let mut j = small(); + j.image_hash = Some("ab".repeat(32)); + assert!(fits(&j, video::WORKLOAD_IMG2VID).is_err(), "no img2vid on CPU"); + } + + #[test] + fn the_cpu_model_is_in_the_manifest_and_marked_cpu() { + let row = video::manifest_model(MODEL_ID).expect("manifest row"); + assert!(row.cpu); + } + + #[test] + fn sd_args_select_video_mode_and_the_pinned_files() { + let job = small(); + let args = sd_args(&job, Path::new("/m"), Path::new("/j/frames/frame_%05d.png"), 16, 42); + let pair = |flag: &str| args.iter().position(|a| a == flag).map(|i| args[i + 1].as_str()); + assert_eq!(pair("-M"), Some("vid_gen"), "Wan needs -M vid_gen; --video-frames alone is not video mode"); + assert_eq!(pair("--diffusion-model"), Some("/m/Wan2.1-T2V-1.3B-Q8_0.gguf")); + assert_eq!(pair("--t5xxl"), Some("/m/umt5-xxl-encoder-Q4_K_M.gguf")); + assert_eq!(pair("--vae"), Some("/m/wan_2.1_vae.safetensors")); + assert_eq!(pair("--video-frames"), Some("33")); + assert_eq!(pair("-W"), Some("480")); + assert_eq!(pair("-H"), Some("272")); + assert_eq!(pair("--steps"), Some("20")); + assert_eq!(pair("--seed"), Some("42")); + assert_eq!(pair("-t"), Some("16")); + assert_eq!(pair("-o"), Some("/j/frames/frame_%05d.png")); + assert_eq!(pair("-n"), Some(DEFAULT_NEGATIVE), "empty negative prompt gets Wan's default"); + assert_eq!(pair("-p"), Some("a fox in the snow")); + + let mut dash = small(); + dash.prompt = "--steps 999".into(); + let args = sd_args(&dash, Path::new("/m"), Path::new("/o.png"), 0, 1); + let p = args.iter().position(|a| a == "-p").unwrap(); + assert_eq!(args[p + 1], " --steps 999", "a prompt never reads as a flag"); + let t = args.iter().position(|a| a == "-t").unwrap(); + assert_eq!(args[t + 1], "1"); + } + + #[test] + fn ffmpeg_makes_a_browser_playable_mp4_of_exactly_the_frames() { + let args = ffmpeg_args(Path::new("/f/frame_%05d.png"), 16, 33, Path::new("/j/out.mp4")); + let pair = |flag: &str| args.iter().position(|a| a == flag).map(|i| args[i + 1].as_str()); + assert_eq!(pair("-framerate"), Some("16")); + assert_eq!(pair("-frames:v"), Some("33")); + assert_eq!(pair("-c:v"), Some("libx264")); + assert_eq!(pair("-pix_fmt"), Some("yuv420p")); + assert_eq!(args.last().map(String::as_str), Some("/j/out.mp4")); + } + + #[test] + fn gates_need_ram_threads_and_consent() { + let config = VideoConfig::default(); + assert_eq!(config.cpu, None, "opt-in: nothing installs by default"); + let idle = diffusion::MemorySnapshot { total: 32 * GIB, available: 20 * GIB }; + let busy = diffusion::MemorySnapshot { total: 91 * GIB, available: 6 * GIB }; + let small_box = diffusion::MemorySnapshot { total: 8 * GIB, available: 7 * GIB }; + assert!(has_headroom_with(&config, idle)); + assert!(!has_headroom_with(&config, busy)); + assert!(!has_headroom_with(&config, small_box)); + assert!(fast_enough_with(&config, 16)); + assert!(!fast_enough_with(&config, 8)); + } + + #[test] + fn tags_name_the_role_the_model_and_cpu() { + let t = tags(); + assert!(t.contains(&video::ROLE_TAG.to_string())); + assert!(t.contains(&"c0mpute:video:model:wan2.1-t2v-1.3b".to_string())); + assert!(t.contains(&CPU_TAG.to_string())); + } + + #[test] + fn the_download_is_about_5_4_gb_and_the_stamp_names_the_pins() { + let gb = download_bytes() as f64 / 1e9; + assert!((5.3..5.5).contains(&gb), "{gb}"); + assert!(ready_stamp().contains(MODEL_ID)); + assert!(ready_stamp().contains(&WEIGHTS[0].sha256[..12])); + } +} diff --git a/node/crates/c0mpute-core/tests/video_e2e.rs b/node/crates/c0mpute-core/tests/video_e2e.rs index 092f6008..74d47cde 100644 --- a/node/crates/c0mpute-core/tests/video_e2e.rs +++ b/node/crates/c0mpute-core/tests/video_e2e.rs @@ -138,7 +138,7 @@ async fn network(tmp: &Path) -> (String, Arc) { dispatch::run_worker_subscriber_with_load( worker.clone(), video::WORKLOAD_TXT2VID.to_string(), - vec![video::ROLE_TAG.to_string(), "c0mpute:gpu:nvidia".to_string()], + vec![video::ROLE_TAG.to_string(), video::model_tag("wan2.2-ti2v-5b"), "c0mpute:gpu:nvidia".to_string()], Some(runner), None, Some(PricingConfig::default()), diff --git a/package.json b/package.json index 65a2277c..c2bab354 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "c0mpute-monorepo", - "version": "0.2.85", + "version": "0.2.86", "private": true, "workspaces": [ "apps/*", diff --git a/packages/shared/package.json b/packages/shared/package.json index 3e5fd486..cc8d6349 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@c0mpute/shared", - "version": "0.2.85", + "version": "0.2.86", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/plugins/ad-generator-agent/module.toml b/plugins/ad-generator-agent/module.toml index 123b1368..cbec07a5 100644 --- a/plugins/ad-generator-agent/module.toml +++ b/plugins/ad-generator-agent/module.toml @@ -21,7 +21,7 @@ [module] id = "ad-generator-agent" name = "Ad Generator Agent — every ad type from one URL" -version = "0.2.85" +version = "0.2.86" kind = "service" category = "applications" status = "available" diff --git a/plugins/ads-manager/module.toml b/plugins/ads-manager/module.toml index 77b61171..6875790d 100644 --- a/plugins/ads-manager/module.toml +++ b/plugins/ads-manager/module.toml @@ -38,7 +38,7 @@ [module] id = "ads-manager" name = "Ads Manager — shared ad operations layer" -version = "0.2.85" +version = "0.2.86" kind = "service" category = "applications" status = "partial" diff --git a/plugins/coinpay/module.toml b/plugins/coinpay/module.toml index a53866cd..53b5946e 100644 --- a/plugins/coinpay/module.toml +++ b/plugins/coinpay/module.toml @@ -7,7 +7,7 @@ [module] id = "coinpay" name = "CoinPay — DID, payments, escrow, reputation" -version = "0.2.85" +version = "0.2.86" kind = "service" category = "applications" status = "available" diff --git a/plugins/crawl/module.toml b/plugins/crawl/module.toml index 52f5df82..4dd68ce8 100644 --- a/plugins/crawl/module.toml +++ b/plugins/crawl/module.toml @@ -8,7 +8,7 @@ [module] id = "crawl" name = "Crawl — distributed web fetching" -version = "0.2.85" +version = "0.2.86" kind = "workload" category = "data" status = "available" diff --git a/plugins/ctv-ads/module.toml b/plugins/ctv-ads/module.toml index a7a62cf4..cba0259e 100644 --- a/plugins/ctv-ads/module.toml +++ b/plugins/ctv-ads/module.toml @@ -28,7 +28,7 @@ [module] id = "ctv-ads" name = "CTV Ads — VAST and VMAP from every gateway" -version = "0.2.85" +version = "0.2.86" kind = "service" category = "applications" status = "partial" diff --git a/plugins/diffusion/module.toml b/plugins/diffusion/module.toml index 818aff70..76d9ec81 100644 --- a/plugins/diffusion/module.toml +++ b/plugins/diffusion/module.toml @@ -11,7 +11,7 @@ [module] id = "diffusion" name = "Diffusion — image generation" -version = "0.2.85" +version = "0.2.86" kind = "workload" category = "ai" status = "available" diff --git a/plugins/embed/module.toml b/plugins/embed/module.toml index bfae4ebd..811dc6d7 100644 --- a/plugins/embed/module.toml +++ b/plugins/embed/module.toml @@ -19,7 +19,7 @@ [module] id = "embed" name = "Embeddings — vector generation" -version = "0.2.85" +version = "0.2.86" kind = "workload" category = "ai" status = "available" diff --git a/plugins/gameserver/module.toml b/plugins/gameserver/module.toml index 04f8b7d4..d49e3683 100644 --- a/plugins/gameserver/module.toml +++ b/plugins/gameserver/module.toml @@ -55,7 +55,7 @@ [module] id = "gameserver" name = "Game server — Minecraft / Factorio / Valheim" -version = "0.2.85" +version = "0.2.86" kind = "workload" category = "infrastructure" status = "available" diff --git a/plugins/hosting/module.toml b/plugins/hosting/module.toml index 96461f7d..3d038d8f 100644 --- a/plugins/hosting/module.toml +++ b/plugins/hosting/module.toml @@ -30,7 +30,7 @@ [module] id = "hosting" name = "Hosting — p2p static sites" -version = "0.2.85" +version = "0.2.86" kind = "service" category = "data" status = "available" diff --git a/plugins/infernet/module.toml b/plugins/infernet/module.toml index ad62ed20..a89fa7a2 100644 --- a/plugins/infernet/module.toml +++ b/plugins/infernet/module.toml @@ -22,7 +22,7 @@ [module] id = "infernet" name = "Infernet Protocol — AI inference" -version = "0.2.85" +version = "0.2.86" kind = "workload" category = "ai" status = "available" diff --git a/plugins/live-stream/module.toml b/plugins/live-stream/module.toml index fca70bfb..73533d4b 100644 --- a/plugins/live-stream/module.toml +++ b/plugins/live-stream/module.toml @@ -34,7 +34,7 @@ [module] id = "live-stream" name = "Live Stream — p2p HLS live streaming" -version = "0.2.85" +version = "0.2.86" kind = "service" category = "media" status = "available" diff --git a/plugins/music/module.toml b/plugins/music/module.toml index d593b89f..2fa651a1 100644 --- a/plugins/music/module.toml +++ b/plugins/music/module.toml @@ -15,7 +15,7 @@ [module] id = "music" name = "Music — song generation (ACE-Step 1.5)" -version = "0.2.85" +version = "0.2.86" kind = "workload" category = "ai" status = "available" diff --git a/plugins/ocr/module.toml b/plugins/ocr/module.toml index 8a0f9778..f6cafafa 100644 --- a/plugins/ocr/module.toml +++ b/plugins/ocr/module.toml @@ -19,7 +19,7 @@ [module] id = "ocr" name = "OCR — document text extraction" -version = "0.2.85" +version = "0.2.86" kind = "workload" category = "ai" status = "available" diff --git a/plugins/podcast-ads/module.toml b/plugins/podcast-ads/module.toml index df1cffcb..f8941298 100644 --- a/plugins/podcast-ads/module.toml +++ b/plugins/podcast-ads/module.toml @@ -31,7 +31,7 @@ [module] id = "podcast-ads" name = "Podcast Ads — dynamic ad insertion" -version = "0.2.85" +version = "0.2.86" kind = "service" category = "applications" status = "partial" diff --git a/plugins/podcasting/module.toml b/plugins/podcasting/module.toml index 526069a4..32f23898 100644 --- a/plugins/podcasting/module.toml +++ b/plugins/podcasting/module.toml @@ -24,7 +24,7 @@ [module] id = "podcasting" name = "Podcasting — p2p podcast publishing" -version = "0.2.85" +version = "0.2.86" kind = "service" category = "media" status = "available" diff --git a/plugins/podcasts/module.toml b/plugins/podcasts/module.toml index 27e715dd..c47a261c 100644 --- a/plugins/podcasts/module.toml +++ b/plugins/podcasts/module.toml @@ -24,7 +24,7 @@ [module] id = "podcasts" name = "Podcasts — p2p podcast listener" -version = "0.2.85" +version = "0.2.86" kind = "service" category = "media" status = "available" diff --git a/plugins/render/module.toml b/plugins/render/module.toml index ad87730c..de24963e 100644 --- a/plugins/render/module.toml +++ b/plugins/render/module.toml @@ -33,7 +33,7 @@ [module] id = "render" name = "Render — Blender 3D rendering" -version = "0.2.85" +version = "0.2.86" kind = "workload" category = "media" status = "available" diff --git a/plugins/secure-chat/module.toml b/plugins/secure-chat/module.toml index 5373be86..5fa7dcf5 100644 --- a/plugins/secure-chat/module.toml +++ b/plugins/secure-chat/module.toml @@ -21,7 +21,7 @@ [module] id = "secure-chat" name = "Secure Chat — E2E encrypted p2p messaging" -version = "0.2.85" +version = "0.2.86" kind = "service" category = "applications" status = "available" diff --git a/plugins/storage/module.toml b/plugins/storage/module.toml index 68ff8bcb..386be57d 100644 --- a/plugins/storage/module.toml +++ b/plugins/storage/module.toml @@ -20,7 +20,7 @@ [module] id = "storage" name = "Storage — Reed-Solomon shard service" -version = "0.2.85" +version = "0.2.86" kind = "service" category = "data" status = "available" diff --git a/plugins/train/module.toml b/plugins/train/module.toml index 120c313a..7e908840 100644 --- a/plugins/train/module.toml +++ b/plugins/train/module.toml @@ -41,7 +41,7 @@ [module] id = "train" name = "Fine-tune — LoRA training" -version = "0.2.85" +version = "0.2.86" kind = "workload" category = "ai" status = "available" diff --git a/plugins/transcode/module.toml b/plugins/transcode/module.toml index 5d8e7829..2daa9a4e 100644 --- a/plugins/transcode/module.toml +++ b/plugins/transcode/module.toml @@ -7,7 +7,7 @@ [module] id = "transcode" name = "FFmpeg Transcoding" -version = "0.2.85" +version = "0.2.86" kind = "workload" category = "media" status = "available" diff --git a/plugins/tts/module.toml b/plugins/tts/module.toml index f5090ecf..afc417d9 100644 --- a/plugins/tts/module.toml +++ b/plugins/tts/module.toml @@ -15,7 +15,7 @@ [module] id = "tts" name = "TTS — text-to-speech" -version = "0.2.85" +version = "0.2.86" kind = "workload" category = "ai" status = "available" diff --git a/plugins/video/module.toml b/plugins/video/module.toml index 8dad8108..b63f348c 100644 --- a/plugins/video/module.toml +++ b/plugins/video/module.toml @@ -32,7 +32,10 @@ # worker, as with render. # # Model choice: Wan 2.2 (Apache-2.0, so commercial use is clean) is the -# default family. HunyuanVideo was the other candidate — quality is +# default family. Wan 2.1 T2V 1.3B is the small one: 8 GiB of VRAM with +# model offload (the runner keeps the 11 GB UMT5 text encoder on the CPU +# when the card is too small to hold everything), and the only model a +# CPU-only node can serve at all (below). HunyuanVideo was the other candidate — quality is # comparable, but it is markedly slower per clip, which is the wrong trade # on a network billing by the second. # @@ -46,26 +49,38 @@ # at $10M+ revenue needs their own commercial license from Lightricks. # Rejected: HyperFlow H3 / MiniMax H3 (license excludes the US, EU, UK and # Korea), Ovi and Kandinsky 6 (both load MMAudio weights, CC-BY-NC). +# +# CPU video (slow, batch): a node with no usable GPU can serve +# `wan2.1-t2v-1.3b` text-to-video through the same stable-diffusion.cpp +# build the diffusion plugin pins (`sd-cli -M vid_gen`), with Apache-2.0 +# GGUF weights pinned by SHA-256 in node/crates/c0mpute-core/src/video_cpu.rs: +# Wan2.1-T2V-1.3B Q8_0 (off-grid-ai, converted with sd.cpp), the UMT5-XXL +# encoder Q4_K_M (city96) and the Wan 2.1 VAE (Comfy-Org), 5.4 GB in all. +# No container, no GPU; ffmpeg encodes the frames to H.264. A small clip +# (33 frames, 480x272) takes 20-30 minutes of a 16-thread box, so it is +# opt-in: `c0mpute video setup --cpu`, or `[video] cpu = true` / +# C0MPUTE_VIDEO_CPU=1 to install in the background. Bids only while the box +# has the RAM and is idle enough, like CPU images. [module] id = "video" -name = "Video — generation (Wan 2.2, LTX-2.3 with audio)" -version = "0.2.85" +name = "Video — generation (Wan 2.2, Wan 2.1 1.3B on 8 GiB or CPU, LTX-2.3 with audio)" +version = "0.2.86" kind = "workload" category = "ai" status = "available" builtin = true -description = "Decentralized video generation on consumer/prosumer GPUs: Wan 2.2 text/image-to-video, and LTX-2.3 video with a synchronized soundtrack." +description = "Decentralized video generation: Wan 2.2 text/image-to-video and LTX-2.3 video with a synchronized soundtrack on 24-48 GiB GPUs, Wan 2.1 1.3B text-to-video on an 8 GiB card, and the same small model on CPU-only nodes (slow, batch)." author = "Profullstack" license = "MIT" -keywords = ["video", "wan", "wan2.2", "ltx-2", "ltx-2.3", "audio", "txt2vid", "img2vid", "generation", "diffusion"] +keywords = ["video", "wan", "wan2.2", "wan2.1", "cpu", "stable-diffusion.cpp", "ltx-2", "ltx-2.3", "audio", "txt2vid", "img2vid", "generation", "diffusion"] [module.requirements] c0mpute = ">=0.2.82" os = ["linux"] arch = ["x86_64"] -capabilities = ["gpu:nvidia", "model-runtime", "container-runtime"] -hardware = "NVIDIA GPU (24 GiB VRAM for the default model, 48 GiB for the 14B and LTX models) + docker with the NVIDIA container runtime" +capabilities = ["model-runtime"] +hardware = "GPU path: NVIDIA GPU + docker with the NVIDIA container runtime (8 GiB VRAM for Wan 2.1 1.3B, 24 GiB for the default Wan 2.2 5B, 48 GiB for the 14B and LTX models). CPU path (opt-in, slow batch): Linux x86_64, 16 GiB RAM, 16 threads, ffmpeg, 6 GB of disk; Wan 2.1 1.3B text-to-video only" [module.workloads] "video.txt2vid" = { command = "txt2vid", validation = "schema_and_ffprobe" } @@ -78,11 +93,19 @@ hardware = "NVIDIA GPU (24 GiB VRAM for the default model, 48 GiB for the 14 # # `backend` picks the diffusers pipeline family (default "wan"); `audio` # means the clip carries a soundtrack, and the runner refuses to return one -# without it. LTX-2 needs frames = 8k+1 and width/height divisible by 32. +# without it. LTX-2 needs frames = 8k+1 and width/height divisible by 32; +# every Wan model needs frames = 4k+1. +# +# `full_vram_gib` is what holding every component on the card at once takes. +# On a smaller card (but at least `vram_gib`) the runner switches to model +# CPU offload and encodes the prompt on the CPU. `frames`/`fps`/`steps`/ +# `width`/`height` are this model's defaults for a job that leaves them out. +# `cpu` means a CPU-only node can serve it too (see the header). [module.models] "wan2.2-ti2v-5b" = { repo = "Wan-AI/Wan2.2-TI2V-5B", diffusers_repo = "Wan-AI/Wan2.2-TI2V-5B-Diffusers", default = true, params = "5B", precision = "fp16", vram_gib = 24, resolution = "720p@24fps", workloads = ["video.txt2vid", "video.img2vid"], license = "apache-2.0" } "wan2.2-t2v-a14b" = { repo = "Wan-AI/Wan2.2-T2V-A14B", diffusers_repo = "Wan-AI/Wan2.2-T2V-A14B-Diffusers", params = "14B MoE", precision = "fp8", vram_gib = 48, resolution = "720p@24fps", workloads = ["video.txt2vid"], license = "apache-2.0" } "wan2.2-i2v-a14b" = { repo = "Wan-AI/Wan2.2-I2V-A14B", diffusers_repo = "Wan-AI/Wan2.2-I2V-A14B-Diffusers", params = "14B MoE", precision = "fp8", vram_gib = 48, resolution = "720p@24fps", workloads = ["video.img2vid"], license = "apache-2.0" } +"wan2.1-t2v-1.3b" = { repo = "Wan-AI/Wan2.1-T2V-1.3B", diffusers_repo = "Wan-AI/Wan2.1-T2V-1.3B-Diffusers", params = "1.3B", precision = "bf16", vram_gib = 8, full_vram_gib = 20, resolution = "480p@16fps", frames = 33, fps = 16, steps = 20, width = 480, height = 272, workloads = ["video.txt2vid"], license = "apache-2.0", cpu = "16 GiB RAM, 16 threads, ~20-30 min per 33-frame 480x272 clip (batch)" } "ltx2.3-av-distilled" = { repo = "Lightricks/LTX-2.3", diffusers_repo = "diffusers/LTX-2.3-Distilled-Diffusers", backend = "ltx2", audio = true, distilled = true, params = "22B", precision = "bf16", vram_gib = 48, resolution = "768x512@24fps + 48kHz stereo", workloads = ["video.txt2vid", "video.img2vid"], license = "ltx-2-community" } [module.dispatch] diff --git a/plugins/video/runner/Dockerfile b/plugins/video/runner/Dockerfile index ce752640..7a03feb9 100644 --- a/plugins/video/runner/Dockerfile +++ b/plugins/video/runner/Dockerfile @@ -1,4 +1,4 @@ -# c0mpute video-generation runner — Wan 2.2 and LTX-2.3 (video + audio) via diffusers. +# c0mpute video-generation runner — Wan 2.2 / 2.1 and LTX-2.3 (video + audio) via diffusers. # # Published as ghcr.io/profullstack/c0mpute-video-runner (see # .github/workflows/video-runner.yml); the tag the node pulls is the one in diff --git a/plugins/video/runner/README.md b/plugins/video/runner/README.md index efe3267b..bee97eaf 100644 --- a/plugins/video/runner/README.md +++ b/plugins/video/runner/README.md @@ -1,4 +1,4 @@ -# video runner — Wan 2.2 and LTX-2.3 (video + audio) +# video runner — Wan 2.2 / 2.1 and LTX-2.3 (video + audio) The container the `video` plugin dispatches to. One job per process: read `job.json`, generate a clip, encode an MP4, write a sidecar, exit non-zero on @@ -57,6 +57,29 @@ therefore cannot drift apart, and adding one is a manifest row plus a rebuild. Each row needs a `diffusers_repo`; rows without one are ignored by this backend rather than breaking it. +A row may also set its own job defaults (`frames`, `fps`, `steps`, `width`, +`height`) for fields a job leaves out; the node's gateway applies the same +ones. Every Wan model needs `frames` = 4k+1 and the runner refuses any other +count up front (the pipeline would round it and fail validation afterwards). + +## Small cards: Wan 2.1 T2V 1.3B on 8 GiB + +`wan2.1-t2v-1.3b` (`Wan-AI/Wan2.1-T2V-1.3B-Diffusers`, Apache-2.0) is the +model an 8 GiB card can serve. Its row says `vram_gib = 8` (the floor with +offload) and `full_vram_gib = 20` (everything on the card at once). When the +card is smaller than `full_vram_gib` the runner: + +- encodes the prompt on the CPU first: the UMT5-XXL text encoder is ~11 GB in + bf16, and model offload would still move it onto the card whole, so the + pipeline gets embeddings and never calls the encoder; +- then `enable_model_cpu_offload()` (transformer and VAE take turns on the + card) and VAE tiling for the decode. + +Expect ~16 GB of system RAM for the encoder on the host. Defaults are small: +33 frames of 480x272 at 16 fps, 20 steps. The sidecar records `offload`. +CPU-only nodes serve the same model id without this runner, through +stable-diffusion.cpp (`node/crates/c0mpute-core/src/video_cpu.rs`). + ## Why diffusers and not ComfyUI ComfyUI would mean shipping hand-authored API graph JSON per model, which @@ -133,8 +156,10 @@ Verified in CI and locally: manifest parsing, job validation and refusals, PNG→MP4 encode, ffprobe validation, the sidecar, and that this pin set imports. -**Not yet verified: a real clip.** That needs a 24 GiB NVIDIA card (48 GiB -for LTX-2.3), which no node in the fleet currently has. The LTX-2 sampling +**Not yet verified: a real clip on a GPU.** The fleet's one NVIDIA card has +8 GiB, which is what `wan2.1-t2v-1.3b` and its offload path are for; the +24/48 GiB models have no card yet. The same model on CPU (sd.cpp, not this +runner) has produced real clips. The LTX-2 sampling call is written against the diffusers 0.42.1 source and the model card, and its audio mux is covered by the selftest with a synthetic tone, but the pipeline itself has never run here. diff --git a/plugins/video/runner/runner.py b/plugins/video/runner/runner.py index ce103a68..82489263 100755 --- a/plugins/video/runner/runner.py +++ b/plugins/video/runner/runner.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """c0mpute video-generation runner. -One job per process: read a job JSON, generate a clip (Wan 2.2, or LTX-2.3 -with a synchronized soundtrack), encode an MP4, write a metadata sidecar, and +One job per process: read a job JSON, generate a clip (Wan 2.2 / 2.1, or +LTX-2.3 with a synchronized soundtrack), encode an MP4, write a metadata sidecar, and exit non-zero on any failure. The node's `video` runner invokes this inside the container and never parses stdout, so the contract is entirely files on disk: @@ -21,7 +21,8 @@ "prompt": "a neon alley in the rain", "negative_prompt": "", # optional "image": "input.png", # img2vid only, relative to the job dir - "frames": 81, # optional, defaults from module.toml + "frames": 81, # optional; the model's defaults from + # module.toml, else 81/24/30/1280x704 "fps": 24, "steps": 30, "width": 1280, @@ -50,7 +51,7 @@ from dataclasses import dataclass from pathlib import Path -RUNNER_VERSION = "2" +RUNNER_VERSION = "3" # Where the image puts the manifest it was built from. Overridable so the # selftest can run against a checkout. @@ -73,6 +74,10 @@ # `steps` from the job is ignored for them and this is what ran. LTX2_DISTILLED_STEPS = 8 +# Job fields a manifest row may give its own default for, and the default +# when it does not. +JOB_DEFAULTS = {"frames": 81, "fps": 24, "steps": 30, "width": 1280, "height": 704} + class JobError(Exception): """A problem with the job itself — refuse it, do not retry.""" @@ -89,6 +94,14 @@ class Model: backend: str = "wan" audio: bool = False distilled: bool = False + # VRAM to hold every component on the card at once. 0 = unknown, and the + # runner loads the whole pipeline onto the card as before. + full_vram_gib: int = 0 + # Job defaults for this model (see JOB_DEFAULTS). + defaults: tuple[tuple[str, int], ...] = () + + def default(self, field: str) -> int: + return dict(self.defaults).get(field, JOB_DEFAULTS[field]) def load_models(path: Path) -> dict[str, Model]: @@ -122,6 +135,8 @@ def load_models(path: Path) -> dict[str, Model]: backend=row.get("backend", "wan"), audio=bool(row.get("audio", False)), distilled=bool(row.get("distilled", False)), + full_vram_gib=int(row.get("full_vram_gib", 0)), + defaults=tuple((k, int(row[k])) for k in JOB_DEFAULTS if k in row), ) if models[mid].backend not in BACKENDS: # Fail loudly: a typo here would otherwise advertise a model the @@ -149,11 +164,15 @@ def parse_job(raw: dict, models: dict[str, Model], job_dir: Path) -> dict: if len(prompt) > MAX_PROMPT_CHARS: raise JobError(f"prompt exceeds {MAX_PROMPT_CHARS} characters") - frames = int(raw.get("frames", 81)) - fps = int(raw.get("fps", 24)) - steps = int(raw.get("steps", 30)) - width = int(raw.get("width", 1280)) - height = int(raw.get("height", 704)) + def field(name: str) -> int: + value = raw.get(name) + return model.default(name) if value is None else int(value) + + frames = field("frames") + fps = field("fps") + steps = field("steps") + width = field("width") + height = field("height") if not 1 <= frames <= MAX_FRAMES: raise JobError(f"frames must be 1..{MAX_FRAMES}") @@ -166,6 +185,11 @@ def parse_job(raw: dict, models: dict[str, Model], job_dir: Path) -> dict: raise JobError("width and height must be multiples of 16") if width * height > MAX_PIXELS: raise JobError(f"width*height must not exceed {MAX_PIXELS}") + if model.backend == "wan" and frames % 4 != 1: + # The Wan VAE compresses time by 4; the pipeline rounds any other + # count with only a warning, and the clip then fails the exact + # frame-count validation after the worker paid for it. + raise JobError(f"{model.id}: frames must be 4k+1 (e.g. 33, 81, 121)") if model.backend == "ltx2": # The pipeline raises on sizes not divisible by 32, and silently # rounds a frame count that is not 8k+1 (its VAE compresses time by @@ -302,8 +326,23 @@ def write_wav(samples, rate: int, path: Path) -> None: w.writeframes(pcm.tobytes()) +def use_offload(model: Model, card_gib: float) -> bool: + """Whether this card is too small to hold the whole pipeline. + + Pure, so the policy is tested without a GPU. A model with no + `full_vram_gib` keeps the old behaviour (everything on the card). + """ + return model.full_vram_gib > 0 and card_gib < model.full_vram_gib + + +def card_vram_gib() -> float: + import torch + + return torch.cuda.get_device_properties(0).total_memory / 2**30 + + def generate_frames(job: dict, frame_dir: Path) -> None: - """Run Wan 2.2 and write frames as zero-padded PNGs. + """Run a Wan pipeline and write frames as zero-padded PNGs. Imported lazily: the selftest and the job-validation path must not need torch, which is most of the image. @@ -320,11 +359,39 @@ def generate_frames(job: dict, frame_dir: Path) -> None: vae = AutoencoderKLWan.from_pretrained(repo, subfolder="vae", torch_dtype=torch.float32) pipe_cls = WanPipeline if job["workload"] == "video.txt2vid" else WanImageToVideoPipeline pipe = pipe_cls.from_pretrained(repo, vae=vae, torch_dtype=dtype) - pipe.to("cuda") - kwargs = dict( + prompt_kwargs: dict = dict( prompt=job["prompt"], negative_prompt=job["negative_prompt"] or None, + ) + job["offload"] = use_offload(model, card_vram_gib()) + if job["offload"]: + # A card below the model's full-load size (an 8 GiB card and Wan 2.1 + # 1.3B): the UMT5-XXL encoder alone is ~11 GB in bf16, and model + # offload would still move it onto the card whole. So encode the + # prompt here on the CPU, before the offload hooks exist, and hand + # the pipeline embeddings; the encoder is then never called and never + # leaves host RAM. The transformer and VAE take turns on the card. + with torch.no_grad(): + embeds, negative = pipe.encode_prompt( + prompt=job["prompt"], + negative_prompt=job["negative_prompt"] or None, + do_classifier_free_guidance=job["guidance_scale"] > 1.0, + device=torch.device("cpu"), + ) + prompt_kwargs = dict( + prompt_embeds=embeds.to("cuda"), + negative_prompt_embeds=None if negative is None else negative.to("cuda"), + ) + pipe.enable_model_cpu_offload() + if hasattr(pipe.vae, "enable_tiling"): + # Decoding a whole clip at once is the other VRAM peak. + pipe.vae.enable_tiling() + else: + pipe.to("cuda") + + kwargs = dict( + **prompt_kwargs, height=job["height"], width=job["width"], num_frames=job["frames"], @@ -527,6 +594,7 @@ def write_sidecar(job_dir: Path, job: dict, probed: dict) -> None: "seed": job["seed"], "steps": job["steps"], "guidance_scale": job["guidance_scale"], + "offload": bool(job.get("offload", False)), "requested": { "frames": job["frames"], "fps": job["fps"], @@ -546,7 +614,8 @@ def selftest(job_dir: Path) -> int: provider's box, the job would have failed too. """ job_dir.mkdir(parents=True, exist_ok=True) - frames, fps, width, height = 8, 8, 64, 64 + # 9 frames: every Wan model needs 4k+1. + frames, fps, width, height = 9, 8, 64, 64 job_path = job_dir / "job.json" job_path.write_text( json.dumps( diff --git a/plugins/video/runner/test_runner.py b/plugins/video/runner/test_runner.py index 5732e420..a2b00060 100755 --- a/plugins/video/runner/test_runner.py +++ b/plugins/video/runner/test_runner.py @@ -68,7 +68,7 @@ def main() -> int: print("manifest is the single source of models") models = runner.load_models(MANIFEST) - check("four models load", len(models) == 4, f"(got {sorted(models)})") + check("five models load", len(models) == 5, f"(got {sorted(models)})") check("default model present", "wan2.2-ti2v-5b" in models) for mid, model in models.items(): check(f"{mid} has a diffusers repo", model.diffusers_repo.endswith("-Diffusers")) @@ -94,6 +94,46 @@ def main() -> int: refuses("bad fps", base_job(fps=0), tmp, "fps must be") refuses("non-multiple-of-16 size", base_job(width=1281), tmp, "multiples of 16") refuses("oversized frame", base_job(width=1920, height=1088), tmp, "exceed") + refuses("wan frames not 4k+1", base_job(frames=80), tmp, "4k+1") + + print("the small Wan 2.1 model fits an 8 GiB card and defaults small") + small = "wan2.1-t2v-1.3b" + check("wan2.1 1.3b is advertised", small in models) + check("8 GiB floor", models[small].vram_gib == 8) + check("text-to-video only", models[small].workloads == ("video.txt2vid",)) + sj = runner.parse_job( + {"workload": "video.txt2vid", "model": small, "prompt": "a fox in the snow"}, models, tmp + ) + check( + "omitted fields take the model's defaults", + (sj["frames"], sj["fps"], sj["steps"], sj["width"], sj["height"]) == (33, 16, 20, 480, 272), + f"(got {sj['frames']} {sj['fps']} {sj['steps']} {sj['width']}x{sj['height']})", + ) + check( + "explicit fields still win", + runner.parse_job(base_job(model=small, frames=81), models, tmp)["frames"] == 81, + ) + check( + "other models keep the old defaults", + runner.parse_job( + {"workload": "video.txt2vid", "model": "wan2.2-ti2v-5b", "prompt": "p"}, models, tmp + )["frames"] == 81, + ) + refuses( + "wan2.1 refuses img2vid", + base_job(workload="video.img2vid", model=small, image="in.png"), + tmp, + "does not serve", + ) + + print("offload only when the card cannot hold the whole pipeline") + check("8 GiB card offloads wan2.1", runner.use_offload(models[small], 8.0)) + check("a card reporting 7.6 GiB offloads too", runner.use_offload(models[small], 7.6)) + check("a 24 GiB card loads it whole", not runner.use_offload(models[small], 24.0)) + check( + "models without full_vram_gib never offload", + not runner.use_offload(models["wan2.2-ti2v-5b"], 8.0), + ) print("a model only serves the workloads it declares") # t2v-a14b is text-to-video only, so an img2vid job naming it is refused diff --git a/plugins/webhook/module.toml b/plugins/webhook/module.toml index f9d679e9..4cd63c30 100644 --- a/plugins/webhook/module.toml +++ b/plugins/webhook/module.toml @@ -7,7 +7,7 @@ [module] id = "webhook" name = "Webhooks — paid, end-to-end encrypted delivery" -version = "0.2.85" +version = "0.2.86" kind = "workload" category = "infrastructure" status = "available" diff --git a/plugins/whisper/module.toml b/plugins/whisper/module.toml index d407af1c..d600a187 100644 --- a/plugins/whisper/module.toml +++ b/plugins/whisper/module.toml @@ -7,7 +7,7 @@ [module] id = "whisper" name = "Whisper — speech-to-text" -version = "0.2.85" +version = "0.2.86" kind = "workload" category = "ai" status = "available" diff --git a/plugins/workflow-step/module.toml b/plugins/workflow-step/module.toml index 6eda5fcd..3be56a5f 100644 --- a/plugins/workflow-step/module.toml +++ b/plugins/workflow-step/module.toml @@ -14,7 +14,7 @@ [module] id = "workflow-step" name = "Workflow step — webhook workflow steps (meshhook)" -version = "0.2.85" +version = "0.2.86" kind = "workload" category = "infrastructure" status = "available"