Skip to main content

gproxy_protocol/transform/video/
native.rs

1//! Native Sora JSON request mapping. Native sizes/durations are not the
2//! OpenRouter contract and do not pass through its request DTO.
3
4use super::ResolvedVideoResource;
5use crate::{
6    transform::{Converted, Report, TransformError},
7    wire::{DeclaredFields, gemini::video as g, openai::video as o},
8};
9use std::collections::BTreeMap;
10
11#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
12pub struct NativeVideoDefaults {
13    /// Effective source settings, supplied from the selected source model's
14    /// invocation contract when the client omitted these controls.
15    pub seconds: o::NativeVideoSeconds,
16    pub size: o::NativeVideoSize,
17}
18
19#[derive(Debug, Clone, PartialEq)]
20pub struct PreparedNativeVeoRequest {
21    pub body: g::PredictLongRunningRequestBody,
22    pub original: o::NativeCreateVideoRequestBody,
23    pub effective: NativeVideoDefaults,
24    pub target_model: String,
25}
26
27pub fn native_to_gemini_request(
28    input: o::NativeCreateVideoRequestBody,
29    target_model: &str,
30    defaults: Option<NativeVideoDefaults>,
31    resources: &BTreeMap<String, ResolvedVideoResource>,
32) -> Result<Converted<PreparedNativeVeoRequest>, TransformError> {
33    let input = input.into_declared();
34
35    let seconds = input
36        .seconds
37        .or(defaults.as_ref().map(|v| v.seconds))
38        .ok_or_else(|| TransformError::missing_metadata("native.effective_seconds"))?;
39    let size = input
40        .size
41        .or(defaults.as_ref().map(|v| v.size))
42        .ok_or_else(|| TransformError::missing_metadata("native.effective_size"))?;
43    let aspect = match size {
44        o::NativeVideoSize::Portrait720 => Some("9:16"),
45        o::NativeVideoSize::Landscape720 => Some("16:9"),
46        _ => None,
47    };
48    let seconds_value = match seconds {
49        o::NativeVideoSeconds::Four => 4,
50        o::NativeVideoSeconds::Eight => 8,
51        o::NativeVideoSeconds::Twelve => 12,
52    };
53    let image = input
54        .input_reference
55        .as_ref()
56        .map(|reference| {
57            let key = match reference {
58                o::NativeInputReference::File(v) => &v.file_id,
59                o::NativeInputReference::Url(v) => &v.image_url,
60            };
61            super::resources::image_resource(key, resources, super::VideoResourceRole::FirstFrame)
62        })
63        .map(crate::transform::optional)
64        .transpose()?
65        .flatten();
66    let mut instance = g::VideoGenerationInstance::builder()
67        .prompt(input.prompt.clone())
68        .build();
69    instance.image = image;
70    let mut parameters = g::VideoGenerationParameters::builder()
71        .sample_count(1)
72        .duration_seconds(seconds_value)
73        .build();
74    parameters.aspect_ratio = aspect.map(str::to_owned);
75    parameters.resolution = aspect.map(|_| "720p".to_owned());
76    let body = g::PredictLongRunningRequestBody::builder(vec![instance])
77        .parameters(parameters)
78        .build();
79    Ok(Converted {
80        value: PreparedNativeVeoRequest {
81            body,
82            original: input,
83            effective: NativeVideoDefaults { seconds, size },
84            target_model: target_model.into(),
85        },
86        report: Report::default(),
87    })
88}
89
90#[derive(Debug, Clone, PartialEq)]
91pub struct PreparedVeoNativeRequest {
92    pub body: o::NativeCreateVideoRequestBody,
93    pub original: g::PredictLongRunningRequestBody,
94    pub target_model: String,
95}
96
97/// One instance/sample; multi-instance or sample fanout belongs to the composed
98/// adapter. The return projection still belongs to the original Veo request.
99pub fn gemini_to_native_request(
100    input: g::PredictLongRunningRequestBody,
101    target_model: &str,
102    defaults: Option<NativeVideoDefaults>,
103    resources: &BTreeMap<String, ResolvedVideoResource>,
104) -> Result<Converted<PreparedVeoNativeRequest>, TransformError> {
105    let input = input.into_declared();
106    if target_model.trim().is_empty() {
107        return Err(TransformError::missing_metadata("target_model"));
108    }
109
110    let instance = input
111        .instances
112        .first()
113        .ok_or_else(|| TransformError::missing_metadata("video.instance"))?;
114
115    let prompt = instance
116        .prompt
117        .as_ref()
118        .filter(|v| !v.trim().is_empty())
119        .ok_or_else(|| TransformError::missing_metadata("native.prompt"))?
120        .clone();
121    let parameters = input.parameters.as_ref();
122
123    let seconds = match parameters.and_then(|p| p.duration_seconds) {
124        Some(4) => Some(o::NativeVideoSeconds::Four),
125        Some(8) => Some(o::NativeVideoSeconds::Eight),
126        Some(12) => Some(o::NativeVideoSeconds::Twelve),
127        Some(_) => None,
128        None => defaults.as_ref().map(|v| v.seconds),
129    };
130    let aspect = parameters.and_then(|p| p.aspect_ratio.as_deref());
131    let resolution = parameters.and_then(|p| p.resolution.as_deref());
132    let size = match (aspect, resolution) {
133        (Some("16:9"), Some("720p")) => Some(o::NativeVideoSize::Landscape720),
134        (Some("9:16"), Some("720p")) => Some(o::NativeVideoSize::Portrait720),
135        (None, None) => defaults.as_ref().map(|v| v.size),
136        _ => None,
137    };
138    let reference = instance
139        .image
140        .as_ref()
141        .map(|image| {
142            super::resources::published_url(image, resources).map(|url| {
143                o::NativeInputReference::Url(o::NativeUrlReference::builder(url).build())
144            })
145        })
146        .map(crate::transform::optional)
147        .transpose()?
148        .flatten();
149    let mut body = o::NativeCreateVideoRequestBody::builder(prompt)
150        .model(target_model)
151        .build();
152    body.seconds = seconds;
153    body.size = size;
154    body.input_reference = reference;
155    Ok(Converted {
156        value: PreparedVeoNativeRequest {
157            body,
158            original: input,
159            target_model: target_model.into(),
160        },
161        report: Report::default(),
162    })
163}