use serde::Deserialize;
use super::truncate;
use super::MediaError;
#[derive(Debug, Clone)]
pub struct VideoGenConfig {
pub endpoint: String,
pub model: String,
pub api_key: String,
}
#[derive(Debug, Clone)]
pub struct VideoGenParams {
pub prompt: String,
pub image: String,
pub last_image: String,
pub ratio: String,
pub resolution: String,
pub duration: u32,
}
impl Default for VideoGenParams {
fn default() -> Self {
Self {
prompt: String::new(),
image: String::new(),
last_image: String::new(),
ratio: "9:16".to_string(),
resolution: "720p".to_string(),
duration: 5,
}
}
}
#[derive(Debug, Clone)]
pub enum VideoTaskStatus {
Pending,
Succeeded(String),
Failed(String),
}
pub trait VideoProvider: Send + Sync {
fn submit(
&self,
params: &VideoGenParams,
) -> impl std::future::Future<Output = Result<String, MediaError>> + Send;
fn poll(
&self,
task_id: &str,
) -> impl std::future::Future<Output = Result<VideoTaskStatus, MediaError>> + Send;
}
pub struct ArkVideoProvider {
client: super::http::MediaClient,
config: VideoGenConfig,
}
impl ArkVideoProvider {
pub fn new(config: VideoGenConfig) -> Self {
Self::with_http(config, &super::MediaHttp::default())
}
pub fn with_http(config: VideoGenConfig, http: &super::MediaHttp) -> Self {
Self {
client: super::http::default_client(http),
config,
}
}
fn tasks_url(&self) -> String {
let e = self.config.endpoint.trim_end_matches('/');
if e.ends_with("/contents/generations/tasks") {
e.to_string()
} else {
format!("{e}/contents/generations/tasks")
}
}
}
#[derive(Deserialize)]
struct SubmitResponse {
#[serde(default)]
id: Option<String>,
}
#[derive(Deserialize)]
struct PollResponse {
#[serde(default)]
status: Option<String>,
#[serde(default)]
content: Option<PollContent>,
#[serde(default)]
error: Option<PollError>,
}
#[derive(Deserialize, Default)]
struct PollContent {
#[serde(default)]
video_url: Option<String>,
}
#[derive(Deserialize, Default)]
struct PollError {
#[serde(default)]
message: Option<String>,
}
impl VideoProvider for ArkVideoProvider {
async fn submit(&self, params: &VideoGenParams) -> Result<String, MediaError> {
let text = format!(
"{} --rs {} --rt {} --dur {}",
params.prompt.trim(),
params.resolution,
params.ratio,
params.duration
);
let mut content = vec![
serde_json::json!({ "type": "text", "text": text.trim() }),
serde_json::json!({
"type": "image_url",
"image_url": { "url": params.image },
"role": "first_frame",
}),
];
if !params.last_image.trim().is_empty() {
content.push(serde_json::json!({
"type": "image_url",
"image_url": { "url": params.last_image },
"role": "last_frame",
}));
}
let body = serde_json::json!({
"model": self.config.model,
"content": content,
"ratio": params.ratio,
"resolution": params.resolution,
"duration": params.duration,
});
let resp = self
.client
.get()?
.post(self.tasks_url())
.bearer_auth(&self.config.api_key)
.json(&body)
.send()
.await
.map_err(|e| {
MediaError::Failed(format!(
"提交图生视频任务失败: {}",
super::http::describe_reqwest_error(&e)
))
})?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(MediaError::Failed(format!(
"图生视频提交 HTTP {}: {}",
status.as_u16(),
truncate(&text, 300)
)));
}
let parsed: SubmitResponse = resp
.json()
.await
.map_err(|e| MediaError::Failed(format!("解析任务创建响应失败: {e}")))?;
parsed
.id
.filter(|s| !s.is_empty())
.ok_or_else(|| MediaError::Failed("任务创建响应未返回 task id".into()))
}
async fn poll(&self, task_id: &str) -> Result<VideoTaskStatus, MediaError> {
let url = format!("{}/{}", self.tasks_url(), task_id);
let resp = self
.client
.get()?
.get(&url)
.bearer_auth(&self.config.api_key)
.send()
.await
.map_err(|e| {
MediaError::Failed(format!(
"查询图生视频任务失败: {}",
super::http::describe_reqwest_error(&e)
))
})?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(MediaError::Failed(format!(
"图生视频查询 HTTP {}: {}",
status.as_u16(),
truncate(&text, 300)
)));
}
let parsed: PollResponse = resp
.json()
.await
.map_err(|e| MediaError::Failed(format!("解析任务状态响应失败: {e}")))?;
match parsed.status.as_deref() {
Some("succeeded") => {
let url = parsed
.content
.and_then(|c| c.video_url)
.filter(|s| !s.is_empty())
.ok_or_else(|| MediaError::Failed("任务成功但未返回视频 URL".into()))?;
Ok(VideoTaskStatus::Succeeded(url))
}
Some("failed") | Some("expired") => {
let msg = parsed
.error
.and_then(|e| e.message)
.unwrap_or_else(|| "任务失败".into());
Ok(VideoTaskStatus::Failed(msg))
}
_ => Ok(VideoTaskStatus::Pending),
}
}
}
pub struct SiliconFlowVideoProvider {
client: super::http::MediaClient,
config: VideoGenConfig,
}
impl SiliconFlowVideoProvider {
pub fn new(config: VideoGenConfig) -> Self {
Self::with_http(config, &super::MediaHttp::default())
}
pub fn with_http(config: VideoGenConfig, http: &super::MediaHttp) -> Self {
Self {
client: super::http::default_client(http),
config,
}
}
fn base(&self) -> String {
self.config.endpoint.trim_end_matches('/').to_string()
}
fn image_size(ratio: &str) -> &'static str {
match ratio {
"16:9" => "1280x720",
"1:1" => "960x960",
_ => "720x1280",
}
}
}
#[derive(Deserialize)]
struct SfSubmitResponse {
#[serde(rename = "requestId", default)]
request_id: Option<String>,
}
#[derive(Deserialize)]
struct SfStatusResponse {
#[serde(default)]
status: Option<String>,
#[serde(default)]
reason: Option<String>,
#[serde(default)]
results: Option<SfResults>,
}
#[derive(Deserialize, Default)]
struct SfResults {
#[serde(default)]
videos: Vec<SfVideo>,
}
#[derive(Deserialize, Default)]
struct SfVideo {
#[serde(default)]
url: Option<String>,
}
impl VideoProvider for SiliconFlowVideoProvider {
async fn submit(&self, params: &VideoGenParams) -> Result<String, MediaError> {
let body = serde_json::json!({
"model": self.config.model,
"prompt": params.prompt,
"image_size": Self::image_size(¶ms.ratio),
"image": params.image, });
let resp = self
.client
.get()?
.post(format!("{}/video/submit", self.base()))
.bearer_auth(&self.config.api_key)
.json(&body)
.send()
.await
.map_err(|e| {
MediaError::Failed(format!(
"提交图生视频任务失败: {}",
super::http::describe_reqwest_error(&e)
))
})?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(MediaError::Failed(format!(
"图生视频提交 HTTP {}: {}",
status.as_u16(),
truncate(&text, 300)
)));
}
let parsed: SfSubmitResponse = resp
.json()
.await
.map_err(|e| MediaError::Failed(format!("解析任务创建响应失败: {e}")))?;
parsed
.request_id
.filter(|s| !s.is_empty())
.ok_or_else(|| MediaError::Failed("任务创建响应未返回 requestId".into()))
}
async fn poll(&self, task_id: &str) -> Result<VideoTaskStatus, MediaError> {
let body = serde_json::json!({ "requestId": task_id });
let resp = self
.client
.get()?
.post(format!("{}/video/status", self.base()))
.bearer_auth(&self.config.api_key)
.json(&body)
.send()
.await
.map_err(|e| {
MediaError::Failed(format!(
"查询图生视频任务失败: {}",
super::http::describe_reqwest_error(&e)
))
})?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(MediaError::Failed(format!(
"图生视频查询 HTTP {}: {}",
status.as_u16(),
truncate(&text, 300)
)));
}
let parsed: SfStatusResponse = resp
.json()
.await
.map_err(|e| MediaError::Failed(format!("解析任务状态响应失败: {e}")))?;
match parsed.status.as_deref() {
Some("Succeed") => {
let url = parsed
.results
.and_then(|r| r.videos.into_iter().next())
.and_then(|v| v.url)
.filter(|s| !s.is_empty())
.ok_or_else(|| MediaError::Failed("任务成功但未返回视频 URL".into()))?;
Ok(VideoTaskStatus::Succeeded(url))
}
Some("Failed") => {
let msg = parsed.reason.unwrap_or_else(|| "任务失败".into());
Ok(VideoTaskStatus::Failed(msg))
}
_ => Ok(VideoTaskStatus::Pending),
}
}
}
pub struct MinimaxVideoProvider {
client: super::http::MediaClient,
config: VideoGenConfig,
}
impl MinimaxVideoProvider {
pub fn new(config: VideoGenConfig) -> Self {
Self::with_http(config, &super::MediaHttp::default())
}
pub fn with_http(config: VideoGenConfig, http: &super::MediaHttp) -> Self {
Self {
client: super::http::default_client(http),
config,
}
}
fn base(&self) -> String {
self.config.endpoint.trim_end_matches('/').to_string()
}
}
#[derive(Deserialize)]
struct MmSubmitResponse {
#[serde(default)]
task_id: Option<String>,
}
#[derive(Deserialize)]
struct MmQueryResponse {
#[serde(default)]
status: Option<String>,
#[serde(default)]
file_id: Option<String>,
}
#[derive(Deserialize)]
struct MmFileResponse {
#[serde(default)]
file: Option<MmFile>,
}
#[derive(Deserialize, Default)]
struct MmFile {
#[serde(default)]
download_url: Option<String>,
}
impl VideoProvider for MinimaxVideoProvider {
async fn submit(&self, params: &VideoGenParams) -> Result<String, MediaError> {
let mut body = serde_json::json!({
"model": self.config.model,
"prompt": params.prompt,
"first_frame_image": params.image,
});
if !params.last_image.trim().is_empty() {
body["last_frame_image"] = serde_json::Value::String(params.last_image.clone());
}
let resp = self
.client
.get()?
.post(format!("{}/video_generation", self.base()))
.bearer_auth(&self.config.api_key)
.json(&body)
.send()
.await
.map_err(|e| {
MediaError::Failed(format!(
"提交图生视频任务失败: {}",
super::http::describe_reqwest_error(&e)
))
})?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(MediaError::Failed(format!(
"图生视频提交 HTTP {}: {}",
status.as_u16(),
truncate(&text, 300)
)));
}
let parsed: MmSubmitResponse = resp
.json()
.await
.map_err(|e| MediaError::Failed(format!("解析任务创建响应失败: {e}")))?;
parsed
.task_id
.filter(|s| !s.is_empty())
.ok_or_else(|| MediaError::Failed("任务创建响应未返回 task_id".into()))
}
async fn poll(&self, task_id: &str) -> Result<VideoTaskStatus, MediaError> {
let url = format!("{}/query/video_generation?task_id={}", self.base(), task_id);
let resp = self
.client
.get()?
.get(&url)
.bearer_auth(&self.config.api_key)
.send()
.await
.map_err(|e| {
MediaError::Failed(format!(
"查询图生视频任务失败: {}",
super::http::describe_reqwest_error(&e)
))
})?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(MediaError::Failed(format!(
"图生视频查询 HTTP {}: {}",
status.as_u16(),
truncate(&text, 300)
)));
}
let parsed: MmQueryResponse = resp
.json()
.await
.map_err(|e| MediaError::Failed(format!("解析任务状态响应失败: {e}")))?;
match parsed.status.as_deref() {
Some("Success") => {
let file_id = parsed
.file_id
.filter(|s| !s.is_empty())
.ok_or_else(|| MediaError::Failed("任务成功但未返回 file_id".into()))?;
let furl = format!("{}/files/retrieve?file_id={}", self.base(), file_id);
let fresp = self
.client
.get()?
.get(&furl)
.bearer_auth(&self.config.api_key)
.send()
.await
.map_err(|e| {
MediaError::Failed(format!(
"取视频文件地址失败: {}",
super::http::describe_reqwest_error(&e)
))
})?;
if !fresp.status().is_success() {
let code = fresp.status().as_u16();
let text = fresp.text().await.unwrap_or_default();
return Err(MediaError::Failed(format!(
"取文件 HTTP {}: {}",
code,
truncate(&text, 300)
)));
}
let fparsed: MmFileResponse = fresp
.json()
.await
.map_err(|e| MediaError::Failed(format!("解析文件响应失败: {e}")))?;
let dl = fparsed
.file
.and_then(|f| f.download_url)
.filter(|s| !s.is_empty())
.ok_or_else(|| MediaError::Failed("文件响应未返回 download_url".into()))?;
Ok(VideoTaskStatus::Succeeded(dl))
}
Some("Fail") => Ok(VideoTaskStatus::Failed("任务失败".into())),
_ => Ok(VideoTaskStatus::Pending),
}
}
}
pub struct ViduVideoProvider {
client: super::http::MediaClient,
config: VideoGenConfig,
}
impl ViduVideoProvider {
pub fn new(config: VideoGenConfig) -> Self {
Self::with_http(config, &super::MediaHttp::default())
}
pub fn with_http(config: VideoGenConfig, http: &super::MediaHttp) -> Self {
Self {
client: super::http::default_client(http),
config,
}
}
fn base(&self) -> String {
self.config.endpoint.trim_end_matches('/').to_string()
}
}
#[derive(Deserialize, Default)]
struct DashOutput {
#[serde(default)]
task_id: Option<String>,
#[serde(default)]
task_status: Option<String>,
#[serde(default)]
video_url: Option<String>,
#[serde(default)]
message: Option<String>,
}
#[derive(Deserialize)]
struct DashResponse {
#[serde(default)]
output: Option<DashOutput>,
#[serde(default)]
message: Option<String>,
}
impl VideoProvider for ViduVideoProvider {
async fn submit(&self, params: &VideoGenParams) -> Result<String, MediaError> {
let body = serde_json::json!({
"model": self.config.model,
"input": {
"media": [ { "type": "image", "url": params.image } ],
"prompt": params.prompt,
},
"parameters": {
"duration": params.duration,
"resolution": params.resolution.to_uppercase(),
},
});
let resp = self
.client
.get()?
.post(format!(
"{}/services/aigc/video-generation/video-synthesis",
self.base()
))
.bearer_auth(&self.config.api_key)
.header("X-DashScope-Async", "enable")
.json(&body)
.send()
.await
.map_err(|e| {
MediaError::Failed(format!(
"提交图生视频任务失败: {}",
super::http::describe_reqwest_error(&e)
))
})?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(MediaError::Failed(format!(
"图生视频提交 HTTP {}: {}",
status.as_u16(),
truncate(&text, 300)
)));
}
let parsed: DashResponse = resp
.json()
.await
.map_err(|e| MediaError::Failed(format!("解析任务创建响应失败: {e}")))?;
parsed
.output
.and_then(|o| o.task_id)
.filter(|s| !s.is_empty())
.ok_or_else(|| {
MediaError::Failed(format!(
"任务创建响应未返回 task_id{}",
parsed.message.map(|m| format!(": {m}")).unwrap_or_default()
))
})
}
async fn poll(&self, task_id: &str) -> Result<VideoTaskStatus, MediaError> {
let resp = self
.client
.get()?
.get(format!("{}/tasks/{}", self.base(), task_id))
.bearer_auth(&self.config.api_key)
.send()
.await
.map_err(|e| {
MediaError::Failed(format!(
"查询图生视频任务失败: {}",
super::http::describe_reqwest_error(&e)
))
})?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(MediaError::Failed(format!(
"图生视频查询 HTTP {}: {}",
status.as_u16(),
truncate(&text, 300)
)));
}
let parsed: DashResponse = resp
.json()
.await
.map_err(|e| MediaError::Failed(format!("解析任务状态响应失败: {e}")))?;
let out = parsed.output.unwrap_or_default();
match out.task_status.as_deref() {
Some("SUCCEEDED") => out
.video_url
.filter(|s| !s.is_empty())
.map(VideoTaskStatus::Succeeded)
.ok_or_else(|| MediaError::Failed("任务成功但未返回 video_url".into())),
Some("FAILED") | Some("CANCELED") | Some("UNKNOWN") => Ok(VideoTaskStatus::Failed(
out.message.unwrap_or_else(|| "任务失败".into()),
)),
_ => Ok(VideoTaskStatus::Pending),
}
}
}
pub struct ZhipuVideoProvider {
client: super::http::MediaClient,
config: VideoGenConfig,
}
impl ZhipuVideoProvider {
pub fn new(config: VideoGenConfig) -> Self {
Self::with_http(config, &super::MediaHttp::default())
}
pub fn with_http(config: VideoGenConfig, http: &super::MediaHttp) -> Self {
Self {
client: super::http::default_client(http),
config,
}
}
fn base(&self) -> String {
self.config.endpoint.trim_end_matches('/').to_string()
}
}
#[derive(Deserialize)]
struct ZhipuSubmit {
#[serde(default)]
id: Option<String>,
}
#[derive(Deserialize)]
struct ZhipuVideoItem {
#[serde(default)]
url: Option<String>,
}
#[derive(Deserialize)]
struct ZhipuResult {
#[serde(default)]
task_status: Option<String>,
#[serde(default)]
video_result: Option<Vec<ZhipuVideoItem>>,
}
impl VideoProvider for ZhipuVideoProvider {
async fn submit(&self, params: &VideoGenParams) -> Result<String, MediaError> {
let image_url = if params.last_image.trim().is_empty() {
serde_json::Value::String(params.image.clone())
} else {
serde_json::json!([params.image, params.last_image])
};
let body = serde_json::json!({
"model": self.config.model,
"prompt": params.prompt,
"image_url": image_url,
});
let resp = self
.client
.get()?
.post(format!("{}/videos/generations", self.base()))
.bearer_auth(&self.config.api_key)
.json(&body)
.send()
.await
.map_err(|e| {
MediaError::Failed(format!(
"提交图生视频任务失败: {}",
super::http::describe_reqwest_error(&e)
))
})?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(MediaError::Failed(format!(
"图生视频提交 HTTP {}: {}",
status.as_u16(),
truncate(&text, 300)
)));
}
let parsed: ZhipuSubmit = resp
.json()
.await
.map_err(|e| MediaError::Failed(format!("解析任务创建响应失败: {e}")))?;
parsed
.id
.filter(|s| !s.is_empty())
.ok_or_else(|| MediaError::Failed("任务创建响应未返回 id".into()))
}
async fn poll(&self, task_id: &str) -> Result<VideoTaskStatus, MediaError> {
let resp = self
.client
.get()?
.get(format!("{}/async-result/{}", self.base(), task_id))
.bearer_auth(&self.config.api_key)
.send()
.await
.map_err(|e| {
MediaError::Failed(format!(
"查询图生视频任务失败: {}",
super::http::describe_reqwest_error(&e)
))
})?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(MediaError::Failed(format!(
"图生视频查询 HTTP {}: {}",
status.as_u16(),
truncate(&text, 300)
)));
}
let parsed: ZhipuResult = resp
.json()
.await
.map_err(|e| MediaError::Failed(format!("解析任务状态响应失败: {e}")))?;
match parsed.task_status.as_deref() {
Some("SUCCESS") => parsed
.video_result
.and_then(|v| v.into_iter().next())
.and_then(|item| item.url)
.filter(|s| !s.is_empty())
.map(VideoTaskStatus::Succeeded)
.ok_or_else(|| MediaError::Failed("任务成功但未返回 video_result url".into())),
Some("FAIL") => Ok(VideoTaskStatus::Failed("任务失败".into())),
_ => Ok(VideoTaskStatus::Pending),
}
}
}
pub struct NewApiVideoProvider {
client: super::http::MediaClient,
config: VideoGenConfig,
}
impl NewApiVideoProvider {
pub fn new(config: VideoGenConfig) -> Self {
Self::with_http(config, &super::MediaHttp::default())
}
pub fn with_http(config: VideoGenConfig, http: &super::MediaHttp) -> Self {
Self {
client: super::http::default_client(http),
config,
}
}
fn videos_url(&self) -> String {
let e = self.config.endpoint.trim_end_matches('/');
if e.ends_with("/video/generations") {
e.to_string()
} else {
format!("{e}/video/generations")
}
}
}
#[derive(Deserialize)]
struct NewApiSubmit {
#[serde(default)]
id: Option<String>,
#[serde(default)]
task_id: Option<String>,
}
#[derive(Deserialize, Default)]
struct NewApiMetadata {
#[serde(default)]
url: Option<String>,
}
#[derive(Deserialize)]
struct NewApiPoll {
#[serde(default)]
status: Option<String>,
#[serde(default)]
metadata: Option<NewApiMetadata>,
#[serde(default)]
video_url: Option<String>,
#[serde(default)]
url: Option<String>,
#[serde(default)]
error: Option<String>,
#[serde(default)]
message: Option<String>,
#[serde(default)]
fail_reason: Option<String>,
}
impl VideoProvider for NewApiVideoProvider {
async fn submit(&self, params: &VideoGenParams) -> Result<String, MediaError> {
let (width, height) = newapi_video_dimensions(¶ms.ratio, ¶ms.resolution);
let mut body = serde_json::json!({
"model": self.config.model,
"prompt": params.prompt,
"duration": params.duration,
"width": width,
"height": height,
});
if !params.image.trim().is_empty() {
body["image"] = serde_json::Value::String(shrink_first_frame(¶ms.image));
}
let resp = self
.client
.get()?
.post(self.videos_url())
.bearer_auth(&self.config.api_key)
.json(&body)
.send()
.await
.map_err(|e| {
MediaError::Failed(format!(
"提交图生视频任务失败: {}",
super::http::describe_reqwest_error(&e)
))
})?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(MediaError::Failed(explain_newapi_submit_error(
status.as_u16(),
&text,
)));
}
let parsed: NewApiSubmit = resp
.json()
.await
.map_err(|e| MediaError::Failed(format!("解析任务创建响应失败: {e}")))?;
parsed
.id
.or(parsed.task_id)
.filter(|s| !s.is_empty())
.ok_or_else(|| MediaError::Failed("任务创建响应未返回 id".into()))
}
async fn poll(&self, task_id: &str) -> Result<VideoTaskStatus, MediaError> {
let url = format!("{}/{}", self.videos_url(), task_id);
let resp = self
.client
.get()?
.get(&url)
.bearer_auth(&self.config.api_key)
.send()
.await
.map_err(|e| {
MediaError::Failed(format!(
"查询图生视频任务失败: {}",
super::http::describe_reqwest_error(&e)
))
})?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(MediaError::Failed(format!(
"图生视频查询 HTTP {}: {}",
status.as_u16(),
truncate(&text, 300)
)));
}
let parsed: NewApiPoll = resp
.json()
.await
.map_err(|e| MediaError::Failed(format!("解析任务状态响应失败: {e}")))?;
match parsed.status.as_deref() {
Some("completed") | Some("succeeded") | Some("success") => parsed
.metadata
.and_then(|m| m.url)
.or(parsed.video_url)
.or(parsed.url)
.filter(|s| !s.is_empty())
.map(VideoTaskStatus::Succeeded)
.ok_or_else(|| MediaError::Failed("任务成功但未返回视频 URL".into())),
Some("failed") | Some("error") => {
let msg = parsed
.error
.or(parsed.message)
.or(parsed.fail_reason)
.unwrap_or_else(|| "任务失败".into());
Ok(VideoTaskStatus::Failed(msg))
}
_ => Ok(VideoTaskStatus::Pending),
}
}
}
fn newapi_video_dimensions(ratio: &str, resolution: &str) -> (u32, u32) {
let short = if resolution.contains("1080") {
1080
} else if resolution.contains("480") {
480
} else {
720
};
let long = short * 16 / 9;
match ratio.trim() {
"16:9" => (long, short), "1:1" => (short, short), _ => (short, long), }
}
fn explain_newapi_submit_error(status: u16, body: &str) -> String {
let low = body.to_ascii_lowercase();
let is_fail_fetch =
low.contains("fail_to_fetch_task") || low.contains("upstream returned status");
let hint = match status {
400 if low.contains("insufficient") || low.contains("quota") || body.contains("余额") => {
"供应商余额不足,请充值后重试"
}
400 if is_fail_fetch => {
"该图被视频供应商拒绝(通常是内容安全审核误判:首帧含躺卧/露肤/暗光等易触发元素)。\
建议:① 换一张构图规整、光线明亮的分镜图重试;② 或对该镜头改用其他视频供应商(如火山方舟,会明确返回审核原因)"
}
400 => "提交被供应商拒绝(参数或内容不被接受)。建议换图或换供应商重试",
401 | 403 if low.contains("insufficient") || low.contains("quota") || body.contains("余额") => {
"供应商余额不足或无权调用该模型,请充值 / 确认该 Key 已开通此视频模型"
}
403 => "无权调用该视频模型(Key 未开通或越权),请在供应商后台确认权限",
413 => "请求体过大(首帧图过大)——按理已自动压缩,若仍出现请反馈",
429 => "供应商限流,请稍后重试",
_ => "",
};
if hint.is_empty() {
format!("图生视频提交 HTTP {status}: {}", truncate(body, 300))
} else {
format!(
"图生视频提交失败(HTTP {status}):{hint}。原始响应:{}",
truncate(body, 200)
)
}
}
fn shrink_first_frame(data_url: &str) -> String {
use base64::Engine;
const SKIP_BELOW: usize = 350 * 1024;
const TARGET_BYTES: usize = 400 * 1024;
let Some(comma) = data_url.find(',') else {
return data_url.to_string();
};
if !data_url[..comma].contains("base64") {
return data_url.to_string();
}
let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(data_url[comma + 1..].trim())
else {
return data_url.to_string();
};
if bytes.len() <= SKIP_BELOW {
return data_url.to_string();
}
let Ok(img) = img::load_from_memory(&bytes) else {
return data_url.to_string();
};
use img::ImageEncoder;
for max_edge in [1280u32, 1024, 768, 512] {
let scaled = img.thumbnail(max_edge, max_edge); let rgb = scaled.to_rgb8(); let (w, h) = (rgb.width(), rgb.height());
let mut out: Vec<u8> = Vec::new();
let enc = img::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 80);
if enc
.write_image(rgb.as_raw(), w, h, img::ExtendedColorType::Rgb8)
.is_err()
{
return data_url.to_string();
}
if out.len() <= TARGET_BYTES || max_edge == 512 {
let nb64 = base64::engine::general_purpose::STANDARD.encode(&out);
return format!("data:image/jpeg;base64,{nb64}");
}
}
data_url.to_string()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum VideoProtocol {
Ark,
Minimax,
Vidu,
Zhipu,
SiliconFlow,
NewApi,
}
impl VideoProtocol {
pub fn detect(endpoint: &str, extra: &str) -> Self {
let ep = endpoint.to_ascii_lowercase();
if ep.contains("minimaxi") {
Self::Minimax
} else if ep.contains("dashscope") {
Self::Vidu
} else if ep.contains("bigmodel") || ep.contains("zhipu") {
Self::Zhipu
} else if ep.contains("siliconflow") {
Self::SiliconFlow
} else if is_newapi(&ep, extra) {
Self::NewApi
} else {
Self::Ark
}
}
}
fn is_newapi(_endpoint: &str, extra: &str) -> bool {
serde_json::from_str::<serde_json::Value>(extra)
.ok()
.and_then(|v| {
v.get("video_api")
.and_then(|x| x.as_str())
.map(|s| s == "newapi")
})
.unwrap_or(false)
}
pub fn supports_last_frame(endpoint: &str, extra: &str) -> bool {
let ep = endpoint.to_ascii_lowercase();
if is_newapi(&ep, extra) || ep.contains("siliconflow") || ep.contains("dashscope") {
return false;
}
true
}
pub fn explain_error(raw: &str) -> String {
let low = raw.to_ascii_lowercase();
if low.contains("privacyinformation")
|| low.contains("sensitivecontent")
|| low.contains("inputimagesensitive")
|| low.contains("真人")
|| low.contains("人脸")
{
return format!(
"首帧图被视频供应商判为「真人/敏感内容」而拒绝(多为写实脸误判)。\
建议:① 把作品画风换成「韩漫·写实身材+动漫脸」或「赛璐璐·保底」再重出该镜首帧;\
② 或改用审核更宽松的视频供应商(如 Vidu)重试。原始错误:{}",
truncate(raw, 300)
);
}
if low.contains("sensitive")
|| low.contains("审核")
|| low.contains("违规")
|| low.contains("risk")
{
return format!(
"首帧或提示词被内容安全审核拦截。建议换构图规整、画风更风格化的首帧图,或改用其他视频供应商重试。原始错误:{}",
truncate(raw, 300)
);
}
truncate(raw, 300)
}
#[non_exhaustive]
pub enum AnyVideoProvider {
Ark(ArkVideoProvider),
Minimax(MinimaxVideoProvider),
Vidu(ViduVideoProvider),
Zhipu(ZhipuVideoProvider),
SiliconFlow(SiliconFlowVideoProvider),
NewApi(NewApiVideoProvider),
}
impl AnyVideoProvider {
pub fn from_config(config: VideoGenConfig, extra: &str) -> Self {
Self::from_config_with(config, extra, &super::MediaHttp::default())
}
pub fn from_config_with(config: VideoGenConfig, extra: &str, http: &super::MediaHttp) -> Self {
match VideoProtocol::detect(&config.endpoint, extra) {
VideoProtocol::Minimax => Self::Minimax(MinimaxVideoProvider::with_http(config, http)),
VideoProtocol::Vidu => Self::Vidu(ViduVideoProvider::with_http(config, http)),
VideoProtocol::Zhipu => Self::Zhipu(ZhipuVideoProvider::with_http(config, http)),
VideoProtocol::SiliconFlow => {
Self::SiliconFlow(SiliconFlowVideoProvider::with_http(config, http))
}
VideoProtocol::NewApi => Self::NewApi(NewApiVideoProvider::with_http(config, http)),
VideoProtocol::Ark => Self::Ark(ArkVideoProvider::with_http(config, http)),
}
}
pub fn protocol(&self) -> VideoProtocol {
match self {
Self::Ark(_) => VideoProtocol::Ark,
Self::Minimax(_) => VideoProtocol::Minimax,
Self::Vidu(_) => VideoProtocol::Vidu,
Self::Zhipu(_) => VideoProtocol::Zhipu,
Self::SiliconFlow(_) => VideoProtocol::SiliconFlow,
Self::NewApi(_) => VideoProtocol::NewApi,
}
}
}
impl VideoProvider for AnyVideoProvider {
async fn submit(&self, params: &VideoGenParams) -> Result<String, MediaError> {
match self {
Self::Ark(p) => p.submit(params).await,
Self::Minimax(p) => p.submit(params).await,
Self::Vidu(p) => p.submit(params).await,
Self::Zhipu(p) => p.submit(params).await,
Self::SiliconFlow(p) => p.submit(params).await,
Self::NewApi(p) => p.submit(params).await,
}
}
async fn poll(&self, task_id: &str) -> Result<VideoTaskStatus, MediaError> {
match self {
Self::Ark(p) => p.poll(task_id).await,
Self::Minimax(p) => p.poll(task_id).await,
Self::Vidu(p) => p.poll(task_id).await,
Self::Zhipu(p) => p.poll(task_id).await,
Self::SiliconFlow(p) => p.poll(task_id).await,
Self::NewApi(p) => p.poll(task_id).await,
}
}
}
#[cfg(test)]
mod dispatch_tests {
use super::*;
#[test]
fn newapi_requires_explicit_extra() {
assert_eq!(
VideoProtocol::detect("https://relay.example.com/v1", ""),
VideoProtocol::Ark
);
assert_eq!(
VideoProtocol::detect("https://relay.example.com/v1", r#"{"video_api":"newapi"}"#),
VideoProtocol::NewApi
);
}
#[test]
fn detect_by_endpoint() {
let cases = [
(
"https://ark.cn-beijing.volces.com/api/v3",
VideoProtocol::Ark,
),
("https://api.minimaxi.com/v1", VideoProtocol::Minimax),
("https://api.302.ai/minimaxi/v1", VideoProtocol::Minimax),
("https://dashscope.aliyuncs.com/api/v1", VideoProtocol::Vidu),
("https://open.bigmodel.cn/api/paas/v4", VideoProtocol::Zhipu),
("https://api.302.ai/zhipu/api/paas/v4", VideoProtocol::Zhipu),
("https://api.siliconflow.cn/v1", VideoProtocol::SiliconFlow),
("https://unknown.example/v1", VideoProtocol::Ark),
];
for (ep, want) in cases {
assert_eq!(VideoProtocol::detect(ep, ""), want, "{ep}");
}
assert_eq!(
VideoProtocol::detect("https://relay.example/v1", r#"{"video_api":"newapi"}"#),
VideoProtocol::NewApi,
"extra 显式标记"
);
}
#[test]
fn every_video_preset_detects_a_real_protocol() {
for p in crate::preset::presets_for(crate::Kind::Video) {
let Some(url) = p.base_url else { continue };
let got = VideoProtocol::detect(url, "");
let want = match p.key {
"seedance" => VideoProtocol::Ark,
"minimax_video" | "ai302_minimax_video" => VideoProtocol::Minimax,
"vidu_video" => VideoProtocol::Vidu,
"zhipu_video" | "ai302_zhipu_video" => VideoProtocol::Zhipu,
"siliconflow_video" => VideoProtocol::SiliconFlow,
other => panic!("新增视频预置 {other} 要在这里登记预期协议"),
};
assert_eq!(got, want, "{}", p.key);
}
}
#[test]
fn supports_last_frame_for_verified_providers() {
assert!(
supports_last_frame("https://ark.cn-beijing.volces.com/api/v3", ""),
"火山方舟"
);
assert!(
supports_last_frame("https://api.minimaxi.com/v1", ""),
"MiniMax 海螺"
);
assert!(
supports_last_frame("https://open.bigmodel.cn/api/paas/v4", ""),
"智谱 bigmodel"
);
assert!(
supports_last_frame("https://example.com/zhipu/v1", ""),
"智谱 zhipu 别名"
);
}
#[test]
fn rejects_unverified_providers() {
assert!(
!supports_last_frame("https://dashscope.aliyuncs.com/api/v1", ""),
"Vidu"
);
assert!(
!supports_last_frame("https://api.siliconflow.cn/v1", ""),
"硅基流动"
);
assert!(
!supports_last_frame("https://relay.example.com/v1", r#"{"video_api":"newapi"}"#),
"new-api 中转站"
);
assert!(
!supports_last_frame("https://any-relay.example/v1", r#"{"video_api":"newapi"}"#),
"extra 标记的 newapi 中转站"
);
}
#[test]
fn relay_check_precedes_vendor_substring() {
assert!(!supports_last_frame(
"https://relay.example/minimaxi/v1",
r#"{"video_api":"newapi"}"#
));
assert!(!supports_last_frame(
"https://relay.example/zhipu/v1",
r#"{"video_api":"newapi"}"#
));
}
#[test]
fn endpoint_matching_is_case_insensitive() {
assert!(supports_last_frame("https://API.MINIMAXI.COM/v1", ""));
assert!(!supports_last_frame(
"https://DASHSCOPE.aliyuncs.com/api/v1",
""
));
assert_eq!(
VideoProtocol::detect("https://API.MINIMAXI.COM/v1", ""),
VideoProtocol::Minimax
);
}
#[test]
fn explain_error_translates_moderation() {
assert!(explain_error("InputImageSensitiveContentDetected")
.starts_with("首帧图被视频供应商判为"));
assert!(explain_error("risk control").starts_with("首帧或提示词被内容安全审核拦截"));
assert_eq!(explain_error("timeout"), "timeout");
}
}