Skip to main content

gproxy_protocol/transform/video/
native_response.rs

1use crate::{
2    transform::{Converted, Report, TransformError},
3    wire::{DeclaredFields, gemini::video as g, openai::video as o},
4};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
7pub enum NativePendingStatus {
8    Queued,
9    InProgress,
10}
11
12/// Facts recorded by the invocation or measured from its result. Veo does not
13/// provide native Sora's required timestamps, size, duration or percentage.
14#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
15pub struct NativeVideoResponseFacts {
16    pub operation_name: String,
17    pub client_id: String,
18    pub created_at: i64,
19    pub model: String,
20    pub seconds: o::NativeVideoSeconds,
21    pub size: o::NativeVideoSize,
22    pub progress: i64,
23    pub pending_status: NativePendingStatus,
24    pub completed_at: Option<i64>,
25    pub expires_at: Option<i64>,
26    pub prompt: Option<String>,
27    /// A caller-supplied real classification when a Google error has no code.
28    pub failure_code: Option<String>,
29}
30
31#[derive(Debug, Clone, PartialEq)]
32pub struct NativeVeoResult {
33    pub body: o::NativeVideo,
34    /// Must be retained for the native content endpoint before exposing `id`.
35    pub videos: Vec<g::Video>,
36}
37
38pub fn gemini_operation_to_native(
39    input: g::VideoOperation,
40    facts: &NativeVideoResponseFacts,
41) -> Result<Converted<NativeVeoResult>, TransformError> {
42    let input = input.into_declared();
43    super::response::operation_name(&facts.operation_name)?;
44    if input.name.as_deref() != Some(&facts.operation_name)
45        || facts.client_id.is_empty()
46        || facts.model.is_empty()
47    {
48        return Err(TransformError::shape(
49            "video.binding",
50            "actual operation and client identity/model required",
51        ));
52    }
53    if facts.created_at < 0
54        || !(0..=100).contains(&facts.progress)
55        || facts.completed_at.is_some_and(|v| v < facts.created_at)
56        || facts.expires_at.is_some_and(|v| v < facts.created_at)
57    {
58        return Err(TransformError::shape(
59            "video.facts",
60            "invalid time/progress facts",
61        ));
62    }
63    if input.error.is_some() && input.response.is_some()
64        || input.done != Some(true) && (input.error.is_some() || input.response.is_some())
65    {
66        return Err(TransformError::invalid_result(
67            "video.operation",
68            "inconsistent operation lifecycle",
69        ));
70    }
71    let mut report = Report::default();
72    let mut videos = Vec::new();
73    let mut error = None;
74    let status = if let Some(source) = input.error {
75        let code = source
76            .code
77            .map(|v| v.to_string())
78            .or_else(|| facts.failure_code.clone())
79            .filter(|v| !v.is_empty())
80            .ok_or_else(|| TransformError::missing_metadata("native.error.code"))?;
81        let message = source
82            .message
83            .filter(|v| !v.is_empty())
84            .ok_or_else(|| TransformError::missing_metadata("native.error.message"))?;
85        if source.details.is_some() {
86            report.omitted(
87                "error.details",
88                "native Sora error lacks Google RPC detail variants",
89            );
90        }
91        error = Some(Some(o::NativeVideoError::builder(code, message).build()));
92        o::NativeVideoStatus::Failed
93    } else if input.done == Some(true) {
94        let result = input
95            .response
96            .and_then(|v| v.generate_video_response)
97            .ok_or_else(|| {
98                TransformError::invalid_result(
99                    "video.response",
100                    "completed operation has no generateVideoResponse",
101                )
102            })?;
103        let filtered = result.rai_media_filtered_count.unwrap_or(0);
104        if filtered < 0 {
105            return Err(TransformError::invalid_result(
106                "raiMediaFilteredCount",
107                "negative count",
108            ));
109        }
110        for sample in result.generated_samples.unwrap_or_default() {
111            let video = sample.video.ok_or_else(|| {
112                TransformError::invalid_result("generatedSamples.video", "missing video")
113            })?;
114            match (&video.uri, &video.encoded_video) {
115                (Some(uri), None) if !uri.is_empty() => {}
116                (None, Some(bytes))
117                    if !bytes.is_empty()
118                        && video.encoding.as_ref().is_some_and(|m| !m.is_empty()) => {}
119                _ => {
120                    return Err(TransformError::invalid_result(
121                        "video.result",
122                        "one nonempty URI or bytes with MIME required",
123                    ));
124                }
125            };
126            videos.push(video);
127        }
128        if videos.len() > 1 {
129            return Err(TransformError::unsupported(
130                "generatedSamples",
131                "native Sora exposes one video per ID; sample fanout binding required",
132            ));
133        }
134        if videos.is_empty() {
135            if filtered == 0 {
136                return Err(TransformError::invalid_result(
137                    "generatedSamples",
138                    "completed operation produced no result",
139                ));
140            }
141            let message = result
142                .rai_media_filtered_reasons
143                .filter(|v| !v.is_empty())
144                .map(|v| v.join("; "))
145                .unwrap_or_else(|| format!("{filtered} video samples filtered"));
146            error = Some(Some(
147                o::NativeVideoError::builder("content_filter".into(), message).build(),
148            ));
149            o::NativeVideoStatus::Failed
150        } else {
151            if filtered > 0 || result.rai_media_filtered_reasons.is_some() {
152                report.omitted(
153                    "rai_filtering",
154                    "native Sora lacks partial filter count/reasons; generated video retained",
155                );
156            }
157            if facts.progress != 100 {
158                return Err(TransformError::shape(
159                    "video.progress",
160                    "completed result contradicts supplied progress",
161                ));
162            }
163            o::NativeVideoStatus::Completed
164        }
165    } else {
166        match facts.pending_status {
167            NativePendingStatus::Queued => o::NativeVideoStatus::Queued,
168            NativePendingStatus::InProgress => o::NativeVideoStatus::InProgress,
169        }
170    };
171    if matches!(
172        status,
173        o::NativeVideoStatus::Queued | o::NativeVideoStatus::InProgress
174    ) && facts.completed_at.is_some()
175    {
176        return Err(TransformError::shape(
177            "video.completed_at",
178            "pending operation cannot have a completion timestamp",
179        ));
180    }
181    if input.metadata.is_some() {
182        report.omitted(
183            "metadata",
184            "native Sora has no arbitrary operation metadata field",
185        );
186    }
187    let seconds = match facts.seconds {
188        o::NativeVideoSeconds::Four => "4",
189        o::NativeVideoSeconds::Eight => "8",
190        o::NativeVideoSeconds::Twelve => "12",
191    };
192    let mut body = o::NativeVideo::builder(
193        facts.client_id.clone(),
194        facts.created_at,
195        facts.model.clone(),
196        o::NativeVideoObject::Video,
197        facts.progress,
198        seconds.into(),
199        facts.size,
200        status,
201    )
202    .build();
203    body.error = error;
204    body.completed_at = facts.completed_at.map(Some);
205    body.expires_at = facts.expires_at.map(Some);
206    body.prompt = facts.prompt.clone().map(Some);
207    Ok(Converted {
208        value: NativeVeoResult { body, videos },
209        report,
210    })
211}
212
213#[derive(Debug, Clone, PartialEq)]
214pub struct NativeToVeoContext {
215    pub source_id: String,
216    pub operation_name: String,
217    /// Actual content obtained through the native download capability when the
218    /// source completed. A native ID is never reinterpreted as a media URL.
219    pub video: Option<g::Video>,
220}
221
222pub fn native_to_gemini_operation(
223    input: o::NativeVideo,
224    context: NativeToVeoContext,
225) -> Result<Converted<g::VideoOperation>, TransformError> {
226    let input = input.into_declared();
227    super::response::operation_name(&context.operation_name)?;
228    if input.id != context.source_id || input.id.is_empty() {
229        return Err(TransformError::shape(
230            "video.binding",
231            "native ID differs from recorded source",
232        ));
233    }
234    if input.created_at < 0
235        || !(0..=100).contains(&input.progress)
236        || !matches!(input.seconds.as_str(), "4" | "8" | "12")
237        || input.model.is_empty()
238    {
239        return Err(TransformError::invalid_result(
240            "native.video",
241            "invalid native metadata",
242        ));
243    }
244    let mut out = g::VideoOperation::builder()
245        .name(context.operation_name)
246        .build();
247    let mut report = Report::default();
248    match input.status {
249        o::NativeVideoStatus::Queued | o::NativeVideoStatus::InProgress => {
250            if input.error.flatten().is_some()
251                || input.completed_at.flatten().is_some()
252                || context.video.is_some()
253            {
254                return Err(TransformError::invalid_result(
255                    "native.status",
256                    "pending video carries terminal fields",
257                ));
258            }
259            out.done = Some(false);
260        }
261        o::NativeVideoStatus::Failed => {
262            if context.video.is_some() {
263                return Err(TransformError::invalid_result(
264                    "native.status",
265                    "failed video has successful content",
266                ));
267            }
268            let error = input
269                .error
270                .flatten()
271                .ok_or_else(|| TransformError::missing_metadata("native.error"))?;
272            if error.message.is_empty() || error.code.is_empty() {
273                return Err(TransformError::invalid_result(
274                    "native.error",
275                    "nonempty error code/message required",
276                ));
277            }
278            out.done = Some(true);
279            out.error = Some(
280                g::GoogleRpcStatus::builder()
281                    .message(format!("{}: {}", error.code, error.message))
282                    .build(),
283            );
284            if error.misalignment.is_some() {
285                report.omitted(
286                    "error.misalignment",
287                    "Veo RPC status has no declared native safety explanation shape",
288                );
289            }
290        }
291        o::NativeVideoStatus::Completed => {
292            if input.error.flatten().is_some() || input.progress != 100 {
293                return Err(TransformError::invalid_result(
294                    "native.status",
295                    "completed video has error or nonfinal progress",
296                ));
297            }
298            let video = context
299                .video
300                .map(DeclaredFields::into_declared)
301                .ok_or_else(|| TransformError::missing_metadata("native.downloaded_video"))?;
302            match (&video.uri, &video.encoded_video) {
303                (Some(uri), None) => {
304                    super::resources::url(uri)?;
305                }
306                (None, Some(encoded))
307                    if !encoded.is_empty()
308                        && video.encoding.as_ref().is_some_and(|v| !v.is_empty()) => {}
309                _ => {
310                    return Err(TransformError::shape(
311                        "native.downloaded_video",
312                        "exactly one usable URI or bytes with MIME required",
313                    ));
314                }
315            };
316            out.done = Some(true);
317            out.response = Some(
318                g::VideoOperationResponse::builder()
319                    .generate_video_response(
320                        g::GenerateVideoResponse::builder()
321                            .generated_samples(vec![
322                                g::GeneratedVideoSample::builder().video(video).build(),
323                            ])
324                            .build(),
325                    )
326                    .build(),
327            );
328        }
329    }
330    report.omitted(
331        "native.video_metadata",
332        "Veo operation has no native created_at/progress/duration/size/model fields",
333    );
334    if input.expires_at.is_some() {
335        report.omitted("expires_at", "Veo operation has no expiry field");
336    }
337    if input.prompt.is_some() {
338        report.omitted("prompt", "Veo operation does not echo the prompt");
339    }
340    if input.remixed_from_video_id.is_some() {
341        report.omitted(
342            "remixed_from_video_id",
343            "Veo operation has no remix ancestry field",
344        );
345    }
346    Ok(Converted { value: out, report })
347}