diff --git a/DESCRIPTION b/DESCRIPTION index d59960d..ae7adbb 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: diffuseR Title: Functional Interface to Diffusion Models in R -Version: 0.2.2.2 +Version: 0.2.2.3 Authors@R: c( person("Troy", "Hernandez", email = "troy@cornball.ai", role = c("aut", "cre"), comment = c(ORCID = "0009-0005-4248-604X")), diff --git a/NEWS.md b/NEWS.md index ec7f263..6eea834 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,18 @@ +# diffuseR 0.2.2.3 + +* Prebuilt NF4 artifacts for flux2 (2.1 GB) and zimage (3.5 GB) are now + hosted on the cornball-ai HuggingFace org, and + `download_flux2_klein()` / `download_zimage_turbo()` fetch them by + default when the resolved precision is nf4 (`prebuilt = FALSE` forces + a local build). This narrowly reverses 0.2.2's no-hosting decision: + the CRAN safetensors cannot read any of the multi-GB upstream sources + (the fix is merged upstream, mlverse/safetensors#14, but unreleased), + which left a stock CRAN install unable to build a quantized artifact + at all. Hosting the two redistributable models (both Apache-2.0, + ungated) gives a plain `install.packages("diffuseR")` setup something + to generate with right away. FLUX.1-schnell (gated repo) and LTX-2.3 + (LTX-2 Community License) still download sources and build locally. + # diffuseR 0.2.2.2 * `recommend()` tier selection now applies a 0.5 GB tolerance to the diff --git a/R/download_flux2.R b/R/download_flux2.R index 52e4a2b..8ef2847 100644 --- a/R/download_flux2.R +++ b/R/download_flux2.R @@ -2,7 +2,8 @@ #' #' Downloads FLUX.2-klein-4B from HuggingFace (Apache-2.0, ungated) and #' quantizes the 4B transformer to a local fp8 (~4 GB) or NF4 (~2.3 GB) -#' artifact. +#' artifact. For nf4 the prebuilt hosted artifact is fetched instead of +#' building, when reachable (see \link{download_prebuilt}). #' #' @name download_flux2 NULL @@ -34,10 +35,15 @@ NULL #' @param quantize Logical. Build the quantized artifact. #' @param precision "auto" (default: fp8 when safetensors supports #' float8, else nf4), "fp8" (~4 GB, GPU-resident; near-bf16 quality), -#' or "nf4" (~2.3 GB). +#' or "nf4" (~2.3 GB, fetched prebuilt from the cornball-ai +#' HuggingFace dataset when available). #' @param output_dir Directory for the quantized artifact. #' @param text_encoders Logical. Also fetch the Qwen3 text encoder, #' tokenizer, VAE, and scheduler config (~8.3 GB). +#' @param prebuilt Logical. When the resolved precision is nf4, fetch +#' the hosted prebuilt artifact (~2.3 GB) instead of downloading the +#' 7.8 GB bf16 source and quantizing locally. \code{FALSE} forces a +#' local build. #' @param verbose Logical. #' #' @return Invisibly, a list with \code{transformer_dir}, @@ -47,7 +53,7 @@ NULL download_flux2_klein <- function(quantize = TRUE, precision = c("auto", "fp8", "nf4"), output_dir = NULL, text_encoders = TRUE, - verbose = TRUE) { + prebuilt = TRUE, verbose = TRUE) { precision <- match.arg(precision) precision <- .flux_resolve_precision(precision, file.path(tools::R_user_dir("diffuseR", "data"), "flux2-klein-4b-")) @@ -69,6 +75,12 @@ download_flux2_klein <- function(quantize = TRUE, all(file.exists(file.path(output_dir, m$shards))) } + fetched <- FALSE + if (prebuilt && quantize && !have_artifact && identical(precision, "nf4")) { + fetched <- .flux_fetch_prebuilt("flux2", output_dir, verbose) + have_artifact <- have_artifact || fetched + } + if (!have_artifact || !quantize) { cached <- tryCatch( hfhub::hub_download(.flux2_repo, .flux2_transformer_files[[2]], @@ -115,7 +127,7 @@ download_flux2_klein <- function(quantize = TRUE, ) } } - } else if (verbose) { + } else if (verbose && !fetched) { message(toupper(precision), " artifact already present: ", output_dir) } diff --git a/R/download_prebuilt.R b/R/download_prebuilt.R new file mode 100644 index 0000000..0bf8d09 --- /dev/null +++ b/R/download_prebuilt.R @@ -0,0 +1,120 @@ +#' Hosted prebuilt NF4 artifacts +#' +#' diffuseR hosts prebuilt NF4 artifacts for the two models whose +#' licenses permit redistribution: FLUX.2-klein-4B and Z-Image-Turbo +#' (both Apache-2.0, ungated). The artifacts are the exact output of +#' \code{\link{flux_quantize}} (sub-2 GB shards, bf16 residents), so +#' stock CRAN safetensors reads them. Nothing else in the catalog is +#' hosted: FLUX.1-schnell sits behind a HuggingFace license gate and +#' LTX-2.3's license does not permit redistribution, so both still +#' download their sources and quantize locally. +#' +#' @name download_prebuilt +#' @keywords internal +NULL + +# model_name -> hosted dataset repo + artifact directory name. The repo +# follows the cornball-ai/-R dataset convention (see +# hf_download_pt); the base mirrors the local artifact directory name so +# a fetched artifact is indistinguishable from a locally built one. +.prebuilt_nf4_spec <- list( + flux2 = list(repo = "cornball-ai/flux2-R", base = "flux2-klein-4b-nf4", + size = "~2.1 GB"), + zimage = list(repo = "cornball-ai/zimage-R", base = "zimage-turbo-nf4", + size = "~3.5 GB") +) + +# TRUE when the hosted artifact is already fully in the hfhub cache, so +# fetching it needs no network and therefore no consent. +.prebuilt_all_cached <- function(spec) { + cached <- function(f) { + !is.null(tryCatch( + hfhub::hub_download(spec$repo, paste0(spec$base, "/", f), + repo_type = "dataset", + local_files_only = TRUE), + error = function(e) NULL)) + } + if (!cached("manifest.json")) { + return(FALSE) + } + m <- jsonlite::fromJSON(hfhub::hub_download(spec$repo, + paste0(spec$base, "/manifest.json"), repo_type = "dataset", + local_files_only = TRUE)) + all(vapply(m$shards, cached, logical(1))) +} + +# Fetch a hosted NF4 artifact into output_dir. Returns TRUE when the +# artifact is complete there, FALSE when the model has no hosted +# artifact or the fetch failed (callers fall back to source + +# quantize). Files are hard-linked out of the hfhub cache when the +# filesystem allows it, copied otherwise. +.flux_fetch_prebuilt <- function(model, output_dir, verbose = TRUE) { + spec <- .prebuilt_nf4_spec[[model]] + if (is.null(spec)) { + return(FALSE) + } + # Every model download is consent-gated (see download_ltx2). A + # declined fetch returns FALSE so the caller falls through to the + # source + quantize path, which carries its own (larger) consent ask. + if (!.prebuilt_all_cached(spec) && + !.ltx23_consent(sprintf("the prebuilt NF4 artifact (%s, %s)", + spec$size, spec$repo))) { + return(FALSE) + } + manifest_cache <- tryCatch( + hfhub::hub_download(spec$repo, paste0(spec$base, "/manifest.json"), + repo_type = "dataset"), + error = function(e) NULL) + if (is.null(manifest_cache)) { + if (verbose) { + message("No hosted NF4 artifact reachable for '", model, + "'; building locally instead.") + } + return(FALSE) + } + manifest <- jsonlite::fromJSON(manifest_cache) + if (verbose) { + message("Downloading the prebuilt NF4 artifact from ", spec$repo, + " (", length(manifest$shards), " shards)...") + } + shard_cache <- vapply(manifest$shards, function(s) { + tryCatch(hfhub::hub_download(spec$repo, paste0(spec$base, "/", s), + repo_type = "dataset"), + error = function(e) NA_character_) + }, character(1)) + if (anyNA(shard_cache)) { + if (verbose) { + message("Prebuilt artifact fetch incomplete; ", + "building locally instead.") + } + return(FALSE) + } + dir.create(output_dir, recursive = TRUE, showWarnings = FALSE) + src <- c(manifest_cache, shard_cache) + dst <- file.path(output_dir, c("manifest.json", manifest$shards)) + for (i in seq_along(src)) { + .link_or_copy(normalizePath(src[[i]]), dst[[i]]) + } + ok <- file.exists(file.path(output_dir, "manifest.json")) + if (ok && verbose) { + message("NF4 artifact ready: ", output_dir) + } + ok +} + +# Hard link (free on one filesystem), falling back to a copy. An +# existing destination is replaced. +.link_or_copy <- function(from, to) { + if (file.exists(to)) { + unlink(to) + } + ok <- suppressWarnings(file.link(from, to)) + if (!ok) { + ok <- file.copy(from, to, overwrite = TRUE) + } + if (!ok) { + stop("Could not place ", basename(to), " in ", dirname(to), + call. = FALSE) + } + invisible(ok) +} diff --git a/R/download_zimage.R b/R/download_zimage.R index 66e2c02..e4ede63 100644 --- a/R/download_zimage.R +++ b/R/download_zimage.R @@ -40,10 +40,15 @@ NULL #' @param quantize Logical. Build the quantized artifact. #' @param precision "auto" (default: fp8 when safetensors supports #' float8, else nf4), "fp8" (~6.3 GB, GPU-resident; near-bf16 -#' quality), or "nf4" (~3.6 GB). +#' quality), or "nf4" (~3.6 GB, fetched prebuilt from the cornball-ai +#' HuggingFace dataset when available). #' @param output_dir Directory for the quantized artifact. #' @param text_encoders Logical. Also fetch the Qwen3-4B text encoder, #' tokenizer, VAE, and scheduler config (~8.2 GB). +#' @param prebuilt Logical. When the resolved precision is nf4, fetch +#' the hosted prebuilt artifact (~3.6 GB) instead of downloading the +#' 24.6 GB float32 source and quantizing locally. \code{FALSE} forces +#' a local build. #' @param verbose Logical. #' #' @return Invisibly, a list with \code{transformer_dir}, @@ -53,7 +58,7 @@ NULL download_zimage_turbo <- function(quantize = TRUE, precision = c("auto", "fp8", "nf4"), output_dir = NULL, text_encoders = TRUE, - verbose = TRUE) { + prebuilt = TRUE, verbose = TRUE) { precision <- match.arg(precision) precision <- .flux_resolve_precision(precision, file.path(tools::R_user_dir("diffuseR", "data"), "zimage-turbo-")) @@ -75,6 +80,12 @@ download_zimage_turbo <- function(quantize = TRUE, all(file.exists(file.path(output_dir, m$shards))) } + fetched <- FALSE + if (prebuilt && quantize && !have_artifact && identical(precision, "nf4")) { + fetched <- .flux_fetch_prebuilt("zimage", output_dir, verbose) + have_artifact <- have_artifact || fetched + } + if (!have_artifact || !quantize) { cached <- tryCatch( hfhub::hub_download(.zimage_repo, .zimage_transformer_files[[3]], @@ -121,7 +132,7 @@ download_zimage_turbo <- function(quantize = TRUE, ) } } - } else if (verbose) { + } else if (verbose && !fetched) { message(toupper(precision), " artifact already present: ", output_dir) } diff --git a/README.md b/README.md index 9a2041b..7b2b4e7 100644 --- a/README.md +++ b/README.md @@ -155,10 +155,13 @@ torch::cuda_empty_cache() ### FLUX and Z-Image FLUX.1-schnell (12B), FLUX.2 Klein (4B), and Z-Image-Turbo (6B) are -step-distilled models: 4-8 denoising steps, no guidance. All are -quantized locally once at download time and fit comfortably on a 16GB -GPU (measured 1024x1024 on an RTX 5060 Ti: FLUX.1 ~55s at 9.6GB peak; -FLUX.2 Klein ~13s at 12.5GB; Z-Image-Turbo ~24s at 13.1GB). +step-distilled models: 4-8 denoising steps, no guidance. All fit +comfortably on a 16GB GPU (measured 1024x1024 on an RTX 5060 Ti: +FLUX.1 ~55s at 9.6GB peak; FLUX.2 Klein ~9s at fp8; Z-Image-Turbo +~24s at 13.1GB). Klein and Z-Image ship prebuilt NF4 artifacts (2.3 +and 3.6 GB, hosted on the cornball-ai HuggingFace org), so a stock +CRAN install downloads a few GB and generates right away; FLUX.1 +downloads its gated source and quantizes locally. ```r library(diffuseR) @@ -170,14 +173,16 @@ download_flux1() txt2img_flux("An astronaut riding a horse on Mars, photorealistic", seed = 7) -# FLUX.2 Klein 4B: ungated. ~16GB download, one-time fp8 quantize to -# a 3.9GB artifact. +# FLUX.2 Klein 4B: ungated. On stock safetensors this fetches the +# prebuilt NF4 artifact (~2.3GB); a float8-capable safetensors builds +# fp8 from the 7.8GB source instead. download_flux2_klein() txt2img_flux2("a red fox sitting in a snowy forest, digital art", seed = 42) # Z-Image-Turbo: ungated, strong at legible text in images (EN + CN). -# ~33GB download, one-time fp8 quantize to a 5.9GB artifact. +# Prebuilt NF4 (~3.6GB) on stock safetensors, or fp8 from the 24.6GB +# float32 source. download_zimage_turbo() txt2img_zimage(paste("A storefront with a large wooden sign that reads", "\"DIFFUSER\" in bold carved letters"), seed = 42) @@ -261,7 +266,7 @@ Same prompt, same seed, 1024x1024, measured on an RTX 5060 Ti 16GB: | Model | Settings | Load | Warm generation | Peak VRAM | |---|---|---|---|---| | SDXL | 50 steps, CFG 7.5 | 45 s | 20 s | **12.7 GB** | -| FLUX.2 Klein 4B | 4 steps, guidance-free | 32 s | **13 s** | 12.5 GB | +| FLUX.2 Klein 4B | 4 steps, guidance-free, fp8 | 32 s | **9.1 s** | 12.5 GB | FLUX.2 is now faster per image (since the allocator gc-gate fix), and its prompt adherence and coherence are in a different class — SDXL diff --git a/inst/tinytest/test_download_prebuilt.R b/inst/tinytest/test_download_prebuilt.R new file mode 100644 index 0000000..e3ad4cf --- /dev/null +++ b/inst/tinytest/test_download_prebuilt.R @@ -0,0 +1,21 @@ +# Hosted-artifact policy: exactly the redistributable pair (Apache-2.0, +# ungated), nothing else. flux1 (gated) and ltx (LTX-2 Community +# License) must never appear here. +expect_equal(sort(names(diffuseR:::.prebuilt_nf4_spec)), + c("flux2", "zimage")) + +# Unhosted models refuse without touching the network. +expect_false(diffuseR:::.flux_fetch_prebuilt("flux1", tempfile(), + verbose = FALSE)) +expect_false(diffuseR:::.flux_fetch_prebuilt("ltx", tempfile(), + verbose = FALSE)) + +# .link_or_copy places a file and replaces an existing destination. +src <- tempfile() +writeLines("x", src) +dst <- tempfile() +diffuseR:::.link_or_copy(normalizePath(src), dst) +expect_true(file.exists(dst)) +diffuseR:::.link_or_copy(normalizePath(src), dst) +expect_equal(readLines(dst), "x") +unlink(c(src, dst)) diff --git a/man/download_flux2.Rd b/man/download_flux2.Rd index 6d84f95..c9a9a82 100644 --- a/man/download_flux2.Rd +++ b/man/download_flux2.Rd @@ -5,5 +5,6 @@ \description{ Downloads FLUX.2-klein-4B from HuggingFace (Apache-2.0, ungated) and quantizes the 4B transformer to a local fp8 (~4 GB) or NF4 (~2.3 GB) -artifact. +artifact. For nf4 the prebuilt hosted artifact is fetched instead of +building, when reachable (see \link{download_prebuilt}). } diff --git a/man/download_flux2_klein.Rd b/man/download_flux2_klein.Rd index af6b7c6..7d0d1df 100644 --- a/man/download_flux2_klein.Rd +++ b/man/download_flux2_klein.Rd @@ -8,6 +8,7 @@ download_flux2_klein( precision = c("auto", "fp8", "nf4"), output_dir = NULL, text_encoders = TRUE, + prebuilt = TRUE, verbose = TRUE ) } @@ -16,13 +17,19 @@ download_flux2_klein( \item{precision}{"auto" (default: fp8 when safetensors supports float8, else nf4), "fp8" (~4 GB, GPU-resident; near-bf16 quality), -or "nf4" (~2.3 GB).} +or "nf4" (~2.3 GB, fetched prebuilt from the cornball-ai +HuggingFace dataset when available).} \item{output_dir}{Directory for the quantized artifact.} \item{text_encoders}{Logical. Also fetch the Qwen3 text encoder, tokenizer, VAE, and scheduler config (~8.3 GB).} +\item{prebuilt}{Logical. When the resolved precision is nf4, fetch +the hosted prebuilt artifact (~2.3 GB) instead of downloading the +7.8 GB bf16 source and quantizing locally. \code{FALSE} forces a +local build.} + \item{verbose}{Logical.} } \value{ diff --git a/man/download_prebuilt.Rd b/man/download_prebuilt.Rd new file mode 100644 index 0000000..aa5e105 --- /dev/null +++ b/man/download_prebuilt.Rd @@ -0,0 +1,15 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{download_prebuilt} +\alias{download_prebuilt} +\title{Hosted prebuilt NF4 artifacts} +\description{ +diffuseR hosts prebuilt NF4 artifacts for the two models whose +licenses permit redistribution: FLUX.2-klein-4B and Z-Image-Turbo +(both Apache-2.0, ungated). The artifacts are the exact output of +\code{\link{flux_quantize}} (sub-2 GB shards, bf16 residents), so +stock CRAN safetensors reads them. Nothing else in the catalog is +hosted: FLUX.1-schnell sits behind a HuggingFace license gate and +LTX-2.3's license does not permit redistribution, so both still +download their sources and quantize locally. +} +\keyword{internal} diff --git a/man/download_zimage_turbo.Rd b/man/download_zimage_turbo.Rd index 323ec6f..c4d1275 100644 --- a/man/download_zimage_turbo.Rd +++ b/man/download_zimage_turbo.Rd @@ -8,6 +8,7 @@ download_zimage_turbo( precision = c("auto", "fp8", "nf4"), output_dir = NULL, text_encoders = TRUE, + prebuilt = TRUE, verbose = TRUE ) } @@ -16,13 +17,19 @@ download_zimage_turbo( \item{precision}{"auto" (default: fp8 when safetensors supports float8, else nf4), "fp8" (~6.3 GB, GPU-resident; near-bf16 -quality), or "nf4" (~3.6 GB).} +quality), or "nf4" (~3.6 GB, fetched prebuilt from the cornball-ai +HuggingFace dataset when available).} \item{output_dir}{Directory for the quantized artifact.} \item{text_encoders}{Logical. Also fetch the Qwen3-4B text encoder, tokenizer, VAE, and scheduler config (~8.2 GB).} +\item{prebuilt}{Logical. When the resolved precision is nf4, fetch +the hosted prebuilt artifact (~3.6 GB) instead of downloading the +24.6 GB float32 source and quantizing locally. \code{FALSE} forces +a local build.} + \item{verbose}{Logical.} } \value{