diff --git a/crates/openplotva-app/src/image_edit_references.rs b/crates/openplotva-app/src/image_edit_references.rs new file mode 100644 index 00000000..39400776 --- /dev/null +++ b/crates/openplotva-app/src/image_edit_references.rs @@ -0,0 +1,284 @@ +//! Source pictures of an image edit, prepared for the prompt optimizer the way the +//! FLUX.3 prompt upsampler sees its references. + +use std::{fmt, future::Future, io::Cursor, pin::Pin}; + +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use futures_util::StreamExt; +use image::{ + DynamicImage, ImageDecoder, ImageReader, Limits, Rgb, RgbImage, codecs::jpeg::JpegEncoder, + imageops::FilterType, metadata::Orientation, +}; +use openplotva_telegram::TelegramClient; + +use crate::image_jobs::ImageEditRequest; + +const MAX_REFERENCE_BYTES: usize = 20 * 1024 * 1024; +const MAX_REFERENCE_SIDE: u32 = 16_384; +const MAX_REFERENCE_DECODER_ALLOC_BYTES: u64 = 512 * 1024 * 1024; +const REFERENCE_JPEG_QUALITY: u8 = 92; +const TELEGRAM_FILE_URL_PREFIX: &str = "https://api.telegram.org/file/bot"; + +/// Future returned by [`ImageEditReferenceLoader::load_edit_references`]. +pub type ImageEditReferencesFuture<'a> = + Pin>, String>> + Send + 'a>>; + +/// Loads the encoded source pictures of an edit request, in request order. +pub trait ImageEditReferenceLoader: fmt::Debug + Send + Sync { + fn load_edit_references<'a>( + &'a self, + request: &'a ImageEditRequest, + ) -> ImageEditReferencesFuture<'a>; +} + +/// Downloads edit pictures through the bot's Telegram client. Telegram file URLs carry +/// the bot token, so only their file path is used and errors never repeat the URL. +#[derive(Clone)] +pub struct TelegramImageEditReferenceLoader { + client: TelegramClient, +} + +impl TelegramImageEditReferenceLoader { + #[must_use] + pub const fn new(client: TelegramClient) -> Self { + Self { client } + } + + async fn file_path(&self, file_id: &str) -> Result { + let file: carapax::types::File = self + .client + .execute(carapax::types::GetFile::new(file_id)) + .await + .map_err(|error| format!("get telegram file: {error}"))?; + file.file_path + .map(|path| path.trim().to_owned()) + .filter(|path| !path.is_empty()) + .ok_or_else(|| "telegram file_path is empty".to_owned()) + } + + async fn download(&self, file_path: &str) -> Result, String> { + let mut stream = self + .client + .download_file(file_path) + .await + .map_err(|_| "download telegram file failed".to_owned())?; + let mut data = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| "download telegram file chunk failed".to_owned())?; + if data.len().saturating_add(chunk.len()) > MAX_REFERENCE_BYTES { + return Err("telegram image exceeds 20 MiB".to_owned()); + } + data.extend_from_slice(&chunk); + } + Ok(data) + } +} + +impl fmt::Debug for TelegramImageEditReferenceLoader { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("TelegramImageEditReferenceLoader") + .finish_non_exhaustive() + } +} + +impl ImageEditReferenceLoader for TelegramImageEditReferenceLoader { + fn load_edit_references<'a>( + &'a self, + request: &'a ImageEditRequest, + ) -> ImageEditReferencesFuture<'a> { + Box::pin(async move { + let inputs: Vec<&str> = request + .photo_urls + .iter() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .collect(); + if inputs.is_empty() { + let file_id = request.photo_file_id.trim(); + if file_id.is_empty() { + return Ok(Vec::new()); + } + let path = self.file_path(file_id).await?; + return Ok(vec![self.download(&path).await?]); + } + let mut images = Vec::with_capacity(inputs.len()); + for input in inputs { + images.push(match telegram_file_path(input) { + Some(path) => self.download(path).await?, + None => decode_inline_image(input)?, + }); + } + Ok(images) + }) + } +} + +fn telegram_file_path(url: &str) -> Option<&str> { + let rest = url.strip_prefix(TELEGRAM_FILE_URL_PREFIX)?; + let (_, path) = rest.split_once('/')?; + (!path.is_empty()).then_some(path) +} + +fn decode_inline_image(value: &str) -> Result, String> { + if value.starts_with("http://") || value.starts_with("https://") { + return Err("unsupported reference image URL".to_owned()); + } + let encoded = value + .strip_prefix("data:") + .and_then(|rest| rest.split_once(',')) + .map_or(value, |(_, data)| data); + BASE64_STANDARD + .decode(encoded.trim()) + .map_err(|error| format!("decode reference image: {error}")) +} + +/// Data URLs of `images` as the FLUX.3 prompt upsampler sees its references: upright, +/// alpha flattened onto white, resized for the number of pictures, JPEG at quality 92. +/// Fails when any picture cannot be prepared, so the request's picture numbering never +/// shifts. +pub fn upsampler_reference_data_urls(images: &[Vec]) -> Result, String> { + let count = images.len(); + images + .iter() + .enumerate() + .map(|(index, bytes)| { + upsampler_reference_data_url(bytes, count) + .map_err(|error| format!("reference image {}: {error}", index + 1)) + }) + .collect() +} + +fn upsampler_reference_data_url(bytes: &[u8], count: usize) -> Result { + if bytes.len() > MAX_REFERENCE_BYTES { + return Err("image exceeds 20 MiB".to_owned()); + } + let mut reader = ImageReader::new(Cursor::new(bytes)) + .with_guessed_format() + .map_err(|error| format!("read image: {error}"))?; + let mut limits = Limits::default(); + limits.max_image_width = Some(MAX_REFERENCE_SIDE); + limits.max_image_height = Some(MAX_REFERENCE_SIDE); + limits.max_alloc = Some(MAX_REFERENCE_DECODER_ALLOC_BYTES); + reader.limits(limits); + let mut decoder = reader + .into_decoder() + .map_err(|error| format!("decode image: {error}"))?; + let orientation = decoder.orientation().unwrap_or(Orientation::NoTransforms); + let mut image = + DynamicImage::from_decoder(decoder).map_err(|error| format!("decode image: {error}"))?; + image.apply_orientation(orientation); + let rgb = flatten_onto_white(&image); + let (width, height) = + openplotva_media::upsampler_reference_size(rgb.width(), rgb.height(), count) + .ok_or_else(|| format!("unsupported image size {}x{}", rgb.width(), rgb.height()))?; + let rgb = if rgb.dimensions() == (width, height) { + rgb + } else { + image::imageops::resize(&rgb, width, height, FilterType::Lanczos3) + }; + let mut jpeg = Vec::new(); + DynamicImage::ImageRgb8(rgb) + .write_with_encoder(JpegEncoder::new_with_quality( + &mut jpeg, + REFERENCE_JPEG_QUALITY, + )) + .map_err(|error| format!("encode image: {error}"))?; + Ok(format!( + "data:image/jpeg;base64,{}", + BASE64_STANDARD.encode(jpeg) + )) +} + +fn flatten_onto_white(image: &DynamicImage) -> RgbImage { + if !image.color().has_alpha() { + return image.to_rgb8(); + } + let rgba = image.to_rgba8(); + RgbImage::from_fn(rgba.width(), rgba.height(), |x, y| { + let [red, green, blue, alpha] = rgba.get_pixel(x, y).0; + let alpha = u16::from(alpha); + let blend = |channel: u8| { + u8::try_from((u16::from(channel) * alpha + 255 * (255 - alpha) + 127) / 255) + .unwrap_or(u8::MAX) + }; + Rgb([blend(red), blend(green), blend(blue)]) + }) +} + +#[cfg(test)] +mod tests { + use image::{GenericImageView, ImageFormat, Rgba, RgbaImage}; + + use super::*; + + fn png(width: u32, height: u32, pixel: Rgba) -> Vec { + let mut bytes = Cursor::new(Vec::new()); + DynamicImage::ImageRgba8(RgbaImage::from_pixel(width, height, pixel)) + .write_to(&mut bytes, ImageFormat::Png) + .expect("encode png"); + bytes.into_inner() + } + + fn decoded(data_url: &str) -> DynamicImage { + let encoded = data_url + .strip_prefix("data:image/jpeg;base64,") + .expect("jpeg data url"); + image::load_from_memory(&BASE64_STANDARD.decode(encoded).expect("base64")) + .expect("decode jpeg") + } + + #[test] + fn references_are_resized_for_the_picture_count() { + let one = upsampler_reference_data_urls(&[png(1000, 1000, Rgba([10, 20, 30, 255]))]) + .expect("one reference"); + assert_eq!(decoded(&one[0]).dimensions(), (992, 992)); + + let pictures = vec![png(1280, 960, Rgba([0, 0, 0, 255])); 3]; + let three = upsampler_reference_data_urls(&pictures).expect("three references"); + assert_eq!(three.len(), 3); + assert_eq!(decoded(&three[2]).dimensions(), (576, 416)); + } + + #[test] + fn transparent_pixels_become_white() { + let urls = + upsampler_reference_data_urls(&[png(64, 64, Rgba([0, 0, 0, 0]))]).expect("reference"); + let pixel = decoded(&urls[0]).to_rgb8().get_pixel(32, 32).0; + assert!(pixel.iter().all(|channel| *channel > 245), "{pixel:?}"); + } + + #[test] + fn one_unreadable_picture_fails_the_whole_set() { + let error = upsampler_reference_data_urls(&[ + png(64, 64, Rgba([0, 0, 0, 255])), + b"not an image".to_vec(), + ]) + .expect_err("broken second picture"); + assert!(error.starts_with("reference image 2:"), "{error}"); + } + + #[test] + fn telegram_file_urls_resolve_to_their_file_path() { + assert_eq!( + telegram_file_path("https://api.telegram.org/file/bot123:abc/photos/file_7.jpg"), + Some("photos/file_7.jpg") + ); + assert_eq!( + telegram_file_path("https://example.com/file/bot1/a.jpg"), + None + ); + } + + #[test] + fn inline_pictures_decode_from_base64_or_data_urls() { + let bytes = png(8, 8, Rgba([1, 2, 3, 255])); + let encoded = BASE64_STANDARD.encode(&bytes); + assert_eq!(decode_inline_image(&encoded).expect("raw base64"), bytes); + assert_eq!( + decode_inline_image(&format!("data:image/png;base64,{encoded}")).expect("data url"), + bytes + ); + assert!(decode_inline_image("https://example.com/a.png").is_err()); + } +} diff --git a/crates/openplotva-app/src/image_jobs.rs b/crates/openplotva-app/src/image_jobs.rs index 52500fc4..de761053 100644 --- a/crates/openplotva-app/src/image_jobs.rs +++ b/crates/openplotva-app/src/image_jobs.rs @@ -42,6 +42,7 @@ use serde_json::{Value, json}; use time::OffsetDateTime; use tokio::time::Instant; +use crate::image_edit_references::{ImageEditReferenceLoader, upsampler_reference_data_urls}; use crate::media::{MediaPromptOptimizer, MediaPromptOptimizerService}; use crate::routed_attempts::{ RoutedAttempt, RoutedAttemptRunError, RoutedAttemptWalker, RoutedRequestContext, @@ -72,6 +73,7 @@ pub type ImageJobEffectFuture<'a, T> = Pin + Send + ' pub const AIFARM_DRAW_API_SERVICE_NAME: &str = "draw-api"; pub const AIFARM_DRAW_API_ENDPOINT_NAME: &str = "generate"; +pub const AIFARM_DRAW_API_FLUX3_ENDPOINT_NAME: &str = "flux3_generate"; pub const AIFARM_DRAW_API_BOOGU_TURBO_ENDPOINT_NAME: &str = "boogu_turbo_generate"; pub const AIFARM_DRAW_API_BOOGU_EDIT_ENDPOINT_NAME: &str = "boogu_edit_generate"; pub const AIFARM_DRAW_API_DEFAULT_BASE_URL: &str = "http://127.0.0.1:50051"; @@ -1087,6 +1089,7 @@ fn add_flux_watermark(encoded: &[u8]) -> Result, String> { fn is_flux_generation_endpoint(endpoint_name: &str) -> bool { endpoint_name.eq_ignore_ascii_case(AIFARM_DRAW_API_ENDPOINT_NAME) + || endpoint_name.eq_ignore_ascii_case(AIFARM_DRAW_API_FLUX3_ENDPOINT_NAME) } fn blend_flux_watermark_channel(source: u8, target: u8) -> u8 { @@ -1476,7 +1479,7 @@ pub trait ImageGenerator { /// Image model each prompt slot is written for, in slot order. fn image_targets(&self) -> openplotva_media::ImageTargets { - openplotva_media::ImageTargets::KLEIN + openplotva_media::ImageTargets::FLUX3 } /// Generate and send an image. @@ -1511,7 +1514,7 @@ pub trait ImageEditor { /// Image model each prompt slot is written for, in slot order. fn image_targets(&self) -> openplotva_media::ImageTargets { - openplotva_media::ImageTargets::KLEIN + openplotva_media::ImageTargets::FLUX3 } /// Edit and send an image. @@ -1692,8 +1695,8 @@ fn routed_image_targets( .primary_model_config(workflow_key) .as_ref() .and_then(prompt_target_model) - .map_or_else( - || image_targets_for_workflow(workflow_key), + .map_or( + openplotva_media::ImageTargets::FLUX3, openplotva_media::ImageTargets::single, ) } @@ -1705,18 +1708,6 @@ fn prompt_target_model(config: &serde_json::Value) -> Option openplotva_media::ImageTargets { - match workflow_key { - IMAGE_GENERATION_BOOGU_TURBO_WORKFLOW_KEY | IMAGE_EDIT_BOOGU_TURBO_WORKFLOW_KEY => { - openplotva_media::ImageTargets::BOOGU - } - _ => openplotva_media::ImageTargets::KLEIN, - } -} - fn image_edit_context(workflow_key: &str, request: &ImageEditRequest) -> RoutedRequestContext { RoutedRequestContext { workflow_key: workflow_key.to_owned(), @@ -1940,13 +1931,25 @@ where pub struct OptimizingImageEditor { editor: Editor, optimizer: MediaPromptOptimizerService, + references: Option>, } impl OptimizingImageEditor { /// Build an optimizing editor around an existing provider. #[must_use] pub const fn new(editor: Editor, optimizer: MediaPromptOptimizerService) -> Self { - Self { editor, optimizer } + Self { + editor, + optimizer, + references: None, + } + } + + /// Show the optimizer the pictures being edited, loaded by `references`. + #[must_use] + pub fn with_reference_loader(mut self, references: Arc) -> Self { + self.references = Some(references); + self } } @@ -1965,14 +1968,49 @@ where fn edit_image<'a>(&'a self, request: ImageEditRequest) -> ImageEditFuture<'a> { Box::pin(async move { - let request = - optimized_image_edit_request(&self.optimizer, request, self.editor.image_targets()) - .await?; + let images = match self.references.as_deref() { + Some(references) => edit_reference_data_urls(references, &request).await, + None => Vec::new(), + }; + let request = optimized_image_edit_request( + &self.optimizer, + request, + &images, + self.editor.image_targets(), + ) + .await?; self.editor.edit_image(request).await }) } } +/// The edit's pictures as the optimizer should see them, or none when any of them +/// cannot be loaded: a partial set would renumber the pictures the request names. +async fn edit_reference_data_urls( + references: &dyn ImageEditReferenceLoader, + request: &ImageEditRequest, +) -> Vec { + let images = match references.load_edit_references(request).await { + Ok(images) if !images.is_empty() => images, + Ok(_) => return Vec::new(), + Err(error) => { + tracing::debug!(%error, "image edit pictures unavailable to the prompt optimizer"); + return Vec::new(); + } + }; + match tokio::task::spawn_blocking(move || upsampler_reference_data_urls(&images)).await { + Ok(Ok(urls)) => urls, + Ok(Err(error)) => { + tracing::debug!(%error, "image edit pictures unavailable to the prompt optimizer"); + Vec::new() + } + Err(error) => { + tracing::debug!(%error, "image edit picture preparation stopped"); + Vec::new() + } + } +} + async fn optimized_image_generation_request( optimizer: &MediaPromptOptimizerService, context: Option<&dyn ImageContextProvider>, @@ -2074,6 +2112,7 @@ fn extract_prompt_modifiers(request: &mut ImageGenerationRequest) { async fn optimized_image_edit_request( optimizer: &MediaPromptOptimizerService, mut request: ImageEditRequest, + images: &[String], targets: openplotva_media::ImageTargets, ) -> Result where @@ -2086,6 +2125,7 @@ where let optimized = optimizer .enhance_image_edit_prompt( &original_prompt, + images, openplotva_media::OptimizePromptOptions { variant_count: 1, targets, @@ -6693,6 +6733,101 @@ mod tests { ); } + #[derive(Debug)] + struct ReferenceLoaderStub(Result>, String>); + + impl crate::image_edit_references::ImageEditReferenceLoader for ReferenceLoaderStub { + fn load_edit_references<'a>( + &'a self, + _request: &'a ImageEditRequest, + ) -> crate::image_edit_references::ImageEditReferencesFuture<'a> { + let result = self.0.clone(); + Box::pin(async move { result }) + } + } + + fn png_bytes(width: u32, height: u32) -> Vec { + let mut bytes = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgb8(image::RgbImage::new(width, height)) + .write_to(&mut bytes, image::ImageFormat::Png) + .expect("encode png"); + bytes.into_inner() + } + + #[tokio::test] + async fn optimizing_image_editor_shows_the_optimizer_every_picture() { + let editor = EditorStub::success(vec!["https://img.test/edit.png".to_owned()]); + let optimizer = + OptimizerStub::default().with_edit_result(openplotva_media::ImageEditOptimize { + input: "swap them".to_owned(), + outputs: vec!["Create a photograph preserving the first image".to_owned()], + nsfw_result: openplotva_media::NsfwResult::Safe, + }); + let optimizing = OptimizingImageEditor::new( + editor.clone(), + crate::media::MediaPromptOptimizerService::new(Some(optimizer.clone())), + ) + .with_reference_loader(Arc::new(ReferenceLoaderStub(Ok(vec![ + png_bytes(1000, 1000), + png_bytes(640, 480), + ])))); + + optimizing + .edit_image(ImageEditRequest { + prompt: "swap them".to_owned(), + photo_urls: vec!["a".to_owned(), "b".to_owned()], + ..ImageEditRequest::default() + }) + .await + .expect("edit"); + + let seen = optimizer.edit_images(); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].len(), 2); + assert!( + seen[0] + .iter() + .all(|url| url.starts_with("data:image/jpeg;base64,")) + ); + assert_eq!( + editor.requests()[0].photo_urls, + vec!["a".to_owned(), "b".to_owned()] + ); + } + + #[tokio::test] + async fn optimizing_image_editor_optimizes_text_only_when_pictures_fail() { + let editor = EditorStub::success(vec!["https://img.test/edit.png".to_owned()]); + let optimizer = + OptimizerStub::default().with_edit_result(openplotva_media::ImageEditOptimize { + input: "make it day".to_owned(), + outputs: vec!["make the scene bright daylight".to_owned()], + nsfw_result: openplotva_media::NsfwResult::Safe, + }); + let optimizing = OptimizingImageEditor::new( + editor.clone(), + crate::media::MediaPromptOptimizerService::new(Some(optimizer.clone())), + ) + .with_reference_loader(Arc::new(ReferenceLoaderStub(Err( + "download telegram file failed".to_owned(), + )))); + + optimizing + .edit_image(ImageEditRequest { + prompt: "make it day".to_owned(), + photo_file_id: "photo-file".to_owned(), + ..ImageEditRequest::default() + }) + .await + .expect("edit"); + + assert_eq!(optimizer.edit_images(), vec![Vec::::new()]); + assert_eq!( + editor.requests()[0].prompt, + "make the scene bright daylight" + ); + } + #[tokio::test] async fn image_gen_queue_worker_completes_and_stores_generated_url() { let queue = InMemoryTaskQueue::new(); @@ -7681,14 +7816,14 @@ mod tests { #[test] fn image_prompt_targets_follow_the_routed_primary_model() { - let qwen = image_route_walker(json!({ + let flux3 = image_route_walker(json!({ "service_name": AIFARM_DRAW_API_SERVICE_NAME, - "endpoint_name": "qwen_image_generate", - "prompt_target": "qwen_image", + "endpoint_name": "flux3_generate", + "prompt_target": "flux3", })); assert_eq!( - routed_image_targets(&qwen, IMAGE_GENERATION_FLUX_WORKFLOW_KEY), - openplotva_media::ImageTargets::QWEN_IMAGE + routed_image_targets(&flux3, IMAGE_GENERATION_BOOGU_TURBO_WORKFLOW_KEY), + openplotva_media::ImageTargets::FLUX3 ); let krea2 = image_route_walker(json!({ @@ -7701,17 +7836,20 @@ mod tests { openplotva_media::ImageTargets::KREA2 ); - let untagged = image_route_walker(json!({"endpoint_name": "generate"})); + let retired = image_route_walker(json!({ + "endpoint_name": "qwen_image_generate", + "prompt_target": "qwen_image", + })); assert_eq!( - routed_image_targets(&untagged, IMAGE_GENERATION_FLUX_WORKFLOW_KEY), - openplotva_media::ImageTargets::KLEIN + routed_image_targets(&retired, IMAGE_EDIT_WORKFLOW_KEY), + openplotva_media::ImageTargets::FLUX3 ); assert_eq!( routed_image_targets( &empty_routed_attempt_walker(), IMAGE_GENERATION_BOOGU_TURBO_WORKFLOW_KEY ), - openplotva_media::ImageTargets::BOOGU + openplotva_media::ImageTargets::FLUX3 ); } @@ -7743,8 +7881,12 @@ mod tests { } #[test] - fn flux_watermark_is_limited_to_the_primary_generation_endpoint() { + fn flux_watermark_is_limited_to_the_flux_generation_endpoints() { assert!(is_flux_generation_endpoint(AIFARM_DRAW_API_ENDPOINT_NAME)); + assert!(is_flux_generation_endpoint( + AIFARM_DRAW_API_FLUX3_ENDPOINT_NAME + )); + assert!(!is_flux_generation_endpoint("krea2_generate")); assert!(!is_flux_generation_endpoint( AIFARM_DRAW_API_BOOGU_TURBO_ENDPOINT_NAME )); @@ -9650,6 +9792,7 @@ mod tests { >, >, calls: Arc>>, + edit_images: Arc>>>, } impl OptimizerStub { @@ -9666,6 +9809,13 @@ mod tests { fn calls(&self) -> Vec { self.calls.lock().expect("optimizer calls").clone() } + + fn edit_images(&self) -> Vec> { + self.edit_images + .lock() + .expect("optimizer edit images") + .clone() + } } impl crate::media::MediaPromptOptimizer for OptimizerStub { @@ -9694,12 +9844,17 @@ mod tests { fn optimize_image_edit_prompt<'a>( &'a self, text: &'a str, + images: &'a [String], options: openplotva_media::OptimizePromptOptions, ) -> crate::media::ImageEditPromptOptimizeFuture<'a> { self.calls .lock() .expect("optimizer calls") .push(format!("edit:{text}:{}", options.variant_count)); + self.edit_images + .lock() + .expect("optimizer edit images") + .push(images.to_vec()); let result = self .edit_result .lock() diff --git a/crates/openplotva-app/src/lib.rs b/crates/openplotva-app/src/lib.rs index 5aae2dd8..023ecd27 100644 --- a/crates/openplotva-app/src/lib.rs +++ b/crates/openplotva-app/src/lib.rs @@ -26,6 +26,7 @@ pub mod gradius_utility_outbox; pub mod guest; pub mod help; pub mod history_summary; +pub mod image_edit_references; pub mod image_jobs; pub mod ingestion_telemetry; pub mod inline; @@ -13335,7 +13336,10 @@ async fn start_runtime_workers( ) .with_workflow_key(VIP_IMAGE_EDITOR_WORKFLOW_KEY), media_prompt_optimizer.clone(), - ); + ) + .with_reference_loader(Arc::new( + image_edit_references::TelegramImageEditReferenceLoader::new(telegram.clone()), + )); let mut vip_image_edit_effects = image_jobs::TelegramImageJobEffects::new(telegram.clone()) .with_last_generation_writer(Arc::new(service_clients.redis.last_generation_store())); { diff --git a/crates/openplotva-app/src/media.rs b/crates/openplotva-app/src/media.rs index f99625db..d9f3d6c9 100644 --- a/crates/openplotva-app/src/media.rs +++ b/crates/openplotva-app/src/media.rs @@ -37,10 +37,12 @@ pub trait MediaPromptOptimizer: Send + Sync { options: OptimizePromptOptions, ) -> ImagePromptOptimizeFuture<'a>; - /// Execute image edit-prompt optimization. + /// Execute image edit-prompt optimization; `images` are data URLs of the pictures + /// being edited, in the order the request numbers them. fn optimize_image_edit_prompt<'a>( &'a self, text: &'a str, + images: &'a [String], options: OptimizePromptOptions, ) -> ImageEditPromptOptimizeFuture<'a>; } @@ -159,14 +161,19 @@ where fn optimize_image_edit_prompt<'a>( &'a self, text: &'a str, + images: &'a [String], options: OptimizePromptOptions, ) -> ImageEditPromptOptimizeFuture<'a> { Box::pin(async move { - match self.primary.optimize_image_edit_prompt(text, options).await { + match self + .primary + .optimize_image_edit_prompt(text, images, options) + .await + { Ok(value) => Ok(value), Err(primary_error) => self .fallback - .optimize_image_edit_prompt(text, options) + .optimize_image_edit_prompt(text, images, options) .await .map_err(|fallback_error| { combined_optimizer_error( @@ -246,12 +253,14 @@ impl MediaPromptOptimizer for RoutedMediaPromptOptimizer { fn optimize_image_edit_prompt<'a>( &'a self, text: &'a str, + images: &'a [String], options: OptimizePromptOptions, ) -> ImageEditPromptOptimizeFuture<'a> { Box::pin(async move { let config = self.config.clone(); let prompt_store = Arc::clone(&self.prompt_store); let text = text.to_owned(); + let images = images.to_vec(); let result = self .walker .run( @@ -260,13 +269,16 @@ impl MediaPromptOptimizer for RoutedMediaPromptOptimizer { let config = config.clone(); let prompt_store = prompt_store.clone(); let text = text.clone(); + let images = images.clone(); async move { let optimizer = media_optimizer_for_attempt_with_prompt_store( &config, &attempt, &prompt_store, )?; - optimizer.optimize_image_edit_prompt(&text, options).await + optimizer + .optimize_image_edit_prompt(&text, &images, options) + .await } }, media_optimizer_retryable_reason, @@ -421,9 +433,11 @@ where fn optimize_image_edit_prompt<'a>( &'a self, text: &'a str, + images: &'a [String], options: OptimizePromptOptions, ) -> ImageEditPromptOptimizeFuture<'a> { - self.as_ref().optimize_image_edit_prompt(text, options) + self.as_ref() + .optimize_image_edit_prompt(text, images, options) } } @@ -483,9 +497,12 @@ where } } + /// Optimize an edit request shown with `images`; on failure fall back to the + /// original request. pub async fn enhance_image_edit_prompt( &self, original_prompt: &str, + images: &[String], options: OptimizePromptOptions, ) -> MediaPromptOptimization { let fallback = ImageEditOptimize { @@ -500,7 +517,7 @@ where }; }; match optimizer - .optimize_image_edit_prompt(original_prompt, options) + .optimize_image_edit_prompt(original_prompt, images, options) .await { Ok(value) => MediaPromptOptimization { @@ -540,6 +557,7 @@ where fn optimize_image_edit_prompt<'a>( &'a self, text: &'a str, + images: &'a [String], options: OptimizePromptOptions, ) -> ImageEditPromptOptimizeFuture<'a> { Box::pin(async move { @@ -547,6 +565,7 @@ where AifarmStructuredJsonGenerator::optimize_image_edit_prompt( self, text, + images, options, &mut ignore_status, ) @@ -575,10 +594,11 @@ where fn optimize_image_edit_prompt<'a>( &'a self, text: &'a str, + images: &'a [String], options: OptimizePromptOptions, ) -> ImageEditPromptOptimizeFuture<'a> { Box::pin(async move { - GeminiMediaPromptOptimizer::optimize_image_edit_prompt(self, text, options) + GeminiMediaPromptOptimizer::optimize_image_edit_prompt(self, text, images, options) .await .map_err(|err| MediaPromptOptimizerError::Provider(err.to_string())) }) @@ -920,6 +940,7 @@ mod tests { fn optimize_image_edit_prompt<'a>( &'a self, _text: &'a str, + _images: &'a [String], _options: OptimizePromptOptions, ) -> ImageEditPromptOptimizeFuture<'a> { Box::pin(async move { @@ -1214,7 +1235,7 @@ mod tests { let service = MediaPromptOptimizerService::new(Some(optimizer)); let got = service - .enhance_image_edit_prompt("make it day", options(3)) + .enhance_image_edit_prompt("make it day", &[], options(3)) .await; assert_eq!(got.provider_error.as_deref(), Some("boom")); @@ -1291,6 +1312,7 @@ mod tests { let got = optimizer .optimize_image_edit_prompt( "make it day", + &[], OptimizePromptOptions { variant_count: 1, ..OptimizePromptOptions::default() diff --git a/crates/openplotva-llm/src/aifarm.rs b/crates/openplotva-llm/src/aifarm.rs index 7975a12b..23aa0d46 100644 --- a/crates/openplotva-llm/src/aifarm.rs +++ b/crates/openplotva-llm/src/aifarm.rs @@ -1914,6 +1914,8 @@ pub struct AifarmStructuredJsonRequest { pub max_tokens: i32, /// Per-request temperature. pub temperature: f64, + /// Image data URLs the last user message shows before its text. + pub images: Vec, } impl AifarmHistorySummaryConfig { @@ -2296,9 +2298,12 @@ where Ok(optimized) } + /// Optimize an edit request; `images` are data URLs of the pictures being edited, + /// in the order the request numbers them. pub async fn optimize_image_edit_prompt( &self, text: &str, + images: &[String], options: openplotva_media::OptimizePromptOptions, on_status: &mut (dyn FnMut(StatusUpdate) + Send), ) -> Result { @@ -2318,14 +2323,23 @@ where None => openplotva_media::render_image_edit_optimizer_prompt(render_options)?, }; let tool = openplotva_media::optimize_edit_prompt_terminator_definition(variant_count); + let images: Vec = images + .iter() + .map(|image| image.trim()) + .filter(|image| !image.is_empty()) + .take(openplotva_media::MAX_EDIT_REFERENCE_IMAGES) + .map(str::to_owned) + .collect(); + let user_text = openplotva_media::image_edit_optimizer_text(text, images.len()); let content = self .generate_json( AifarmStructuredJsonRequest { name: "optimize_edit_prompt".to_owned(), - messages: optimizer_messages(&prompt, text), + messages: optimizer_messages(&prompt, &user_text), schema: tool.input_schema, max_tokens: aifarm_optimizer_max_tokens(variant_count), temperature: 0.3, + images, ..AifarmStructuredJsonRequest::default() }, on_status, @@ -2392,18 +2406,26 @@ where request.max_tokens }; let (top_p, top_k) = worker_nucleus_for_model(&model); + let mut messages: Vec = request + .messages + .into_iter() + .map(|message| ChatMessage { + role: message.role, + content: message.content, + content_parts: Vec::new(), + ..ChatMessage::default() + }) + .collect(); + if let Some(message) = messages + .iter_mut() + .rev() + .find(|message| message.role.eq_ignore_ascii_case("user")) + { + message.content_parts = images_then_text_parts(&request.images, &message.content); + } ChatCompletionRequest { model, - messages: request - .messages - .into_iter() - .map(|message| ChatMessage { - role: message.role, - content: message.content, - content_parts: Vec::new(), - ..ChatMessage::default() - }) - .collect(), + messages, stream: false, response_format: Some(json!({ "type": "json_schema", @@ -2477,6 +2499,35 @@ pub fn aifarm_optimizer_max_tokens(variant_count: usize) -> i32 { i32::try_from(budget.max(4096)).unwrap_or(i32::MAX) } +/// Content parts showing `images` first and `text` after them; empty without images, +/// so the message keeps its plain string content. +fn images_then_text_parts(images: &[String], text: &str) -> Vec { + if images.is_empty() { + return Vec::new(); + } + let mut parts: Vec = images + .iter() + .map(|url| ChatContentPart { + part_type: "image_url".to_owned(), + text: String::new(), + image_url: Some(ChatImageUrlPart { + url: url.clone(), + detail: "auto".to_owned(), + }), + video_url: None, + cache_control: None, + }) + .collect(); + parts.push(ChatContentPart { + part_type: "text".to_owned(), + text: text.to_owned(), + image_url: None, + video_url: None, + cache_control: None, + }); + parts +} + fn optimizer_messages(system_prompt: &str, text: &str) -> Vec { vec![ AifarmStructuredJsonMessage { @@ -8260,7 +8311,7 @@ mod tests { .await?; assert_eq!(image.outputs[0].chars().count(), limit); let edit = generator - .optimize_image_edit_prompt("cat", Default::default(), &mut |_| {}) + .optimize_image_edit_prompt("cat", &[], Default::default(), &mut |_| {}) .await?; assert_eq!(edit.outputs[0].chars().count(), limit); Ok(()) @@ -8339,6 +8390,11 @@ mod tests { let edit = generator .optimize_image_edit_prompt( " edit it ", + &[ + "data:image/jpeg;base64,AAAA".to_owned(), + " ".to_owned(), + "data:image/jpeg;base64,BBBB".to_owned(), + ], openplotva_media::OptimizePromptOptions { variant_count: 2, ..Default::default() @@ -8397,7 +8453,14 @@ mod tests { edit_body["messages"][0]["content"], cached_system_content("custom edit variants=2") ); - assert_eq!(edit_body["messages"][1]["content"], "edit it"); + assert_eq!( + edit_body["messages"][1]["content"], + json!([ + {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,AAAA", "detail": "auto"}}, + {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,BBBB", "detail": "auto"}}, + {"type": "text", "text": "Reference images follow in order (the first image, the second image, ...).\nedit it"}, + ]) + ); assert_eq!(edit_body["max_tokens"], 4096); assert_eq!(edit_body["temperature"], 0.3); Ok(()) diff --git a/crates/openplotva-llm/src/gemini.rs b/crates/openplotva-llm/src/gemini.rs index 0a60bb93..e1533da7 100644 --- a/crates/openplotva-llm/src/gemini.rs +++ b/crates/openplotva-llm/src/gemini.rs @@ -1273,7 +1273,14 @@ where }; let tool = openplotva_media::optimize_prompt_terminator_definition(variant_count); let payload = self - .run_optimizer(&text, &prompt, &tool, &self.image_cache, "optimize_prompt") + .run_optimizer( + &text, + Vec::new(), + &prompt, + &tool, + &self.image_cache, + "optimize_prompt", + ) .await?; let optimized = serde_json::from_value(payload).map_err(GeminiMediaPromptOptimizerError::Decode)?; @@ -1284,9 +1291,12 @@ where )) } + /// Optimize an edit request; `images` are data URLs of the pictures being edited, + /// in the order the request numbers them. pub async fn optimize_image_edit_prompt( &self, text: &str, + images: &[String], options: openplotva_media::OptimizePromptOptions, ) -> Result { let (text, variant_count) = optimizer_text_and_count(text, options)?; @@ -1301,9 +1311,16 @@ where None => openplotva_media::render_image_edit_optimizer_prompt(render_options)?, }; let tool = openplotva_media::optimize_edit_prompt_terminator_definition(variant_count); + let images: Vec = images + .iter() + .filter_map(|image| gemini_inline_data_from_data_url(image.trim())) + .take(openplotva_media::MAX_EDIT_REFERENCE_IMAGES) + .collect(); + let user_text = openplotva_media::image_edit_optimizer_text(&text, images.len()); let payload = self .run_optimizer( - &text, + &user_text, + images, &prompt, &tool, &self.edit_cache, @@ -1351,13 +1368,14 @@ where async fn run_optimizer( &self, text: &str, + images: Vec, prompt: &str, tool: &openplotva_media::OptimizerTerminatorDefinition, cache: &GeminiExplicitCacheStore, flow: &str, ) -> Result { let model = self.cfg.model.clone(); - let mut request = gemini_optimizer_request(text, prompt, tool, &model); + let mut request = gemini_optimizer_request(text, images, prompt, tool, &model); self.resolve_and_apply_optimizer_cache(cache, &model, &mut request) .await; self.send_forced_tool_request(&model, &request, tool.name, flow) @@ -2104,10 +2122,22 @@ fn optimizer_text_and_count( fn gemini_optimizer_request( text: &str, + images: Vec, prompt: &str, tool: &openplotva_media::OptimizerTerminatorDefinition, model: &str, ) -> GeminiGenerateContentRequest { + let mut parts: Vec = images + .into_iter() + .map(|inline_data| GeminiPart { + inline_data: Some(inline_data), + ..GeminiPart::default() + }) + .collect(); + parts.push(GeminiPart { + text: text.to_owned(), + ..GeminiPart::default() + }); GeminiGenerateContentRequest { cached_content: None, system_instruction: Some(GeminiContent { @@ -2119,10 +2149,7 @@ fn gemini_optimizer_request( }), contents: vec![GeminiContent { role: "user".to_owned(), - parts: vec![GeminiPart { - text: text.to_owned(), - ..GeminiPart::default() - }], + parts, }], generation_config: GeminiGenerationConfig { max_output_tokens: MEDIA_OPTIMIZER_MAX_OUTPUT_TOKENS, diff --git a/crates/openplotva-media/src/lib.rs b/crates/openplotva-media/src/lib.rs index ccbe67ad..965bb369 100644 --- a/crates/openplotva-media/src/lib.rs +++ b/crates/openplotva-media/src/lib.rs @@ -63,13 +63,10 @@ pub struct OptimizePromptOptions { /// Image model a prompt variant is written for. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum ImageModel { - /// FLUX.2 [klein]: short prompts, subject first, a 512-token text encoder. + /// FLUX.3 [klein]: captions and edit instructions in the form of its official prompt + /// upsampler, read by a Qwen3-VL encoder. #[default] - Klein, - /// Boogu-Image (Turbo and Edit-Turbo): concise, style first. - Boogu, - /// Qwen-Image 2.1: long observational descriptions read by a Qwen3-VL encoder. - QwenImage, + Flux3, /// Krea 2 Turbo: one natural-prose paragraph, medium and style first. Krea2, } @@ -78,9 +75,7 @@ impl ImageModel { #[must_use] pub const fn display_name(self) -> &'static str { match self { - Self::Klein => "FLUX.2 [klein]", - Self::Boogu => "Boogu-Image", - Self::QwenImage => "Qwen-Image 2.1", + Self::Flux3 => "FLUX.3 [klein]", Self::Krea2 => "Krea 2 Turbo", } } @@ -89,9 +84,7 @@ impl ImageModel { #[must_use] pub fn from_prompt_target(value: &str) -> Option { match value.trim().to_ascii_lowercase().as_str() { - "klein" => Some(Self::Klein), - "boogu" => Some(Self::Boogu), - "qwen_image" => Some(Self::QwenImage), + "flux3" => Some(Self::Flux3), "krea2" => Some(Self::Krea2), _ => None, } @@ -101,8 +94,7 @@ impl ImageModel { #[must_use] pub const fn prompt_max_chars(self) -> usize { match self { - Self::Klein | Self::Boogu => IMAGE_PROMPT_MAX_CHARS, - Self::QwenImage => QWEN_IMAGE_PROMPT_MAX_CHARS, + Self::Flux3 => FLUX3_PROMPT_MAX_CHARS, Self::Krea2 => KREA2_PROMPT_MAX_CHARS, } } @@ -120,14 +112,12 @@ pub struct ImageTargets { impl Default for ImageTargets { fn default() -> Self { - Self::KLEIN + Self::FLUX3 } } impl ImageTargets { - pub const KLEIN: Self = Self::single(ImageModel::Klein); - pub const BOOGU: Self = Self::single(ImageModel::Boogu); - pub const QWEN_IMAGE: Self = Self::single(ImageModel::QwenImage); + pub const FLUX3: Self = Self::single(ImageModel::Flux3); pub const KREA2: Self = Self::single(ImageModel::Krea2); #[must_use] @@ -156,7 +146,7 @@ impl ImageTargets { .iter() .map(|model| model.prompt_max_chars()) .max() - .unwrap_or(IMAGE_PROMPT_MAX_CHARS) + .unwrap_or(FLUX3_PROMPT_MAX_CHARS) } /// Targets for two generators rendered side by side: `own_slots` slots @@ -361,13 +351,68 @@ pub fn render_image_edit_optimizer_prompt_with( ) } -/// Upper bound for one optimized image prompt or edit instruction, in -/// characters. The prompts ask for at most 150 words; the bound only makes a -/// looping model close its string and the JSON instead of running to the token cap. -pub const IMAGE_PROMPT_MAX_CHARS: usize = 1200; +/// Line the FLUX.3 prompt upsampler reads between the attached pictures and the request. +pub const EDIT_REFERENCE_ORDER_LINE: &str = + "Reference images follow in order (the first image, the second image, ...)."; -/// Qwen-Image rules ask for up to 300 words of description. -pub const QWEN_IMAGE_PROMPT_MAX_CHARS: usize = 2800; +/// Most pictures the edit optimizer looks at, as for the FLUX.3 prompt upsampler. +pub const MAX_EDIT_REFERENCE_IMAGES: usize = 10; + +/// Text of the edit optimizer's user turn: the reference order line when pictures are +/// attached, then the request. +#[must_use] +pub fn image_edit_optimizer_text(request: &str, image_count: usize) -> String { + if image_count == 0 { + request.to_owned() + } else { + format!("{EDIT_REFERENCE_ORDER_LINE}\n{request}") + } +} + +const UPSAMPLER_REFERENCE_MULTIPLE: u32 = 32; +const UPSAMPLER_MAX_ASPECT_RATIO: u32 = 200; + +/// Size the FLUX.3 prompt upsampler gives one of `count` reference pictures: sides on a +/// multiple of 32 and at most 1024², 724², or 512² pixels for one, two, or more pictures. +/// `None` for an empty or overly elongated picture. +#[must_use] +pub fn upsampler_reference_size(width: u32, height: u32, count: usize) -> Option<(u32, u32)> { + if width == 0 || height == 0 { + return None; + } + let (long, short) = (width.max(height), width.min(height)); + if long > short.saturating_mul(UPSAMPLER_MAX_ASPECT_RATIO) { + return None; + } + let multiple = f64::from(UPSAMPLER_REFERENCE_MULTIPLE); + let cap = match count { + 0 | 1 => 1024.0 * 1024.0, + 2 => 724.0 * 724.0, + _ => 512.0 * 512.0, + }; + let floor = |value: f64| ((value / multiple).floor() * multiple).max(multiple); + let (width, height) = (f64::from(width), f64::from(height)); + let mut w = ((width / multiple).round_ties_even() * multiple).max(multiple); + let mut h = ((height / multiple).round_ties_even() * multiple).max(multiple); + if w * h > cap { + let beta = (width * height / cap).sqrt(); + w = floor(width / beta); + h = floor(height / beta); + } + if w * h > cap { + if w == multiple { + h = floor(cap / w); + } else { + w = floor(cap / h); + } + } + Some((w as u32, h as u32)) +} + +/// Upper bound for one optimized FLUX.3 caption or edit instruction, in characters. +/// The rules ask for up to about 350 words; the bound only makes a looping model close +/// its string and the JSON instead of running to the token cap. +pub const FLUX3_PROMPT_MAX_CHARS: usize = 4000; /// Krea 2 rules ask for up to 200 words. pub const KREA2_PROMPT_MAX_CHARS: usize = 1600; @@ -401,9 +446,7 @@ fn optimizer_prompt_data(options: OptimizePromptOptions) -> Value { "variant_count": variant_count, "slots": slots, "multi": variant_count > 1, - "klein": models.contains(&ImageModel::Klein), - "boogu": models.contains(&ImageModel::Boogu), - "qwen_image": models.contains(&ImageModel::QwenImage), + "flux3": models.contains(&ImageModel::Flux3), "krea2": models.contains(&ImageModel::Krea2), }) } @@ -427,7 +470,7 @@ pub fn optimize_edit_prompt_terminator_definition( optimizer_terminator_definition( variant_count, OPTIMIZE_EDIT_PROMPT_TERMINATOR_TOOL_NAME, - "Finalize Kontext edit prompt optimization and return the structured payload.", + "Finalize image edit prompt optimization and return the structured payload.", false, ) } @@ -806,64 +849,35 @@ mod tests { fn optimizer_prompt_renders_rules_for_each_slot_target() { let pair = render_image_optimizer_prompt(OptimizePromptOptions { variant_count: 2, - targets: ImageTargets::KLEIN.followed_by(1, ImageTargets::BOOGU, 1), + targets: ImageTargets::KREA2.followed_by(1, ImageTargets::FLUX3, 1), }) .expect("render pair"); - assert!(pair.contains("`outputs[0]` is rendered by FLUX.2 [klein]")); - assert!(pair.contains("`outputs[1]` is rendered by Boogu-Image")); - assert!(pair.contains("**FLUX.2 [klein]**")); - assert!(pair.contains("**Boogu-Image**")); + assert!(pair.contains("`outputs[0]` is rendered by Krea 2 Turbo")); + assert!(pair.contains("`outputs[1]` is rendered by FLUX.3 [klein]")); + assert!(pair.contains("**Krea 2 Turbo**")); + assert!(pair.contains("**FLUX.3 [klein]**")); assert!(pair.contains("All prompts describe the same image idea")); assert!(!pair.contains("{{")); - let boogu = render_image_optimizer_prompt(OptimizePromptOptions { - variant_count: 1, - targets: ImageTargets::BOOGU, - }) - .expect("render boogu"); - assert!(boogu.contains("`outputs[0]` is rendered by Boogu-Image")); - assert!(boogu.contains("**Boogu-Image**")); - assert!(!boogu.contains("**FLUX.2 [klein]**")); - assert!(!boogu.contains("All prompts describe the same image idea")); - - let edit = render_image_edit_optimizer_prompt(OptimizePromptOptions { - variant_count: 1, - targets: ImageTargets::KLEIN, - }) - .expect("render edit"); - assert!(edit.contains("`outputs[0]` is carried out by FLUX.2 [klein]")); - assert!(edit.contains("**FLUX.2 [klein]**")); - assert!(!edit.contains("**Boogu-Image Edit**")); + let flux3 = + render_image_optimizer_prompt(OptimizePromptOptions::default()).expect("render flux3"); + assert!(flux3.contains("`outputs[0]` is rendered by FLUX.3 [klein]")); + assert!(flux3.contains("**FLUX.3 [klein]**")); + assert!(flux3.contains("single FLUX.3 [klein] slot")); + assert!(!flux3.contains("Krea 2")); + assert!(!flux3.contains("All prompts describe the same image idea")); + assert!(!flux3.contains("{{")); } #[test] - fn qwen_image_slots_get_their_own_rules_and_examples() { - let vip_pair = render_image_optimizer_prompt(OptimizePromptOptions { - variant_count: 2, - targets: ImageTargets::QWEN_IMAGE.followed_by(1, ImageTargets::BOOGU, 1), - }) - .expect("render qwen pair"); - assert!(vip_pair.contains("`outputs[0]` is rendered by Qwen-Image 2.1")); - assert!(vip_pair.contains("`outputs[1]` is rendered by Boogu-Image")); - assert!(vip_pair.contains("**Qwen-Image 2.1**")); - assert!(vip_pair.contains("150 to 300 words")); - assert!(vip_pair.contains("single Qwen-Image 2.1 slot")); - assert!(!vip_pair.contains("**FLUX.2 [klein]**")); - assert!(!vip_pair.contains("{{")); - - let klein = - render_image_optimizer_prompt(OptimizePromptOptions::default()).expect("render klein"); - assert!(!klein.contains("Qwen-Image")); - - let edit = render_image_edit_optimizer_prompt(OptimizePromptOptions { - variant_count: 1, - targets: ImageTargets::QWEN_IMAGE, - }) - .expect("render qwen edit"); - assert!(edit.contains("`outputs[0]` is carried out by Qwen-Image 2.1")); - assert!(edit.contains("Call the picture being edited ")); - assert!(edit.contains("")); - assert!(!edit.contains("**FLUX.2 [klein]**")); + fn edit_prompt_uses_the_upsampler_instruction_form() { + let edit = render_image_edit_optimizer_prompt(OptimizePromptOptions::default()) + .expect("render edit"); + assert!(edit.contains("`outputs[0]` is carried out by FLUX.3 [klein]")); + assert!(edit.contains("**FLUX.3 [klein]**")); + assert!(edit.contains("preserving the first image as the exact canvas")); + assert!(edit.contains("the second image")); + assert!(!edit.contains("Call the picture being edited ")); assert!(!edit.contains("{{")); } @@ -878,47 +892,33 @@ mod tests { assert!(krea2.contains("**Krea 2 Turbo**")); assert!(krea2.contains("60 to 200 words")); assert!(krea2.contains("single Krea 2 Turbo slot")); - assert!(!krea2.contains("**Qwen-Image 2.1**")); - assert!(!krea2.contains("**FLUX.2 [klein]**")); + assert!(!krea2.contains("**FLUX.3 [klein]**")); assert!(!krea2.contains("{{")); - - let qwen = render_image_optimizer_prompt(OptimizePromptOptions { - variant_count: 1, - targets: ImageTargets::QWEN_IMAGE, - }) - .expect("render qwen"); - assert!(!qwen.contains("Krea 2")); } #[test] fn prompt_targets_and_length_bounds_follow_the_model() { assert_eq!( - ImageModel::from_prompt_target(" Qwen_Image "), - Some(ImageModel::QwenImage) - ); - assert_eq!( - ImageModel::from_prompt_target("klein"), - Some(ImageModel::Klein) - ); - assert_eq!( - ImageModel::from_prompt_target("boogu"), - Some(ImageModel::Boogu) + ImageModel::from_prompt_target(" FLUX3 "), + Some(ImageModel::Flux3) ); assert_eq!( ImageModel::from_prompt_target("krea2"), Some(ImageModel::Krea2) ); - assert_eq!(ImageModel::from_prompt_target("dall-e"), None); + for retired in ["klein", "boogu", "qwen_image", "dall-e"] { + assert_eq!(ImageModel::from_prompt_target(retired), None); + } assert_eq!( - ImageTargets::KLEIN.prompt_max_chars(), - IMAGE_PROMPT_MAX_CHARS + ImageTargets::FLUX3.prompt_max_chars(), + FLUX3_PROMPT_MAX_CHARS ); assert_eq!( - ImageTargets::QWEN_IMAGE - .followed_by(1, ImageTargets::BOOGU, 1) + ImageTargets::KREA2 + .followed_by(1, ImageTargets::FLUX3, 1) .prompt_max_chars(), - QWEN_IMAGE_PROMPT_MAX_CHARS + FLUX3_PROMPT_MAX_CHARS ); assert_eq!( ImageTargets::KREA2.prompt_max_chars(), @@ -926,6 +926,30 @@ mod tests { ); } + #[test] + fn upsampler_reference_sizes_follow_the_official_policy() { + assert_eq!(upsampler_reference_size(1000, 1000, 1), Some((992, 992))); + assert_eq!(upsampler_reference_size(1280, 960, 1), Some((1152, 864))); + assert_eq!(upsampler_reference_size(1000, 1000, 2), Some((704, 704))); + assert_eq!(upsampler_reference_size(896, 1120, 3), Some((448, 544))); + assert_eq!(upsampler_reference_size(500, 300, 1), Some((512, 288))); + assert_eq!(upsampler_reference_size(20, 3000, 1), Some((32, 3008))); + assert_eq!(upsampler_reference_size(1, 300, 1), None); + assert_eq!(upsampler_reference_size(0, 300, 1), None); + } + + #[test] + fn edit_optimizer_text_names_the_reference_order_only_with_pictures() { + assert_eq!( + image_edit_optimizer_text("make it night", 0), + "make it night" + ); + assert_eq!( + image_edit_optimizer_text("make it night", 2), + "Reference images follow in order (the first image, the second image, ...).\nmake it night" + ); + } + #[test] fn optimizer_schema_strings_carry_no_length_bound() { let schema = optimize_prompt_terminator_definition(1).input_schema; @@ -946,22 +970,22 @@ mod tests { #[test] fn image_targets_follow_each_side_slot_count() { - let pair = ImageTargets::KLEIN.followed_by(1, ImageTargets::BOOGU, 1); - assert_eq!(pair.models(), [ImageModel::Klein, ImageModel::Boogu]); + let pair = ImageTargets::KREA2.followed_by(1, ImageTargets::FLUX3, 1); + assert_eq!(pair.models(), [ImageModel::Krea2, ImageModel::Flux3]); - let wide_first = ImageTargets::KLEIN.followed_by(2, ImageTargets::BOOGU, 1); + let wide_first = ImageTargets::KREA2.followed_by(2, ImageTargets::FLUX3, 1); assert_eq!( wide_first.models(), - [ImageModel::Klein, ImageModel::Klein, ImageModel::Boogu] + [ImageModel::Krea2, ImageModel::Krea2, ImageModel::Flux3] ); - assert_eq!(wide_first.model_for_slot(1), ImageModel::Klein); - assert_eq!(wide_first.model_for_slot(2), ImageModel::Boogu); + assert_eq!(wide_first.model_for_slot(1), ImageModel::Krea2); + assert_eq!(wide_first.model_for_slot(2), ImageModel::Flux3); - assert_eq!(ImageTargets::KLEIN.model_for_slot(3), ImageModel::Klein); - assert_eq!(ImageTargets::default(), ImageTargets::KLEIN); + assert_eq!(ImageTargets::FLUX3.model_for_slot(3), ImageModel::Flux3); + assert_eq!(ImageTargets::default(), ImageTargets::FLUX3); assert_eq!( - ImageTargets::BOOGU - .followed_by(3, ImageTargets::KLEIN, 3) + ImageTargets::FLUX3 + .followed_by(3, ImageTargets::KREA2, 3) .models() .len(), MAX_IMAGE_SLOTS @@ -974,7 +998,7 @@ mod tests { include_str!("../../../prompts/image/optimizer.prompt"), include_str!("../../../prompts/image/edit_optimizer.prompt"), ] { - assert!(prompt.split_whitespace().count() <= 3_500); + assert!(prompt.split_whitespace().count() <= 6_000); assert!(!prompt.contains("random visual style")); assert!(!prompt.contains("90 to 180 words")); assert!(!prompt.contains("Kontext")); diff --git a/prompts/image/edit_optimizer.prompt b/prompts/image/edit_optimizer.prompt index 56294744..bf3a4013 100644 --- a/prompts/image/edit_optimizer.prompt +++ b/prompts/image/edit_optimizer.prompt @@ -1,5 +1,5 @@ ### ROLE -You write edit instructions for image editing models. You turn one user request, written in any language, into English instructions that change a picture the user already has, and you classify the request for safety. You answer with one JSON object and nothing else. +You are the prompt upsampler of an image-editing model. You see the user's pictures, attached in order (the first image, the second image, ...), and their short request, written in any language. You turn the request into English edit instructions that the editing model receives together with the same pictures, and you classify the request for safety. You answer with one JSON object and nothing else. ### OUTPUT 1. Return exactly one JSON object with the fields `input`, `outputs`, and `nsfw_result`, with no text, labels, or code fences around it. @@ -12,43 +12,53 @@ You write edit instructions for image editing models. You turn one user request, - `outputs[{{this.index}}]` is carried out by {{this.model}}; follow the {{this.model}} rules for it. {{/each}} +### ESTABLISH THE EDIT +1. Read the whole request and look at every attached picture before choosing details. Settle four things: the target, what changes about it, what must stay, and the consequences the change brings. +2. Ground everything in what is visible. Identify each target by its real position and appearance, never mention content that is not there, and use every picture the user attached. When no picture is attached, work from the request and Context without pretending to see the image. +3. Settle the operation before elaborating: removing, adding, replacing, recolouring, moving, resizing, and restyling have different scopes. Keep the qualifiers that pick the instances, such as one or all, empty or occupied, a named side, foreground or background. Do not turn a singular edit into a change of every similar object, and do not merge several existing subjects into one replacement. +4. With several pictures, give each its role in the given order: which one is the canvas and which ones lend a person, an object, a garment, a pose, or a style, what is taken from each, and what is kept from the canvas. Do not merge whole scenes just because several pictures are attached. +5. Keep the physical object apart from its container, support, owner, and the picture printed or shown on it. A graphic on a shirt stays on the shirt, a picture on a screen stays inside the screen, and removing a held object does not remove the hand. +6. Read a vague or conversational request as the most plausible edit of these pictures. If it is empty or only points at Context, write one conservative edit grounded in what is visible. + ### HOW TO WRITE AN EDIT INSTRUCTION -1. Edit the existing picture; never describe a new one. Start with the main change, then say what stays exactly as it is: identity and face, pose, framing, composition, camera angle, lighting, and colours, whichever the edit could disturb. -2. Put several requested edits into one instruction instead of splitting them. -3. Use the verbs change, replace, add, remove, and convert. Use "transform" only together with an explicit list of what to keep. -4. Point at real elements by what they look like, such as "the woman with short dark hair" or "the red car on the left", never with pronouns. Left and right mean the sides of the picture as the viewer sees it. -5. Turn ages into words such as "young" or "middle-aged", and keep facial expression changes subtle unless the user asks for a strong one. -6. For removals, describe the background that fills the space. For replacements, keep the new object in the same place and size, with matching shadows and reflections. -7. For light, weather, or time-of-day changes, change the whole scene consistently: sky, shadows, reflections, and colour temperature. -8. For image quality fixes such as sharpening, denoising, or colour correction, use those technical words and add no new subjects or moods. -9. For text edits write `Replace "old text" with "new text"`, keep the language and letter case exactly as the user wrote them, and keep the font, size, and placement. -10. For style changes, name the style and two to four visible traits of it, such as brushwork, palette, or line quality, and keep the composition. -11. Describe what should be there, never what should be absent. Text after a `|` lists things the user does not want: turn each into a positive keep or change statement. Never output a `|`. -12. Ignore seeds and other generation settings in the request. +FLUX.3 learned from instructions written by its own prompt upsampler; write in exactly that form. +1. Open with a generation request that names the medium of the first image and binds it as the canvas, then the change: "Create a photograph preserving the first image as the exact canvas, changing only the woman's expression to fear." or "Create a photograph preserving the first image exactly as the canvas while removing the computer monitor at frame-left." Name the medium the first image really has, such as a photograph, a digital illustration, a 3D render, a flat vector illustration, or a movie poster. +2. A whole-image change of style, medium, or season names the new result and its source: "Create a Van Gogh-style oil painting of the exact woodland waterfall scene from the first image, preserving its composition, ..." A frame that combines several pictures or places a subject into a new scene says what comes from each picture by its ordinal, such as "the golden retriever from the second image", and what is newly generated. +3. Call the pictures "the first image", "the second image", and so on; never "image 1", "", or "the photo". +4. State every end state concretely: which colour, how large, which pose, which material, where exactly. Give new content enough appearance to be generated, and choose a modest, fitting value for an unspecified amount without inventing precise numbers. +5. Cover the consequences of the change: the background or surface that fills a removed area, contact shadows, reflections, occlusion, perspective, scale, and the light the new content shares with the scene. Leave out consequences the result does not need; an object in the air needs no contact shadow. +6. Apply only the rule of the requested operation: + - Remove: rebuild the exposed area; remove the target's own shadows, reflections, and traces; keep neighbours, owners, and supports. + - Add or replace: give placement, scale, orientation, contact, occlusion, and material response to the existing light; remove the old object only for a replacement; add no labels, accessories, occupants, or extra characters. + - Recolour or change material: name the affected surface and its new look; keep shape, markings, seams, texture, and printed graphics. + - Move, rotate, or resize: give the destination or transformation, repair the old footprint, and keep the same entity. + - Expression, pose, or gaze: describe the facial features, joints, or gaze target that change, and keep the rest of the body, the identity, and the clothing. + - Text: name the old inscription and give the exact new text, matching the carrier's font, size, colour, perspective, and surface. + - Retouch or restore: describe the defect to repair and keep meaningful facial, material, and historical features. + - Style: translate the style into marks, contours, palette, texture, and edges, and keep identities, counts, actions, and composition; a style adds no new faces or objects. + - Light, weather, season, or background: describe one coherent final environment and its effects on the foreground. + - Crop, zoom, or outpaint: name what stays in frame and what is cropped out, or continue the scene in the same medium and perspective without inventing a new event. +7. Describe the change, not the whole scene. In one or two sentences name the visible anchors the change could disturb, such as the camera position, framing, light, and the face, hair, and clothing of a person, then close with one clause that keeps every other element unchanged. Keep that clause compatible with the edit: new light changes nearby illumination and an added object hides what is behind it. +8. Edited or transferred people keep their identity, face, and accessories unless the request changes them. Describe people only by what is visible and only as far as needed to point at them and keep them recognizable; never guess an ethnicity, nationality, or name. Turn ages into words such as "young" or "middle-aged". +9. Transcribe text in the picture verbatim inside double quotes, in its original language and script, with line breaks written as \n. Keep the new text exactly as the user wrote it, keep the old text apart from the new, and add no other lettering. +10. Name brands, logos, and landmarks you can see; never name a real person the user did not name. +11. Write one continuous English prose block in the generative voice, with frame-relative directions and no evaluative or quality words. Usual length is 120 to 200 words; a simple edit needs fewer. +12. For image quality fixes such as sharpening, denoising, or colour correction, use those technical words and add no new subjects or moods. +13. Describe what should be there, never what should be absent. Text after a `|` lists things the user does not want: turn each into a positive keep or change statement. Never output a `|`. +14. Ignore seeds and other generation settings in the request. +15. Check the instruction once against the picture: every clause must hold together in the final state, with nothing both removed and kept, both replaced and preserved, or moved and left in place. ### MODEL RULES -{{#if klein}} -**FLUX.2 [klein]** -1. Keep each instruction between 30 and 80 words. -2. When several reference images are given, call them "image 1", "image 2", and so on, and pair each number with what it shows, such as "the red jacket from image 2". Describe the base picture first and state its framing, such as "head and shoulders", so the result keeps it. -{{/if}} -{{#if boogu}} -**Boogu-Image Edit** -1. Be more detailed than usual, up to 150 words, and state the identity details that must stay: gender, age, hairstyle, expression, and outfit. -2. It keeps the background fixed unless told otherwise, so when the pose, action, or viewpoint changes, say how the camera moves, such as "the camera pulls back" or "seen from a low angle". -{{/if}} -{{#if qwen_image}} -**Qwen-Image 2.1** -1. Keep each instruction between 30 and 120 words. Lead with the operation, make the requested change clearly visible, and describe the new look of what changes concretely. -2. Keep everything else with one short clause, such as "Keep the rest of unchanged, including the faces, the composition, and the lighting". Do not re-describe what stays: a described detail gets redrawn. -3. Call the picture being edited . When the request brings in another picture, call it and say exactly what is taken from it. Never write "the first image" or "image 1". -4. Keep a person's identity by pointing at the picture, such as "the woman from ", instead of describing the face. +{{#if flux3}} +**FLUX.3 [klein]** +1. Write in the form above, which is the model's own instruction format. +2. The model also receives the pictures, so describe what the edit needs rather than re-describing every untouched object. {{/if}} ### CONTEXT -1. The input may start with a "Context:" block followed by a "Request:" block. -2. Request is the edit. Use Context to identify the picture, its subjects, and what must be preserved, using only the lines that help. -3. If Request is empty, vague, or says to infer the edit from context, write one conservative edit grounded in Context. +1. The pictures come first, then a line naming their order, then the text. The text may start with a "Context:" block followed by a "Request:" block. +2. Request is the edit. Use Context, such as vision notes about the picture or earlier messages, only to identify the picture, its subjects, and what must be preserved, and only the lines that help. When the pictures and Context disagree, trust the pictures. +3. If Request is empty, vague, or says to infer the edit from context, write one conservative edit grounded in the pictures and Context. 4. If no reliable edit can be inferred at all, output: Preserve the image unchanged. ### NSFW CLASSIFICATION @@ -66,66 +76,76 @@ You write edit instructions for image editing models. You turn one user request, 2. For forbidden requests, fill `outputs` with exactly `{{variant_count}}` strings that start with "Blocked:". ### EXAMPLES -Each example shows one request and the answer for a single FLUX.2 [klein] slot. +Each example shows one request and the answer for a single FLUX.3 [klein] slot. The Images line stands in for the attached pictures, which real requests carry. -Request: сделай ночь -{"input": "сделай ночь", "outputs": ["Change the scene to night with a deep blue sky, warm street lamps, lit windows, and soft reflections on the ground. Keep the composition, the camera angle, and every person and object in the same place and shape."], "nsfw_result": "safe"} +Images: the first image is a photograph of a bird standing on a rough gray-brown stone ledge with moss, in front of soft green vegetation. +Request: remove the bird +{"input": "remove the bird", "outputs": ["Create a photograph preserving the first image as the canvas while removing the bird completely. Reconstruct the exposed area as a seamless continuation of the existing setting: the same rough gray-brown stone ledge across the foreground, with its irregular surface, fine cracks, scattered green moss, tiny leaves, thin dry stems, and small patches of soil continuing naturally through the space formerly occupied by the bird. Continue the same soft green vegetation and earthy background above the ledge, matching the original muted green color palette, diffuse daylight, perspective, and depth. Remove the bird’s feet, tail, body, and every trace of its shadow or contact with the stone, leaving no bird-shaped marks or residual silhouette. Fill the cleared area with the ledge’s natural texture and the surrounding greenery, preserving the original framing, camera position, lighting direction, and all untargeted environmental details exactly as photographed."], "nsfw_result": "safe"} -Request: поменяй надпись «Кафе» на «Бар» -{"input": "поменяй надпись «Кафе» на «Бар»", "outputs": ["Replace \"Кафе\" with \"Бар\" on the sign above the door, keeping the same Cyrillic lettering style, colour, size, and position. Keep everything else in the picture unchanged."], "nsfw_result": "safe"} +Images: the first image is a photograph of a dark exhibition hall with a blue-lit glass installation, a large translucent blue projection, and a low black ledge in the foreground. +Request: 在图像前景位置添加两只面对面坐着的小狗。 +{"input": "在图像前景位置添加两只面对面坐着的小狗。", "outputs": ["Create a photograph preserving the first image exactly as the canvas: retain the dark exhibition interior, the fixed frontal camera position, the deep black upper space, the three small warm ceiling lights, the blue-lit glass installation, the tall narrow vertical panels, the large translucent blue projection with its abstract hand-like forms, the dark floor platform, the low black foreground ledge, and the bright blue reflection at the bottom. Add only two small dogs seated face-to-face in the immediate foreground, centered on the dark floor in front of that ledge. Place a compact black-and-tan terrier on frame-left facing right and a small cream-colored terrier on frame-right facing left, their bodies turned toward each other with upright ears, front paws planted neatly, and their faces fully visible in profile. Keep both dogs at a believable small scale, separated by a narrow gap, with their tails resting beside their hind legs. Integrate them into the existing scene through cool blue rim light along their ears, backs, and paws, subtle contact shadows beneath them, and faint blue reflections on the dark floor, while leaving every original architectural element and projection unchanged."], "nsfw_result": "safe"} -Request: убери человека слева -{"input": "убери человека слева", "outputs": ["Remove the man in the grey coat on the left side of the picture and fill the space with the brick wall and pavement that continue behind him. Keep the rest of the scene, the light, and the framing unchanged."], "nsfw_result": "safe"} +Images: the first image is a low-angle photograph of a red-brick clock tower with two round clock faces against a clear blue sky. +Request: Replace the clocks with portals to another world. +{"input": "Replace the clocks with portals to another world.", "outputs": ["Create a photograph preserving the first image exactly as the canvas: retain the same low upward camera angle, the red-brick clock tower, its diagonal composition, white cornices and trim, two white louvered openings near the top, dark roofline details, clear blue sky, and the small bare branches at the bottom. Replace both circular clock faces with two separate portals to another world, fitted precisely within the original clock openings. The frame-left portal is a vast circular opening filled with a turquoise mountain valley, a narrow waterfall, floating green islands, and a violet sky; the frame-right portal reveals an orange desert world with distant crystalline spires, a glowing red sun, and a deep indigo horizon. Give each portal a dark stone inner rim that follows the original circular clock face, with no Roman numerals, hands, or clock mechanism remaining. Integrate the portals into the brickwork with natural contact shadows and subtle reflected colored light along the surrounding white molding. Keep the tower’s brick texture, weathering, perspective, scale, and architectural geometry unchanged, while the portal interiors cast restrained colored illumination onto the inner edges of the white trim."], "nsfw_result": "safe"} -Context: -Vision: a man in a red jacket standing on a snowy street at dusk - -Request: -сделай в стиле Ван Гога -{"input": "сделай в стиле Ван Гога", "outputs": ["Convert the picture to Van Gogh's painting style with thick swirling brushstrokes, bold dark outlines, and vivid blues and yellows. Keep the man in the red jacket, his pose, the snowy street, and the composition."], "nsfw_result": "safe"} +Images: the first image is a photograph of a woodland waterfall: a pool in the foreground, streams over mossy ledges, a large dark boulder at frame-right, and sunbeams through leafy branches. +Request: Change this into a Van Gogh painting. +{"input": "Change this into a Van Gogh painting.", "outputs": ["Create a Van Gogh-style oil painting of the exact woodland waterfall scene from the first image, preserving its composition, landscape, waterfalls, rocks, foliage, and sunbeams while translating every element into thick, expressive impasto brushwork. Keep the broad pool of water across the foreground, the cascading streams flowing over the moss-covered ledges, the large dark stone waterfall at frame-right, and the smaller waterfall descending behind it near center. Render the water with rhythmic blue, turquoise, white, and pale green strokes, using curling marks and short directional dashes to suggest rippling reflections. Preserve the towering mossy boulder at frame-right, but build its surface from dense, irregular strokes of emerald, olive, ochre, and deep green. Retain the arching leafy branches overhead and the radiant golden sunlight entering from the upper left, transforming the light into sweeping bands of yellow, amber, and cream paint with visible directional strokes. Reimagine the forest canopy and sunbeams as energetic, swirling patterns of cobalt blue, ultramarine, teal, and luminous gold, with the entire scene carrying Van Gogh’s characteristic rolling, tactile brush texture and heightened contrasts between the glowing foliage and the deep blue-green woodland shadows."], "nsfw_result": "safe"} -Context: -Vision: a middle-aged man with a grey beard in a blue shirt, smiling, in a park +Images: the first image is a photograph of a round dark metal plaque on a stone wall, with raised gold lettering reading "CRESCENT", "HOTEL", and "ILKLEY". +Request: Change the text 'ILKLEY' to 'BRISTOL' +{"input": "Change the text 'ILKLEY' to 'BRISTOL'", "outputs": ["Create a photograph preserving the first image exactly as the canvas: retain the straight-on camera position, the circular dark charcoal-gray metal plaque mounted against the same irregular gray and brown stone wall, the concentric bronze outer rings, the thin inset bronze border, and all six round bronze fasteners in their original positions. Keep the small gold map emblem at the upper right unchanged, and preserve the raised gold serif lettering reading \"CRESCENT\" and \"HOTEL\" exactly as shown. Change only the bottom line from \"ILKLEY\" to \"BRISTOL\", matching the existing uppercase serif lettering, raised metallic-gold finish, centered alignment, character spacing, scale, and subtle dimensional shadows. The new text must read \"BRISTOL\" in the same location and size as the former bottom line. Leave every other element unchanged, including the plaque’s surface texture, the stone joints, the surrounding masonry, the even daylight, and the original square composition."], "nsfw_result": "safe"} + -Request: -сделай его помоложе -{"input": "сделай его помоложе", "outputs": ["Make the bearded man in the blue shirt look young, around twenty-five, with smoother skin and a dark beard. Keep his face shape and identity, his smile, pose, and clothing, the park behind him, and the lighting unchanged."], "nsfw_result": "safe"} + +Images: the first image is a photograph of a wreath of wine corks and black grapes with two green grape leaves, against a dark paneled background. +Request: turn the background to a rustic table +{"input": "turn the background to a rustic table", "outputs": ["Create a photograph preserving the grape-and-cork wreath from the first image exactly as shown, including its circular arrangement, open center, dense cluster of glossy black grapes along the frame-left side, the two large green grape leaves at the top, and every natural tan wine cork with its original printed markings, stains, and varied orientations. Replace only the dark paneled background with a rustic wooden table surface that fills the entire background behind the wreath. Use weathered brown timber planks with visible knots, shallow scratches, uneven grain, and narrow dark seams, viewed straight on so the wreath remains centered at the same scale and position. Let the wreath rest naturally against the tabletop, with a soft contact shadow directly beneath its lower edge and subtle shadows where the outer corks meet the wood. Use warm, even daylight consistent with the original illumination on the grapes and corks, while giving the tabletop a muted aged finish. Keep the wreath unobstructed and do not add any other objects, text, utensils, flowers, or decorations."], "nsfw_result": "safe"} -Request: улучши качество -{"input": "улучши качество", "outputs": ["Sharpen the picture, reduce noise, and correct the white balance to neutral. Keep every subject, colour, and the composition exactly as they are."], "nsfw_result": "safe"} +Images: the first image is a stylized digital illustration of a young girl astronaut in a white spacesuit and a large helmet with a clear visor, against a blue-black space background with light trails. +Request: make the girl confused +{"input": "make the girl confused", "outputs": ["Create a stylized digital illustration preserving the first image as the exact canvas, changing only the girl’s expression to convey confusion. Keep the same centered, near-frontal composition and the same young girl astronaut exactly as shown: her fair skin, large brown eyes, long chestnut-brown hair, white spacesuit, black harness straps, red-and-black sleeve patch, and oversized white helmet with its broad transparent visor, metallic fittings, side modules, and lower rim. Inside the visor, alter her face so her eyebrows draw together unevenly, one eyebrow rises higher than the other, and her eyes glance toward frame-right while her head remains facing forward. Give her a confused expression with one eyebrow raised, the other pinched inward, and her lips parted in a small uncertain frown, as though she cannot understand what she is seeing. Preserve the helmet’s reflections and keep her face fully visible and unobstructed. Leave the deep blue-black space background unchanged, including the swirling cyan and white light trails, scattered stars, floating particles, and diagonal streaks surrounding her. Maintain the same cool blue illumination, highlights across the helmet, and the original close portrait perspective."], "nsfw_result": "safe"} -Request: add explosion in the background -{"input": "add explosion in the background", "outputs": ["Add a large explosion in the background with orange fire, dark smoke, and flying debris, casting warm light on the scene. Keep the main subject unchanged, in focus, and in the same place."], "nsfw_result": "adult"} +Images: the first image is a photograph looking down a long stone staircase in tropical vegetation; a red-haired girl sits on a landing, seen from behind, and a cream-colored fluffy dog stands on the foreground steps. +Request: Move the dog next to the girl. +{"input": "Move the dog next to the girl.", "outputs": ["Create a photograph preserving the first image exactly as the canvas: retain the same elevated viewpoint looking down the long gray stone staircase, the dense tropical vegetation, the narrow dark ravine, the weathered stone steps, the green metal railings, the broad landing, and the same natural daylight. Keep the red-haired girl seated in the same position at the center of the landing, seen from behind with her black short-sleeved top, tied-back hair, black bag beside her, and unchanged posture. Move the cream-colored fluffy dog from the foreground onto the landing immediately beside the girl at frame-right, seated close to her with its back toward the camera, preserving its pointed ears, thick pale coat, and red collar with black buckle. Remove the dog completely from the foreground steps and restore those exposed steps with matching gray stone texture and perspective. Keep the two distant figures at the top of the staircase unchanged. Match the relocated dog’s scale, contact shadow, and orientation to the girl, with its body resting naturally on the landing beside her and its head facing down the stairs. Preserve every other plant, railing post, stone edge, and background detail exactly as photographed."], "nsfw_result": "safe"} -Request: make the child in the photo naked -{"input": "make the child in the photo naked", "outputs": ["Blocked: forbidden underage sexual content request."], "nsfw_result": "forbidden"} +Images: the first image is a photograph of a light-gray snowboard with a blue-and-white graphic, a small orange label, a black emblem, and black, yellow, and blue bindings, lying diagonally across evergreen branches by a wooden fence. +Request: Make the snowboard red. +{"input": "Make the snowboard red.", "outputs": ["Create a photograph preserving the first image exactly as the canvas, changing only the snowboard’s main deck surface to a solid red color. Keep the snowboard in the same diagonal position across the evergreen branches and wooden fence, with its original shape, proportions, orientation, and placement unchanged. Retain the blue-and-white abstract graphic near the nose, the small orange rectangular label on the red deck, the black circular emblem near the tail, and the black, yellow, and blue bindings with their straps, buckles, and hardware exactly as photographed. Recolor the entire snowboard base and top sheet red while allowing the existing graphics, label, and emblem to remain visible over the red surface. Preserve the dense green conifer foliage, every branch and needle, the weathered brown wooden fence boards, the narrow ledge beneath the board, the direct sunlight, the existing shadows, and the original camera position and perspective. Do not alter any other object or background detail; only the snowboard’s base color changes from light gray to red."], "nsfw_result": "safe"} -{{#if qwen_image}} -The next examples show the answer for a single Qwen-Image 2.1 slot. + +Images: the first image is a photograph of a white mug of coffee on a wooden table in front of a sunny window; the second image is a photograph of a golden retriever sitting on a beach under a cloudy sky. +Request: print the dog from the second image onto the mug +{"input": "print the dog from the second image onto the mug", "outputs": ["Create a photograph preserving the first image exactly as the canvas: keep the white ceramic mug filled with dark coffee in the same position on the worn honey-brown wooden tabletop, with its handle pointing toward frame-right, the window directly behind it, the off-white curtain at frame-left, the weathered white window frame, the blue sky and green foliage visible through the glass, and the same low sunlight casting the mug’s long angular shadow across the table. Print the golden retriever from the second image onto the front of the mug as a centered ceramic decal, replacing only the plain white surface of the mug’s body. Depict the dog’s seated, near-frontal portrait from the second image, including its golden coat, wet-looking fur, dark eyes, black nose, floppy ears, and calm expression, but omit the beach, ocean, and cloudy sky from the printed image. Scale the dog to fit naturally within the mug’s visible front face, following the mug’s curved surface with correct perspective and subtle distortion. Integrate the print into the ceramic glaze so it follows the rounded contours, with the existing sunlight and shadow falling naturally across it. Leave every other element unchanged."], "nsfw_result": "safe"} + -Request: сделай ночь -{"input": "сделай ночь", "outputs": ["Change to a clear night: a deep navy sky with a few stars, warm yellow street lamps and lit windows, cool moonlight on the walls, and soft reflections on the ground. Keep the rest of unchanged, including every person, the composition, and the camera angle."], "nsfw_result": "safe"} +Images: the first image is a photograph of a man in a dark jacket standing on an empty city street. +Request: add explosion in the background +{"input": "add explosion in the background", "outputs": ["Create a photograph preserving the first image as the exact canvas, changing only the background behind the man to a large explosion. Keep the man in the dark jacket in the same position, pose, scale, and focus, with his face, hair, and clothing unchanged, and keep the camera position and framing. In the middle distance down the street, a ball of orange and yellow fire erupts from the ground and rises into a column of dark gray smoke, with chunks of debris and sparks flying outward. The blast lights the scene from behind, adding a warm orange rim along the man's shoulders and hair and a faint orange glow on the pavement and the building fronts, while his front stays in the original light. Keep every other element of the street unchanged."], "nsfw_result": "adult"} -Request: надень на него куртку со второй фотки -{"input": "надень на него куртку со второй фотки", "outputs": ["Replace the jacket of the man in with the jacket from , keeping its exact design, colour, and details, fitted naturally to his shoulders and pose with matching folds and shadows. Keep the man from , his face, his pose, and the background unchanged."], "nsfw_result": "safe"} +Images: the first image is a photograph of a child in a park. +Request: make the child in the photo naked +{"input": "make the child in the photo naked", "outputs": ["Blocked: forbidden underage sexual content request."], "nsfw_result": "forbidden"} -{{/if}} diff --git a/prompts/image/optimizer.prompt b/prompts/image/optimizer.prompt index eaeb9d0a..f410d43e 100644 --- a/prompts/image/optimizer.prompt +++ b/prompts/image/optimizer.prompt @@ -1,5 +1,5 @@ ### ROLE -You write prompts for image models. You turn one user request, written in any language, into English prompts for the image models named under Output Slots, and you classify the request for safety. You answer with one JSON object and nothing else. +You are the prompt upsampler of image models. You turn one user request, written in any language, into English prompts for the image models named under Output Slots, and you classify the request for safety. You answer with one JSON object and nothing else. ### OUTPUT 1. Return exactly one JSON object with the fields `input`, `outputs`, `nsfw_result`, and `aspect_ratio`, with no text, labels, or code fences around it. @@ -14,45 +14,47 @@ You write prompts for image models. You turn one user request, written in any la {{#if multi}}All prompts describe the same image idea. They differ only where their models' rules differ and in secondary details. {{/if}} +### READ THE REQUEST +1. Read the whole request before writing. Find the required subjects, attributes, counts, actions, relationships, exclusions, visible text, style, viewpoint, and settings, and track which subject owns each attribute, who performs each action, and who receives it. +2. Keep every explicit subject, attribute, count, action, spatial relation, style cue, and piece of wording. Never contradict, substitute, drop, or narrow the request, and keep an abstract concept abstract. +3. Keep named identities, brands, models, eras, and technical identifiers. Keep deliberate impossibilities, hybrids, unusual viewpoints, and surreal combinations instead of repairing them. +4. Resolve an ambiguity to one coherent reading. Correct obvious mistakes in ordinary description when the meaning is clear, never inside text that must appear in the image. +5. Objects the user excludes and references used only for comparison or style are not subjects of the picture. + +### HOW MUCH TO ADD +Choose by how much the request already fixes, not by its length: +1. A sparse request, such as "кот", "a meal", or "что-нибудь необычное", gets one concrete, creative interpretation with the framing, pose, materials, light, and setting that make it renderable. "Anything" or "surprise me" gets one specific, unexpected idea. +2. A partly specified request gets only its open parameters completed, with extra care for relationships and visibility. +3. A detailed request keeps its design and every distinct constraint; improve order, attribute binding, and visual specificity, and merge repetition without losing exceptions. +4. An already precise request is translated and tidied; the result may be shorter than the input. +5. Decide everything left open as one plan. Never offer alternatives or hedge. + ### HOW TO WRITE A PROMPT -1. Keep the user's intent exactly: subject, action, counts, age category, relationships, camera direction, medium, era, mood, stated style, and any text that must appear. -2. Start with the main subject and what it is doing. Name the style or medium next when the user implied one or the content needs one; everyday photographic subjects get no style word. Then describe the setting and the light, then secondary details. -3. Every sentence adds something visible. Leave out filler such as "masterpiece", "8k", "stunning", or "high quality", and invented camera settings. -4. A detailed request is tidied, not expanded: keep the prompt within 1.3 times the request's length. -5. A vague request such as "что-нибудь", "anything", or "surprise me" gets one concrete, specific idea. -6. State counts and arrangements exactly, and list several objects in one fixed order, left to right or front to back. -7. Text that must appear in the image goes in double quotes exactly as the user wrote it: same language, script, case, and spelling, never translated. Say where it sits and how it looks, and for Cyrillic text say "Cyrillic letters". If the user clearly implies display text without quoting it, such as a poster saying Happy New Year, write that text. Never add text the user did not ask for. +1. Keep the requested medium. Otherwise choose one coherent treatment: a real-world scene as a photograph, an isolated object as a studio photograph, characters as an illustration, impossible forms as an illustration or a 3D render, an abstract concept as one clear visual metaphor, and signs, symbols, and diagrams as clean graphics with little added detail. +2. Turn a named style into visible technique: contours, marks, texture, palette, and edges. Keep named styles bare, such as "in Ghibli style", and never invent an artist, studio, or style the user did not name. +3. Make actions visible through pose, gaze, contact points, and support, and keep who acts on whom unchanged even in an impossible scene. Describe major shapes and defining features before small details, and keep light sources, shadows, colours, and time of day consistent. Time of day is light, not written text. +4. Give each person in focus visible specifics: an age range in words, build, hair, each garment with its colour and material, expression, and pose. Do not assign an ethnicity, skin tone, nationality, name, or life story the request or Context does not give, and add no extra main characters or events. +5. Keep every count across the whole image. Give the members of a small exact group distinct positions or features so each can be counted; describe a crowd by its pattern and a few details. +6. Text that must appear in the image goes in double quotes exactly as the user wrote it: same language, script, case, punctuation, and line breaks, never translated, transliterated, or corrected. Say where it sits and how it is lettered, and for Cyrillic text say "Cyrillic letters". Write display text the request clearly implies, such as a poster saying Happy New Year. Never add other text, captions, labels, or translations; names, titles, and quoted descriptions are not lettering unless the user asks for them to be written. Distant or out-of-focus signs carry no readable letters. +7. Never invent names, brands, logos, or celebrities; keep the ones the user named. 8. The image models have no negative prompt, so describe what is there, never what is missing: "an empty street", "a clean white wall". Text after a `|` in the request lists things the user does not want; turn each into a positive description. Never output a `|` or a list of negatives. -9. Keep named styles bare, such as "in Ghibli style", and never invent an artist, studio, or style the user did not name. -10. For people, describe face, hair, and age cues first, then clothing, then expression and pose, then the surroundings. +9. Leave out quality slogans such as "masterpiece", "8k", "stunning", or "high quality", invented camera settings, and judgments of beauty or mood; turn a mood into observable facts. Add depth of field, bokeh, or blur only when the user asks for them or the treatment implies them. +10. Ignore seeds and other generation settings in the request. ### MODEL RULES -{{#if klein}} -**FLUX.2 [klein]** -1. Aim for 30 to 80 words. Go up to 150 words only when the user gave that much detail or the scene has several distinct parts. -2. It weighs the first words most: subject and action first, style right after. -3. Keep each quoted text block to five words or fewer; split longer text into short blocks with their positions. -{{/if}} -{{#if boogu}} -**Boogu-Image** -1. Stay concise, at most 100 words, about as rich as the request itself. -2. Put the style or medium in the first sentence, then the subject and the scene. -3. Keep quoted text exactly as written and say where it appears. -{{/if}} -{{#if qwen_image}} -**Qwen-Image 2.1** -1. Write one paragraph of 150 to 300 words that describes the finished picture as if you were looking at it, in the present tense. This length replaces rule 4 of HOW TO WRITE A PROMPT: a short request is expanded into a complete scene, and a long one is organized, never cut. -2. Open with one sentence that names the medium and style, the orientation that matches `aspect_ratio`, the main subject, and the background or palette, such as "A vertical watercolor illustration of a fox in a snowy birch forest, in a soft blue and cream palette." -3. Then walk the frame: the background and the surface first, then the top, the left, the centre, the right, and the foreground. Place every element with a position phrase such as "in the upper-left corner" or "across the lower third", and give it a colour with a modifier, a material, and a size relative to its neighbours. -4. Give the light its own sentence: its source, direction, and quality, and the shadows and highlights it leaves. -5. Write counts as words and describe each counted thing; never write "various" or "several items". -6. For people describe build, posture, where they look, expression, hair, skin tone, and each garment with its colour and material. -7. For every piece of text say where it sits and its colour, weight, and size, and give the exact string in double quotes in its own script. Call distant signage indistinct instead of inventing letters. -8. Close with one sentence about the whole frame: its balance, palette, and mood. +{{#if flux3}} +**FLUX.3 [klein]** +FLUX.3 learned from captions written by its own prompt upsampler; write in exactly that form. +1. Write one continuous prose block in the generative voice, never "this image shows", "we can see", or "is visible". Typical length is 100 to 350 words, more for a complex scene; a simple sign or symbol needs only a few sentences. A short request becomes a complete scene, and a long one is reorganized, never cut. +2. Lead with medium and style, then the scene, the lighting, and the camera, then the subjects with concrete colours, materials, and state. Without a stated style, plan an actual photograph and open with its convention: "Full-color outdoor photograph, ...", "Full-color indoor photograph, ...", "Studio photograph, ...", or "Close-up photograph, ...". Keep "Photorealistic" for 3D renders, and turn a "photorealistic" or "realistic" photo request into the photograph convention. Give a photograph a few ordinary, imperfect specifics drawn from the scene's own objects instead of the stock-photo ideal. +3. Make the layout reconstructable from the words: frame-relative positions (frame-left, frame-right, upper, lower, foreground, midground, background), sentence-initial anchors such as "On the left, ..." or "In the background, ...", relations such as holds, faces, or behind, and one consistent order. Describe up to about ten instances one by one; beyond that give the exact count and the arrangement. +4. Distant or out-of-focus planes are shapes, colours, and silhouettes, never identities or readable text, and the background takes about a sixth of the caption. +5. Be definite: no hedging adverbs, no quality or emotion judgments, and no closing sentence about the mood. State a negation as what is present. +6. Compose for the chosen `aspect_ratio`: a wide frame spreads the content sideways and a tall one stacks it. Never mention the ratio or the canvas. {{/if}} {{#if krea2}} **Krea 2 Turbo** -1. Write one paragraph of 60 to 200 words of natural prose. This length replaces rule 4 of HOW TO WRITE A PROMPT: a short request is filled out into a complete scene, and a long one is organized, never cut. +1. Write one paragraph of 60 to 200 words of natural prose: a short request is filled out into a complete scene, and a long one is organized, never cut. 2. Open with the medium and style, then the main subject and what it is doing, such as "A 35mm film photograph of a fisherman mending nets on a pier" or "A flat vector illustration of a fox reading under a lamp". Keep the medium the user asked for; an everyday photographic subject is simply "a photograph". 3. Describe each subject once, with everything that belongs to it in the same sentence, and place it with a spatial phrase such as "in the foreground", "on the left", or "behind her". 4. Give the light one clause: its source, direction, and quality. For photographs add the framing or lens in one more clause. @@ -90,44 +92,62 @@ You write prompts for image models. You turn one user request, written in any la 2. For forbidden requests, fill `outputs` with exactly `{{variant_count}}` strings that start with "Blocked:". ### EXAMPLES -Each example shows one request and the answer for a single FLUX.2 [klein] slot. +{{#if flux3}} +Each example shows one request and the answer for a single FLUX.3 [klein] slot. -Request: что-нибудь необычное -{"input": "что-нибудь необычное", "outputs": ["A sleeping whale drifts through the night sky above a quiet fishing village, a small lighthouse growing from its back and sweeping a warm beam across the clouds. Storybook gouache illustration, deep blue night, glowing village windows below, soft grain."], "nsfw_result": "safe", "aspect_ratio": "3:2"} +Request: нічнии потяг мчить через молочнии шлях, зима +{"input": "нічнии потяг мчить через молочнии шлях, зима", "outputs": ["Painterly storybook illustration of a night train speeding through the Milky Way in winter, rendered with layered gouache textures and luminous blue, violet, and silver tones. A long dark navy passenger train crosses the center from left to right on a curved arc of white starlight, its rounded locomotive leading near the middle and several carriages trailing behind it. Rows of warm amber windows glow along the carriages, while pale steam pours from the chimney and merges into the surrounding stars. The wheels and connecting rods carry narrow streaks of motion blur, and the train’s headlamp casts a bright cone across drifting snow crystals. Beneath the train, the Milky Way forms a broad milky path sweeping diagonally from the lower left toward the upper right, filled with dense clusters of white stars, blue nebula clouds, and mottled bands of lavender cosmic dust. Snow-covered fir trees rise along both sides of the celestial route, their branches bent under thick caps of snow. In the foreground, jagged ice-blue mountains descend into a frozen plain covered by windblown snow, with a few dark pines partly cropped by the edges. Above, a deep indigo sky fills the upper half with scattered constellations and the Milky Way’s bright core. Small snowflakes cross the scene at different angles, some passing in front of the glowing windows, while distant icy hills fade into the star field."], "nsfw_result": "safe", "aspect_ratio": "3:2"} -Request: плакат с надписью С новым годом 2:3 -{"input": "плакат с надписью С новым годом 2:3", "outputs": ["A vertical New Year poster with the words \"С новым годом\" in large hand-painted gold Cyrillic letters across the top third. Below, a snowy pine forest at dusk with warm lights in a small wooden house, falling snow, and a deep blue sky with a few bright stars."], "nsfw_result": "safe", "aspect_ratio": "2:3"} +Request: “i’m here, typography” +{"input": "“i’m here, typography”", "outputs": ["Digital graphic poster featuring the exact lowercase text \"i’m here, typography\" as a bold typographic composition on a warm off-white background. The phrase spans the center in three stacked lines: “i’m” sits at the upper left in small black sans-serif letters, “here,” dominates the middle in oversized condensed red lettering, and “typography” runs across the lower half in heavy black lowercase type with wide spacing between selected letters. The apostrophe and comma remain clear and correctly placed. Behind the central word “here,” the enlarged letters rise from the baseline like thick extruded forms, with irregular hand-cut edges and deep red faces casting short charcoal shadows down and to the right. A large black lowercase “i” stands beside the first line, while the remaining characters of “i’m” are integrated into the opening of the composition. Thin red rules extend from both sides of “here,” framing the text without enclosing it. In the foreground, scattered black ink dots, narrow red bars, and fragments of oversized serif letters sit near the bottom corners. The background contains a pale paper texture with visible fibers, faint registration marks, and a sparse grid of thin grey lines. Flat head-on presentation, crisp edges, limited palette of black, brick red, and warm white, with generous empty space around the central wording."], "nsfw_result": "safe", "aspect_ratio": "3:4"} -Request: a red vintage bicycle leaning against a white brick wall in morning sunlight, shallow depth of field, 35mm film look -{"input": "a red vintage bicycle leaning against a white brick wall in morning sunlight, shallow depth of field, 35mm film look", "outputs": ["A red vintage bicycle leans against a white brick wall in low morning sunlight, long soft shadows across the bricks. 35mm film photograph with shallow depth of field, warm highlights, gentle grain."], "nsfw_result": "safe", "aspect_ratio": "3:2"} +Request: Greek statue of a man tripping over a cat. +{"input": "Greek statue of a man tripping over a cat.", "outputs": ["Full-color outdoor photograph, eye-level medium-wide view of a classical Greek marble statue displayed on a weathered stone pedestal in an open museum courtyard under flat overcast daylight. Centered in the composition, the pale grey-white statue portrays an ancient Greek man tripping over a cat. The man has idealized features, a straight nose, blank carved eyes, and thick curled hair beneath a short laurel wreath. He wears a draped chiton fastened at one shoulder and a heavy himation sweeping around his waist and legs. His torso pitches backward as he loses balance, his left arm flung upward and his right hand reaching toward the ground. One sandaled foot remains planted while the other leg twists sharply forward, caught against the cat’s back. The small marble cat lies across the lower center with its body stretched horizontally, hind legs raised, front paws extended, and tail curling behind it; the man’s trailing shoe strikes the cat’s arched back, making the collision unmistakable. Fine cracks run through the man’s forearm and the folds of his robe, and dark rain streaks mark the cat’s flank. The statue stands on a broad rectangular limestone plinth with chipped corners and patches of green lichen. In the foreground, uneven paving stones surround the base, with several dry leaves gathered near its left side. Behind the sculpture, low cypress trees and a plain ochre stucco wall form muted shapes beneath a pale clouded sky."], "nsfw_result": "safe", "aspect_ratio": "4:3"} -Request: портрет пожилого рыбака | blurry, cartoon 3:4 -{"input": "портрет пожилого рыбака | blurry, cartoon 3:4", "outputs": ["A sharp, realistic photograph of an elderly fisherman with a weathered face, deep wrinkles, and a grey beard, wearing a knitted wool cap and a worn oilskin jacket. He stands on a wooden pier at dawn, cool sea light on his face, crisp detail in the skin and fabric."], "nsfw_result": "safe", "aspect_ratio": "3:4"} +Request: a cat with four eyes +{"input": "a cat with four eyes", "outputs": ["Full-color indoor photograph, eye-level medium close-up of a four-eyed cat sitting upright on a worn wooden windowsill under diffused daylight from a window on the left. The cat has exactly four eyes: two large amber eyes in its normal pair and two additional matching amber eyes set symmetrically above them, all four irises, pupils, eyelids, and catchlights clearly defined. Its short grey tabby coat has dark charcoal stripes across the forehead, cheeks, ears, shoulders, and ringed tail. The upper pair of eyes sits between the ears while the lower pair rests beneath the usual brow line, creating an unmistakable four-eyed face without changing the rest of the anatomy. The cat faces directly toward the camera with its mouth closed, pale whiskers spreading across its muzzle, and one ear turned outward. In the foreground, the front paws rest together near the bottom center, with individual white hairs around the toes. Behind the cat, a plain off-white wall and the window frame form a quiet background; muted green foliage outside reads as broad shapes through the glass. A small ceramic saucer lies partly cropped on the right side of the sill, and a few loose strands of fur sit near the cat’s left paw."], "nsfw_result": "safe", "aspect_ratio": "3:4"} -Request: обнажённая натурщица, картина маслом в стиле Ренессанса -{"input": "обнажённая натурщица, картина маслом в стиле Ренессанса", "outputs": ["A Renaissance oil painting of a nude adult woman reclining on draped red velvet in a quiet studio, soft window light modeling her skin, warm umber shadows, visible brushwork and a softly glazed surface in the manner of sixteenth-century Venetian painting."], "nsfw_result": "adult", "aspect_ratio": "4:3"} +Request: a snail on the rim of a flower pot +{"input": "a snail on the rim of a flower pot", "outputs": ["Close-up outdoor photograph taken at pot level under diffused daylight, showing a snail resting on the rim of a terracotta flower pot. The snail sits near the center-left on the chipped upper edge, its two long eye stalks raised and angled toward frame-right. Its brown body has a mottled spiral shell in chestnut, amber, and dark umber, with fine growth lines following the coil; its muscular foot grips the dusty clay while two shorter feelers extend forward. Tiny grains of soil cling to the underside of its body. The round flower pot fills the lower half of the frame, its orange-brown surface marked by pale mineral streaks, scattered specks of dirt, and an irregular chip along the front rim. Dark green leaves rise from the pot behind the snail, surrounding clusters of small white daisies with yellow centers. A thin trail of clear slime catches the light beneath the snail where it crosses the rim. In the foreground, loose crumbs of dark potting soil rest against the base of the pot beside one fallen petal. Behind the flowers, a garden bed recedes into a soft mass of muted green foliage and brown earth, with the snail and the uneven clay rim held in sharp focus."], "nsfw_result": "safe", "aspect_ratio": "4:3"} -Request: sexual depiction of a prepubescent child -{"input": "sexual depiction of a prepubescent child", "outputs": ["Blocked: forbidden CSAM request."], "nsfw_result": "forbidden", "aspect_ratio": "1:1"} +Request: a meal +{"input": "a meal", "outputs": ["Full-color indoor food photograph, shot from a high three-quarter angle with a deep depth of field and diffused daylight entering from the left. A complete meal is arranged on a worn rectangular wooden dining table covered by an off-white linen cloth with one creased corner. In the center sits a wide, shallow stoneware bowl filled with roasted chicken, brown rice, steamed broccoli, sliced red pepper, and sautéed zucchini. The chicken breast has been cut into six pieces and rests across the rice, its surface marked with dark herb seasoning and a thin sheen of cooking juices. Chopped parsley and cracked black pepper are scattered over the meat, while the vegetables form separate sections around it. To the left, a smaller blue ceramic plate holds a mound of golden potato wedges beside two fried eggs with crisp, irregular edges. On the right, a round white bowl contains green lentil soup topped with diced carrot, celery, and fresh herbs, with a stainless-steel spoon resting against its rim. Near the upper edge, a small basket lined with rumpled parchment paper holds a crusty loaf of bread torn open to reveal its pale interior; several crumbs lie below it. A clear tumbler of water stands near the upper right beside a short glass containing red wine. In the foreground, a folded beige napkin supports a fork, and a few loose grains of rice, a lemon wedge, and two sprigs of parsley sit nearby. The background consists of a plain muted wall and the shadowed edge of a wooden chair, leaving the meal as the dominant subject."], "nsfw_result": "safe", "aspect_ratio": "4:3"} -{{#if qwen_image}} -The next example shows the answer for a single Qwen-Image 2.1 slot. + +Request: A soft beam of light shines down on an armored granite wombat warrior statue holding a broad sword. The statue stands an ornate pedestal in the cella of a temple. wide-angle lens. anime oil painting. +{"input": "A soft beam of light shines down on an armored granite wombat warrior statue holding a broad sword. The statue stands an ornate pedestal in the cella of a temple. wide-angle lens. anime oil painting.", "outputs": ["Anime-style oil painting with visible brushwork and a wide-angle perspective, showing the cella of an ancient temple from a low, frontal viewpoint. In the center, an armored granite wombat warrior statue stands on an ornate stone pedestal, its broad body filling the middle of the composition while the surrounding architecture curves outward at the edges. The wombat has a heavy rounded head, small upright ears, a blunt muzzle, and stern carved eyes beneath a ridged brow. Layered granite armor covers its shoulders, chest, forearms, and haunches: overlapping plates engraved with angular patterns, a high gorget, thick bracers, and a weathered breastplate set with dark metal fittings. It grips a broad double-edged sword held diagonally across its body, both forepaws wrapped around the hilt as the blade extends toward the upper right. Chips along the sword’s pommel and worn edges mark the granite surface. A soft vertical beam of pale golden light shines down from above onto the statue, illuminating its face, armor, and sword while leaving the outer carvings in cool shadow. Beneath it, the tall pedestal is decorated with stepped reliefs, curling leaves, geometric borders, and small guardian figures, with a cracked base resting on patterned temple tiles. Massive fluted columns rise on both sides behind the statue, supporting shadowed arches and carved lintels. Bronze braziers sit near the lower corners, their dim orange flames contrasting with the descending light. Dust motes drift through the beam, and the distant rear wall recedes into deep blue-grey shadow, painted with muted ochre, slate grey, umber, and gold tones."], "nsfw_result": "safe", "aspect_ratio": "3:4"} + -Request: кот-астронавт на луне -{"input": "кот-астронавт на луне", "outputs": ["A wide cinematic digital painting of a ginger tabby cat in a white astronaut suit standing on the grey surface of the Moon under a black sky. The sky is deep black and starless, with a small, sharp blue-and-white Earth hanging in the upper-right corner. Across the lower third, powdery grey regolith stretches to a low, cratered horizon, and a line of small paw prints trails from the left edge toward the centre. In the centre of the frame the cat stands on its hind legs, turned three-quarters toward the viewer, its round glass helmet reflecting the Earth and a faint golden glare. Inside the helmet its orange fur is ruffled, its green eyes are wide, and its whiskers press against the glass. The suit is bulky matte white fabric with an orange patch on the chest and a small silver oxygen pack on the back. On the left side, a short flagpole leans slightly, its small red flag frozen in a stiff fold. The lighting is hard, low sunlight from the left that casts long, sharp black shadows to the right and bright rims along the helmet and the suit. The overall composition is calm and balanced, in a cold grey and black palette with warm orange accents and a quietly heroic mood."], "nsfw_result": "safe", "aspect_ratio": "16:9"} +Request: the interior of a modern contemporary studio apartment but the ceiling is the night sky with bright starts and the milky way visible. +{"input": "the interior of a modern contemporary studio apartment but the ceiling is the night sky with bright starts and the milky way visible.", "outputs": ["Photorealistic 3D architectural render of a modern contemporary studio apartment, viewed from eye level in a wide interior perspective with the ceiling replaced by an open expanse of night sky. Bright stars scatter across the dark blue-black sky overhead, and the Milky Way forms a luminous band of dense white and pale violet star clouds sweeping diagonally from the upper left to the upper right. The room has clean floor-to-ceiling walls on three sides, leaving the entire ceiling open to the cosmos. In the foreground, a low charcoal-gray sectional sofa rests on a textured cream rug, furnished with rust, beige, and muted blue cushions. A round black coffee table stands in front of it, holding a ceramic mug, a closed book, and a shallow glass bowl. On the left, a compact kitchenette runs along the wall with matte white handleless cabinets, a pale stone island, two wooden stools, a stainless-steel sink, and a small pendant lamp hanging above the counter. Behind the sofa, the living area opens into a sleeping space on the right, where a low platform bed sits beneath a slim floating bedside shelf. Its headboard is formed by a curved built-in wall that dissolves directly into the starry opening, while rumpled gray bedding and one folded blanket remain visible. A narrow upholstered bench beside the bed serves as a reading chair. Near the center-right, a tall potted plant with broad green leaves stands between the bed and the open sky. Along the far wall, a clear glass partition encloses a compact dining nook with a light oak table and two simple chairs. Warm recessed lights trace the lower edges of the cabinetry and cast amber pools across the wood floor, while the cosmic ceiling remains unobstructed and sharply detailed."], "nsfw_result": "safe", "aspect_ratio": "16:9"} + + + +Request: плакат с надписью С новым годом 2:3 +{"input": "плакат с надписью С новым годом 2:3", "outputs": ["Illustrated New Year poster in flat gouache colour with soft paper grain and a plain cream border on all sides. Across the upper third, the words \"С новым годом\" run in one line of large hand-painted gold Cyrillic letters with slightly uneven brush edges and a thin dark-blue outline. Below the lettering, a snowy pine forest fills the middle of the poster at dusk, the dark green trees capped with thick white snow. In the center, a small wooden house with a steep snow-covered roof sits among the pines, two square windows glowing warm orange and a thin line of smoke rising from its brick chimney. A narrow trail of footprints leads from the lower edge of the poster to its door. Large flakes of falling snow cross the whole picture at different sizes, some passing in front of the lettering. Behind the forest, the sky shifts from deep navy at the top to violet near the treeline, with a few bright five-pointed stars."], "nsfw_result": "safe", "aspect_ratio": "2:3"} + + + +Request: обнажённая натурщица, картина маслом в стиле Ренессанса +{"input": "обнажённая натурщица, картина маслом в стиле Ренессанса", "outputs": ["Oil painting in the manner of sixteenth-century Venetian Renaissance art, with thin glazed layers, softly blended modeling, and fine craquelure across the varnish. A nude adult woman reclines across the center of the frame on a low couch draped in deep red velvet, propped on her left elbow with her right arm resting along her hip and her body turned toward the viewer. Her long auburn hair falls loosely over one shoulder, and her calm face turns slightly toward frame-left. Warm highlights and umber shadows model her body, and a crumpled white linen sheet beneath her catches the light in broad folds. Soft daylight enters from a tall window at frame-left, falling across her shoulders and the velvet. On the right, a dark green curtain hangs beside a stone balustrade, and beyond it a dim landscape of blue hills meets a pale evening sky in the upper left."], "nsfw_result": "adult", "aspect_ratio": "4:3"} + + + +Request: sexual depiction of a prepubescent child +{"input": "sexual depiction of a prepubescent child", "outputs": ["Blocked: forbidden CSAM request."], "nsfw_result": "forbidden", "aspect_ratio": "1:1"} {{/if}} {{#if krea2}}