use anyhow::{Context, Result};
use clap::{Args, Subcommand, ValueEnum};
use nolgia_client::types::{BitrateMode, Modality, Model, QualityCapabilities};
use super::CommandContext;
use crate::output::{OutputFormat, print_json};
#[derive(Subcommand, Debug)]
pub enum ModelsCommand {
List(ListArgs),
Get(GetArgs),
}
#[derive(Args, Debug)]
pub struct ListArgs {
#[arg(long, value_enum)]
pub modality: Option<ModalityFilter>,
}
#[derive(Args, Debug)]
pub struct GetArgs {
pub id: String,
}
#[derive(ValueEnum, Clone, Copy, Debug)]
pub enum ModalityFilter {
Image,
Video,
Audio,
}
impl ModalityFilter {
fn matches(self, modality: &Modality) -> bool {
matches!(
(self, modality),
(Self::Image, Modality::Image)
| (Self::Video, Modality::Video)
| (Self::Audio, Modality::Audio)
)
}
}
pub async fn run(command: ModelsCommand, ctx: &CommandContext) -> Result<()> {
match command {
ModelsCommand::List(args) => list(args, ctx).await,
ModelsCommand::Get(args) => get(args, ctx).await,
}
}
async fn fetch(ctx: &CommandContext) -> Result<Vec<Model>> {
Ok(ctx
.client()
.list_models()
.send()
.await
.context("fetching model catalog")?
.into_inner()
.models)
}
fn cost_line(model: &Model) -> String {
match &model.cost {
Some(cost) => {
let unit = format!("{:?}", cost.unit).to_lowercase();
match cost.baseline_seconds {
Some(base) => format!("{} credits per {base}s clip", cost.credits),
None => format!("{} credits ({unit})", cost.credits),
}
}
None => "pricing pending".to_string(),
}
}
fn capability_line(model: &Model) -> String {
if let Some(video) = &model.video {
let mut parts: Vec<String> = Vec::new();
if !video.durations.is_empty() {
parts.push(
video
.durations
.iter()
.map(|d| d.to_string())
.collect::<Vec<_>>()
.join("/")
+ "s",
);
} else if let (Some(min), Some(max)) = (video.min_duration, video.max_duration) {
parts.push(format!("{min}-{max}s"));
}
let ratios = video
.aspect_ratios
.iter()
.map(|r| format!("{r:?}").replace('N', "").replace('_', ":"))
.collect::<Vec<_>>()
.join(" ");
if !ratios.is_empty() {
parts.push(ratios);
}
if video.image_input == Some(true) {
parts.push("image-input".to_string());
}
if let Some(quality) = &model.quality {
parts.push(quality_summary(quality));
}
if let Some(refs) = &model.references {
if refs.end_frame {
parts.push("end-frame".to_string());
}
if refs.video_refs_max > 0 {
parts.push(format!("video-refs:{}", refs.video_refs_max));
}
if refs.element_refs_max > 0 {
parts.push(format!("elements:{}", refs.element_refs_max));
}
if !refs.bitrate_modes.is_empty() {
parts.push("bitrate".to_string());
}
}
return parts.join(" ");
}
if let Some(audio) = &model.audio
&& !audio.voices.is_empty()
{
return format!("{} voices", audio.voices.len());
}
if let Some(quality) = &model.quality {
return quality_summary(quality);
}
String::new()
}
fn quality_summary(quality: &QualityCapabilities) -> String {
quality
.options
.iter()
.map(|o| {
if o.premium {
format!("{}*", o.id)
} else {
o.id.clone()
}
})
.collect::<Vec<_>>()
.join("/")
}
fn quality_lines(model: &Model, quality: &QualityCapabilities) -> Vec<String> {
let unit = match model.cost.as_ref().and_then(|c| c.baseline_seconds) {
Some(base) => format!("credits per {base}s clip"),
None => "credits".to_string(),
};
quality
.options
.iter()
.map(|option| {
let mut line = format!("{} — {} {unit}", option.id, option.credits);
if quality.default.as_deref() == Some(option.id.as_str()) {
line.push_str(" (default)");
}
if option.premium {
line.push_str(" (premium)");
}
line
})
.collect()
}
fn bitrate_list(modes: &[BitrateMode]) -> String {
modes
.iter()
.map(|m| m.to_string())
.collect::<Vec<_>>()
.join("|")
}
fn quality_choices(model: &Model, quality: &QualityCapabilities) -> String {
quality_lines(model, quality).join(", ")
}
pub async fn quote_video(
ctx: &CommandContext,
model_id: &str,
duration_seconds: u64,
quality: Option<&str>,
) -> Result<String> {
let models = fetch(ctx).await?;
let model = models.iter().find(|m| m.id == model_id).with_context(|| {
format!("model {model_id:?} not in the catalog — see `nolgia models list`")
})?;
let Some(cost) = &model.cost else {
return Ok(format!(
"{model_id}: pricing pending — the server will quote at submit time"
));
};
let (base_credits, tier_note) = match quality {
Some(tier) => {
let option = check_quality(model, tier)?;
(option.credits, format!(", {tier}"))
}
None => (cost.credits, String::new()),
};
let credits = match cost.baseline_seconds {
Some(base) => (base_credits.get() * duration_seconds).div_ceil(base.get()),
None => base_credits.get(),
};
Ok(format!(
"{credits} credits ({model_id}, {duration_seconds}s{tier_note})"
))
}
pub struct VideoOptions<'a> {
pub quality: Option<&'a str>,
pub bitrate: Option<BitrateMode>,
pub video_refs: usize,
pub elements: usize,
pub end_frame: bool,
}
pub async fn precheck_video_options(
ctx: &CommandContext,
model_id: &str,
options: &VideoOptions<'_>,
) -> Result<()> {
let Ok(models) = fetch(ctx).await else {
return Ok(());
};
let Some(model) = models.iter().find(|m| m.id == model_id) else {
return Ok(());
};
if let Some(tier) = options.quality {
check_quality(model, tier)?;
}
let Some(refs) = &model.references else {
return Ok(());
};
if options.video_refs as u64 > refs.video_refs_max {
anyhow::bail!(match refs.video_refs_max {
0 => format!(
"--video-ref: {model_id} takes no reference videos — use a \
reference-to-video model (see `nolgia models list`)"
),
max => format!("--video-ref: {model_id} accepts at most {max} reference videos"),
});
}
if options.elements as u64 > refs.element_refs_max {
anyhow::bail!(match refs.element_refs_max {
0 => format!("--element: {model_id} takes no element/reference images"),
max => format!("--element: {model_id} accepts at most {max} element images"),
});
}
if options.end_frame && !refs.end_frame {
anyhow::bail!(
"--end-frame: {model_id} does not support end-frame pinning \
(see `references.end_frame` in `nolgia models get {model_id}`)"
);
}
if let Some(mode) = options.bitrate
&& !refs.bitrate_modes.contains(&mode)
{
anyhow::bail!(match refs.bitrate_modes.is_empty() {
true => format!("--bitrate: {model_id} has no bitrate selection"),
false => format!(
"--bitrate: {model_id} supports {}",
bitrate_list(&refs.bitrate_modes)
),
});
}
Ok(())
}
pub fn check_quality<'m>(
model: &'m Model,
tier: &str,
) -> Result<&'m nolgia_client::types::QualityOption> {
let Some(quality) = &model.quality else {
anyhow::bail!(
"--quality: {} has no quality tiers — omit --quality for its single fixed quality",
model.id
);
};
quality
.options
.iter()
.find(|o| o.id == tier)
.with_context(|| {
format!(
"--quality {tier:?}: not a tier of {}; available: {}",
model.id,
quality_choices(model, quality)
)
})
}
pub async fn precheck_image_quality(
ctx: &CommandContext,
model_id: &str,
tier: &str,
) -> Result<()> {
let Ok(models) = fetch(ctx).await else {
return Ok(());
};
let Some(model) = models.iter().find(|m| m.id == model_id) else {
return Ok(());
};
check_quality(model, tier).map(|_| ())
}
async fn list(args: ListArgs, ctx: &CommandContext) -> Result<()> {
let mut models = fetch(ctx).await?;
if let Some(filter) = args.modality {
models.retain(|m| filter.matches(&m.modality));
}
match ctx.format() {
OutputFormat::Json => print_json(&models),
OutputFormat::Text => {
for model in &models {
let star = if model.recommended { "*" } else { " " };
println!(
"{star} {:52} {:7} {:28} {}",
model.id,
format!("{:?}", model.modality).to_lowercase(),
cost_line(model),
capability_line(model),
);
}
println!("\n* recommended. `nolgia models get <id>` for details.");
Ok(())
}
}
}
async fn get(args: GetArgs, ctx: &CommandContext) -> Result<()> {
let models = fetch(ctx).await?;
let model = models.iter().find(|m| m.id == args.id).with_context(|| {
format!(
"model {:?} not in the catalog — see `nolgia models list`",
args.id
)
})?;
match ctx.format() {
OutputFormat::Json => print_json(model),
OutputFormat::Text => {
println!("{}", model.id);
println!(" modality: {:?}", model.modality);
println!(" recommended: {}", model.recommended);
println!(" cost: {}", cost_line(model));
let caps = capability_line(model);
if !caps.is_empty() {
println!(" supports: {caps}");
}
if let Some(quality) = &model.quality {
let mut label = "quality: ";
for line in quality_lines(model, quality) {
println!(" {label} {line}");
label = " ";
}
}
if let Some(refs) = &model.references {
let mut parts: Vec<String> = Vec::new();
if refs.start_frame {
parts.push("start-frame".to_string());
}
if refs.end_frame {
parts.push("end-frame".to_string());
}
if refs.video_refs_max > 0 {
parts.push(format!("video-refs <={}", refs.video_refs_max));
}
if refs.element_refs_max > 0 {
parts.push(format!("elements <={}", refs.element_refs_max));
}
if refs.audio_refs_max > 0 {
parts.push(format!("audio-refs <={}", refs.audio_refs_max));
}
if !refs.bitrate_modes.is_empty() {
parts.push(format!("bitrate {}", bitrate_list(&refs.bitrate_modes)));
}
if !parts.is_empty() {
println!(" references: {}", parts.join(", "));
}
}
if let Some(audio) = &model.audio
&& !audio.voices.is_empty()
{
for voice in &audio.voices {
match &voice.label {
Some(label) => println!(" voice: {} ({label})", voice.id),
None => println!(" voice: {}", voice.id),
}
}
}
Ok(())
}
}
}