Skip to main content

gproxy_protocol/adapt/video/
state.rs

1use 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/// Stable host facts. `origin` must identify the selected target in the host's
10/// state scope. The host supplies its API prefix; it is never inferred from IDs.
11#[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    /// None means reserved with an uncertain creation outcome. It is never
34    /// permission to repeat the create request.
35    pub operation: Option<g::VideoOperation>,
36    pub published_urls: BTreeMap<String, String>,
37}
38
39/// Caller-owned progress survives dropped futures and post-create failures.
40/// The native response is retained before binding/publication work begins.
41#[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
110/// How long a video job's state outlives its latest write. Every write (the
111/// reservation, the create reply, each poll) pushes the expiry out again, so
112/// the window counts from the last time the client touched the job. Veo takes
113/// minutes to finish a job and keeps a generated video for two days; seven
114/// days covers a slow poller and a content download well after that, while no
115/// longer keeping every job the gateway has ever created.
116pub const VIDEO_STATE_TTL: Duration = Duration::from_secs(7 * 24 * 60 * 60);
117
118/// A state row expires [`VIDEO_STATE_TTL`] after this write, or at `floor`
119/// when that is later: a reverse job keeps its state until its own
120/// publications expire, since a load before then must still find it.
121pub(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}