mod bfl;
mod cancel;
mod clock;
mod comfy;
mod config;
mod genai;
mod kling;
mod ledger;
mod masked;
mod mcp;
mod openai;
mod out;
mod provider;
mod retry;
mod runway;
mod setup;
mod skill;
mod spend;
mod stability;
#[cfg(test)]
mod testserver;
mod update;
mod video;
use anyhow::{Context, Result};
use clap::{Args, Parser, Subcommand};
use provider::{Aspect, Backend, ImageProvider, ImageRequest, Size, infer_backend};
use std::path::{Path, PathBuf};
use video::VideoRequest;
#[derive(Parser)]
#[command(
name = "lucida",
version,
about = "Generate images and video with Google Gemini, Veo, Runway, Kling, a local ComfyUI, FLUX, Stability AI or OpenAI",
long_about = "Generate and edit images with Google Gemini, a local ComfyUI, \
hosted FLUX from Black Forest Labs, Stability AI, or OpenAI, \
and video with Veo, Runway or Kling.\n\n\
Google reads GEMINI_API_KEY — one key for both images and Veo \
video. Image generation requires billing to be enabled on the \
project behind the key; free-tier keys report a quota of \
zero.\n\n\
ComfyUI needs no credential. It is found at \
http://127.0.0.1:8188 unless LUCIDA_COMFYUI_URL says otherwise.\n\n\
Black Forest Labs reads BFL_API_KEY and bills per image. Its \
capabilities differ per model — run `lucida models --provider bfl`.\n\n\
Stability reads STABILITY_API_KEY; OpenAI reads OPENAI_API_KEY, \
and model access there is granted per project.\n\n\
Any of these can live in a config file; see `lucida config`.",
disable_version_flag = true
)]
struct Cli {
#[arg(short = 'v', short_alias = 'V', long, action = clap::ArgAction::Version)]
version: Option<bool>,
#[arg(long, global = true)]
json: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Args, Clone, Default)]
struct ImageOptions {
#[arg(short, long)]
aspect: Option<String>,
#[arg(short, long)]
size: Option<String>,
#[arg(short, long)]
model: Option<String>,
#[arg(short, long)]
provider: Option<String>,
#[arg(short, long)]
negative: Option<String>,
#[arg(long, value_name = "FILE")]
workflow: Option<String>,
#[arg(long)]
mask: Option<String>,
#[arg(long)]
seed: Option<u64>,
#[arg(long)]
steps: Option<u32>,
#[arg(short, long)]
guidance: Option<f32>,
#[arg(long, default_value_t = 1, value_name = "N")]
count: usize,
#[arg(long)]
dry_run: bool,
}
#[derive(Subcommand)]
enum Command {
Generate {
prompt: String,
#[arg(short, long, default_value = "image.png")]
out: PathBuf,
#[arg(short, long = "ref")]
reference: Vec<String>,
#[command(flatten)]
opts: ImageOptions,
},
Edit {
image: String,
prompt: String,
#[arg(short, long)]
out: Option<PathBuf>,
#[arg(short, long = "ref")]
reference: Vec<String>,
#[command(flatten)]
opts: ImageOptions,
},
Video {
prompt: String,
#[arg(short, long, default_value = "video.mp4")]
out: PathBuf,
#[arg(short, long)]
image: Option<String>,
#[arg(short, long)]
aspect: Option<String>,
#[arg(short, long)]
resolution: Option<String>,
#[arg(short, long)]
negative: Option<String>,
#[arg(short, long)]
model: Option<String>,
#[arg(long)]
provider: Option<String>,
#[arg(short = 'd', long)]
duration: Option<u32>,
#[arg(long)]
seed: Option<u64>,
#[arg(long)]
mode: Option<String>,
#[arg(long)]
no_wait: bool,
#[arg(long)]
dry_run: bool,
},
Check {
operation: String,
#[arg(long)]
provider: Option<String>,
#[arg(short, long, default_value = "video.mp4")]
out: PathBuf,
},
Ops,
History {
#[arg(short = 'n', long, default_value_t = 20)]
count: usize,
},
Models {
#[arg(short, long, default_value = "google")]
provider: String,
},
Config {
#[arg(long)]
init: bool,
#[arg(long, value_name = "NAME", conflicts_with_all = ["init", "remove"])]
set: Option<String>,
#[arg(long, value_name = "NAME", conflicts_with = "init")]
remove: Option<String>,
},
Setup {
#[arg(long, value_name = "DIR", num_args = 0..=1, default_missing_value = ".")]
project: Option<PathBuf>,
#[arg(long)]
dry_run: bool,
#[arg(short = 'y', long, conflicts_with = "dry_run")]
yes: bool,
},
Skill,
Update {
#[arg(long)]
check: bool,
#[arg(short = 'y', long, conflicts_with = "check")]
yes: bool,
},
Mcp,
}
fn main() {
let cli = Cli::parse();
out::set_json(cli.json);
let announce =
!matches!(cli.command, Command::Mcp | Command::Update { .. }) && !cli.json;
let code = match run(cli) {
Ok(code) => code,
Err(e) => {
let code = out::code_for(&e);
eprintln!("error: {e:#}");
out::emit_error(&e, code);
std::process::exit(code);
}
};
if announce {
update::notify_if_due(env!("CARGO_PKG_VERSION"));
}
if code != out::OK {
std::process::exit(code);
}
}
fn run(cli: Cli) -> Result<i32> {
match cli.command {
Command::Mcp => mcp::serve().map(|()| out::OK),
Command::Models { provider } => match Backend::parse(&provider) {
Ok(backend) => list_models(backend).map(|()| out::OK),
Err(image_error) => match provider::VideoBackend::parse(&provider) {
Ok(backend) => list_video_models(backend).map(|()| out::OK),
Err(_) => Err(image_error),
},
},
Command::Setup {
project,
dry_run,
yes,
} => {
let scope = match project {
Some(dir) => setup::Scope::Project(
std::fs::canonicalize(&dir).unwrap_or(dir),
),
None => setup::Scope::User,
};
setup::run(scope, dry_run, yes).map(|()| out::OK)
}
Command::Skill => {
skill::print();
Ok(out::OK)
}
Command::Update { check, yes } => {
let mode = match (check, yes) {
(true, _) => update::Mode::Check,
(_, true) => update::Mode::Yes,
_ => update::Mode::Ask,
};
update::Updater::new()?.run(mode).map(|()| out::OK)
}
Command::Config { init, set, remove } => match (set, remove) {
(Some(name), _) => set_config(&name).map(|()| out::OK),
(_, Some(name)) => remove_config(&name).map(|()| out::OK),
_ if init => init_config().map(|()| out::OK),
_ => {
show_config();
Ok(out::OK)
}
},
Command::Generate {
prompt,
out,
reference,
opts,
} => {
let (count, dry_run) = (opts.count, opts.dry_run);
let (request, backend, source) = opts.into_request(prompt, reference)?;
execute(request, backend, out, count, dry_run, source).map(|()| out::OK)
}
Command::Edit {
image,
prompt,
out,
reference,
opts,
} => {
let mut references = vec![image.clone()];
references.extend(reference);
let destination = out.unwrap_or_else(|| PathBuf::from(&image));
let (count, dry_run) = (opts.count, opts.dry_run);
let (request, backend, source) = opts.into_request(prompt, references)?;
execute(request, backend, destination, count, dry_run, source).map(|()| out::OK)
}
Command::Check {
operation,
provider,
out,
} => {
let backend = match &provider {
Some(name) => provider::VideoBackend::parse(name)?,
None => provider::infer_video_backend_from_operation(&operation),
};
match open_video(backend)?.poll(&operation)? {
video::VideoStatus::Pending => {
eprintln!("Still rendering. Try again in half a minute.");
out::emit(serde_json::json!({
"ok": true,
"status": "pending",
"operation": operation,
"exit_code": out::PENDING,
}));
Ok(out::PENDING)
}
video::VideoStatus::Done(bytes) => {
let written = write_image(correct_extension(&out, "video/mp4"), &bytes)?;
eprintln!(
"Wrote {} ({:.1} MB)",
written.display(),
bytes.len() as f64 / 1_048_576.0
);
ledger::video_done(&operation, &written.to_string_lossy());
if out::json() {
out::emit(serde_json::json!({
"ok": true,
"status": "done",
"operation": operation,
"path": written.to_string_lossy(),
"bytes": bytes.len(),
"exit_code": out::OK,
}));
} else {
println!("{}", written.display());
}
Ok(out::OK)
}
}
}
Command::Video {
prompt,
out,
image,
aspect,
resolution,
negative,
model,
provider,
duration,
seed,
mode,
no_wait,
dry_run,
} => {
let (backend, default_source) = match &provider {
Some(name) => (provider::VideoBackend::parse(name)?, None),
None => match &model {
Some(model) => (provider::infer_video_backend(model), None),
None => {
let (backend, source) =
provider::resolve_default::<provider::VideoBackend>()?;
(backend, Some(source))
}
},
};
announce_default(&default_source, backend.name());
let model = model.unwrap_or_else(|| backend.default_model().to_string());
if let Some(note) = provider::retirement_note(&model) {
eprintln!(
"⚠ {model} {note} — expect this to fail. Current ids: {}.",
video::VIDEO_ALIASES
.iter()
.map(|(alias, _)| *alias)
.collect::<Vec<_>>()
.join(", ")
);
}
let request = VideoRequest {
prompt,
model,
aspect: aspect.map(|a| Aspect::parse(&a)).transpose()?,
resolution,
negative_prompt: negative,
image,
duration,
seed,
mode,
};
let caps = provider::video_capabilities_for(backend, &request.model);
caps.check(&request)?;
let resolved = resolve_video_model(backend, &request.model);
let price = spend::video_price(backend, &resolved, request.duration);
spend::check(price, "video render")?;
if dry_run {
report_plan(serde_json::json!({
"ok": true,
"status": "dry-run",
"provider": backend.name(),
"provider_source": default_source.as_ref().map(|s| s.tag()),
"model": resolved,
"prompt": request.prompt,
"aspect": request.aspect.map(|a| a.to_string()),
"duration": request.duration,
"mode": request.mode,
"seed": request.seed,
"image": request.image,
"estimated_usd": price.against_budget(),
"exit_code": out::OK,
}))?;
return Ok(out::OK);
}
eprintln!("Rendering with {resolved} — {}.", price.describe());
let client = open_video(backend)?;
let operation = client.start(&request)?;
ledger::video_started(
&resolved,
&request.prompt,
&operation,
price.against_budget(),
);
eprintln!("{}", video::resume_notice(&operation));
if no_wait {
if out::json() {
out::emit(serde_json::json!({
"ok": true,
"status": "started",
"operation": operation,
"model": resolved,
"estimated_usd": price.against_budget(),
"exit_code": out::OK,
}));
} else {
println!("{operation}");
}
return Ok(out::OK);
}
let bytes = await_video(client.as_ref(), &operation)?;
let written = write_image(correct_extension(&out, "video/mp4"), &bytes)?;
eprintln!(
"Wrote {} ({:.1} MB)",
written.display(),
bytes.len() as f64 / 1_048_576.0
);
ledger::video_done(&operation, &written.to_string_lossy());
if out::json() {
out::emit(serde_json::json!({
"ok": true,
"status": "done",
"operation": operation,
"path": written.to_string_lossy(),
"model": resolved,
"bytes": bytes.len(),
"estimated_usd": price.against_budget(),
"exit_code": out::OK,
}));
} else {
println!("{}", written.display());
}
Ok(out::OK)
}
Command::Ops => show_operations().map(|()| out::OK),
Command::History { count } => show_history(count).map(|()| out::OK),
}
}
fn show_operations() -> Result<()> {
if ledger::disabled() {
eprintln!(
"The render ledger is off (LUCIDA_NO_LEDGER is set), so nothing was \
recorded to list."
);
return Ok(());
}
let open = ledger::outstanding();
if out::json() {
out::emit(serde_json::json!({
"ok": true,
"operations": open,
"exit_code": out::OK,
}));
return Ok(());
}
if open.is_empty() {
println!("No video renders are waiting to be collected.");
return Ok(());
}
println!("Video renders started and not yet collected:\n");
for entry in &open {
let operation = entry["operation"].as_str().unwrap_or("?");
println!(
" {} {}\n {}\n lucida check {operation}\n",
clock::stamp(entry["at"].as_i64().unwrap_or(0)),
entry["model"].as_str().unwrap_or("?"),
truncate(entry["prompt"].as_str().unwrap_or(""), 68),
);
}
Ok(())
}
fn show_history(count: usize) -> Result<()> {
if ledger::disabled() {
eprintln!("The render ledger is off (LUCIDA_NO_LEDGER is set).");
return Ok(());
}
let all = ledger::entries();
if out::json() {
let recent: Vec<_> = all.iter().rev().take(count).rev().cloned().collect();
out::emit(serde_json::json!({
"ok": true,
"entries": recent,
"estimated_usd_24h": spend::spent_recently(),
"budget_usd": spend::budget(),
"exit_code": out::OK,
}));
return Ok(());
}
if all.is_empty() {
println!("Nothing recorded yet.");
return Ok(());
}
for entry in all.iter().rev().take(count).rev() {
let seed = match entry["seed"].as_u64() {
Some(seed) => format!(" seed {seed}"),
None => String::new(),
};
println!(
"{} {:9} {:10} {}{seed}",
clock::stamp(entry["at"].as_i64().unwrap_or(0)),
entry["provider"].as_str().unwrap_or("?"),
entry["status"].as_str().unwrap_or("?"),
entry["path"]
.as_str()
.or_else(|| entry["operation"].as_str())
.unwrap_or("?"),
);
let prompt = entry["prompt"].as_str().unwrap_or("");
if !prompt.is_empty() {
println!(" {}", truncate(prompt, 72));
}
}
let spent = spend::spent_recently();
if spent > 0.0 {
print!("\nEstimated spend in the last 24 hours: ${spent:.2}");
match spend::budget() {
Some(budget) => println!(" of a ${budget:.2} LUCIDA_BUDGET"),
None => println!(" (no LUCIDA_BUDGET set)"),
}
}
Ok(())
}
fn truncate(text: &str, limit: usize) -> String {
if text.chars().count() <= limit {
return text.to_string();
}
text.chars().take(limit.saturating_sub(1)).collect::<String>() + "…"
}
impl ImageOptions {
fn into_request(
self,
prompt: String,
references: Vec<String>,
) -> Result<(ImageRequest, Backend, Option<provider::DefaultSource>)> {
if self.workflow.is_some() && self.model.is_some() {
anyhow::bail!(
"a workflow and an explicit `--model` cannot be combined.\n\n\
A supplied workflow names its own checkpoints, so there is \
nowhere to put a model id. Name the model inside the workflow \
file, or drop `--workflow` to use the built-in graph."
);
}
let (backend, default_source) = match (&self.provider, &self.model) {
(Some(name), _) => (Backend::parse(name)?, None),
(None, Some(model)) => (infer_backend(model), None),
(None, None) => {
let (backend, source) = provider::resolve_default::<Backend>()?;
(backend, Some(source))
}
};
announce_default(&default_source, backend.name());
let model = self.model.unwrap_or_else(|| backend.default_model().to_string());
let request = ImageRequest {
prompt,
model,
aspect: self.aspect.as_deref().map(Aspect::parse).transpose()?,
size: self.size.as_deref().map(Size::parse).transpose()?,
references,
negative_prompt: self.negative,
mask: self.mask,
workflow: self.workflow,
seed: self.seed,
steps: self.steps,
guidance: self.guidance,
};
Ok((request, backend, default_source))
}
}
fn show_config() {
match config::source() {
Some(path) => println!("Config file: {}", path.display()),
None => println!("Config file: none found"),
}
match ledger::path() {
Some(path) => println!("Render ledger: {}", path.display()),
None if ledger::disabled() => {
println!("Render ledger: off (LUCIDA_NO_LEDGER is set)")
}
None => println!("Render ledger: nowhere to write one"),
}
println!("\nLooked for it at:");
for path in config::search_paths() {
let mark = if path.is_file() { "found" } else { "not found" };
println!(" {} ({mark})", path.display());
}
println!("\nSettings visible to this process:");
let mut shadowed: Vec<&str> = Vec::new();
for (key, purpose) in config::KNOWN_KEYS {
let source = match config::origin(key) {
Some(config::Origin::File) => "set (config file)",
Some(config::Origin::Environment) => "set (environment)",
Some(config::Origin::FileOverridingEnvironment) => {
shadowed.push(key);
"set (config file)"
}
None => "not set",
};
println!(" {key:<22} {source:<20} {purpose}");
}
if !shadowed.is_empty() {
println!("\nAlso set in this environment, and not used — the config file wins:");
for key in shadowed {
println!(" {key}");
}
}
let retired = config::retired_in_use();
if !retired.is_empty() {
println!("\nSet, but no longer read by Lucida:");
for (old, new) in retired {
println!(" {old} (renamed — use {new})");
}
}
let unrecognised: Vec<String> = config::keys_in_file()
.into_iter()
.filter(|name| {
!config::KNOWN_KEYS.iter().any(|(known, _)| known == name)
&& config::replacement_for(name).is_none()
})
.collect();
if !unrecognised.is_empty() {
println!("\nIn the config file but not recognised by Lucida:");
for name in &unrecognised {
println!(" {name} (ignored — check the spelling)");
}
}
if config::source().is_none() {
println!(
"\nNo config file yet. `lucida config --init` writes one — useful when \
a GUI-launched\napp cannot see your shell's environment."
);
}
}
fn validate_setting_name(name: &str) -> Result<()> {
if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
anyhow::bail!(
"`{name}` is not a valid setting name — expected something like GEMINI_API_KEY"
);
}
Ok(())
}
fn assigns(line: &str, name: &str) -> bool {
let bare = line.trim().strip_prefix("export ").unwrap_or(line.trim());
bare.split_once('=').is_some_and(|(key, _)| key.trim() == name)
}
fn remove_config(name: &str) -> Result<()> {
let name = name.trim();
validate_setting_name(name)?;
let Some(path) = config::source().map(|p| p.to_path_buf()) else {
anyhow::bail!(
"no config file was found, so there is nothing to remove from.\n\n\
`lucida config` lists where one is looked for."
);
};
let existing = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
let kept: Vec<&str> = existing
.lines()
.filter(|line| !assigns(line, name))
.collect();
if kept.len() == existing.lines().count() {
eprintln!(
"{name} is not in {}, so there is nothing to remove.",
path.display()
);
println!("{}", path.display());
return Ok(());
}
let mut body = kept.join("\n");
if !body.is_empty() {
body.push('\n');
}
config::write_replacing(&path, &body, true)?;
eprintln!("Removed {name} from {}.", path.display());
if std::env::var(name).is_ok_and(|v| !v.trim().is_empty()) {
eprintln!("Note: {name} is set in this environment, so that value now applies.");
}
println!("{}", path.display());
Ok(())
}
fn set_config(name: &str) -> Result<()> {
let name = name.trim();
validate_setting_name(name)?;
if let Some(replacement) = config::replacement_for(name) {
anyhow::bail!(
"`{name}` is no longer read — it was renamed to `{replacement}`.\n\n\
Set that instead:\n lucida config --set {replacement}\n\n\
And clear the old one if it is still in the file:\n \
lucida config --remove {name}"
);
}
use std::io::{IsTerminal, Read};
let stdin = std::io::stdin();
let mut value = String::new();
if stdin.is_terminal() {
eprint!("Value for {name}: ");
std::io::Write::flush(&mut std::io::stderr()).ok();
value = masked::read_masked()?;
eprintln!("({} characters)", value.trim().chars().count());
} else {
stdin
.lock()
.read_to_string(&mut value)
.context("reading the value from stdin")?;
}
let value = value.trim();
if value.is_empty() {
anyhow::bail!(
"no value was given, so there is nothing to set.\n\n\
Type it at the prompt, or pipe it in: \
`pbpaste | lucida config --set {name}`."
);
}
let path = config::preferred_path()
.context(
"could not determine a config location: none of XDG_CONFIG_HOME, HOME or \
USERPROFILE is set",
)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
let existing = std::fs::read_to_string(&path).unwrap_or_default();
let mut lines: Vec<String> = existing.lines().map(str::to_string).collect();
let assignment = format!("{name}={value}");
let target = lines.iter().position(|line| assigns(line, name));
let replaced = target.is_some();
match target {
Some(at) => lines[at] = assignment,
None => lines.push(assignment),
}
let mut body = lines.join("\n");
body.push('\n');
config::write_replacing(&path, &body, true)?;
eprintln!(
"{} {name} in {}.",
if replaced { "Updated" } else { "Added" },
path.display()
);
if std::env::var(name).is_ok_and(|v| !v.trim().is_empty()) {
eprintln!(
"Note: {name} is also set in this environment. Lucida will use the value \
you just set — the config file takes precedence."
);
}
println!("{}", path.display());
Ok(())
}
fn init_config() -> Result<()> {
let path = config::preferred_path()
.context(
"could not determine a config location: none of XDG_CONFIG_HOME, HOME or \
USERPROFILE is set",
)?;
if path.exists() {
eprintln!("{} already exists; leaving it alone.", path.display());
println!("{}", path.display());
return Ok(());
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
config::write_replacing(&path, &config::template(), true)?;
eprintln!(
"Wrote {}.\n\nEvery line is commented out, so nothing changed yet. \
Uncomment the key you need\nand set it, then check with `lucida config`.",
path.display()
);
println!("{}", path.display());
Ok(())
}
fn list_video_models(backend: provider::VideoBackend) -> Result<()> {
let caps = provider::video_capabilities_for(backend, backend.default_model());
match backend {
provider::VideoBackend::Google => {
println!("Video models available to the google provider:");
for (alias, id) in video::VIDEO_ALIASES {
let default = if *id == video::DEFAULT_VIDEO_MODEL { " (default)" } else { "" };
println!(" {alias:<14} -> {id}{default}");
}
}
provider::VideoBackend::Runway => {
match runway::Client::from_env().and_then(|c| c.credits()) {
Ok(credits) => println!("Key is valid. Remaining credits: {credits}"),
Err(e) => println!("The runway provider cannot be used right now:\n\n {e:#}\n"),
}
println!("Video models available to the runway provider:");
for model in runway::MODELS {
let default = if *model == runway::DEFAULT_MODEL { " (default)" } else { "" };
let per_model = provider::video_capabilities_for(backend, model);
let text = if per_model.text_to_video { "" } else { "; needs a still" };
println!(" {model}{default}{text}");
}
}
provider::VideoBackend::Kling => {
match kling::Client::from_env().and_then(|c| c.credits()) {
Ok(units) => println!("Key is valid. Remaining units: {units}"),
Err(e) => println!("The kling provider cannot be used right now:\n\n {e:#}\n"),
}
println!("Video models available to the kling provider:");
for model in kling::MODELS {
let default = if *model == kling::DEFAULT_MODEL { " (default)" } else { "" };
println!(" {model}{default}");
}
println!("\nAliases:");
for (alias, target) in kling::MODEL_ALIASES {
println!(" {alias:<16} -> {target}");
}
}
}
println!("\nThis provider supports:");
println!(" aspect ratio {}", describe_aspect(caps.aspect));
println!(" duration {}", caps.duration.describe());
println!(" from a still {}", yes_no(caps.image_to_video));
println!(" from text alone {}", yes_no(caps.text_to_video));
println!(" negative prompt {}", yes_no(caps.negative_prompt));
println!(" resolution {}", yes_no(caps.resolution));
println!(" seed {}", yes_no(caps.seed));
if !caps.modes.is_empty() {
println!(" quality tiers {}", caps.modes.join(", "));
}
println!(" output carries {}", caps.provenance.describe());
Ok(())
}
fn open_video(backend: provider::VideoBackend) -> Result<Box<dyn provider::VideoProvider>> {
Ok(match backend {
provider::VideoBackend::Google => Box::new(genai::Client::from_env()?),
provider::VideoBackend::Runway => Box::new(runway::Client::from_env()?),
provider::VideoBackend::Kling => Box::new(kling::Client::from_env()?),
})
}
fn resolve_video_model(backend: provider::VideoBackend, model: &str) -> String {
match backend {
provider::VideoBackend::Google => video::resolve_video_model(model),
provider::VideoBackend::Runway => runway::resolve_model(model),
provider::VideoBackend::Kling => kling::resolve_model(model),
}
}
fn await_video(client: &dyn provider::VideoProvider, operation: &str) -> Result<Vec<u8>> {
let started = std::time::Instant::now();
let deadline = std::time::Duration::from_secs(900);
let mut interval = std::time::Duration::from_secs(5);
loop {
cancel::check().map_err(|e| {
anyhow::anyhow!("{e}\n\nCollect it later with: lucida check {operation}")
})?;
if started.elapsed() > deadline {
anyhow::bail!(
"gave up after {} minutes; the render may still finish. \
Poll it with: lucida check {operation}",
deadline.as_secs() / 60
);
}
std::thread::sleep(interval);
interval = (interval * 2).min(std::time::Duration::from_secs(30));
if let video::VideoStatus::Done(bytes) = client.poll(operation)? {
eprintln!("Render finished in {}s.", started.elapsed().as_secs());
return Ok(bytes);
}
eprintln!(" still rendering ({}s elapsed)…", started.elapsed().as_secs());
}
}
fn open(backend: Backend) -> Result<Box<dyn ImageProvider>> {
Ok(match backend {
Backend::Google => Box::new(genai::Client::from_env()?),
Backend::ComfyUi => Box::new(comfy::Client::from_env()?),
Backend::Bfl => Box::new(bfl::Client::from_env()?),
Backend::Stability => Box::new(stability::Client::from_env()?),
Backend::OpenAi => Box::new(openai::Client::from_env()?),
})
}
enum Reachability {
Listed(Vec<String>),
Unavailable(String),
Unreachable(String),
}
fn list_models(backend: Backend) -> Result<()> {
let caps = provider::capabilities_for(backend, backend.default_model());
let reachability = match open(backend) {
Err(e) => Reachability::Unavailable(format!("{e:#}")),
Ok(provider) => match provider.list_models() {
Ok(models) => Reachability::Listed(models),
Err(e) => Reachability::Unreachable(format!("{e:#}")),
},
};
let models = match &reachability {
Reachability::Listed(models) => models.clone(),
Reachability::Unavailable(why) => {
println!("The {} provider cannot be used right now:\n\n {why}\n", caps.provider);
println!("What it supports is a fact about the provider, not about your \
credentials, so it is printed anyway:\n");
Vec::new()
}
Reachability::Unreachable(why) => {
println!("The {} provider did not answer:\n\n {why}\n", caps.provider);
Vec::new()
}
};
if models.is_empty() && matches!(reachability, Reachability::Listed(_)) {
println!("No image models visible to the {} provider.", caps.provider);
} else if !models.is_empty() {
println!("Image models available to the {} provider:", caps.provider);
for model in &models {
let mut notes: Vec<String> = Vec::new();
if model == backend.default_model() {
notes.push("default".into());
}
if model.starts_with("imagen") {
notes.push("Imagen family — a different endpoint, not implemented".into());
}
if let Some(note) = provider::retirement_note(model) {
notes.push(note);
}
if backend == Backend::Bfl {
let per_model = provider::capabilities_for(backend, model);
if per_model.steps {
notes.push("steps + guidance".into());
}
notes.push(if per_model.references {
"edits".into()
} else {
"generate only".into()
});
}
let suffix = if notes.is_empty() {
String::new()
} else {
format!(" ({})", notes.join("; "))
};
println!(" {model}{suffix}");
}
}
let aliases: &[(&str, &str)] = match backend {
Backend::Google => genai::MODEL_ALIASES,
Backend::ComfyUi => comfy::MODEL_ALIASES,
Backend::Bfl => bfl::MODEL_ALIASES,
Backend::Stability => stability::MODEL_ALIASES,
Backend::OpenAi => openai::MODEL_ALIASES,
};
if !aliases.is_empty() {
println!("\nAliases:");
for (alias, target) in aliases {
println!(" {alias:<16} -> {target}");
}
}
println!("\nThis provider supports:");
println!(" aspect ratio {}", describe_aspect(caps.aspect));
println!(" output size {}", yes_no(caps.size));
println!(" seed {}", yes_no(caps.seed));
println!(" negative prompt {}", yes_no(caps.negative_prompt));
println!(" reference image {}", yes_no(caps.references));
println!(" own workflow {}", yes_no(caps.workflow));
println!(" mask {}", caps.mask.describe());
println!(" steps {}", yes_no(caps.steps));
println!(" guidance {}", yes_no(caps.guidance));
println!(" output carries {}", caps.provenance.describe());
Ok(())
}
fn yes_no(supported: bool) -> &'static str {
if supported { "yes" } else { "no" }
}
fn describe_aspect(support: provider::AspectSupport) -> String {
match support {
provider::AspectSupport::Named(ratios) => ratios.join(", "),
provider::AspectSupport::Free { multiple_of } => {
format!("any, rounded to {multiple_of} pixels")
}
}
}
fn announce_default(source: &Option<provider::DefaultSource>, chosen: &str) {
if let Some(source) = source {
eprintln!("Provider: {}", source.describe(chosen));
}
}
fn report_plan(plan: serde_json::Value) -> Result<()> {
if out::json() {
out::emit(plan);
} else {
eprintln!("Dry run — nothing was sent.");
println!("{}", serde_json::to_string_pretty(&plan)?);
}
Ok(())
}
fn execute(
request: ImageRequest,
backend: Backend,
out: PathBuf,
count: usize,
dry_run: bool,
default_source: Option<provider::DefaultSource>,
) -> Result<()> {
let caps = provider::capabilities_for(backend, &request.model);
caps.check(&request)?;
let price = spend::price_for(backend, &request.model);
spend::check_batch(price, count, "render")?;
if count > 1 && request.seed.is_some() {
return Err(anyhow::Error::new(out::Refused(format!(
"`--seed` pins one image and `--count {count}` asks for several, so \
together they would render the same picture {count} times and bill \
for each.\n\n\
Drop `--seed` to get {count} different images, or drop `--count` to \
reproduce the one the seed names."
))));
}
if dry_run {
report_plan(serde_json::json!({
"ok": true,
"status": "dry-run",
"provider": caps.provider,
"provider_source": default_source.as_ref().map(|s| s.tag()),
"model": request.model,
"prompt": request.prompt,
"count": count,
"aspect": request.aspect.map(|a| a.to_string()),
"size": request.size.map(|s| s.0),
"seed": request.seed,
"references": request.references,
"estimated_usd": price.against_budget() * count as f64,
"exit_code": out::OK,
}))?;
return Ok(());
}
let mut written = Vec::new();
for n in 1..=count {
let destination = numbered(&out, n, count);
written.push(render_one(&request, backend, caps, price, destination)?);
}
if out::json() {
out::emit(serde_json::json!({
"ok": true,
"status": "done",
"images": written,
"exit_code": out::OK,
}));
} else {
for image in &written {
println!("{}", image["path"].as_str().unwrap_or_default());
}
}
Ok(())
}
fn numbered(out: &Path, n: usize, count: usize) -> PathBuf {
if count <= 1 {
return out.to_path_buf();
}
let stem = out.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
let numbered = match out.extension().and_then(|e| e.to_str()) {
Some(extension) => format!("{stem}-{n}.{extension}"),
None => format!("{stem}-{n}"),
};
out.with_file_name(numbered)
}
fn render_one(
request: &ImageRequest,
backend: Backend,
caps: provider::Capabilities,
price: spend::Price,
out: PathBuf,
) -> Result<serde_json::Value> {
let request = request.clone();
let provider = open(backend)?;
let verb = if request.references.is_empty() {
"Generating"
} else {
"Editing"
};
eprintln!("{verb} via {}…", caps.provider);
let image = provider.generate(&request)?;
let destination = correct_extension(&out, &image.mime_type);
if destination != out {
eprintln!(
"note: the model returned {}, so writing {} rather than {}",
image.mime_type,
destination.display(),
out.display()
);
}
let written = write_image(&destination, &image.bytes)?;
if let Some(commentary) = &image.commentary
&& !commentary.is_empty()
{
eprintln!("{commentary}");
}
if let Some(seed) = image.seed {
eprintln!("Seed {seed} — pass `--seed {seed}` to render this again.");
}
let size = match image_dimensions(&image.bytes, &image.mime_type) {
Some((w, h)) => format!("{w}x{h}, "),
None => String::new(),
};
eprintln!(
"Wrote {} ({size}{} KB)",
written.display(),
image.bytes.len() / 1024
);
eprintln!("Provenance: {}.", caps.provenance.describe());
if price != spend::Price::Free {
eprintln!("Cost: {}.", price.describe());
}
ledger::image(
caps.provider,
&request.model,
&request.prompt,
&written.to_string_lossy(),
image.seed,
price.against_budget(),
);
let (width, height) = match image_dimensions(&image.bytes, &image.mime_type) {
Some((w, h)) => (Some(w), Some(h)),
None => (None, None),
};
Ok(serde_json::json!({
"path": written.to_string_lossy(),
"provider": caps.provider,
"model": request.model,
"mime": image.mime_type,
"bytes": image.bytes.len(),
"width": width,
"height": height,
"seed": image.seed,
"provenance": caps.provenance.describe(),
"estimated_usd": price.against_budget(),
}))
}
pub fn sniff_mime(bytes: &[u8]) -> Option<&'static str> {
match bytes {
[0x89, b'P', b'N', b'G', ..] => Some("image/png"),
[0xFF, 0xD8, 0xFF, ..] => Some("image/jpeg"),
_ if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" => {
Some("image/webp")
}
_ => None,
}
}
pub fn image_dimensions(bytes: &[u8], mime: &str) -> Option<(u32, u32)> {
match mime {
"image/png" => {
let (w, h) = (bytes.get(16..20)?, bytes.get(20..24)?);
Some((
u32::from_be_bytes(w.try_into().ok()?),
u32::from_be_bytes(h.try_into().ok()?),
))
}
"image/jpeg" => {
let mut at = 2;
while at + 9 < bytes.len() {
if bytes[at] != 0xFF {
at += 1;
continue;
}
let marker = bytes[at + 1];
let is_frame = matches!(marker, 0xC0..=0xCF)
&& !matches!(marker, 0xC4 | 0xC8 | 0xCC);
if is_frame {
let h = u16::from_be_bytes([bytes[at + 5], bytes[at + 6]]);
let w = u16::from_be_bytes([bytes[at + 7], bytes[at + 8]]);
return Some((u32::from(w), u32::from(h)));
}
let length = u16::from_be_bytes([bytes[at + 2], bytes[at + 3]]) as usize;
at += 2 + length.max(2);
}
None
}
"image/webp" => match bytes.get(12..16)? {
b"VP8X" => {
let le24 =
|b: &[u8]| u32::from(b[0]) | u32::from(b[1]) << 8 | u32::from(b[2]) << 16;
Some((le24(bytes.get(24..27)?) + 1, le24(bytes.get(27..30)?) + 1))
}
b"VP8 " => {
if bytes.get(23..26)? != [0x9D, 0x01, 0x2A] {
return None;
}
let w = u16::from_le_bytes([*bytes.get(26)?, *bytes.get(27)?]) & 0x3FFF;
let h = u16::from_le_bytes([*bytes.get(28)?, *bytes.get(29)?]) & 0x3FFF;
Some((u32::from(w), u32::from(h)))
}
b"VP8L" => {
if *bytes.get(20)? != 0x2F {
return None;
}
let b = bytes.get(21..25)?;
let w = 1 + (u32::from(b[1] & 0x3F) << 8 | u32::from(b[0]));
let h = 1 + (u32::from(b[3] & 0x0F) << 10
| u32::from(b[2]) << 2
| u32::from(b[1] >> 6));
Some((w, h))
}
_ => None,
},
_ => None,
}
}
pub fn correct_extension(path: &Path, mime: &str) -> PathBuf {
let expected = match mime {
"image/jpeg" => "jpg",
"image/png" => "png",
"image/webp" => "webp",
"video/mp4" => "mp4",
_ => return path.to_path_buf(),
};
let actual = path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase);
let matches = match actual.as_deref() {
Some("jpg" | "jpeg") => expected == "jpg",
Some(other) => other == expected,
None => false,
};
if matches {
path.to_path_buf()
} else {
path.with_extension(expected)
}
}
pub fn write_atomically(path: &Path, bytes: &[u8], private: bool) -> Result<()> {
let staged = staging_path(path);
let staged_then = |result: Result<()>| -> Result<()> {
if result.is_err() {
let _ = std::fs::remove_file(&staged);
}
result
};
staged_then(
std::fs::write(&staged, bytes).with_context(|| format!("writing {}", staged.display())),
)?;
if private {
staged_then(config::restrict_to_owner(&staged))?;
}
staged_then(
std::fs::rename(&staged, path)
.with_context(|| format!("replacing {} with {}", path.display(), staged.display())),
)
}
fn staging_path(path: &Path) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(0);
let name = path.file_name().unwrap_or_default().to_string_lossy();
let nonce = NEXT.fetch_add(1, Ordering::Relaxed);
path.with_file_name(format!(
".{name}.lucida-{}-{nonce}",
std::process::id()
))
}
pub fn write_image(path: impl AsRef<Path>, bytes: &[u8]) -> Result<PathBuf> {
let path = path.as_ref();
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)
.with_context(|| format!("creating directory {}", parent.display()))?;
}
write_atomically(path, bytes, false)?;
Ok(std::fs::canonicalize(path)
.map(strip_unc_prefix)
.unwrap_or_else(|_| path.to_path_buf()))
}
fn strip_unc_prefix(path: PathBuf) -> PathBuf {
match path.to_str().and_then(|s| s.strip_prefix(r"\\?\")) {
Some(stripped) => PathBuf::from(stripped),
None => path,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_shopfront_names_every_provider_and_video() {
use clap::CommandFactory;
let banner = Cli::command().get_about().map(|a| a.to_string()).unwrap();
for surface in [env!("CARGO_PKG_DESCRIPTION"), banner.as_str()] {
for backend in Backend::ALL {
assert!(
surface.contains(backend.product_name()),
"`{}` is missing from a surface someone reads before installing: {surface}",
backend.product_name()
);
}
for backend in provider::VideoBackend::ALL {
let name = Backend::video_product_name(*backend);
assert!(
surface.contains(name),
"`{name}` is missing from a surface someone reads before installing: {surface}"
);
}
}
}
#[test]
fn every_readme_link_points_at_a_heading_that_exists() {
let readme =
std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("README.md"))
.expect("README.md must exist");
let slug = |heading: &str| -> String {
let mut text = heading.to_string();
text = text.replace('`', "");
while let (Some(open), Some(close)) = (text.find("]("), text.find(')')) {
if open < close {
text.replace_range(open..=close, "");
} else {
break;
}
}
text.replace('[', "")
.to_lowercase()
.chars()
.filter(|c| c.is_alphanumeric() || *c == ' ' || *c == '-')
.collect::<String>()
.trim()
.replace(' ', "-")
};
let mut anchors: Vec<String> = Vec::new();
for line in readme.lines() {
if let Some(rest) = line.trim_start().strip_prefix('#') {
let heading = rest.trim_start_matches('#').trim();
if !heading.is_empty() {
anchors.push(slug(heading));
}
}
if let Some(at) = line.find("<h")
&& let Some(start) = line[at..].find("id=\"")
{
let rest = &line[at + start + 4..];
anchors.push(rest[..rest.find('"').unwrap()].to_string());
}
}
let mut links = 0;
for (offset, _) in readme.match_indices("](#") {
let rest = &readme[offset + 3..];
let target = &rest[..rest.find(')').expect("an unterminated link")];
links += 1;
assert!(
anchors.contains(&target.to_string()),
"README links to #{target}, which is not a heading in it.\n\
headings are: {anchors:?}"
);
}
assert!(links > 10, "only {links} internal links found — the scan broke");
}
#[test]
fn a_staged_write_never_touches_the_target_until_it_is_whole() {
let path = std::path::Path::new("/tmp/gallery/cat.png");
let staged = staging_path(path);
assert_ne!(staged, path);
assert_eq!(staged.parent(), path.parent());
assert!(
staged.file_name().unwrap().to_string_lossy().starts_with('.'),
"the staging file shows up in a listing mid-write: {}",
staged.display()
);
}
#[test]
fn concurrent_writes_do_not_share_a_staging_path() {
let path = std::path::Path::new("image.png");
assert_ne!(staging_path(path), staging_path(path));
}
#[test]
fn writing_an_image_over_itself_leaves_a_whole_file() {
let dir = std::env::temp_dir().join(format!("lucida-image-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("cat.png");
std::fs::write(&path, b"original").unwrap();
write_image(&path, b"edited").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"edited");
let left: Vec<String> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|entry| Some(entry.ok()?.file_name().to_string_lossy().into_owned()))
.collect();
assert_eq!(left, vec!["cat.png"], "a staging file survived: {left:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn only_a_batch_numbers_its_output() {
let out = Path::new("public/icon.png");
assert_eq!(numbered(out, 1, 1), PathBuf::from("public/icon.png"));
assert_eq!(numbered(out, 1, 3), PathBuf::from("public/icon-1.png"));
assert_eq!(numbered(out, 3, 3), PathBuf::from("public/icon-3.png"));
assert_eq!(numbered(out, 2, 3).parent(), out.parent());
assert_eq!(numbered(Path::new("out/frame"), 2, 2), PathBuf::from("out/frame-2"));
}
#[test]
fn the_resume_notice_carries_the_id_and_the_command_that_uses_it() {
let notice = video::resume_notice("operations/abc123");
assert!(notice.contains("operations/abc123"), "{notice}");
assert!(
notice.contains("lucida check operations/abc123"),
"the id alone is not a way forward; the command has to be there: {notice}"
);
}
#[test]
fn video_can_start_a_render_without_waiting_for_it() {
use clap::Parser;
let cli = Cli::try_parse_from(["lucida", "video", "a fox running", "--no-wait"])
.expect("--no-wait must parse");
match cli.command {
Command::Video { no_wait, .. } => assert!(no_wait),
_ => panic!("`video --no-wait` parsed as the wrong subcommand"),
}
}
#[test]
fn the_mask_help_states_no_capability_fact() {
use clap::CommandFactory;
let command = Cli::command();
let generate = command
.get_subcommands()
.find(|c| c.get_name() == "generate")
.expect("no `generate` subcommand");
let mask = generate
.get_arguments()
.find(|a| a.get_id() == "mask")
.expect("no `--mask` argument");
let help = mask
.get_help()
.expect("`--mask` has no help")
.to_string()
.to_lowercase();
for backend in Backend::ALL {
assert!(
!help.contains(backend.name()),
"the --mask help names `{}` — which providers mask is generated, \
and a literal here cannot follow it",
backend.name()
);
}
for claim in ["advisory", "binding"] {
assert!(
!help.contains(claim),
"the --mask help says `{claim}` — the kind of mask a provider has \
lives in MaskSupport, and every generated surface reads it"
);
}
assert!(help.contains("lucida models"), "{help}");
}
#[test]
fn png_dimensions_come_from_the_ihdr_chunk() {
let mut png = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
png.extend_from_slice(&13u32.to_be_bytes());
png.extend_from_slice(b"IHDR");
png.extend_from_slice(&1360u32.to_be_bytes());
png.extend_from_slice(&768u32.to_be_bytes());
assert_eq!(image_dimensions(&png, "image/png"), Some((1360, 768)));
}
#[test]
fn jpeg_dimensions_are_found_by_walking_to_the_frame_header() {
let mut jpeg = vec![0xFF, 0xD8];
jpeg.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x10]);
jpeg.extend_from_slice(&[0u8; 14]);
jpeg.extend_from_slice(&[0xFF, 0xC0, 0x00, 0x11, 0x08]);
jpeg.extend_from_slice(&576u16.to_be_bytes()); jpeg.extend_from_slice(&1024u16.to_be_bytes());
jpeg.extend_from_slice(&[0u8; 8]);
assert_eq!(image_dimensions(&jpeg, "image/jpeg"), Some((1024, 576)));
}
#[test]
fn mime_is_sniffed_from_magic_bytes_not_names() {
assert_eq!(sniff_mime(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A]), Some("image/png"));
assert_eq!(sniff_mime(&[0xFF, 0xD8, 0xFF, 0xE0]), Some("image/jpeg"));
let mut webp = b"RIFF".to_vec();
webp.extend_from_slice(&[0; 4]);
webp.extend_from_slice(b"WEBP");
assert_eq!(sniff_mime(&webp), Some("image/webp"));
assert_eq!(sniff_mime(b"GIF89a"), None);
assert_eq!(sniff_mime(&[]), None);
}
#[test]
fn webp_dimensions_come_out_of_all_three_container_layouts() {
let mut vp8x = b"RIFF\0\0\0\0WEBPVP8X".to_vec();
vp8x.extend_from_slice(&[10, 0, 0, 0]); vp8x.extend_from_slice(&[0; 4]); vp8x.extend_from_slice(&(1360u32 - 1).to_le_bytes()[..3]);
vp8x.extend_from_slice(&(768u32 - 1).to_le_bytes()[..3]);
assert_eq!(image_dimensions(&vp8x, "image/webp"), Some((1360, 768)));
let mut vp8 = b"RIFF\0\0\0\0WEBPVP8 ".to_vec();
vp8.extend_from_slice(&[0; 4]); vp8.extend_from_slice(&[0; 3]); vp8.extend_from_slice(&[0x9D, 0x01, 0x2A]);
vp8.extend_from_slice(&1024u16.to_le_bytes());
vp8.extend_from_slice(&576u16.to_le_bytes());
assert_eq!(image_dimensions(&vp8, "image/webp"), Some((1024, 576)));
let mut vp8l = b"RIFF\0\0\0\0WEBPVP8L".to_vec();
vp8l.extend_from_slice(&[0; 4]); vp8l.push(0x2F); vp8l.extend_from_slice(&[0xFF, 0xC3, 0x8F, 0x00]);
assert_eq!(image_dimensions(&vp8l, "image/webp"), Some((1024, 576)));
}
#[test]
fn a_workflow_refuses_an_explicit_model() {
let opts = ImageOptions {
workflow: Some("graph.json".into()),
model: Some("klein".into()),
..Default::default()
};
let error = opts
.into_request("x".into(), Vec::new())
.unwrap_err()
.to_string();
assert!(error.contains("--workflow"), "must name the conflict: {error}");
assert!(error.contains("--model"));
let alone = ImageOptions {
workflow: Some("graph.json".into()),
provider: Some("comfyui".into()),
..Default::default()
};
assert!(alone.into_request("x".into(), Vec::new()).is_ok());
}
#[test]
fn truncated_or_unknown_data_reports_nothing_rather_than_guessing() {
assert_eq!(image_dimensions(&[0x89, b'P', b'N', b'G'], "image/png"), None);
assert_eq!(image_dimensions(&[0xFF, 0xD8], "image/jpeg"), None);
assert_eq!(image_dimensions(&[0; 64], "image/webp"), None);
}
}