gproxy_protocol/transform/video/
native_response.rs1use 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#[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 pub failure_code: Option<String>,
29}
30
31#[derive(Debug, Clone, PartialEq)]
32pub struct NativeVeoResult {
33 pub body: o::NativeVideo,
34 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 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}