1use super::{
2 dimensions::dimensions,
3 resources::{frame_from_resource, frame_role, image_resource, published_url, video_url},
4};
5use crate::wire::DeclaredFields;
6use std::collections::BTreeMap;
7
8use crate::{
9 Rest,
10 transform::{Converted, Report, TransformError},
11 wire::{gemini::video as g, openai::video as o},
12};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum VideoResourceRole {
16 FirstFrame,
17 LastFrame,
18 ReferenceImage,
19 ReferenceVideo,
20 ReferenceAudio,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct VideoResourceNeed {
25 pub reference: String,
26 pub role: VideoResourceRole,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Default)]
33pub struct ResolvedVideoResource {
34 pub reference: String,
35 pub url: Option<String>,
36 pub bytes_base64_encoded: Option<String>,
37 pub mime_type: Option<String>,
38}
39
40#[derive(Debug, Clone, PartialEq)]
41pub struct VeoRequestContext {
42 pub source_model: Option<String>,
43 pub target_model: String,
44 pub source_request: Option<o::CreateVideoRequestBody>,
45 pub source_veo_request: Option<g::PredictLongRunningRequestBody>,
46}
47
48#[derive(Debug, Clone, PartialEq)]
49pub struct PreparedVeoRequest {
50 pub body: g::PredictLongRunningRequestBody,
51 pub context: VeoRequestContext,
52}
53
54pub fn resource_needs(input: &o::CreateVideoRequestBody) -> Vec<VideoResourceNeed> {
55 let mut needs = Vec::new();
56 if let Some(frames) = &input.frame_images {
57 for frame in frames {
58 needs.push(VideoResourceNeed {
59 reference: frame.image_url.url.clone(),
60 role: match frame.frame_type {
61 o::FrameType::FirstFrame => VideoResourceRole::FirstFrame,
62 o::FrameType::LastFrame => VideoResourceRole::LastFrame,
63 },
64 });
65 }
66 }
67 if let Some(references) = &input.input_references {
68 for reference in references {
69 match reference {
70 o::InputReference::Image(value) => needs.push(VideoResourceNeed {
71 reference: value.image_url.url.clone(),
72 role: VideoResourceRole::ReferenceImage,
73 }),
74 o::InputReference::Video(value) => needs.push(VideoResourceNeed {
75 reference: value.video_url.url.clone(),
76 role: VideoResourceRole::ReferenceVideo,
77 }),
78 o::InputReference::Audio(value) => needs.push(VideoResourceNeed {
79 reference: value.audio_url.url.clone(),
80 role: VideoResourceRole::ReferenceAudio,
81 }),
82 }
83 }
84 }
85 needs
86}
87
88pub fn openai_to_gemini_request(
89 input: o::CreateVideoRequestBody,
90 target_model: &str,
91 resources: &BTreeMap<String, ResolvedVideoResource>,
92) -> Result<Converted<PreparedVeoRequest>, TransformError> {
93 let input = input.into_declared();
94 let target_model = non_empty(target_model, "target_model")?;
95 let source_model = Some(non_empty(&input.model, "source_model")?);
96 reject_openai_only(&input)?;
97 let (aspect_ratio, resolution) = dimensions(&input)?;
98
99 let mut instance = g::VideoGenerationInstance {
100 prompt: input.prompt.clone(),
101 image: None,
102 video: None,
103 last_frame: None,
104 reference_images: None,
105 rest: Rest::new(),
106 };
107 if let Some(frames) = &input.frame_images {
108 for frame in frames {
109 let resource = image_resource(&frame.image_url.url, resources, frame_role(frame))?;
110 match frame.frame_type {
111 o::FrameType::FirstFrame => {
112 if instance.image.is_some() {
113 return Err(TransformError::new(
114 crate::transform::TransformErrorKind::Conflict,
115 "frame_images",
116 "multiple first frames are ambiguous",
117 ));
118 }
119 instance.image = Some(resource);
120 }
121 o::FrameType::LastFrame => {
122 if instance.last_frame.is_some() {
123 return Err(TransformError::new(
124 crate::transform::TransformErrorKind::Conflict,
125 "frame_images",
126 "multiple last frames are ambiguous",
127 ));
128 }
129 instance.last_frame = Some(resource);
130 }
131 }
132 }
133 }
134 if let Some(references) = &input.input_references {
135 let mut output = Vec::with_capacity(references.len());
136 for reference in references {
137 match reference {
138 o::InputReference::Image(value) => output.push(g::VideoReferenceImage {
139 image: Some(image_resource(
140 &value.image_url.url,
141 resources,
142 VideoResourceRole::ReferenceImage,
143 )?),
144 reference_type: Some(g::VideoReferenceType::Asset),
145 rest: Rest::new(),
146 }),
147 o::InputReference::Video(value) => {
148 let resource =
149 super::resources::bound(&value.video_url.url, resources, "video/")?;
150 let encoded = resource.bytes_base64_encoded.clone().ok_or_else(|| {
151 TransformError::missing_metadata(format!(
152 "video resource {} bytes",
153 value.video_url.url
154 ))
155 })?;
156 let mime = resource.mime_type.clone().ok_or_else(|| {
157 TransformError::missing_metadata(format!(
158 "video resource {} mime",
159 value.video_url.url
160 ))
161 })?;
162 instance.video = Some(g::Video {
163 uri: None,
164 encoded_video: Some(encoded),
165 encoding: Some(mime),
166 rest: Rest::new(),
167 });
168 }
169 o::InputReference::Audio(_) => {
170 return Err(TransformError::unsupported(
171 "input_references.audio_url",
172 "Developer API Veo has no audio reference input",
173 ));
174 }
175 }
176 if let o::InputReference::Image(_) = reference {
177 continue;
180 }
181 }
182 if !output.is_empty() {
183 instance.reference_images = Some(output);
184 }
185 }
186
187 let mut parameters = g::VideoGenerationParameters {
188 sample_count: None,
189 duration_seconds: input
190 .duration
191 .map(|value| {
192 i32::try_from(value)
193 .map_err(|_| TransformError::shape("duration", "overflows Gemini"))
194 })
195 .map(crate::transform::optional)
196 .transpose()?
197 .flatten(),
198 aspect_ratio,
199 resolution,
200 person_generation: None,
201 negative_prompt: None,
202 enhance_prompt: None,
203 rest: Rest::new(),
204 };
205 super::options::to_gemini(input.provider.as_ref(), &mut parameters)?;
206
207 let body = g::PredictLongRunningRequestBody {
208 instances: vec![instance],
209 parameters: Some(parameters),
210 webhook_config: None,
211 rest: Rest::new(),
212 };
213 Ok(Converted {
214 value: PreparedVeoRequest {
215 body,
216 context: VeoRequestContext {
217 source_model,
218 target_model,
219 source_request: Some(input),
220 source_veo_request: None,
221 },
222 },
223 report: Report::default(),
224 })
225}
226
227pub fn gemini_to_openai_request(
228 input: g::PredictLongRunningRequestBody,
229 target_model: &str,
230 resources: &BTreeMap<String, ResolvedVideoResource>,
231) -> Result<Converted<PreparedOpenAiRequest>, TransformError> {
232 let input = input.into_declared();
233 let target_model = non_empty(target_model, "target_model")?;
234 let source_veo_request = input.clone();
235
236 let mut report = Report::default();
237 if input.instances.len() > 1 {
238 report.omitted(
239 "instances",
240 "OpenAI creates one video per request; only the first instance is mapped",
241 );
242 }
243 let instance = input
244 .instances
245 .into_iter()
246 .next()
247 .ok_or_else(|| TransformError::shape("instances", "one video instance is required"))?;
248 let mut frame_images = Vec::new();
249 if let Some(image) = instance.image {
250 frame_images.push(frame_from_resource(
251 &image,
252 o::FrameType::FirstFrame,
253 resources,
254 )?);
255 }
256 if let Some(image) = instance.last_frame {
257 frame_images.push(frame_from_resource(
258 &image,
259 o::FrameType::LastFrame,
260 resources,
261 )?);
262 }
263 let mut input_references = Vec::new();
264 if let Some(video) = instance.video {
265 input_references.push(o::InputReference::Video(o::InputVideoReference {
266 video_url: o::ReferenceUrl {
267 url: video_url(&video, resources)?,
268 rest: Rest::new(),
269 },
270 rest: Rest::new(),
271 }));
272 }
273 for reference in instance.reference_images.unwrap_or_default() {
274 let image = reference
275 .image
276 .ok_or_else(|| TransformError::missing_metadata("referenceImages[].image"))?;
277 let url = published_url(&image, resources)?;
278 input_references.push(o::InputReference::Image(o::InputImageReference {
279 image_url: o::ReferenceUrl {
280 url,
281 rest: Rest::new(),
282 },
283 rest: Rest::new(),
284 }));
285 }
286 let parameters = input.parameters.unwrap_or(g::VideoGenerationParameters {
287 sample_count: None,
288 duration_seconds: None,
289 aspect_ratio: None,
290 resolution: None,
291 person_generation: None,
292 negative_prompt: None,
293 enhance_prompt: None,
294 rest: Rest::new(),
295 });
296
297 let provider = super::options::to_openrouter(¶meters);
298
299 let body = o::CreateVideoRequestBody {
300 model: target_model.clone(),
301 prompt: instance.prompt,
302 duration: parameters.duration_seconds.map(i64::from),
303 aspect_ratio: parse_enum(parameters.aspect_ratio.as_deref(), "aspect_ratio")?,
304 resolution: parse_enum(parameters.resolution.as_deref(), "resolution")?,
305 size: None,
306 seed: None,
307 frame_images: if frame_images.is_empty() {
308 None
309 } else {
310 Some(frame_images)
311 },
312 input_references: if input_references.is_empty() {
313 None
314 } else {
315 Some(input_references)
316 },
317 generate_audio: None,
318 callback_url: None,
319 provider,
320 rest: Rest::new(),
321 };
322 Ok(Converted {
323 value: PreparedOpenAiRequest {
324 body,
325 context: VeoRequestContext {
326 source_model: None,
327 target_model,
328 source_request: None,
329 source_veo_request: Some(source_veo_request),
330 },
331 },
332 report,
333 })
334}
335
336#[derive(Debug, Clone, PartialEq)]
337pub struct PreparedOpenAiRequest {
338 pub body: o::CreateVideoRequestBody,
339 pub context: VeoRequestContext,
340}
341
342fn reject_openai_only(_input: &o::CreateVideoRequestBody) -> Result<(), TransformError> {
343 Ok(())
344}
345
346fn non_empty(value: &str, field: &str) -> Result<String, TransformError> {
347 if value.trim().is_empty() {
348 Err(TransformError::missing_metadata(field))
349 } else {
350 Ok(value.to_owned())
351 }
352}
353
354pub(super) fn enum_string<T: serde::Serialize>(value: T) -> String {
355 serde_json::to_value(value)
356 .expect("wire enum serializes")
357 .as_str()
358 .expect("wire enum is a string")
359 .to_owned()
360}
361
362fn parse_enum<T: serde::de::DeserializeOwned>(
363 value: Option<&str>,
364 field: &str,
365) -> Result<Option<T>, TransformError> {
366 value
367 .map(|value| {
368 serde_json::from_value(Value::String(value.to_owned())).map_err(|_| {
369 TransformError::unsupported(field, "value has no OpenRouter equivalent")
370 })
371 })
372 .transpose()
373}
374
375use serde_json::Value;