Backend: AI generation endpoints (Gemini nano-banana + Veo)
Server-side proxy so the Google API key (from Infisical) never reaches the client. Behind an AiClient trait with a fake for headless tests. - ai.rs: GeminiClient — refine prompt (gemini-2.5-flash), generate image (gemini-2.5-flash-image / "nano-banana"), and async video (veo-2.0: predictLongRunning → poll → proxy the redirecting download). DisabledAiClient when no key is configured. - POST /v1/ai/refine, /v1/ai/image (rate-limited 60/h, 20/h) - POST /v1/ai/video (submit, 5/h) + /v1/ai/video/status (poll → base64 mp4) - config: GEMINI_API_KEY via SecretSource - 5 endpoint/parse tests; full workspace gate green (114 tests) Live-verified: refine + nano-banana return through the proxy; Veo submits, polls to done in ~60s, and streams back a valid mp4. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
eddca62f23
commit
4be1cf5fa3
@@ -0,0 +1,302 @@
|
|||||||
|
//! AI image generation via Google Gemini (PRD §21 Phase 5 "Card AI Generator").
|
||||||
|
//!
|
||||||
|
//! Two steps: refine the user's rough fields into a single vivid prompt
|
||||||
|
//! (gemini-2.5-flash), then generate the image (gemini-2.5-flash-image, aka
|
||||||
|
//! "nano-banana"). The API key lives only here on the server — loaded from
|
||||||
|
//! Infisical via config — and never reaches the client.
|
||||||
|
//!
|
||||||
|
//! Behind an [`AiClient`] trait so handlers/tests don't depend on the network.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
const GEMINI_BASE: &str = "https://generativelanguage.googleapis.com/v1beta";
|
||||||
|
const TEXT_MODEL: &str = "gemini-2.5-flash";
|
||||||
|
const IMAGE_MODEL: &str = "gemini-2.5-flash-image"; // "nano-banana"
|
||||||
|
const VIDEO_MODEL: &str = "veo-2.0-generate-001"; // Google Veo (async)
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AiError {
|
||||||
|
#[error("AI is not configured")]
|
||||||
|
NotConfigured,
|
||||||
|
#[error("gemini request failed: {0}")]
|
||||||
|
Request(String),
|
||||||
|
#[error("gemini returned no {0}")]
|
||||||
|
Empty(&'static str),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rough fields collected by the "Describe your scene" wizard.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct SceneBrief {
|
||||||
|
pub scene: String,
|
||||||
|
pub style: Option<String>,
|
||||||
|
pub mood: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A generated image as base64 plus its MIME type.
|
||||||
|
pub struct GeneratedImage {
|
||||||
|
pub mime_type: String,
|
||||||
|
pub base64: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State of an async Veo video job.
|
||||||
|
pub enum VideoStatus {
|
||||||
|
Pending,
|
||||||
|
Done(Vec<u8>),
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AiClient: Send + Sync {
|
||||||
|
/// Refine the brief into a single image-generation prompt.
|
||||||
|
async fn refine_prompt(&self, brief: &SceneBrief) -> Result<String, AiError>;
|
||||||
|
/// Generate an image from a prompt (nano-banana).
|
||||||
|
async fn generate_image(&self, prompt: &str) -> Result<GeneratedImage, AiError>;
|
||||||
|
/// Submit a Veo video job; returns the long-running operation name.
|
||||||
|
async fn start_video(&self, prompt: &str) -> Result<String, AiError>;
|
||||||
|
/// Poll a Veo operation; downloads + returns the MP4 bytes when done.
|
||||||
|
async fn poll_video(&self, operation: &str) -> Result<VideoStatus, AiError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Gemini (production) --------------------------------------------------
|
||||||
|
|
||||||
|
pub struct GeminiClient {
|
||||||
|
api_key: String,
|
||||||
|
http: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GeminiClient {
|
||||||
|
/// Returns `None` when no key is configured (AI stays disabled).
|
||||||
|
pub fn new(api_key: String) -> Option<Self> {
|
||||||
|
if api_key.trim().is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Self {
|
||||||
|
api_key,
|
||||||
|
http: reqwest::Client::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn generate_content(
|
||||||
|
&self,
|
||||||
|
model: &str,
|
||||||
|
text: &str,
|
||||||
|
) -> Result<serde_json::Value, AiError> {
|
||||||
|
let url = format!("{GEMINI_BASE}/models/{model}:generateContent");
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"contents": [{ "parts": [{ "text": text }] }]
|
||||||
|
});
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.post(&url)
|
||||||
|
.query(&[("key", &self.api_key)])
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AiError::Request(e.to_string()))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(AiError::Request(format!("status {}", resp.status())));
|
||||||
|
}
|
||||||
|
resp.json::<serde_json::Value>()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AiError::Request(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AiClient for GeminiClient {
|
||||||
|
async fn refine_prompt(&self, brief: &SceneBrief) -> Result<String, AiError> {
|
||||||
|
let instruction = format!(
|
||||||
|
"You are helping design a digital business card image. Turn the \
|
||||||
|
following into ONE vivid, concrete image-generation prompt (2-3 \
|
||||||
|
sentences, no preamble, no quotes).\nScene: {}\nStyle: {}\nMood: {}",
|
||||||
|
brief.scene,
|
||||||
|
brief.style.as_deref().unwrap_or("(any)"),
|
||||||
|
brief.mood.as_deref().unwrap_or("(any)"),
|
||||||
|
);
|
||||||
|
let json = self.generate_content(TEXT_MODEL, &instruction).await?;
|
||||||
|
first_text(&json).ok_or(AiError::Empty("text"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn generate_image(&self, prompt: &str) -> Result<GeneratedImage, AiError> {
|
||||||
|
let json = self.generate_content(IMAGE_MODEL, prompt).await?;
|
||||||
|
first_inline_image(&json).ok_or(AiError::Empty("image"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_video(&self, prompt: &str) -> Result<String, AiError> {
|
||||||
|
let url = format!("{GEMINI_BASE}/models/{VIDEO_MODEL}:predictLongRunning");
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"instances": [{ "prompt": prompt }],
|
||||||
|
"parameters": { "aspectRatio": "9:16", "durationSeconds": 5 }
|
||||||
|
});
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.post(&url)
|
||||||
|
.query(&[("key", &self.api_key)])
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AiError::Request(e.to_string()))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(AiError::Request(format!("status {}", resp.status())));
|
||||||
|
}
|
||||||
|
let json: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AiError::Request(e.to_string()))?;
|
||||||
|
json["name"]
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_string)
|
||||||
|
.ok_or(AiError::Empty("operation"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn poll_video(&self, operation: &str) -> Result<VideoStatus, AiError> {
|
||||||
|
let url = format!("{GEMINI_BASE}/{operation}");
|
||||||
|
let json: serde_json::Value = self
|
||||||
|
.http
|
||||||
|
.get(&url)
|
||||||
|
.query(&[("key", &self.api_key)])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AiError::Request(e.to_string()))?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AiError::Request(e.to_string()))?;
|
||||||
|
|
||||||
|
if let Some(err) = json.get("error") {
|
||||||
|
let msg = err["message"].as_str().unwrap_or("video generation failed");
|
||||||
|
return Ok(VideoStatus::Failed(msg.to_string()));
|
||||||
|
}
|
||||||
|
if !json["done"].as_bool().unwrap_or(false) {
|
||||||
|
return Ok(VideoStatus::Pending);
|
||||||
|
}
|
||||||
|
// Done: the sample carries a download URI (302-redirects to a signed
|
||||||
|
// URL; reqwest follows it). The key authorizes the first hop.
|
||||||
|
let uri = json
|
||||||
|
.pointer("/response/generateVideoResponse/generatedSamples/0/video/uri")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or(AiError::Empty("video"))?;
|
||||||
|
let bytes = self
|
||||||
|
.http
|
||||||
|
.get(uri)
|
||||||
|
.query(&[("key", &self.api_key)])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AiError::Request(e.to_string()))?
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|e| AiError::Request(e.to_string()))?
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AiError::Request(e.to_string()))?;
|
||||||
|
Ok(VideoStatus::Done(bytes.to_vec()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the first text part from a generateContent response.
|
||||||
|
fn first_text(json: &serde_json::Value) -> Option<String> {
|
||||||
|
json["candidates"][0]["content"]["parts"]
|
||||||
|
.as_array()?
|
||||||
|
.iter()
|
||||||
|
.find_map(|p| p["text"].as_str())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the first inline image part (base64 + mime) from a response.
|
||||||
|
fn first_inline_image(json: &serde_json::Value) -> Option<GeneratedImage> {
|
||||||
|
json["candidates"][0]["content"]["parts"]
|
||||||
|
.as_array()?
|
||||||
|
.iter()
|
||||||
|
.find_map(|p| {
|
||||||
|
let data = p["inlineData"]["data"].as_str()?;
|
||||||
|
let mime = p["inlineData"]["mimeType"].as_str().unwrap_or("image/png");
|
||||||
|
Some(GeneratedImage {
|
||||||
|
mime_type: mime.to_string(),
|
||||||
|
base64: data.to_string(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Used when no API key is configured — every call errors `NotConfigured`.
|
||||||
|
pub struct DisabledAiClient;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AiClient for DisabledAiClient {
|
||||||
|
async fn refine_prompt(&self, _brief: &SceneBrief) -> Result<String, AiError> {
|
||||||
|
Err(AiError::NotConfigured)
|
||||||
|
}
|
||||||
|
async fn generate_image(&self, _prompt: &str) -> Result<GeneratedImage, AiError> {
|
||||||
|
Err(AiError::NotConfigured)
|
||||||
|
}
|
||||||
|
async fn start_video(&self, _prompt: &str) -> Result<String, AiError> {
|
||||||
|
Err(AiError::NotConfigured)
|
||||||
|
}
|
||||||
|
async fn poll_video(&self, _operation: &str) -> Result<VideoStatus, AiError> {
|
||||||
|
Err(AiError::NotConfigured)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Fake (tests) ---------------------------------------------------------
|
||||||
|
|
||||||
|
/// Deterministic fake: echoes a refined prompt and returns a 1x1 PNG.
|
||||||
|
pub struct FakeAiClient;
|
||||||
|
|
||||||
|
const PIXEL_PNG_B64: &str =
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AiClient for FakeAiClient {
|
||||||
|
async fn refine_prompt(&self, brief: &SceneBrief) -> Result<String, AiError> {
|
||||||
|
Ok(format!(
|
||||||
|
"A {} {} scene: {}",
|
||||||
|
brief.mood.as_deref().unwrap_or("striking"),
|
||||||
|
brief.style.as_deref().unwrap_or("cinematic"),
|
||||||
|
brief.scene,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn generate_image(&self, _prompt: &str) -> Result<GeneratedImage, AiError> {
|
||||||
|
Ok(GeneratedImage {
|
||||||
|
mime_type: "image/png".to_string(),
|
||||||
|
base64: PIXEL_PNG_B64.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async fn start_video(&self, _prompt: &str) -> Result<String, AiError> {
|
||||||
|
Ok("models/veo-2.0-generate-001/operations/fake".to_string())
|
||||||
|
}
|
||||||
|
async fn poll_video(&self, _operation: &str) -> Result<VideoStatus, AiError> {
|
||||||
|
// Minimal valid MP4 ftyp header — enough for the endpoint contract.
|
||||||
|
Ok(VideoStatus::Done(vec![
|
||||||
|
0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d,
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_text_and_image_from_gemini_shape() {
|
||||||
|
let text_resp = serde_json::json!({
|
||||||
|
"candidates": [{ "content": { "parts": [{ "text": " a vivid prompt " }] } }]
|
||||||
|
});
|
||||||
|
assert_eq!(first_text(&text_resp).as_deref(), Some("a vivid prompt"));
|
||||||
|
|
||||||
|
let img_resp = serde_json::json!({
|
||||||
|
"candidates": [{ "content": { "parts": [
|
||||||
|
{ "text": "here is your image" },
|
||||||
|
{ "inlineData": { "mimeType": "image/png", "data": "QUJD" } }
|
||||||
|
] } }]
|
||||||
|
});
|
||||||
|
let img = first_inline_image(&img_resp).unwrap();
|
||||||
|
assert_eq!(img.mime_type, "image/png");
|
||||||
|
assert_eq!(img.base64, "QUJD");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disabled_without_key() {
|
||||||
|
assert!(GeminiClient::new("".into()).is_none());
|
||||||
|
assert!(GeminiClient::new(" ".into()).is_none());
|
||||||
|
assert!(GeminiClient::new("real-key".into()).is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
//! AI card-image generation handlers (PRD §21 Phase 5). Two steps the mobile
|
||||||
|
//! wizard calls: refine the brief, then generate the image (nano-banana). Heavily
|
||||||
|
//! rate-limited because each call costs money; production also gates these behind
|
||||||
|
//! auth + the Pro tier.
|
||||||
|
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
use axum::Json;
|
||||||
|
use base64::Engine;
|
||||||
|
use cardclaws_types::AppError;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::ai::{AiError, SceneBrief, VideoStatus};
|
||||||
|
use crate::error::ApiResult;
|
||||||
|
use crate::handlers::analytics::client_ip;
|
||||||
|
use crate::middleware::rate_limit;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RefineRequest {
|
||||||
|
pub scene: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub style: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mood: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct RefineResponse {
|
||||||
|
pub prompt: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ImageRequest {
|
||||||
|
pub prompt: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ImageResponse {
|
||||||
|
pub mime_type: String,
|
||||||
|
/// Base64-encoded image; the client renders it as a `data:` URI.
|
||||||
|
pub image_base64: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_ai_err(e: AiError) -> AppError {
|
||||||
|
match e {
|
||||||
|
AiError::NotConfigured => AppError::Internal("AI is not configured".into()),
|
||||||
|
other => AppError::Internal(other.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn refine(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(req): Json<RefineRequest>,
|
||||||
|
) -> ApiResult<Json<RefineResponse>> {
|
||||||
|
rate_limit::check(state.cache.as_ref(), &rl_key("refine", &headers), 60, 3600).await?;
|
||||||
|
if req.scene.trim().is_empty() {
|
||||||
|
return Err(AppError::Validation("scene is required".into()).into());
|
||||||
|
}
|
||||||
|
let brief = SceneBrief {
|
||||||
|
scene: req.scene,
|
||||||
|
style: req.style,
|
||||||
|
mood: req.mood,
|
||||||
|
};
|
||||||
|
let prompt = state.ai.refine_prompt(&brief).await.map_err(map_ai_err)?;
|
||||||
|
Ok(Json(RefineResponse { prompt }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn image(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(req): Json<ImageRequest>,
|
||||||
|
) -> ApiResult<Json<ImageResponse>> {
|
||||||
|
// Image generation is the expensive call — tighter limit.
|
||||||
|
rate_limit::check(state.cache.as_ref(), &rl_key("image", &headers), 20, 3600).await?;
|
||||||
|
if req.prompt.trim().is_empty() {
|
||||||
|
return Err(AppError::Validation("prompt is required".into()).into());
|
||||||
|
}
|
||||||
|
let img = state
|
||||||
|
.ai
|
||||||
|
.generate_image(&req.prompt)
|
||||||
|
.await
|
||||||
|
.map_err(map_ai_err)?;
|
||||||
|
Ok(Json(ImageResponse {
|
||||||
|
mime_type: img.mime_type,
|
||||||
|
image_base64: img.base64,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Video (Veo, async) ---------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct VideoRequest {
|
||||||
|
pub prompt: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct StartVideoResponse {
|
||||||
|
pub operation_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct VideoStatusRequest {
|
||||||
|
pub operation_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct VideoStatusResponse {
|
||||||
|
/// "pending" | "done" | "failed".
|
||||||
|
pub status: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub mime_type: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub video_base64: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Submit a Veo job. Very tightly rate-limited — each clip is costly.
|
||||||
|
pub async fn start_video(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(req): Json<VideoRequest>,
|
||||||
|
) -> ApiResult<Json<StartVideoResponse>> {
|
||||||
|
rate_limit::check(state.cache.as_ref(), &rl_key("video", &headers), 5, 3600).await?;
|
||||||
|
if req.prompt.trim().is_empty() {
|
||||||
|
return Err(AppError::Validation("prompt is required".into()).into());
|
||||||
|
}
|
||||||
|
let operation_id = state
|
||||||
|
.ai
|
||||||
|
.start_video(&req.prompt)
|
||||||
|
.await
|
||||||
|
.map_err(map_ai_err)?;
|
||||||
|
Ok(Json(StartVideoResponse { operation_id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll a Veo operation. Not rate-limited (the client polls repeatedly); the
|
||||||
|
/// backend proxies the download so the API key never reaches the client.
|
||||||
|
pub async fn video_status(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<VideoStatusRequest>,
|
||||||
|
) -> ApiResult<Json<VideoStatusResponse>> {
|
||||||
|
let resp = match state
|
||||||
|
.ai
|
||||||
|
.poll_video(&req.operation_id)
|
||||||
|
.await
|
||||||
|
.map_err(map_ai_err)?
|
||||||
|
{
|
||||||
|
VideoStatus::Pending => VideoStatusResponse {
|
||||||
|
status: "pending".into(),
|
||||||
|
mime_type: None,
|
||||||
|
video_base64: None,
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
VideoStatus::Failed(e) => VideoStatusResponse {
|
||||||
|
status: "failed".into(),
|
||||||
|
mime_type: None,
|
||||||
|
video_base64: None,
|
||||||
|
error: Some(e),
|
||||||
|
},
|
||||||
|
VideoStatus::Done(bytes) => VideoStatusResponse {
|
||||||
|
status: "done".into(),
|
||||||
|
mime_type: Some("video/mp4".into()),
|
||||||
|
video_base64: Some(base64::engine::general_purpose::STANDARD.encode(bytes)),
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Ok(Json(resp))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rl_key(op: &str, headers: &HeaderMap) -> String {
|
||||||
|
let ip = client_ip(headers).unwrap_or_else(|| "unknown".into());
|
||||||
|
format!("ai:{op}:{ip}")
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod account;
|
pub mod account;
|
||||||
|
pub mod ai;
|
||||||
pub mod analytics;
|
pub mod analytics;
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
//! test doubles (in-memory cache, capturing email, fake Apple keys). The binary
|
//! test doubles (in-memory cache, capturing email, fake Apple keys). The binary
|
||||||
//! (`main.rs`) wires the production implementations.
|
//! (`main.rs`) wires the production implementations.
|
||||||
|
|
||||||
|
pub mod ai;
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod email;
|
pub mod email;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use cardclaws_api::ai::{AiClient, DisabledAiClient, GeminiClient};
|
||||||
use cardclaws_api::assets::R2Store;
|
use cardclaws_api::assets::R2Store;
|
||||||
use cardclaws_api::cache::RedisCache;
|
use cardclaws_api::cache::RedisCache;
|
||||||
use cardclaws_api::email::ResendEmailSender;
|
use cardclaws_api::email::ResendEmailSender;
|
||||||
@@ -50,6 +51,13 @@ async fn main() -> Result<(), BoxError> {
|
|||||||
let pass_signer = build_pass_signer(&secrets).await?;
|
let pass_signer = build_pass_signer(&secrets).await?;
|
||||||
let google_signer = build_google_signer(&secrets).await;
|
let google_signer = build_google_signer(&secrets).await;
|
||||||
let geo = build_geo_resolver(&secrets).await;
|
let geo = build_geo_resolver(&secrets).await;
|
||||||
|
let ai: Arc<dyn AiClient> = match GeminiClient::new(config.gemini_api_key.clone()) {
|
||||||
|
Some(c) => {
|
||||||
|
tracing::info!("AI card generator enabled (Gemini)");
|
||||||
|
Arc::new(c)
|
||||||
|
}
|
||||||
|
None => Arc::new(DisabledAiClient),
|
||||||
|
};
|
||||||
|
|
||||||
// Brand glyphs bundled into every pass. Solid-fill placeholders for now;
|
// Brand glyphs bundled into every pass. Solid-fill placeholders for now;
|
||||||
// replaced by real CardClaws artwork when design assets land.
|
// replaced by real CardClaws artwork when design assets land.
|
||||||
@@ -64,6 +72,7 @@ async fn main() -> Result<(), BoxError> {
|
|||||||
email: Arc::new(ResendEmailSender::new(resend_key, email_from)),
|
email: Arc::new(ResendEmailSender::new(resend_key, email_from)),
|
||||||
assets: Arc::new(assets),
|
assets: Arc::new(assets),
|
||||||
geo,
|
geo,
|
||||||
|
ai,
|
||||||
jwt: JwtKeys::new(&config.jwt_secret),
|
jwt: JwtKeys::new(&config.jwt_secret),
|
||||||
apple: Arc::new(HttpJwkProvider::new()),
|
apple: Arc::new(HttpJwkProvider::new()),
|
||||||
apple_audience,
|
apple_audience,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use axum::routing::{get, post};
|
|||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
|
||||||
use crate::handlers::{
|
use crate::handlers::{
|
||||||
account, analytics, assets, auth, cards, health, profile, share, teams, wallet, webhooks,
|
account, ai, analytics, assets, auth, cards, health, profile, share, teams, wallet, webhooks,
|
||||||
};
|
};
|
||||||
use crate::middleware::cors;
|
use crate::middleware::cors;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
@@ -65,6 +65,10 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
axum::routing::delete(teams::delete_template),
|
axum::routing::delete(teams::delete_template),
|
||||||
)
|
)
|
||||||
.route("/analytics/event", post(analytics::ingest_event))
|
.route("/analytics/event", post(analytics::ingest_event))
|
||||||
|
.route("/ai/refine", post(ai::refine))
|
||||||
|
.route("/ai/image", post(ai::image))
|
||||||
|
.route("/ai/video", post(ai::start_video))
|
||||||
|
.route("/ai/video/status", post(ai::video_status))
|
||||||
.route("/account/export", get(account::export_data))
|
.route("/account/export", get(account::export_data))
|
||||||
.route("/account", axum::routing::delete(account::delete_account))
|
.route("/account", axum::routing::delete(account::delete_account))
|
||||||
.route("/assets/upload", post(assets::presign_upload))
|
.route("/assets/upload", post(assets::presign_upload))
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use cardclaws_wallet::apple::signer::PassSigner;
|
|||||||
use cardclaws_wallet::apple::BrandAssets;
|
use cardclaws_wallet::apple::BrandAssets;
|
||||||
use cardclaws_wallet::google::jwt_signer::GoogleWalletSigner;
|
use cardclaws_wallet::google::jwt_signer::GoogleWalletSigner;
|
||||||
|
|
||||||
|
use crate::ai::AiClient;
|
||||||
use crate::assets::ObjectStore;
|
use crate::assets::ObjectStore;
|
||||||
use crate::cache::Cache;
|
use crate::cache::Cache;
|
||||||
use crate::email::EmailSender;
|
use crate::email::EmailSender;
|
||||||
@@ -24,6 +25,7 @@ pub struct AppState {
|
|||||||
pub email: Arc<dyn EmailSender>,
|
pub email: Arc<dyn EmailSender>,
|
||||||
pub assets: Arc<dyn ObjectStore>,
|
pub assets: Arc<dyn ObjectStore>,
|
||||||
pub geo: Arc<dyn GeoResolver>,
|
pub geo: Arc<dyn GeoResolver>,
|
||||||
|
pub ai: Arc<dyn AiClient>,
|
||||||
pub jwt: JwtKeys,
|
pub jwt: JwtKeys,
|
||||||
pub apple: Arc<dyn JwkProvider>,
|
pub apple: Arc<dyn JwkProvider>,
|
||||||
/// Apple Services ID / bundle id the identity token must be addressed to.
|
/// Apple Services ID / bundle id the identity token must be addressed to.
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
//! AI generator endpoints (PRD §21 Phase 5). Uses the fake AI client; a live
|
||||||
|
//! Gemini/nano-banana run is exercised manually with a real key.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn refine_returns_a_prompt() {
|
||||||
|
let app = require_app!();
|
||||||
|
let (status, body) = app
|
||||||
|
.request(
|
||||||
|
"POST",
|
||||||
|
"/v1/ai/refine",
|
||||||
|
None,
|
||||||
|
Some(json!({ "scene": "neon Tokyo street", "style": "Cinematic", "mood": "Bold" })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
let prompt = body["prompt"].as_str().unwrap();
|
||||||
|
assert!(prompt.contains("neon Tokyo street"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn refine_requires_a_scene() {
|
||||||
|
let app = require_app!();
|
||||||
|
let (status, body) = app
|
||||||
|
.request(
|
||||||
|
"POST",
|
||||||
|
"/v1/ai/refine",
|
||||||
|
None,
|
||||||
|
Some(json!({ "scene": " " })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||||
|
assert_eq!(body["code"], "validation");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn image_returns_base64_png() {
|
||||||
|
let app = require_app!();
|
||||||
|
let (status, body) = app
|
||||||
|
.request(
|
||||||
|
"POST",
|
||||||
|
"/v1/ai/image",
|
||||||
|
None,
|
||||||
|
Some(json!({ "prompt": "a cinematic neon street" })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(body["mimeType"], "image/png");
|
||||||
|
assert!(!body["imageBase64"].as_str().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn video_submit_then_poll_returns_clip() {
|
||||||
|
let app = require_app!();
|
||||||
|
let (status, body) = app
|
||||||
|
.request(
|
||||||
|
"POST",
|
||||||
|
"/v1/ai/video",
|
||||||
|
None,
|
||||||
|
Some(json!({ "prompt": "a calm forest at dawn" })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
let op = body["operationId"].as_str().unwrap().to_string();
|
||||||
|
assert!(!op.is_empty());
|
||||||
|
|
||||||
|
let (status, body) = app
|
||||||
|
.request(
|
||||||
|
"POST",
|
||||||
|
"/v1/ai/video/status",
|
||||||
|
None,
|
||||||
|
Some(json!({ "operationId": op })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(body["status"], "done");
|
||||||
|
assert_eq!(body["mimeType"], "video/mp4");
|
||||||
|
assert!(!body["videoBase64"].as_str().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn video_requires_a_prompt() {
|
||||||
|
let app = require_app!();
|
||||||
|
let (status, body) = app
|
||||||
|
.request("POST", "/v1/ai/video", None, Some(json!({ "prompt": "" })))
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||||
|
assert_eq!(body["code"], "validation");
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ use http_body_util::BodyExt;
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
use cardclaws_api::ai::FakeAiClient;
|
||||||
use cardclaws_api::assets::InMemoryStore;
|
use cardclaws_api::assets::InMemoryStore;
|
||||||
use cardclaws_api::cache::InMemoryCache;
|
use cardclaws_api::cache::InMemoryCache;
|
||||||
use cardclaws_api::email::CapturingEmailSender;
|
use cardclaws_api::email::CapturingEmailSender;
|
||||||
@@ -84,6 +85,7 @@ pub async fn try_setup() -> Option<TestApp> {
|
|||||||
email: email.clone(),
|
email: email.clone(),
|
||||||
assets: assets.clone(),
|
assets: assets.clone(),
|
||||||
geo: Arc::new(FakeGeo),
|
geo: Arc::new(FakeGeo),
|
||||||
|
ai: Arc::new(FakeAiClient),
|
||||||
jwt: JwtKeys::new("test-jwt-secret"),
|
jwt: JwtKeys::new("test-jwt-secret"),
|
||||||
apple: Arc::new(NoopApple),
|
apple: Arc::new(NoopApple),
|
||||||
apple_audience: "com.cardclaws.test".into(),
|
apple_audience: "com.cardclaws.test".into(),
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ pub struct Config {
|
|||||||
pub ip_hash_secret: String,
|
pub ip_hash_secret: String,
|
||||||
/// Shared secret RevenueCat sends in the webhook `Authorization` header.
|
/// Shared secret RevenueCat sends in the webhook `Authorization` header.
|
||||||
pub billing_webhook_secret: String,
|
pub billing_webhook_secret: String,
|
||||||
|
/// Google Gemini API key (for the AI card generator). Empty = AI disabled.
|
||||||
|
pub gemini_api_key: String,
|
||||||
pub bind_addr: String,
|
pub bind_addr: String,
|
||||||
pub r2: R2Config,
|
pub r2: R2Config,
|
||||||
pub wallet: WalletConfig,
|
pub wallet: WalletConfig,
|
||||||
@@ -102,6 +104,7 @@ impl Config {
|
|||||||
profile_base_url: optional(src, "PROFILE_BASE_URL", "https://cardclaws.com").await,
|
profile_base_url: optional(src, "PROFILE_BASE_URL", "https://cardclaws.com").await,
|
||||||
ip_hash_secret: require(src, "IP_HASH_SECRET").await?,
|
ip_hash_secret: require(src, "IP_HASH_SECRET").await?,
|
||||||
billing_webhook_secret: optional(src, "BILLING_WEBHOOK_SECRET", "").await,
|
billing_webhook_secret: optional(src, "BILLING_WEBHOOK_SECRET", "").await,
|
||||||
|
gemini_api_key: optional(src, "GEMINI_API_KEY", "").await,
|
||||||
bind_addr: optional(src, "BIND_ADDR", "0.0.0.0:8080").await,
|
bind_addr: optional(src, "BIND_ADDR", "0.0.0.0:8080").await,
|
||||||
r2: R2Config {
|
r2: R2Config {
|
||||||
endpoint: optional(
|
endpoint: optional(
|
||||||
|
|||||||
Reference in New Issue
Block a user