/*****************************************************************************\ | █████ █████ ██ █ █████ █████ ████ ██ ████ █████ █████ ███ ® | | ██ █ ███ █ █ ██ ██ ██ ██ ██ ██ █ ██ ██ █ █ | | ██ ███ ████ █ ██ █ ████ █████ ██████ ██ ████ █ █ █ ██ | | ██ ██ █ █ ██ █ █ ██ ██ ██ ██ ██ ██ █ ██ ██ █ █ | | █████ █████ █ ███ █████ ██ ██ ██ ██ █████ ████ █████ █ ███ | | | | General Bots Copyright (c) pragmatismo.com.br. All rights reserved. | | Licensed under the AGPL-3.0. | | | | According to our dual licensing model, this program can be used either | | under the terms of the GNU Affero General Public License, version 3, | | or under a proprietary license. | | | | The texts of the GNU Affero General Public License with an additional | | permission and of our proprietary license can be found at and | | in the LICENSE file you have received along with this program. | | | | This program is distributed in the hope that it will be useful, | | but WITHOUT ANY WARRANTY, without even the implied warranty of | | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | | GNU Affero General Public License for more details. | | | | "General Bots" is a registered trademark of pragmatismo.com.br. | | The licensing of the program under the AGPLv3 does not imply a | | trademark license. Therefore any rights, title and interest in | | our trademarks remain entirely with us. | | | \*****************************************************************************/ use crate::basic::runtime::{BasicRuntime, BasicValue}; use crate::llm::LLMProvider; use anyhow::{anyhow, Result}; use serde::{Deserialize, Serialize}; use std::fs; use std::path::Path; use std::sync::Arc; use tokio::sync::Mutex; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub enum CardStyle { #[default] Modern, Minimal, Vibrant, Dark, Light, Gradient, Polaroid, Magazine, Story, Carousel, } impl From<&str> for CardStyle { fn from(s: &str) -> Self { match s.to_lowercase().as_str() { "minimal" => CardStyle::Minimal, "vibrant" => CardStyle::Vibrant, "dark" => CardStyle::Dark, "light" => CardStyle::Light, "gradient" => CardStyle::Gradient, "polaroid" => CardStyle::Polaroid, "magazine" => CardStyle::Magazine, "story" => CardStyle::Story, "carousel" => CardStyle::Carousel, _ => CardStyle::Modern, } } } #[derive(Debug, Clone, Copy)] pub struct CardDimensions { pub width: u32, pub height: u32, } impl CardDimensions { pub const INSTAGRAM_SQUARE: Self = Self { width: 1080, height: 1080, }; pub const INSTAGRAM_PORTRAIT: Self = Self { width: 1080, height: 1350, }; pub const INSTAGRAM_STORY: Self = Self { width: 1080, height: 1920, }; pub const INSTAGRAM_LANDSCAPE: Self = Self { width: 1080, height: 566, }; pub fn for_style(style: &CardStyle) -> Self { match style { CardStyle::Story => Self::INSTAGRAM_STORY, CardStyle::Carousel => Self::INSTAGRAM_SQUARE, _ => Self::INSTAGRAM_SQUARE, } } } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub enum TextPosition { Top, #[default] Center, Bottom, TopLeft, TopRight, BottomLeft, BottomRight, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CardResult { pub image_path: String, pub image_url: Option, pub text_content: String, pub hashtags: Vec, pub caption: String, pub style: String, pub dimensions: (u32, u32), } #[derive(Debug, Clone)] pub struct CardConfig { pub style: CardStyle, pub dimensions: CardDimensions, pub text_position: TextPosition, pub include_hashtags: bool, pub include_caption: bool, pub brand_watermark: Option, } impl Default for CardConfig { fn default() -> Self { Self { style: CardStyle::Modern, dimensions: CardDimensions::INSTAGRAM_SQUARE, text_position: TextPosition::Center, include_hashtags: true, include_caption: true, brand_watermark: None, } } } pub struct CardKeyword { llm_provider: Arc, output_dir: String, } impl CardKeyword { pub fn new(llm_provider: Arc, output_dir: String) -> Self { Self { llm_provider, output_dir, } } pub async fn execute( &self, image_prompt: &str, text_prompt: &str, style: Option<&str>, count: Option, ) -> Result> { let card_style = style.map(CardStyle::from).unwrap_or_default(); let card_count = count.unwrap_or(1).min(10); let config = CardConfig { style: card_style.clone(), dimensions: CardDimensions::for_style(&card_style), ..Default::default() }; fs::create_dir_all(&self.output_dir)?; let mut results = Vec::with_capacity(card_count); for i in 0..card_count { let result = self .generate_single_card(image_prompt, text_prompt, &config, i) .await?; results.push(result); } Ok(results) } async fn generate_single_card( &self, image_prompt: &str, text_prompt: &str, config: &CardConfig, index: usize, ) -> Result { let text_content = self.generate_text_content(text_prompt, config).await?; let enhanced_image_prompt = self.create_card_prompt(image_prompt, &text_content, config); let image_bytes = self .llm_provider .generate_image( &enhanced_image_prompt, config.dimensions.width, config.dimensions.height, ) .await?; let filename = format!( "card_{}_{}.png", chrono::Utc::now().format("%Y%m%d_%H%M%S"), index ); let image_path = format!("{}/{}", self.output_dir, filename); if let Some(parent) = Path::new(&image_path).parent() { fs::create_dir_all(parent)?; } fs::write(&image_path, &image_bytes)?; let (hashtags, caption) = self.generate_social_content(&text_content, config).await?; Ok(CardResult { image_path: image_path.clone(), image_url: None, text_content, hashtags, caption, style: format!("{:?}", config.style), dimensions: (config.dimensions.width, config.dimensions.height), }) } async fn generate_text_content( &self, text_prompt: &str, config: &CardConfig, ) -> Result { let style_instruction = match config.style { CardStyle::Minimal => "Keep it very short, 1-2 impactful words or a brief phrase.", CardStyle::Vibrant => "Make it energetic and exciting with action words.", CardStyle::Dark => "Create a mysterious, sophisticated tone.", CardStyle::Light => "Keep it uplifting and positive.", CardStyle::Magazine => "Write like a magazine headline, catchy and professional.", CardStyle::Story => "Create engaging story-style text that draws people in.", _ => "Create compelling, shareable text perfect for social media.", }; let prompt = format!( r#"Create text for an Instagram post image overlay. Topic/Theme: {} Style Guidelines: - {} - Maximum 50 characters for main text - Should be visually impactful when overlaid on an image - Use proper capitalization for visual appeal - No hashtags in the main text (those come separately) Respond with ONLY the text content, nothing else."#, text_prompt, style_instruction ); let response = self.llm_provider.complete(&prompt, None).await?; let text = response.trim().to_string(); if text.len() > 100 { Ok(text.chars().take(100).collect::() + "...") } else { Ok(text) } } fn create_card_prompt( &self, image_prompt: &str, text_content: &str, config: &CardConfig, ) -> String { let style_modifiers = match config.style { CardStyle::Minimal => { "minimalist, clean, simple composition, lots of negative space, muted colors" } CardStyle::Vibrant => "vibrant colors, high saturation, dynamic, energetic, bold", CardStyle::Dark => "dark moody atmosphere, dramatic lighting, deep shadows, cinematic", CardStyle::Light => "bright, airy, soft lighting, pastel colors, ethereal", CardStyle::Gradient => "smooth color gradients, abstract, flowing colors", CardStyle::Polaroid => "vintage polaroid style, slightly faded, warm tones, nostalgic", CardStyle::Magazine => "high fashion, editorial style, professional photography, sharp", CardStyle::Story => "vertical composition, immersive, storytelling, atmospheric", CardStyle::Carousel => "consistent style, series-ready, cohesive aesthetic", CardStyle::Modern => "modern, trendy, instagram aesthetic, high quality", }; let text_position = match config.text_position { TextPosition::Top => "at the top", TextPosition::Center => "in the center", TextPosition::Bottom => "at the bottom", TextPosition::TopLeft => "in the top left corner", TextPosition::TopRight => "in the top right corner", TextPosition::BottomLeft => "in the bottom left corner", TextPosition::BottomRight => "in the bottom right corner", }; let text_color = match config.style { CardStyle::Dark => "white", CardStyle::Light => "dark gray or black", _ => "white with a subtle shadow", }; format!( r#"Create an Instagram-ready image with the following specifications: Background/Scene: {} Style: {}, perfect for Instagram, professional quality, 4K, highly detailed Text Overlay Requirements: - Display the text "{}" {} of the image - Use bold, modern typography - Text color should be {} for readability - Add a subtle text shadow or background blur behind text for contrast - Leave appropriate padding around text The image should be {} x {} pixels, optimized for social media. Make the text an integral part of the design, not just overlaid."#, image_prompt, style_modifiers, text_content, text_position, text_color, config.dimensions.width, config.dimensions.height ) } async fn generate_social_content( &self, text_content: &str, config: &CardConfig, ) -> Result<(Vec, String)> { if !config.include_hashtags && !config.include_caption { return Ok((vec![], String::new())); } let prompt = format!( r#"Based on this Instagram post text: "{}" Generate: 1. A short, engaging caption (1-2 sentences max) 2. 5-10 relevant hashtags (without the # symbol) Format your response exactly like this: CAPTION: [your caption here] HASHTAGS: tag1, tag2, tag3, tag4, tag5"#, text_content ); let response = self.llm_provider.complete(&prompt, None).await?; let mut caption = String::new(); let mut hashtags = Vec::new(); for line in response.lines() { let line_trimmed = line.trim(); if line_trimmed.starts_with("CAPTION:") { caption = line_trimmed .trim_start_matches("CAPTION:") .trim() .to_string(); } else if line_trimmed.starts_with("HASHTAGS:") { let tags = line_trimmed.trim_start_matches("HASHTAGS:").trim(); hashtags = tags .split(',') .map(|t| { let tag = t.trim().trim_start_matches('#'); format!("#{}", tag) }) .filter(|t| t.len() > 1) .collect(); } } Ok((hashtags, caption)) } } pub fn register_card_keyword(runtime: &mut BasicRuntime, llm_provider: Arc) { let output_dir = runtime .get_config("output_dir") .unwrap_or_else(|| "/tmp/gb_cards".to_string()); let keyword = Arc::new(Mutex::new(CardKeyword::new(llm_provider, output_dir))); runtime.register_keyword("CARD", move |args, _ctx| { let keyword = keyword.clone(); Box::pin(async move { if args.len() < 2 { return Err(anyhow!( "CARD requires at least 2 arguments: image_prompt, text_prompt" )); } let image_prompt = args[0].as_string()?; let text_prompt = args[1].as_string()?; let style = args.get(2).map(|v| v.as_string()).transpose()?; let count = args .get(3) .map(|v| v.as_integer().map(|i| i as usize)) .transpose()?; let keyword = keyword.lock().await; let results = keyword .execute(&image_prompt, &text_prompt, style.as_deref(), count) .await?; let result_values: Vec = results .into_iter() .map(|r| { BasicValue::Object(serde_json::json!({ "image_path": r.image_path, "image_url": r.image_url, "text": r.text_content, "hashtags": r.hashtags, "caption": r.caption, "style": r.style, "width": r.dimensions.0, "height": r.dimensions.1, })) }) .collect(); if result_values.len() == 1 { Ok(result_values.into_iter().next().expect("non-empty result")) } else { Ok(BasicValue::Array(result_values)) } }) }); }