@pheem49/mint 1.6.2 → 1.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +14 -0
- package/Cargo.lock +3 -3
- package/Cargo.toml +1 -1
- package/README.md +82 -21
- package/assets/Mint_CLI.png +0 -0
- package/assets/Mint_Desktop.png +0 -0
- package/assets/Mint_Web.png +0 -0
- package/assets/Mint_WorkSpace.png +0 -0
- package/assets/Setting.png +0 -0
- package/assets/bridges.svg +4 -0
- package/assets/chat.svg +3 -0
- package/assets/code.svg +4 -0
- package/assets/features.svg +3 -0
- package/assets/imagegen.svg +5 -0
- package/assets/live2d.svg +6 -0
- package/assets/memory.svg +5 -0
- package/assets/screencapture.svg +4 -0
- package/assets/setup.svg +3 -0
- package/assets/tools.svg +4 -0
- package/bin/mint +0 -0
- package/crates/mint-cli/src/agent.rs +6 -2
- package/crates/mint-cli/src/main.rs +145 -5
- package/crates/mint-cli/src/onboard.rs +134 -1
- package/crates/mint-core/src/api_server.rs +108 -3
- package/crates/mint-core/src/config.rs +20 -1
- package/crates/mint-core/src/image_gen.rs +895 -0
- package/crates/mint-core/src/lib.rs +4 -0
- package/package.json +3 -3
- package/src/bin/index.js +0 -0
- package/src/renderer/index-web.html +1 -1
- package/src/renderer/index.html +1 -1
- package/src/renderer/src/components/ChatPanel.tsx +252 -26
- package/src/renderer/src/components/DashboardSidebar.tsx +11 -3
- package/src/renderer/src/components/ImageStudioPanel.tsx +638 -0
- package/src/renderer/src/components/MintDashboard.tsx +14 -5
- package/src/renderer/src/components/SettingsWindow.tsx +29 -20
- package/src/renderer/src/css/image-studio.css +722 -0
- package/src/renderer/src/css/settings.css +13 -3
- package/src/renderer/src/css/styles.css +50 -26
- package/src/renderer/src/index.css +106 -0
- package/src/renderer/src/tauri.ts +123 -0
- package/src/renderer/src-web/components/ChatPanel.tsx +273 -25
- package/src/renderer/src-web/components/DashboardSidebar.tsx +11 -3
- package/src/renderer/src-web/components/ImageStudioPanel.tsx +638 -0
- package/src/renderer/src-web/components/MintDashboard.tsx +13 -4
- package/src/renderer/src-web/components/SettingsWindow.tsx +29 -20
- package/src/renderer/src-web/css/image-studio.css +722 -0
- package/src/renderer/src-web/css/settings.css +14 -4
- package/src/renderer/src-web/css/styles.css +50 -26
- package/src/renderer/src-web/index.css +106 -0
- package/src/renderer/src-web/tauri.ts +124 -0
- package/src-tauri/Cargo.toml +29 -0
- package/src-tauri/build.rs +3 -0
- package/src-tauri/capabilities/main.json +7 -0
- package/src-tauri/gen/schemas/acl-manifests.json +1 -0
- package/src-tauri/gen/schemas/capabilities.json +1 -0
- package/src-tauri/gen/schemas/desktop-schema.json +2412 -0
- package/src-tauri/gen/schemas/linux-schema.json +2412 -0
- package/src-tauri/src/browser.rs +141 -0
- package/src-tauri/src/desktop.rs +392 -0
- package/src-tauri/src/discord_rpc.rs +99 -0
- package/src-tauri/src/events.rs +70 -0
- package/src-tauri/src/headless.rs +222 -0
- package/src-tauri/src/integrations.rs +126 -0
- package/src-tauri/src/lib.rs +1076 -0
- package/src-tauri/src/main.rs +3 -0
- package/src-tauri/src/plugins.rs +16 -0
- package/src-tauri/src/proactive.rs +254 -0
- package/src-tauri/src/system.rs +250 -0
- package/src-tauri/src/updater.rs +148 -0
- package/src-tauri/src/webhooks.rs +255 -0
- package/src-tauri/src/workflows.rs +70 -0
- package/src-tauri/tauri.conf.json +48 -0
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,895 @@
|
|
|
1
|
+
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
|
2
|
+
use reqwest::Client;
|
|
3
|
+
use serde::{Deserialize, Serialize};
|
|
4
|
+
use serde_json::{Value, json};
|
|
5
|
+
use thiserror::Error;
|
|
6
|
+
|
|
7
|
+
use crate::MintConfig;
|
|
8
|
+
|
|
9
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
10
|
+
// Public request / response types
|
|
11
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
/// A request to generate images from any supported provider.
|
|
14
|
+
#[derive(Debug, Clone, Deserialize)]
|
|
15
|
+
#[serde(rename_all = "camelCase")]
|
|
16
|
+
pub struct ImageGenRequest {
|
|
17
|
+
pub prompt: String,
|
|
18
|
+
#[serde(default)]
|
|
19
|
+
pub negative_prompt: Option<String>,
|
|
20
|
+
/// Aspect ratio hint: "1:1", "16:9", "9:16", "4:3". Default: "1:1"
|
|
21
|
+
#[serde(default)]
|
|
22
|
+
pub aspect_ratio: Option<String>,
|
|
23
|
+
/// Number of images to return (1–4). Default: 1
|
|
24
|
+
#[serde(default)]
|
|
25
|
+
pub num_images: Option<u8>,
|
|
26
|
+
/// Override the model for the selected provider.
|
|
27
|
+
#[serde(default)]
|
|
28
|
+
pub model: Option<String>,
|
|
29
|
+
/// Which provider to use: "nanobanana" | "dalle" | "stability" | "ideogram" | "replicate".
|
|
30
|
+
/// Falls back to `config.image_gen_provider` when omitted.
|
|
31
|
+
#[serde(default)]
|
|
32
|
+
pub provider: Option<String>,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// One generated image returned from the model.
|
|
36
|
+
#[derive(Debug, Clone, Serialize)]
|
|
37
|
+
#[serde(rename_all = "camelCase")]
|
|
38
|
+
pub struct GeneratedImage {
|
|
39
|
+
/// Full data URI: `data:image/png;base64,...`
|
|
40
|
+
pub data_uri: String,
|
|
41
|
+
pub mime_type: String,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// The complete response from an image generation call.
|
|
45
|
+
#[derive(Debug, Clone, Serialize)]
|
|
46
|
+
#[serde(rename_all = "camelCase")]
|
|
47
|
+
pub struct ImageGenResponse {
|
|
48
|
+
pub images: Vec<GeneratedImage>,
|
|
49
|
+
pub model: String,
|
|
50
|
+
pub provider: String,
|
|
51
|
+
pub prompt: String,
|
|
52
|
+
/// Optional descriptive text returned alongside the image by some models.
|
|
53
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
54
|
+
pub description: Option<String>,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#[derive(Debug, Error)]
|
|
58
|
+
pub enum ImageGenError {
|
|
59
|
+
// ── NanoBanana ──────────────────────────────────────────────────────────
|
|
60
|
+
#[error(
|
|
61
|
+
"missing Gemini API key — set GEMINI_API_KEY or configure api_key for NanoBanana image generation"
|
|
62
|
+
)]
|
|
63
|
+
MissingApiKey,
|
|
64
|
+
// ── DALL·E ───────────────────────────────────────────────────────────────
|
|
65
|
+
#[error("missing OpenAI API key — configure openai_api_key to use DALL·E")]
|
|
66
|
+
MissingOpenAiKey,
|
|
67
|
+
// ── Stability AI ─────────────────────────────────────────────────────────
|
|
68
|
+
#[error("missing Stability AI API key — configure stability_api_key")]
|
|
69
|
+
MissingStabilityKey,
|
|
70
|
+
// ── Ideogram ─────────────────────────────────────────────────────────────
|
|
71
|
+
#[error("missing Ideogram API key — configure ideogram_api_key")]
|
|
72
|
+
MissingIdeogramKey,
|
|
73
|
+
// ── Replicate ────────────────────────────────────────────────────────────
|
|
74
|
+
#[error("missing Replicate API key — configure replicate_api_key")]
|
|
75
|
+
MissingReplicateKey,
|
|
76
|
+
// ── Generic ──────────────────────────────────────────────────────────────
|
|
77
|
+
#[error("unsupported image generation provider: {0}")]
|
|
78
|
+
UnsupportedProvider(String),
|
|
79
|
+
#[error("API request failed: {0}")]
|
|
80
|
+
Request(#[from] reqwest::Error),
|
|
81
|
+
#[error("the model returned no images for this prompt")]
|
|
82
|
+
NoImagesReturned,
|
|
83
|
+
#[error("model error: {0}")]
|
|
84
|
+
ModelError(String),
|
|
85
|
+
#[error("unexpected response structure from the image model")]
|
|
86
|
+
UnexpectedResponse,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
90
|
+
// Main entry-point — dispatch to the correct provider
|
|
91
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/// Generate images using whichever provider is specified in the request or config.
|
|
94
|
+
pub async fn generate_images(
|
|
95
|
+
config: &MintConfig,
|
|
96
|
+
request: &ImageGenRequest,
|
|
97
|
+
) -> Result<ImageGenResponse, ImageGenError> {
|
|
98
|
+
let client = crate::HTTP_CLIENT.clone();
|
|
99
|
+
|
|
100
|
+
let provider = request
|
|
101
|
+
.provider
|
|
102
|
+
.as_deref()
|
|
103
|
+
.map(str::trim)
|
|
104
|
+
.filter(|s| !s.is_empty())
|
|
105
|
+
.unwrap_or_else(|| config.image_gen_provider.trim());
|
|
106
|
+
|
|
107
|
+
let provider = if provider.is_empty() {
|
|
108
|
+
"nanobanana"
|
|
109
|
+
} else {
|
|
110
|
+
provider
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
match provider {
|
|
114
|
+
"nanobanana" => call_nanobanana(&client, config, request).await,
|
|
115
|
+
"dalle" => call_dalle(&client, config, request).await,
|
|
116
|
+
"stability" => call_stability(&client, config, request).await,
|
|
117
|
+
"ideogram" => call_ideogram(&client, config, request).await,
|
|
118
|
+
"replicate" => call_replicate(&client, config, request).await,
|
|
119
|
+
other => Err(ImageGenError::UnsupportedProvider(other.to_owned())),
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
124
|
+
// NanoBanana — Google Gemini image generation
|
|
125
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
async fn call_nanobanana(
|
|
128
|
+
client: &Client,
|
|
129
|
+
config: &MintConfig,
|
|
130
|
+
request: &ImageGenRequest,
|
|
131
|
+
) -> Result<ImageGenResponse, ImageGenError> {
|
|
132
|
+
let api_key = if config.api_key.trim().is_empty() {
|
|
133
|
+
std::env::var("GEMINI_API_KEY").unwrap_or_default()
|
|
134
|
+
} else {
|
|
135
|
+
config.api_key.clone()
|
|
136
|
+
};
|
|
137
|
+
if api_key.trim().is_empty() {
|
|
138
|
+
return Err(ImageGenError::MissingApiKey);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let model = request
|
|
142
|
+
.model
|
|
143
|
+
.as_deref()
|
|
144
|
+
.map(str::trim)
|
|
145
|
+
.filter(|m| !m.is_empty())
|
|
146
|
+
.map(str::to_owned)
|
|
147
|
+
.unwrap_or_else(|| {
|
|
148
|
+
let m = config.nanobanana_model.trim();
|
|
149
|
+
if m.is_empty() {
|
|
150
|
+
"gemini-2.5-flash-image".to_owned()
|
|
151
|
+
} else {
|
|
152
|
+
m.to_owned()
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
let url =
|
|
157
|
+
format!("https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent");
|
|
158
|
+
|
|
159
|
+
let full_prompt = build_prompt(request);
|
|
160
|
+
let num_images = request.num_images.unwrap_or(1).clamp(1, 4);
|
|
161
|
+
|
|
162
|
+
let payload = json!({
|
|
163
|
+
"contents": [{
|
|
164
|
+
"parts": [{ "text": full_prompt }]
|
|
165
|
+
}],
|
|
166
|
+
"generationConfig": {
|
|
167
|
+
"responseModalities": ["TEXT", "IMAGE"],
|
|
168
|
+
"candidateCount": num_images
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
let response: Value = client
|
|
173
|
+
.post(&url)
|
|
174
|
+
.header("x-goog-api-key", &api_key)
|
|
175
|
+
.json(&payload)
|
|
176
|
+
.send()
|
|
177
|
+
.await?
|
|
178
|
+
.error_for_status()
|
|
179
|
+
.map_err(ImageGenError::Request)?
|
|
180
|
+
.json()
|
|
181
|
+
.await?;
|
|
182
|
+
|
|
183
|
+
if let Some(err) = response.get("error") {
|
|
184
|
+
let msg = err["message"].as_str().unwrap_or("unknown API error");
|
|
185
|
+
return Err(ImageGenError::ModelError(msg.to_owned()));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
parse_nanobanana_response(&response, &model, &request.prompt)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
fn parse_nanobanana_response(
|
|
192
|
+
response: &Value,
|
|
193
|
+
model: &str,
|
|
194
|
+
original_prompt: &str,
|
|
195
|
+
) -> Result<ImageGenResponse, ImageGenError> {
|
|
196
|
+
let candidates = response["candidates"]
|
|
197
|
+
.as_array()
|
|
198
|
+
.ok_or(ImageGenError::UnexpectedResponse)?;
|
|
199
|
+
|
|
200
|
+
let mut images = Vec::new();
|
|
201
|
+
let mut description: Option<String> = None;
|
|
202
|
+
|
|
203
|
+
for candidate in candidates {
|
|
204
|
+
let parts_owned: Vec<Value>;
|
|
205
|
+
let parts = match candidate["content"]["parts"].as_array() {
|
|
206
|
+
Some(p) => p,
|
|
207
|
+
None => {
|
|
208
|
+
parts_owned = vec![];
|
|
209
|
+
&parts_owned
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
for part in parts {
|
|
214
|
+
if let Some(text) = part["text"].as_str() {
|
|
215
|
+
let text = text.trim();
|
|
216
|
+
if !text.is_empty() {
|
|
217
|
+
description.get_or_insert_with(String::new).push_str(text);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if let Some(inline) = part.get("inlineData") {
|
|
222
|
+
let mime_type = inline["mimeType"]
|
|
223
|
+
.as_str()
|
|
224
|
+
.unwrap_or("image/png")
|
|
225
|
+
.to_owned();
|
|
226
|
+
let b64 = inline["data"].as_str().unwrap_or("");
|
|
227
|
+
if b64.is_empty() {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if STANDARD.decode(b64).is_err() {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
let data_uri = format!("data:{mime_type};base64,{b64}");
|
|
234
|
+
images.push(GeneratedImage {
|
|
235
|
+
data_uri,
|
|
236
|
+
mime_type,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if images.is_empty() {
|
|
243
|
+
return Err(ImageGenError::NoImagesReturned);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
Ok(ImageGenResponse {
|
|
247
|
+
images,
|
|
248
|
+
model: model.to_owned(),
|
|
249
|
+
provider: "nanobanana".to_owned(),
|
|
250
|
+
prompt: original_prompt.to_owned(),
|
|
251
|
+
description,
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
256
|
+
// DALL·E — OpenAI Images API
|
|
257
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
async fn call_dalle(
|
|
260
|
+
client: &Client,
|
|
261
|
+
config: &MintConfig,
|
|
262
|
+
request: &ImageGenRequest,
|
|
263
|
+
) -> Result<ImageGenResponse, ImageGenError> {
|
|
264
|
+
let api_key = if config.openai_api_key.trim().is_empty() {
|
|
265
|
+
std::env::var("OPENAI_API_KEY").unwrap_or_default()
|
|
266
|
+
} else {
|
|
267
|
+
config.openai_api_key.clone()
|
|
268
|
+
};
|
|
269
|
+
if api_key.trim().is_empty() {
|
|
270
|
+
return Err(ImageGenError::MissingOpenAiKey);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
let model = request
|
|
274
|
+
.model
|
|
275
|
+
.as_deref()
|
|
276
|
+
.map(str::trim)
|
|
277
|
+
.filter(|m| !m.is_empty())
|
|
278
|
+
.map(str::to_owned)
|
|
279
|
+
.unwrap_or_else(|| {
|
|
280
|
+
let m = config.dalle_model.trim();
|
|
281
|
+
if m.is_empty() {
|
|
282
|
+
"dall-e-3".to_owned()
|
|
283
|
+
} else {
|
|
284
|
+
m.to_owned()
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// DALL·E 3 supports only n=1; DALL·E 2 supports up to 10.
|
|
289
|
+
let n = if model.starts_with("dall-e-3") || model == "gpt-image-1" {
|
|
290
|
+
1u8
|
|
291
|
+
} else {
|
|
292
|
+
request.num_images.unwrap_or(1).clamp(1, 10)
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
let size = aspect_ratio_to_dalle_size(request.aspect_ratio.as_deref());
|
|
296
|
+
|
|
297
|
+
let payload = json!({
|
|
298
|
+
"model": model,
|
|
299
|
+
"prompt": request.prompt,
|
|
300
|
+
"n": n,
|
|
301
|
+
"size": size,
|
|
302
|
+
"response_format": "b64_json"
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
let response: Value = client
|
|
306
|
+
.post("https://api.openai.com/v1/images/generations")
|
|
307
|
+
.bearer_auth(&api_key)
|
|
308
|
+
.json(&payload)
|
|
309
|
+
.send()
|
|
310
|
+
.await?
|
|
311
|
+
.error_for_status()
|
|
312
|
+
.map_err(ImageGenError::Request)?
|
|
313
|
+
.json()
|
|
314
|
+
.await?;
|
|
315
|
+
|
|
316
|
+
if let Some(err) = response.get("error") {
|
|
317
|
+
let msg = err["message"]
|
|
318
|
+
.as_str()
|
|
319
|
+
.unwrap_or("unknown OpenAI API error");
|
|
320
|
+
return Err(ImageGenError::ModelError(msg.to_owned()));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
let data = response["data"]
|
|
324
|
+
.as_array()
|
|
325
|
+
.ok_or(ImageGenError::UnexpectedResponse)?;
|
|
326
|
+
|
|
327
|
+
let images: Vec<GeneratedImage> = data
|
|
328
|
+
.iter()
|
|
329
|
+
.filter_map(|item| {
|
|
330
|
+
let b64 = item["b64_json"].as_str()?;
|
|
331
|
+
if b64.is_empty() || STANDARD.decode(b64).is_err() {
|
|
332
|
+
return None;
|
|
333
|
+
}
|
|
334
|
+
let data_uri = format!("data:image/png;base64,{b64}");
|
|
335
|
+
Some(GeneratedImage {
|
|
336
|
+
data_uri,
|
|
337
|
+
mime_type: "image/png".to_owned(),
|
|
338
|
+
})
|
|
339
|
+
})
|
|
340
|
+
.collect();
|
|
341
|
+
|
|
342
|
+
if images.is_empty() {
|
|
343
|
+
return Err(ImageGenError::NoImagesReturned);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
Ok(ImageGenResponse {
|
|
347
|
+
images,
|
|
348
|
+
model,
|
|
349
|
+
provider: "dalle".to_owned(),
|
|
350
|
+
prompt: request.prompt.clone(),
|
|
351
|
+
description: None,
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
fn aspect_ratio_to_dalle_size(ratio: Option<&str>) -> &'static str {
|
|
356
|
+
match ratio.unwrap_or("1:1") {
|
|
357
|
+
"16:9" => "1792x1024",
|
|
358
|
+
"9:16" => "1024x1792",
|
|
359
|
+
_ => "1024x1024",
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
364
|
+
// Stability AI — Stable Image Core / SD3.x REST API
|
|
365
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
async fn call_stability(
|
|
368
|
+
client: &Client,
|
|
369
|
+
config: &MintConfig,
|
|
370
|
+
request: &ImageGenRequest,
|
|
371
|
+
) -> Result<ImageGenResponse, ImageGenError> {
|
|
372
|
+
let api_key = if config.stability_api_key.trim().is_empty() {
|
|
373
|
+
std::env::var("STABILITY_API_KEY").unwrap_or_default()
|
|
374
|
+
} else {
|
|
375
|
+
config.stability_api_key.clone()
|
|
376
|
+
};
|
|
377
|
+
if api_key.trim().is_empty() {
|
|
378
|
+
return Err(ImageGenError::MissingStabilityKey);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
let model = request
|
|
382
|
+
.model
|
|
383
|
+
.as_deref()
|
|
384
|
+
.map(str::trim)
|
|
385
|
+
.filter(|m| !m.is_empty())
|
|
386
|
+
.map(str::to_owned)
|
|
387
|
+
.unwrap_or_else(|| {
|
|
388
|
+
let m = config.stability_model.trim();
|
|
389
|
+
if m.is_empty() {
|
|
390
|
+
"sd3.5-large".to_owned()
|
|
391
|
+
} else {
|
|
392
|
+
m.to_owned()
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// Route: Stable Image Core uses /core; SD3.x models use /sd3
|
|
397
|
+
let (endpoint, model_param) = if model == "core" {
|
|
398
|
+
(
|
|
399
|
+
"https://api.stability.ai/v2beta/stable-image/generate/core".to_owned(),
|
|
400
|
+
None,
|
|
401
|
+
)
|
|
402
|
+
} else {
|
|
403
|
+
(
|
|
404
|
+
"https://api.stability.ai/v2beta/stable-image/generate/sd3".to_owned(),
|
|
405
|
+
Some(model.clone()),
|
|
406
|
+
)
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
let aspect_ratio = request.aspect_ratio.as_deref().unwrap_or("1:1");
|
|
410
|
+
|
|
411
|
+
// Build multipart/form-data manually (reqwest multipart feature not enabled).
|
|
412
|
+
let boundary = format!("MintBoundary{}", uuid_v4_hex());
|
|
413
|
+
|
|
414
|
+
let mut body_parts: Vec<(String, String)> = vec![
|
|
415
|
+
("prompt".into(), request.prompt.clone()),
|
|
416
|
+
("aspect_ratio".into(), aspect_ratio.to_owned()),
|
|
417
|
+
("output_format".into(), "png".to_owned()),
|
|
418
|
+
];
|
|
419
|
+
|
|
420
|
+
if let Some(neg) = &request.negative_prompt {
|
|
421
|
+
let neg = neg.trim();
|
|
422
|
+
if !neg.is_empty() {
|
|
423
|
+
body_parts.push(("negative_prompt".into(), neg.to_owned()));
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if let Some(m) = model_param {
|
|
428
|
+
body_parts.push(("model".into(), m));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
let raw_body = build_multipart_text(&boundary, &body_parts);
|
|
432
|
+
|
|
433
|
+
let response = client
|
|
434
|
+
.post(&endpoint)
|
|
435
|
+
.bearer_auth(&api_key)
|
|
436
|
+
.header("accept", "image/*")
|
|
437
|
+
.header(
|
|
438
|
+
"content-type",
|
|
439
|
+
format!("multipart/form-data; boundary={boundary}"),
|
|
440
|
+
)
|
|
441
|
+
.body(raw_body)
|
|
442
|
+
.send()
|
|
443
|
+
.await?
|
|
444
|
+
.error_for_status()
|
|
445
|
+
.map_err(ImageGenError::Request)?;
|
|
446
|
+
|
|
447
|
+
let bytes = response.bytes().await?;
|
|
448
|
+
if bytes.is_empty() {
|
|
449
|
+
return Err(ImageGenError::NoImagesReturned);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
let b64 = STANDARD.encode(&bytes);
|
|
453
|
+
let data_uri = format!("data:image/png;base64,{b64}");
|
|
454
|
+
|
|
455
|
+
Ok(ImageGenResponse {
|
|
456
|
+
images: vec![GeneratedImage {
|
|
457
|
+
data_uri,
|
|
458
|
+
mime_type: "image/png".to_owned(),
|
|
459
|
+
}],
|
|
460
|
+
model,
|
|
461
|
+
provider: "stability".to_owned(),
|
|
462
|
+
prompt: request.prompt.clone(),
|
|
463
|
+
description: None,
|
|
464
|
+
})
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
468
|
+
// Ideogram — Ideogram v3 REST API
|
|
469
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
async fn call_ideogram(
|
|
472
|
+
client: &Client,
|
|
473
|
+
config: &MintConfig,
|
|
474
|
+
request: &ImageGenRequest,
|
|
475
|
+
) -> Result<ImageGenResponse, ImageGenError> {
|
|
476
|
+
let api_key = if config.ideogram_api_key.trim().is_empty() {
|
|
477
|
+
std::env::var("IDEOGRAM_API_KEY").unwrap_or_default()
|
|
478
|
+
} else {
|
|
479
|
+
config.ideogram_api_key.clone()
|
|
480
|
+
};
|
|
481
|
+
if api_key.trim().is_empty() {
|
|
482
|
+
return Err(ImageGenError::MissingIdeogramKey);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
let model = request
|
|
486
|
+
.model
|
|
487
|
+
.as_deref()
|
|
488
|
+
.map(str::trim)
|
|
489
|
+
.filter(|m| !m.is_empty())
|
|
490
|
+
.map(str::to_owned)
|
|
491
|
+
.unwrap_or_else(|| {
|
|
492
|
+
let m = config.ideogram_model.trim();
|
|
493
|
+
if m.is_empty() {
|
|
494
|
+
"V_3".to_owned()
|
|
495
|
+
} else {
|
|
496
|
+
m.to_owned()
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
let num_images = request.num_images.unwrap_or(1).clamp(1, 4);
|
|
501
|
+
let aspect_ratio = aspect_ratio_to_ideogram(request.aspect_ratio.as_deref());
|
|
502
|
+
|
|
503
|
+
let mut body = json!({
|
|
504
|
+
"image_request": {
|
|
505
|
+
"prompt": request.prompt,
|
|
506
|
+
"model": model,
|
|
507
|
+
"num_images": num_images,
|
|
508
|
+
"aspect_ratio": aspect_ratio,
|
|
509
|
+
"magic_prompt_option": "AUTO"
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
if let Some(neg) = &request.negative_prompt {
|
|
514
|
+
let neg = neg.trim();
|
|
515
|
+
if !neg.is_empty() {
|
|
516
|
+
body["image_request"]["negative_prompt"] = json!(neg);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
let response: Value = client
|
|
521
|
+
.post("https://api.ideogram.ai/generate")
|
|
522
|
+
.header("Api-Key", &api_key)
|
|
523
|
+
.json(&body)
|
|
524
|
+
.send()
|
|
525
|
+
.await?
|
|
526
|
+
.error_for_status()
|
|
527
|
+
.map_err(ImageGenError::Request)?
|
|
528
|
+
.json()
|
|
529
|
+
.await?;
|
|
530
|
+
|
|
531
|
+
if let Some(err) = response.get("error") {
|
|
532
|
+
let msg = err.as_str().unwrap_or("unknown Ideogram API error");
|
|
533
|
+
return Err(ImageGenError::ModelError(msg.to_owned()));
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Response shape: { data: [ { url: "..." }, ... ] }
|
|
537
|
+
let data = response["data"]
|
|
538
|
+
.as_array()
|
|
539
|
+
.ok_or(ImageGenError::UnexpectedResponse)?;
|
|
540
|
+
|
|
541
|
+
let mut images = Vec::new();
|
|
542
|
+
for item in data {
|
|
543
|
+
if let Some(url) = item["url"].as_str() {
|
|
544
|
+
if url.is_empty() {
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
// Fetch the image bytes and convert to base64 data URI
|
|
548
|
+
match fetch_url_as_data_uri(client, url).await {
|
|
549
|
+
Ok(data_uri) => images.push(GeneratedImage {
|
|
550
|
+
data_uri,
|
|
551
|
+
mime_type: "image/jpeg".to_owned(),
|
|
552
|
+
}),
|
|
553
|
+
Err(_) => continue,
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if images.is_empty() {
|
|
559
|
+
return Err(ImageGenError::NoImagesReturned);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
Ok(ImageGenResponse {
|
|
563
|
+
images,
|
|
564
|
+
model,
|
|
565
|
+
provider: "ideogram".to_owned(),
|
|
566
|
+
prompt: request.prompt.clone(),
|
|
567
|
+
description: None,
|
|
568
|
+
})
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
fn aspect_ratio_to_ideogram(ratio: Option<&str>) -> &'static str {
|
|
572
|
+
match ratio.unwrap_or("1:1") {
|
|
573
|
+
"16:9" => "ASPECT_16_9",
|
|
574
|
+
"9:16" => "ASPECT_9_16",
|
|
575
|
+
"4:3" => "ASPECT_4_3",
|
|
576
|
+
"3:4" => "ASPECT_3_4",
|
|
577
|
+
_ => "ASPECT_1_1",
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
582
|
+
// Replicate — Predictions API (supports FLUX, SDXL, etc.)
|
|
583
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
584
|
+
|
|
585
|
+
async fn call_replicate(
|
|
586
|
+
client: &Client,
|
|
587
|
+
config: &MintConfig,
|
|
588
|
+
request: &ImageGenRequest,
|
|
589
|
+
) -> Result<ImageGenResponse, ImageGenError> {
|
|
590
|
+
let api_key = if config.replicate_api_key.trim().is_empty() {
|
|
591
|
+
std::env::var("REPLICATE_API_TOKEN").unwrap_or_default()
|
|
592
|
+
} else {
|
|
593
|
+
config.replicate_api_key.clone()
|
|
594
|
+
};
|
|
595
|
+
if api_key.trim().is_empty() {
|
|
596
|
+
return Err(ImageGenError::MissingReplicateKey);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
let model = request
|
|
600
|
+
.model
|
|
601
|
+
.as_deref()
|
|
602
|
+
.map(str::trim)
|
|
603
|
+
.filter(|m| !m.is_empty())
|
|
604
|
+
.map(str::to_owned)
|
|
605
|
+
.unwrap_or_else(|| {
|
|
606
|
+
let m = config.replicate_model.trim();
|
|
607
|
+
if m.is_empty() {
|
|
608
|
+
"black-forest-labs/flux-1.1-pro".to_owned()
|
|
609
|
+
} else {
|
|
610
|
+
m.to_owned()
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
let num_images = request.num_images.unwrap_or(1).clamp(1, 4);
|
|
615
|
+
let aspect_ratio = request.aspect_ratio.as_deref().unwrap_or("1:1");
|
|
616
|
+
|
|
617
|
+
// Build input — common fields used by FLUX and SDXL families
|
|
618
|
+
let mut input = json!({
|
|
619
|
+
"prompt": request.prompt,
|
|
620
|
+
"num_outputs": num_images,
|
|
621
|
+
"aspect_ratio": aspect_ratio,
|
|
622
|
+
"output_format": "png",
|
|
623
|
+
"output_quality": 90
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
if let Some(neg) = &request.negative_prompt {
|
|
627
|
+
let neg = neg.trim();
|
|
628
|
+
if !neg.is_empty() {
|
|
629
|
+
input["negative_prompt"] = json!(neg);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// POST to /v1/models/{model}/predictions
|
|
634
|
+
let create_url = format!("https://api.replicate.com/v1/models/{model}/predictions");
|
|
635
|
+
let create_payload = json!({ "input": input });
|
|
636
|
+
|
|
637
|
+
let create_response: Value = client
|
|
638
|
+
.post(&create_url)
|
|
639
|
+
.bearer_auth(&api_key)
|
|
640
|
+
.header("Prefer", "wait=60") // ask Replicate to wait up to 60s synchronously
|
|
641
|
+
.json(&create_payload)
|
|
642
|
+
.send()
|
|
643
|
+
.await?
|
|
644
|
+
.error_for_status()
|
|
645
|
+
.map_err(ImageGenError::Request)?
|
|
646
|
+
.json()
|
|
647
|
+
.await?;
|
|
648
|
+
|
|
649
|
+
// If already succeeded (Prefer: wait worked), parse immediately
|
|
650
|
+
let prediction = if create_response["status"].as_str() == Some("succeeded") {
|
|
651
|
+
create_response
|
|
652
|
+
} else {
|
|
653
|
+
// Fall back to polling
|
|
654
|
+
let prediction_url = create_response["urls"]["get"]
|
|
655
|
+
.as_str()
|
|
656
|
+
.ok_or(ImageGenError::UnexpectedResponse)?
|
|
657
|
+
.to_owned();
|
|
658
|
+
|
|
659
|
+
poll_replicate(client, &api_key, &prediction_url).await?
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
if let Some(err) = prediction.get("error") {
|
|
663
|
+
if !err.is_null() {
|
|
664
|
+
let msg = err.as_str().unwrap_or("unknown Replicate error");
|
|
665
|
+
return Err(ImageGenError::ModelError(msg.to_owned()));
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Output is an array of image URLs
|
|
670
|
+
let output = prediction["output"]
|
|
671
|
+
.as_array()
|
|
672
|
+
.ok_or(ImageGenError::UnexpectedResponse)?;
|
|
673
|
+
|
|
674
|
+
let mut images = Vec::new();
|
|
675
|
+
for url_val in output {
|
|
676
|
+
if let Some(url) = url_val.as_str() {
|
|
677
|
+
match fetch_url_as_data_uri(client, url).await {
|
|
678
|
+
Ok(data_uri) => images.push(GeneratedImage {
|
|
679
|
+
data_uri,
|
|
680
|
+
mime_type: "image/png".to_owned(),
|
|
681
|
+
}),
|
|
682
|
+
Err(_) => continue,
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if images.is_empty() {
|
|
688
|
+
return Err(ImageGenError::NoImagesReturned);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
Ok(ImageGenResponse {
|
|
692
|
+
images,
|
|
693
|
+
model,
|
|
694
|
+
provider: "replicate".to_owned(),
|
|
695
|
+
prompt: request.prompt.clone(),
|
|
696
|
+
description: None,
|
|
697
|
+
})
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/// Poll a Replicate prediction URL until it reaches a terminal state.
|
|
701
|
+
async fn poll_replicate(client: &Client, api_key: &str, url: &str) -> Result<Value, ImageGenError> {
|
|
702
|
+
for _ in 0..60 {
|
|
703
|
+
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
|
704
|
+
|
|
705
|
+
let response: Value = client
|
|
706
|
+
.get(url)
|
|
707
|
+
.bearer_auth(api_key)
|
|
708
|
+
.send()
|
|
709
|
+
.await?
|
|
710
|
+
.error_for_status()
|
|
711
|
+
.map_err(ImageGenError::Request)?
|
|
712
|
+
.json()
|
|
713
|
+
.await?;
|
|
714
|
+
|
|
715
|
+
match response["status"].as_str() {
|
|
716
|
+
Some("succeeded") | Some("failed") | Some("canceled") => return Ok(response),
|
|
717
|
+
_ => continue,
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
Err(ImageGenError::ModelError(
|
|
722
|
+
"Replicate prediction timed out after 120 seconds".to_owned(),
|
|
723
|
+
))
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
727
|
+
// Shared helpers
|
|
728
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
729
|
+
|
|
730
|
+
/// Download an image URL and return it as a `data:<mime>;base64,<data>` URI.
|
|
731
|
+
async fn fetch_url_as_data_uri(client: &Client, url: &str) -> Result<String, ImageGenError> {
|
|
732
|
+
let resp = client
|
|
733
|
+
.get(url)
|
|
734
|
+
.send()
|
|
735
|
+
.await?
|
|
736
|
+
.error_for_status()
|
|
737
|
+
.map_err(ImageGenError::Request)?;
|
|
738
|
+
|
|
739
|
+
let mime_type = resp
|
|
740
|
+
.headers()
|
|
741
|
+
.get("content-type")
|
|
742
|
+
.and_then(|v| v.to_str().ok())
|
|
743
|
+
.map(|ct| {
|
|
744
|
+
ct.split(';')
|
|
745
|
+
.next()
|
|
746
|
+
.unwrap_or("image/jpeg")
|
|
747
|
+
.trim()
|
|
748
|
+
.to_owned()
|
|
749
|
+
})
|
|
750
|
+
.unwrap_or_else(|| "image/jpeg".to_owned());
|
|
751
|
+
|
|
752
|
+
let bytes = resp.bytes().await?;
|
|
753
|
+
if bytes.is_empty() {
|
|
754
|
+
return Err(ImageGenError::NoImagesReturned);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
let b64 = STANDARD.encode(&bytes);
|
|
758
|
+
Ok(format!("data:{mime_type};base64,{b64}"))
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/// Generate a short random hex string suitable for a multipart boundary.
|
|
762
|
+
/// Uses `/dev/urandom` on Linux/macOS via the `sha2` crate's internal
|
|
763
|
+
/// randomness; falls back to a time-based seed. No extra crate required.
|
|
764
|
+
fn uuid_v4_hex() -> String {
|
|
765
|
+
use std::time::{SystemTime, UNIX_EPOCH};
|
|
766
|
+
// Mix nanos + a simple linear-congruential step for fast unique strings
|
|
767
|
+
let seed = SystemTime::now()
|
|
768
|
+
.duration_since(UNIX_EPOCH)
|
|
769
|
+
.map(|d| d.subsec_nanos())
|
|
770
|
+
.unwrap_or(12345);
|
|
771
|
+
format!(
|
|
772
|
+
"{:016x}{:016x}",
|
|
773
|
+
seed as u64 ^ 0xDEAD_BEEF_CAFE_BABE,
|
|
774
|
+
seed as u64
|
|
775
|
+
)
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/// Encode `fields` as a `multipart/form-data` text-only body for the given boundary.
|
|
779
|
+
fn build_multipart_text(boundary: &str, fields: &[(String, String)]) -> String {
|
|
780
|
+
let mut body = String::new();
|
|
781
|
+
for (name, value) in fields {
|
|
782
|
+
body.push_str(&format!("--{boundary}\r\n"));
|
|
783
|
+
body.push_str(&format!(
|
|
784
|
+
"Content-Disposition: form-data; name=\"{name}\"\r\n\r\n"
|
|
785
|
+
));
|
|
786
|
+
body.push_str(value);
|
|
787
|
+
body.push_str("\r\n");
|
|
788
|
+
}
|
|
789
|
+
body.push_str(&format!("--{boundary}--\r\n"));
|
|
790
|
+
body
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/// Build the final prompt string, optionally embedding aspect ratio and negative
|
|
794
|
+
/// prompt guidance. Used by providers that accept a plain text prompt.
|
|
795
|
+
fn build_prompt(request: &ImageGenRequest) -> String {
|
|
796
|
+
let mut prompt = request.prompt.trim().to_owned();
|
|
797
|
+
|
|
798
|
+
if let Some(ref neg) = request.negative_prompt {
|
|
799
|
+
let neg = neg.trim();
|
|
800
|
+
if !neg.is_empty() {
|
|
801
|
+
prompt.push_str(&format!(
|
|
802
|
+
"\n\nNegative prompt (avoid these elements): {neg}"
|
|
803
|
+
));
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
if let Some(ref ratio) = request.aspect_ratio {
|
|
808
|
+
let ratio = ratio.trim();
|
|
809
|
+
if !ratio.is_empty() && ratio != "1:1" {
|
|
810
|
+
prompt.push_str(&format!(
|
|
811
|
+
"\n\nGenerate the image with a {ratio} aspect ratio."
|
|
812
|
+
));
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
prompt
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
820
|
+
// Tests
|
|
821
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
822
|
+
|
|
823
|
+
#[cfg(test)]
|
|
824
|
+
mod tests {
|
|
825
|
+
use super::*;
|
|
826
|
+
|
|
827
|
+
fn make_request(prompt: &str) -> ImageGenRequest {
|
|
828
|
+
ImageGenRequest {
|
|
829
|
+
prompt: prompt.to_owned(),
|
|
830
|
+
negative_prompt: None,
|
|
831
|
+
aspect_ratio: None,
|
|
832
|
+
num_images: None,
|
|
833
|
+
model: None,
|
|
834
|
+
provider: None,
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
#[test]
|
|
839
|
+
fn build_prompt_plain() {
|
|
840
|
+
let req = make_request("a red apple");
|
|
841
|
+
assert_eq!(build_prompt(&req), "a red apple");
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
#[test]
|
|
845
|
+
fn build_prompt_with_negative_and_ratio() {
|
|
846
|
+
let req = ImageGenRequest {
|
|
847
|
+
prompt: "a cat".to_owned(),
|
|
848
|
+
negative_prompt: Some("blurry, low quality".to_owned()),
|
|
849
|
+
aspect_ratio: Some("16:9".to_owned()),
|
|
850
|
+
num_images: Some(2),
|
|
851
|
+
model: None,
|
|
852
|
+
provider: None,
|
|
853
|
+
};
|
|
854
|
+
let result = build_prompt(&req);
|
|
855
|
+
assert!(result.contains("a cat"));
|
|
856
|
+
assert!(result.contains("Negative prompt"));
|
|
857
|
+
assert!(result.contains("16:9"));
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
#[test]
|
|
861
|
+
fn build_prompt_default_ratio_not_added() {
|
|
862
|
+
let req = ImageGenRequest {
|
|
863
|
+
prompt: "a dog".to_owned(),
|
|
864
|
+
negative_prompt: None,
|
|
865
|
+
aspect_ratio: Some("1:1".to_owned()),
|
|
866
|
+
num_images: None,
|
|
867
|
+
model: None,
|
|
868
|
+
provider: None,
|
|
869
|
+
};
|
|
870
|
+
assert!(!build_prompt(&req).contains("aspect ratio"));
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
#[test]
|
|
874
|
+
fn num_images_clamped() {
|
|
875
|
+
assert_eq!(5u8.clamp(1, 4), 4);
|
|
876
|
+
assert_eq!(0u8.clamp(1, 4), 1);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
#[test]
|
|
880
|
+
fn dalle_size_mapping() {
|
|
881
|
+
assert_eq!(aspect_ratio_to_dalle_size(Some("16:9")), "1792x1024");
|
|
882
|
+
assert_eq!(aspect_ratio_to_dalle_size(Some("9:16")), "1024x1792");
|
|
883
|
+
assert_eq!(aspect_ratio_to_dalle_size(Some("1:1")), "1024x1024");
|
|
884
|
+
assert_eq!(aspect_ratio_to_dalle_size(None), "1024x1024");
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
#[test]
|
|
888
|
+
fn ideogram_ratio_mapping() {
|
|
889
|
+
assert_eq!(aspect_ratio_to_ideogram(Some("16:9")), "ASPECT_16_9");
|
|
890
|
+
assert_eq!(aspect_ratio_to_ideogram(Some("9:16")), "ASPECT_9_16");
|
|
891
|
+
assert_eq!(aspect_ratio_to_ideogram(Some("4:3")), "ASPECT_4_3");
|
|
892
|
+
assert_eq!(aspect_ratio_to_ideogram(Some("1:1")), "ASPECT_1_1");
|
|
893
|
+
assert_eq!(aspect_ratio_to_ideogram(None), "ASPECT_1_1");
|
|
894
|
+
}
|
|
895
|
+
}
|