gproxy_protocol/adapt/video/
state.rs1use super::*;
2use crate::{
3 capability::{CasResult, ResourceReference, StateStore, StateWrite, Version},
4 transform::TransformErrorKind,
5};
6use serde::{Deserialize, Serialize};
7use std::time::{Duration, SystemTime};
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct VideoBinding {
13 pub client_id: String,
14 pub origin: String,
15 pub model: String,
16 pub polling_url: String,
17 pub operation_prefix: String,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub enum VideoOriginalRequest {
22 OpenRouter(o::CreateVideoRequestBody),
23 Native(o::NativeCreateVideoRequestBody),
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct VideoJobState {
28 pub schema: u32,
29 pub binding: VideoBinding,
30 pub original: VideoOriginalRequest,
31 pub native_facts: Option<video::NativeVideoResponseFacts>,
32 pub effective: g::PredictLongRunningRequestBody,
33 pub operation: Option<g::VideoOperation>,
36 pub published_urls: BTreeMap<String, String>,
37}
38
39#[derive(Debug, Default)]
42pub struct VideoProgress {
43 pub(super) binding: Option<VideoBinding>,
44 pub reserved: bool,
45 pub send_started: bool,
46 pub binding_saved: bool,
47 pub raw_response: Option<crate::WireResponse<bytes::Bytes>>,
48 pub native_response: Option<crate::WireResponse<g::VideoOperation>>,
49 pub publication_ids: Vec<String>,
50 pub publications: Vec<ResourceReference>,
51}
52
53impl VideoBinding {
54 pub(super) fn create_path(&self) -> Result<String, TransformError> {
55 let model = self.model.strip_prefix("models/").unwrap_or(&self.model);
56 if model.is_empty()
57 || !model
58 .chars()
59 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '~'))
60 || matches!(model, "." | "..")
61 {
62 return Err(TransformError::shape(
63 "video.model",
64 "Developer API model ID or models/{id} required",
65 ));
66 }
67 Ok(format!(
68 "{}models/{model}:predictLongRunning",
69 self.operation_prefix
70 ))
71 }
72 pub(super) fn key(&self) -> String {
73 format!("video/job/{}:{}", self.client_id.len(), self.client_id)
74 }
75 pub(super) fn query_path(&self, name: &str) -> Result<String, TransformError> {
76 let parts: Vec<_> = name.split('/').collect();
77 if parts.len() < 2
78 || parts[parts.len() - 2] != "operations"
79 || parts
80 .iter()
81 .any(|s| s.is_empty() || matches!(*s, "." | ".."))
82 || name
83 .chars()
84 .any(|c| !(c.is_ascii_alphanumeric() || matches!(c, '/' | '_' | '-' | '.' | '~')))
85 {
86 return Err(TransformError::shape(
87 "video.operation.name",
88 "safe operations resource name required",
89 ));
90 }
91 Ok(format!("{}{name}", self.operation_prefix))
92 }
93}
94
95pub(super) fn public_url(value: &str) -> Result<(), TransformError> {
96 let uri: http::Uri = value
97 .parse()
98 .map_err(|e: http::uri::InvalidUri| TransformError::shape("video.url", e.to_string()))?;
99 if !matches!(uri.scheme_str(), Some("http" | "https"))
100 || uri.authority().is_none_or(|a| a.as_str().contains('@'))
101 {
102 return Err(TransformError::shape(
103 "video.url",
104 "absolute HTTP(S) URL without credentials required",
105 ));
106 }
107 Ok(())
108}
109
110pub const VIDEO_STATE_TTL: Duration = Duration::from_secs(7 * 24 * 60 * 60);
117
118pub(super) fn state_expiry(floor: Option<SystemTime>) -> SystemTime {
122 let ttl = SystemTime::now() + VIDEO_STATE_TTL;
123 floor.map_or(ttl, |floor| floor.max(ttl))
124}
125
126pub(super) async fn save<S: StateStore>(
127 store: &S,
128 scope: &S::Scope,
129 state: &VideoJobState,
130 expected: Option<Version>,
131 limits: VideoLimits,
132) -> Result<Version, TransformError> {
133 save_payload(
134 store,
135 scope,
136 &state.binding.key(),
137 state,
138 expected,
139 state_expiry(None),
140 limits,
141 )
142 .await
143}
144
145pub(super) async fn save_payload<S: StateStore, T: Serialize>(
146 store: &S,
147 scope: &S::Scope,
148 key: &str,
149 state: &T,
150 expected: Option<Version>,
151 expires_at: SystemTime,
152 limits: VideoLimits,
153) -> Result<Version, TransformError> {
154 let mut codec = limits.codec;
155 codec.max_body_bytes = codec.max_body_bytes.min(store.limits().write_bytes);
156 codec.max_buffer_bytes = codec.max_buffer_bytes.min(codec.max_body_bytes);
157 codec.max_value_bytes = codec.max_value_bytes.min(codec.max_body_bytes);
158 let payload = crate::codec::encode_json(state, codec).map_err(|e| {
159 TransformError::new(TransformErrorKind::Limit, "video.state", e.to_string())
160 })?;
161 match store
162 .compare_exchange(
163 scope,
164 key,
165 expected,
166 Some(StateWrite {
167 payload,
168 expires_at: Some(expires_at),
169 }),
170 )
171 .await?
172 {
173 CasResult::Applied(Some(version)) => Ok(version),
174 CasResult::Applied(None) => Err(TransformError::invalid_result(
175 "video.state",
176 "CAS replacement did not return a version",
177 )),
178 CasResult::Conflict => Err(TransformError::new(
179 TransformErrorKind::Conflict,
180 "video.state",
181 "job reservation or update conflicted; creation must not be retried",
182 )),
183 }
184}
185
186pub(super) async fn load<S: StateStore>(
187 store: &S,
188 scope: &S::Scope,
189 binding: &VideoBinding,
190 limits: VideoLimits,
191) -> Result<(VideoJobState, Version), TransformError> {
192 let entry = store
193 .get(scope, &binding.key())
194 .await?
195 .ok_or_else(|| TransformError::missing_metadata("video.job_state"))?;
196 if entry.payload.len() as u64 > store.limits().read_bytes {
197 return Err(super::resources::limit("video.state"));
198 }
199 let mut state: VideoJobState = crate::codec::decode_json(&entry.payload, limits.codec)
200 .map_err(|e| TransformError::invalid_result("video.state", e.to_string()))?;
201 if state.schema != 1 || &state.binding != binding {
202 return Err(TransformError::shape(
203 "video.state.binding",
204 "schema/client/origin/model/polling/API binding differs",
205 ));
206 }
207 state.original = match state.original {
208 VideoOriginalRequest::OpenRouter(v) => VideoOriginalRequest::OpenRouter(v.into_declared()),
209 VideoOriginalRequest::Native(v) => VideoOriginalRequest::Native(v.into_declared()),
210 };
211 state.effective = state.effective.into_declared();
212 state.operation = state.operation.map(DeclaredFields::into_declared);
213 Ok((state, entry.version))
214}