use crate::provider::{
Aspect, AspectSupport, DurationSupport, Provenance, VideoCapabilities, VideoProvider,
};
use crate::video::{VideoRequest, VideoStatus};
use anyhow::{Context, Result, anyhow, bail};
use serde_json::{Value, json};
use std::time::Duration;
const API_ROOT: &str = "https://api.dev.runwayml.com/v1";
const API_VERSION: &str = "2024-11-06";
pub const MODELS: &[&str] = &["gen4_turbo", "gen4", "gen4.5"];
pub const DEFAULT_MODEL: &str = "gen4.5";
pub const MODEL_ALIASES: &[(&str, &str)] = &[
("runway", "gen4.5"),
("gen4", "gen4"),
("gen4-turbo", "gen4_turbo"),
("gen4.5", "gen4.5"),
];
const TURBO_RATIOS: &[&str] = &[
"1280:720", "720:1280", "1104:832", "832:1104", "960:960", "1584:672",
];
const GEN45_RATIOS: &[&str] = &["1280:720", "720:1280"];
pub fn resolve_model(input: &str) -> String {
let key = input.trim().to_ascii_lowercase();
MODEL_ALIASES
.iter()
.find(|(alias, _)| *alias == key)
.map(|(_, id)| (*id).to_string())
.unwrap_or(key)
}
pub fn is_runway_model(model: &str) -> bool {
let id = resolve_model(model);
MODELS.contains(&id.as_str())
}
pub fn capabilities(model: &str) -> VideoCapabilities {
let id = resolve_model(model);
let turbo = id == "gen4_turbo" || id == "gen4";
VideoCapabilities {
provider: "runway",
tagline: "Gen-4. Paid, per second, and a real alternative to Veo — no Google account, and durations from 2 to 10 seconds rather than three fixed lengths.",
aspect: AspectSupport::Named(if turbo { TURBO_RATIOS } else { GEN45_RATIOS }),
duration: DurationSupport::Range { min: 2, max: 10 },
image_to_video: true,
text_to_video: !turbo,
negative_prompt: false,
resolution: false,
seed: true,
modes: &[],
provenance: Provenance::Unverified,
}
}
pub struct Client {
key: String,
http: reqwest::blocking::Client,
base: String,
}
impl Client {
pub fn from_env() -> Result<Self> {
let key = crate::config::var("RUNWAY_API_KEY").ok_or_else(|| {
let where_to_put_it = match crate::config::preferred_path() {
Some(path) => format!(
"Set RUNWAY_API_KEY, or add it to {} — `lucida config \
--set RUNWAY_API_KEY` reads it from stdin so it stays \
out of your shell history.",
path.display()
),
None => "Set RUNWAY_API_KEY.".to_string(),
};
anyhow!(
"no Runway API key found.\n\n{where_to_put_it}\n\n\
Keys come from https://dev.runwayml.com — this is a paid API and \
every render costs credits."
)
})?;
let http = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(180))
.connect_timeout(crate::retry::CONNECT_TIMEOUT)
.build()
.context("building HTTP client")?;
Ok(Self {
key,
http,
base: API_ROOT.to_string(),
})
}
#[cfg(test)]
pub(crate) fn recorded(base: &str) -> Self {
Self {
key: "test-key".into(),
base: base.to_string(),
http: reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.connect_timeout(crate::retry::CONNECT_TIMEOUT)
.no_proxy()
.build()
.unwrap(),
}
}
fn authed(&self, builder: reqwest::blocking::RequestBuilder) -> reqwest::blocking::RequestBuilder {
builder
.header("Authorization", format!("Bearer {}", self.key))
.header("X-Runway-Version", API_VERSION)
}
pub fn credits(&self) -> Result<f64> {
let response = crate::retry::send_idempotent("checking the balance", || {
self.authed(self.http.get(format!("{}/organization", self.base)))
})
.context("checking the Runway credit balance")?;
let status = response.status();
if !status.is_success() {
let text = response.text().unwrap_or_default();
bail!("{}", explain_error(status.as_u16(), &text));
}
let payload: Value = response.json().context("parsing the balance response")?;
payload["creditBalance"]
.as_f64()
.ok_or_else(|| anyhow!("no creditBalance in the response: {payload}"))
}
fn body(&self, req: &VideoRequest, model: &str) -> Result<(&'static str, Value)> {
let mut body = serde_json::Map::new();
body.insert("model".into(), json!(model));
let endpoint = match &req.image {
Some(path) => {
let bytes = std::fs::read(path)
.with_context(|| format!("reading source image {path}"))?;
let mime = crate::sniff_mime(&bytes).unwrap_or("image/png");
use base64::{Engine as _, engine::general_purpose::STANDARD};
body.insert(
"promptImage".into(),
json!(format!("data:{mime};base64,{}", STANDARD.encode(&bytes))),
);
if !req.prompt.trim().is_empty() {
body.insert("promptText".into(), json!(req.prompt));
}
"image_to_video"
}
None => {
body.insert("promptText".into(), json!(req.prompt));
"text_to_video"
}
};
let accepted = match capabilities(model).aspect {
AspectSupport::Named(ratios) => ratios,
AspectSupport::Free { .. } => GEN45_RATIOS,
};
body.insert("ratio".into(), json!(nearest_ratio(req.aspect, accepted)));
if let Some(seconds) = req.duration {
body.insert("duration".into(), json!(seconds));
}
if let Some(seed) = req.seed {
body.insert("seed".into(), json!(seed));
}
Ok((endpoint, Value::Object(body)))
}
}
fn nearest_ratio(requested: Option<Aspect>, accepted: &[&str]) -> String {
let fallback = accepted.first().copied().unwrap_or("1280:720").to_string();
let Some(aspect) = requested else {
return fallback;
};
let wanted = f64::from(aspect.w) / f64::from(aspect.h);
accepted
.iter()
.filter_map(|pair| {
let parsed = Aspect::parse(pair).ok()?;
let ratio = f64::from(parsed.w) / f64::from(parsed.h);
Some((pair, (ratio - wanted).abs()))
})
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(pair, _)| (*pair).to_string())
.unwrap_or(fallback)
}
impl VideoProvider for Client {
fn start(&self, req: &VideoRequest) -> Result<String> {
let model = resolve_model(&req.model);
let (endpoint, body) = self.body(req, &model)?;
let response = self
.authed(self.http.post(format!("{}/{endpoint}", self.base)))
.json(&body)
.send()
.context("starting the Runway render")?;
let status = response.status();
if !status.is_success() {
let text = response.text().unwrap_or_default();
bail!("{}", explain_error(status.as_u16(), &text));
}
let payload: Value = response.json().context("parsing the task response")?;
payload["id"]
.as_str()
.map(str::to_string)
.ok_or_else(|| anyhow!("Runway accepted the job but returned no task id: {payload}"))
}
fn poll(&self, operation: &str) -> Result<VideoStatus> {
let response = crate::retry::send_idempotent("polling the render", || {
self.authed(self.http.get(format!("{}/tasks/{operation}", self.base)))
})
.context("polling the Runway task")?;
let status = response.status();
if !status.is_success() {
let text = response.text().unwrap_or_default();
bail!("{}", explain_error(status.as_u16(), &text));
}
let payload: Value = response.json().context("parsing the task response")?;
match payload["status"].as_str().unwrap_or_default() {
"SUCCEEDED" => {
let url = payload["output"][0]
.as_str()
.ok_or_else(|| anyhow!("the render finished but carries no output: {payload}"))?;
Ok(VideoStatus::Done(self.download(url)?))
}
"FAILED" | "CANCELLED" => {
let reason = payload["failure"]
.as_str()
.or_else(|| payload["failureCode"].as_str())
.unwrap_or("no reason given");
bail!("the render failed: {reason}");
}
_ => Ok(VideoStatus::Pending),
}
}
}
impl Client {
fn download(&self, url: &str) -> Result<Vec<u8>> {
let response = crate::retry::send_idempotent("downloading the video", || {
self.http.get(url)
})
.with_context(|| {
format!(
"downloading the finished video. The render was billed; its URL \
expires, so fetch it by hand while it lasts:\n\n {url}"
)
})?;
if !response.status().is_success() {
bail!(
"the output URL returned HTTP {}. These URLs are signed and \
expire.\n\n {url}",
response.status().as_u16()
);
}
Ok(response.bytes().context("reading video bytes")?.to_vec())
}
}
fn explain_error(status: u16, body: &str) -> String {
let payload: Value = serde_json::from_str(body).unwrap_or(Value::Null);
let message = payload["error"].as_str().unwrap_or(body);
let mut out = match status {
401 | 403 => format!(
"HTTP {status} — Runway rejected the key. {message}\n\n\
Check RUNWAY_API_KEY, and that the key has not been rotated in \
the Developer Portal."
),
429 => format!(
"HTTP {status} — {message}\n\nRunway caps concurrent and daily \
generations per tier; this is a rate limit rather than a bad request."
),
_ => format!("HTTP {status} — {message}"),
};
if let Some(issues) = payload["issues"].as_array() {
for issue in issues {
let path = issue["path"]
.as_array()
.map(|p| {
p.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join(".")
})
.unwrap_or_default();
if path.is_empty() {
continue;
}
match issue["values"].as_array() {
Some(values) => {
let accepted: Vec<&str> = values.iter().filter_map(|v| v.as_str()).collect();
out.push_str(&format!("\n `{path}` accepts: {}", accepted.join(", ")));
}
None => {
if let Some(detail) = issue["message"].as_str() {
out.push_str(&format!("\n `{path}`: {detail}"));
}
}
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testserver::{Reply, serve};
fn wired(server: &crate::testserver::Server) -> Client {
Client::recorded(server.url())
}
#[test]
fn only_runways_own_models_are_claimed() {
for model in MODELS {
assert!(is_runway_model(model), "{model} is ours and must be claimed");
}
for aggregated in [
"kling3.0_pro",
"veo3.1",
"veo3.1_fast",
"seedance2",
"hailuo3",
"grok_imagine_1_5",
"gemini_omni_flash",
] {
assert!(
!is_runway_model(aggregated),
"`{aggregated}` is another vendor's model behind Runway's endpoint \
and must not be claimed here"
);
}
}
#[test]
fn a_simplified_ratio_becomes_the_pixel_pair_that_is_that_ratio() {
assert_eq!(nearest_ratio(Aspect::parse("16:9").ok(), TURBO_RATIOS), "1280:720");
assert_eq!(nearest_ratio(Aspect::parse("9:16").ok(), TURBO_RATIOS), "720:1280");
assert_eq!(nearest_ratio(Aspect::parse("1:1").ok(), TURBO_RATIOS), "960:960");
assert_eq!(nearest_ratio(Aspect::parse("1584:672").ok(), TURBO_RATIOS), "1584:672");
assert_eq!(nearest_ratio(None, TURBO_RATIOS), "1280:720");
let square = nearest_ratio(Aspect::parse("1:1").ok(), GEN45_RATIOS);
assert!(GEN45_RATIOS.contains(&square.as_str()), "{square}");
}
#[test]
fn only_gen45_renders_from_text_alone() {
assert!(!capabilities("gen4_turbo").text_to_video);
assert!(!capabilities("gen4").text_to_video);
assert!(capabilities("gen4.5").text_to_video);
for model in MODELS {
assert!(capabilities(model).image_to_video, "{model} must animate a still");
}
}
#[test]
fn every_request_carries_the_mandatory_version_header() {
let server = serve(vec![Reply::json(
r#"{"id":"4f1a2b3c-0000-4000-8000-000000000000"}"#,
)]);
let request = VideoRequest {
prompt: "a fox running".into(),
model: "gen4.5".into(),
aspect: Aspect::parse("16:9").ok(),
duration: Some(5),
..Default::default()
};
let id = wired(&server).start(&request).unwrap();
assert_eq!(id, "4f1a2b3c-0000-4000-8000-000000000000");
let requests = server.finish();
assert_eq!(requests[0].path, "/text_to_video");
assert_eq!(requests[0].header("x-runway-version"), Some(API_VERSION));
assert_eq!(requests[0].header("authorization"), Some("Bearer test-key"));
let body = requests[0].json();
assert_eq!(body["model"], "gen4.5");
assert_eq!(body["promptText"], "a fox running");
assert_eq!(body["ratio"], "1280:720");
assert_eq!(body["duration"], 5);
}
#[test]
fn animating_a_still_posts_to_image_to_video() {
let dir = std::env::temp_dir().join(format!("lucida-runway-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let source = dir.join("frame.png");
std::fs::write(&source, [0x89u8, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]).unwrap();
let server = serve(vec![Reply::json(
r#"{"id":"4f1a2b3c-0000-4000-8000-000000000001"}"#,
)]);
let request = VideoRequest {
prompt: "pan slowly right".into(),
model: "gen4_turbo".into(),
image: Some(source.to_string_lossy().into_owned()),
..Default::default()
};
wired(&server).start(&request).unwrap();
let requests = server.finish();
assert_eq!(requests[0].path, "/image_to_video");
let body = requests[0].json();
assert!(
body["promptImage"].as_str().unwrap().starts_with("data:image/png;base64,"),
"the still must travel as a data URI"
);
assert_eq!(body["promptText"], "pan slowly right");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_download_does_not_leak_the_key_to_object_storage() {
let server = serve(vec![
Reply::json(
r#"{"status":"SUCCEEDED","output":["{{server}}/signed/out.mp4"]}"#,
),
Reply::bytes("video/mp4", b"mp4-bytes"),
]);
let status = wired(&server).poll("4f1a2b3c-0000-4000-8000-000000000000").unwrap();
match status {
VideoStatus::Done(bytes) => assert_eq!(bytes, b"mp4-bytes"),
VideoStatus::Pending => panic!("expected the render to be done"),
}
let requests = server.finish();
assert_eq!(requests[0].path, "/tasks/4f1a2b3c-0000-4000-8000-000000000000");
assert_eq!(requests[1].path, "/signed/out.mp4");
assert_eq!(
requests[1].header("authorization"),
None,
"the key was sent to object storage"
);
}
#[test]
fn every_in_flight_status_reads_as_pending() {
for state in ["PENDING", "RUNNING", "THROTTLED", "SOMETHING_NEW"] {
let server = serve(vec![Reply::json(&format!(r#"{{"status":"{state}"}}"#))]);
let status = wired(&server).poll("4f1a2b3c-0000-4000-8000-000000000000").unwrap();
assert!(
matches!(status, VideoStatus::Pending),
"`{state}` was not treated as in flight"
);
server.finish();
}
}
#[test]
fn a_rejection_reports_the_values_that_would_have_worked() {
let body = r#"{"error":"Validation of body failed","issues":[
{"code":"invalid_value","values":["gen4_turbo","gen4","gen4.5"],"path":["model"]},
{"code":"too_big","message":"Too big: expected number to be <=10","path":["duration"]}
]}"#;
let explained = explain_error(400, body);
assert!(explained.contains("`model` accepts: gen4_turbo, gen4, gen4.5"), "{explained}");
assert!(explained.contains("`duration`"), "{explained}");
assert!(explained.contains("<=10"), "{explained}");
}
#[test]
fn a_rejected_key_says_so() {
let explained = explain_error(401, r#"{"error":"Unauthorized"}"#);
assert!(explained.contains("RUNWAY_API_KEY"), "{explained}");
}
}