1use base64::Engine;
4use ferrin_provider_util::headers::is_same_origin;
5use ferrin_provider_util::http::ResponseHandlers;
6use ferrin_provider_util::http::get;
7use ferrin_provider_util::http::json_response_handler;
8use ferrin_provider_util::http::post_json;
9use ferrin_spec::FileData;
10use ferrin_spec::JsonObject;
11use ferrin_spec::JsonValue;
12use ferrin_spec::MediaType;
13use ferrin_spec::ModelId;
14use ferrin_spec::ProviderId;
15use ferrin_spec::ResponseMetadata;
16use ferrin_spec::error::InvalidResponseDataError;
17use ferrin_spec::error::ProviderError;
18use ferrin_spec::shared::Warning;
19use ferrin_spec::video_model::FrameType;
20use ferrin_spec::video_model::VideoAspectRatio;
21use ferrin_spec::video_model::VideoData;
22use ferrin_spec::video_model::VideoFile;
23use ferrin_spec::video_model::VideoModel;
24use ferrin_spec::video_model::VideoOptions;
25use ferrin_spec::video_model::VideoResult;
26use ferrin_spec::video_model::VideoStartOptions;
27use ferrin_spec::video_model::VideoStartResult;
28use ferrin_spec::video_model::VideoStatusOptions;
29use ferrin_spec::video_model::VideoStatusResult;
30use secrecy::ExposeSecret;
31use serde::Deserialize;
32use serde_json::json;
33use url::Url;
34
35use crate::api_types::RpcStatus;
36use crate::config::CANONICAL_OPTIONS_KEY;
37use crate::config::SharedConfig;
38use crate::error::failed_response_handler;
39use crate::output::OutputMapper;
40
41pub const MAX_VIDEOS_PER_CALL: usize = 4;
43
44const CONSUMED_OPTION_KEYS: [&str; 5] = [
47 "pollIntervalMs",
48 "pollTimeoutMs",
49 "personGeneration",
50 "negativePrompt",
51 "referenceImages",
52];
53
54#[derive(Debug, Clone, Default, Deserialize)]
56#[serde(rename_all = "camelCase")]
57pub struct Operation {
58 #[serde(default)]
60 pub name: Option<String>,
61 #[serde(default)]
63 pub done: Option<bool>,
64 #[serde(default)]
66 pub error: Option<RpcStatus>,
67 #[serde(default)]
69 pub response: Option<JsonValue>,
70}
71
72fn seconds_value(seconds: f64) -> JsonValue {
75 if seconds.is_finite() && seconds.fract() == 0.0 && seconds >= 0.0 {
76 #[allow(
79 clippy::cast_possible_truncation,
80 clippy::cast_sign_loss,
81 reason = "guarded by the integral and non-negative checks above"
82 )]
83 return JsonValue::from(seconds as u64);
84 }
85 json!(seconds)
86}
87
88fn image_object(file: &VideoFile, warnings: &mut Vec<Warning>) -> Option<JsonValue> {
89 let media_type = file
90 .media_type
91 .as_ref()
92 .map_or("image/png", MediaType::as_str)
93 .to_owned();
94 match &file.data {
95 FileData::Url { url } if url.scheme() == "gs" => {
96 Some(json!({"gcsUri": url.as_str(), "mimeType": "image/png"}))
97 }
98 FileData::Url { .. } | FileData::Reference { .. } => {
99 warnings.push(Warning::unsupported_with_details(
100 "URL-based image input",
101 "Google Generative AI video models require base64-encoded images or GCS URIs. URL will be ignored.",
102 ));
103 None
104 }
105 FileData::Bytes { data } => Some(json!({
106 "bytesBase64Encoded": base64::engine::general_purpose::STANDARD.encode(data),
107 "mimeType": media_type,
108 })),
109 FileData::Text { text } => Some(json!({
110 "bytesBase64Encoded": base64::engine::general_purpose::STANDARD.encode(text.as_bytes()),
111 "mimeType": media_type,
112 })),
113 #[allow(unreachable_patterns, reason = "FileData is non-exhaustive")]
114 _ => None,
115 }
116}
117
118fn reference_image(reference: &JsonValue) -> JsonValue {
119 if let Some(bytes) = reference
120 .get("bytesBase64Encoded")
121 .and_then(JsonValue::as_str)
122 .filter(|bytes| !bytes.is_empty())
123 {
124 return json!({"image": {"bytesBase64Encoded": bytes, "mimeType": "image/png"}, "referenceType": "asset"});
125 }
126 if let Some(uri) = reference
127 .get("gcsUri")
128 .and_then(JsonValue::as_str)
129 .filter(|uri| !uri.is_empty())
130 {
131 return json!({"image": {"gcsUri": uri, "mimeType": "image/png"}, "referenceType": "asset"});
132 }
133 reference.clone()
134}
135
136#[derive(Debug, Clone, PartialEq)]
138pub struct PreparedVideoRequest {
139 pub body: JsonValue,
141 pub warnings: Vec<Warning>,
143}
144
145#[derive(Debug, Clone)]
147pub struct GoogleVideoModel {
148 config: SharedConfig,
149 provider: ProviderId,
150 model_id: ModelId,
151}
152
153impl GoogleVideoModel {
154 #[must_use]
156 pub fn new(config: SharedConfig, model_id: impl Into<ModelId>) -> Self {
157 Self {
158 provider: ProviderId::new(config.name.clone()),
159 config,
160 model_id: model_id.into(),
161 }
162 }
163
164 #[must_use]
166 pub fn prepare_request(&self, options: &VideoOptions) -> PreparedVideoRequest {
167 let mut warnings = Vec::new();
168 let google: JsonObject = options
169 .provider_options
170 .get(self.config.options_key())
171 .or_else(|| options.provider_options.get(CANONICAL_OPTIONS_KEY))
172 .cloned()
173 .unwrap_or_default();
174 let mut instance = JsonObject::new();
175 if let Some(prompt) = &options.prompt {
176 instance.insert("prompt".to_owned(), JsonValue::from(prompt.as_str()));
177 }
178 let first_frame = options
179 .frame_images
180 .iter()
181 .find(|frame| frame.frame_type == FrameType::FirstFrame)
182 .map(|frame| &frame.image)
183 .or(options.image.as_ref());
184 if let Some(image) = first_frame.and_then(|file| image_object(file, &mut warnings)) {
185 instance.insert("image".to_owned(), image);
186 }
187 let last_frame = options
188 .frame_images
189 .iter()
190 .find(|frame| frame.frame_type == FrameType::LastFrame)
191 .map(|frame| &frame.image);
192 if let Some(image) = last_frame.and_then(|file| image_object(file, &mut warnings)) {
193 instance.insert("lastFrame".to_owned(), image);
194 }
195 if options.frame_images.is_empty() && !options.input_references.is_empty() {
196 let references: Vec<JsonValue> = options
197 .input_references
198 .iter()
199 .filter_map(|file| image_object(file, &mut warnings))
200 .map(|image| json!({"image": image, "referenceType": "asset"}))
201 .collect();
202 instance.insert("referenceImages".to_owned(), JsonValue::Array(references));
203 } else if let Some(JsonValue::Array(references)) = google.get("referenceImages") {
204 instance.insert(
205 "referenceImages".to_owned(),
206 JsonValue::Array(references.iter().map(reference_image).collect()),
207 );
208 }
209 let mut parameters = JsonObject::new();
210 parameters.insert("sampleCount".to_owned(), JsonValue::from(options.n));
211 match &options.aspect_ratio {
212 Some(VideoAspectRatio::Ratio(ratio)) => {
213 parameters.insert("aspectRatio".to_owned(), JsonValue::from(ratio.to_string()));
214 }
215 Some(VideoAspectRatio::Adaptive) => {
216 parameters.insert("aspectRatio".to_owned(), JsonValue::from("adaptive"));
217 }
218 #[allow(unreachable_patterns, reason = "VideoAspectRatio is non-exhaustive")]
219 Some(_) => {}
220 None => {}
221 }
222 if let Some(resolution) = &options.resolution {
223 let mapped = match (resolution.width, resolution.height) {
224 (1280, 720) => "720p".to_owned(),
225 (1920, 1080) => "1080p".to_owned(),
226 (3840, 2160) => "4k".to_owned(),
227 _ => resolution.to_string(),
228 };
229 parameters.insert("resolution".to_owned(), JsonValue::from(mapped));
230 }
231 if let Some(duration) = options.duration.filter(|duration| *duration != 0.0) {
232 parameters.insert("durationSeconds".to_owned(), seconds_value(duration));
233 }
234 if let Some(seed) = options.seed.filter(|seed| *seed != 0) {
235 parameters.insert("seed".to_owned(), JsonValue::from(seed));
236 }
237 if options.fps.is_some() {
238 warnings.push(Warning::unsupported("fps"));
239 }
240 if options.generate_audio.is_some() {
241 warnings.push(Warning::unsupported("generateAudio"));
242 }
243 for key in ["personGeneration", "negativePrompt"] {
244 if let Some(value) = google.get(key).filter(|value| !value.is_null()) {
245 parameters.insert(key.to_owned(), value.clone());
246 }
247 }
248 for (key, value) in &google {
249 if !CONSUMED_OPTION_KEYS.contains(&key.as_str()) {
250 parameters.insert(key.clone(), value.clone());
251 }
252 }
253 PreparedVideoRequest {
254 body: json!({"instances": [instance], "parameters": parameters}),
255 warnings,
256 }
257 }
258
259 fn response_metadata(&self, headers: ferrin_spec::Headers) -> ResponseMetadata {
260 ResponseMetadata {
261 id: None,
262 timestamp: Some(chrono::Utc::now()),
263 model_id: Some(self.model_id.clone()),
264 headers: Some(headers),
265 body: None,
266 }
267 }
268
269 fn completed(
270 &self,
271 operation: &Operation,
272 headers: ferrin_spec::Headers,
273 ) -> Result<VideoStatusResult, ProviderError> {
274 let samples = operation
275 .response
276 .as_ref()
277 .and_then(|response| response.get("generateVideoResponse"))
278 .and_then(|response| response.get("generatedSamples"))
279 .and_then(JsonValue::as_array)
280 .filter(|samples| !samples.is_empty())
281 .ok_or_else(|| {
282 ProviderError::InvalidResponseData(Box::new(InvalidResponseDataError::new(
283 "no videos in the video generation response",
284 operation.response.clone().unwrap_or(JsonValue::Null),
285 )))
286 })?;
287 let api_key = self
288 .config
289 .headers(&ferrin_spec::Headers::new())
290 .ok()
291 .and_then(|headers| {
292 headers
293 .get_str(crate::config::API_KEY_HEADER)
294 .map(|key| secrecy::SecretString::from(key.to_owned()))
295 });
296 let mut videos = Vec::new();
297 let mut metadata = Vec::new();
298 for sample in samples {
299 let Some(uri) = sample
300 .get("video")
301 .and_then(|video| video.get("uri"))
302 .and_then(JsonValue::as_str)
303 else {
304 continue;
305 };
306 let Ok(mut url) = Url::parse(uri) else {
307 continue;
308 };
309 if let Some(key) = &api_key
310 && is_same_origin(&url, &self.config.base_url)
311 {
312 url.query_pairs_mut()
313 .append_pair("key", key.expose_secret());
314 }
315 videos.push(VideoData {
316 data: FileData::Url { url },
317 media_type: MediaType::new("video/mp4"),
318 });
319 metadata.push(json!({"uri": uri}));
320 }
321 if videos.is_empty() {
322 return Err(ProviderError::InvalidResponseData(Box::new(
323 InvalidResponseDataError::new(
324 "no valid videos in the video generation response",
325 operation.response.clone().unwrap_or(JsonValue::Null),
326 ),
327 )));
328 }
329 let mapper = OutputMapper::new(self.config.clone(), Default::default());
330 let mut object = JsonObject::new();
331 object.insert("videos".to_owned(), JsonValue::Array(metadata));
332 Ok(VideoStatusResult::Completed {
333 videos,
334 warnings: Vec::new(),
335 provider_metadata: Some(mapper.metadata(object)),
336 response: self.response_metadata(headers),
337 })
338 }
339}
340
341impl VideoModel for GoogleVideoModel {
342 fn provider(&self) -> &ProviderId {
343 &self.provider
344 }
345
346 fn model_id(&self) -> &ModelId {
347 &self.model_id
348 }
349
350 fn max_videos_per_call(&self) -> Option<usize> {
351 Some(MAX_VIDEOS_PER_CALL)
352 }
353
354 async fn do_generate(&self, options: VideoOptions) -> Result<VideoResult, ProviderError> {
355 let _ = options;
356 Err(ProviderError::unsupported(
357 "synchronous video generation; use do_start and do_status",
358 ))
359 }
360
361 fn supports_operations(&self) -> bool {
362 true
363 }
364
365 #[tracing::instrument(skip_all, fields(model = %self.model_id))]
366 async fn do_start(
367 &self,
368 options: VideoStartOptions,
369 ) -> Result<VideoStartResult, ProviderError> {
370 let mut prepared = self.prepare_request(&options.options);
371 if options.webhook_url.is_some() {
372 prepared.warnings.push(Warning::unsupported("webhookUrl"));
373 }
374 let handlers = ResponseHandlers::new(
375 json_response_handler::<Operation>(),
376 failed_response_handler(),
377 );
378 let response = post_json(
379 self.config.transport.as_ref(),
380 self.config
381 .model_url(self.model_id.as_str(), "predictLongRunning"),
382 self.config.headers(&options.options.headers)?,
383 &prepared.body,
384 &handlers,
385 options.options.cancellation.clone(),
386 )
387 .await?;
388 let name = response.value.name.clone().ok_or_else(|| {
389 ProviderError::InvalidResponseData(Box::new(InvalidResponseDataError::new(
390 "no operation name returned from the video generation API",
391 response.raw.clone().unwrap_or(JsonValue::Null),
392 )))
393 })?;
394 Ok(VideoStartResult {
395 operation: json!({"operationName": name}),
396 warnings: prepared.warnings,
397 provider_metadata: None,
398 response: self.response_metadata(response.response_headers),
399 })
400 }
401
402 #[tracing::instrument(skip_all, fields(model = %self.model_id))]
403 async fn do_status(
404 &self,
405 options: VideoStatusOptions,
406 ) -> Result<VideoStatusResult, ProviderError> {
407 let name = options
408 .operation
409 .get("operationName")
410 .and_then(JsonValue::as_str)
411 .ok_or_else(|| {
412 ProviderError::InvalidArgument(ferrin_spec::error::InvalidArgumentError::new(
413 "operation",
414 "operation must contain an `operationName` string",
415 ))
416 })?;
417 let handlers = ResponseHandlers::new(
418 json_response_handler::<Operation>(),
419 failed_response_handler(),
420 );
421 let response = get(
422 self.config.transport.as_ref(),
423 self.config.url(name),
424 self.config.headers(&options.headers)?,
425 &handlers,
426 options.cancellation.clone(),
427 )
428 .await?;
429 let operation = response.value;
430 if operation.done != Some(true) {
431 return Ok(VideoStatusResult::Pending {
432 warnings: Vec::new(),
433 provider_metadata: None,
434 response: self.response_metadata(response.response_headers),
435 });
436 }
437 if let Some(error) = &operation.error {
438 return Ok(VideoStatusResult::Error {
439 error: format!(
440 "Video generation failed: {}",
441 error.message.as_deref().unwrap_or("unknown error")
442 ),
443 provider_metadata: None,
444 response: self.response_metadata(response.response_headers),
445 });
446 }
447 self.completed(&operation, response.response_headers)
448 }
449}