Skip to main content

gproxy_protocol/transform/video/
request.rs

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/// Facts supplied by a host after URL resolution/download or publication.
30/// `bytes_base64_encoded`/`mime_type` are needed by Gemini; `url` is needed by
31/// the OpenRouter-shaped request.  No URL or bytes are inferred by a mapper.
32#[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                // Image references are appended above.  Video references use
178                // the dedicated instance.video field and cannot be referenceImages.
179                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(&parameters);
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;