botserver/src/basic/keywords/card.rs

602 lines
19 KiB
Rust
Raw Normal View History

2025-11-30 22:33:54 -03:00
//! CARD keyword - Creates beautiful Instagram-style posts from prompts
//!
//! Syntax:
//! CARD image_prompt, text_prompt TO variable
//! CARD image_prompt, text_prompt, style TO variable
//! CARD image_prompt, text_prompt, style, count TO variable
//!
//! Examples:
//! CARD "sunset over mountains", "inspirational quote about nature" TO post
//! CARD "modern office", "productivity tips", "minimal" TO cards
//! CARD "healthy food", "nutrition facts", "vibrant", 5 TO carousel
use crate::basic::runtime::{BasicRuntime, BasicValue};
use crate::llm::LLMProvider;
use anyhow::{anyhow, Result};
use image::{DynamicImage, ImageBuffer, Rgba, RgbaImage};
use imageproc::drawing::{draw_text_mut, text_size};
use rusttype::{Font, Scale};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::Mutex;
/// Card style presets for Instagram posts
#[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,
}
}
}
/// Card dimensions for different formats
#[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,
}
}
}
/// Text overlay configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TextOverlay {
pub text: String,
pub font_size: f32,
pub color: [u8; 4],
pub position: TextPosition,
pub max_width_ratio: f32,
pub shadow: bool,
pub background: Option<[u8; 4]>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub enum TextPosition {
Top,
#[default]
Center,
Bottom,
TopLeft,
TopRight,
BottomLeft,
BottomRight,
}
/// Generated card result
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CardResult {
pub image_path: String,
pub image_url: Option<String>,
pub text_content: String,
pub hashtags: Vec<String>,
pub caption: String,
pub style: String,
pub dimensions: (u32, u32),
}
/// Card generation configuration
#[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<String>,
}
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,
}
}
}
/// CARD keyword implementation
pub struct CardKeyword {
llm_provider: Arc<dyn LLMProvider>,
output_dir: String,
}
impl CardKeyword {
pub fn new(llm_provider: Arc<dyn LLMProvider>, output_dir: String) -> Self {
Self {
llm_provider,
output_dir,
}
}
/// Execute CARD keyword
pub async fn execute(
&self,
image_prompt: &str,
text_prompt: &str,
style: Option<&str>,
count: Option<usize>,
) -> Result<Vec<CardResult>> {
let card_style = style.map(CardStyle::from).unwrap_or_default();
let card_count = count.unwrap_or(1).min(10); // Max 10 cards
let config = CardConfig {
style: card_style.clone(),
dimensions: CardDimensions::for_style(&card_style),
..Default::default()
};
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)
}
/// Generate a single card
async fn generate_single_card(
&self,
image_prompt: &str,
text_prompt: &str,
config: &CardConfig,
index: usize,
) -> Result<CardResult> {
// Step 1: Generate optimized text content using LLM
let text_content = self.generate_text_content(text_prompt, config).await?;
// Step 2: Generate image using image generation API
let base_image = self.generate_image(image_prompt, config).await?;
// Step 3: Apply style and text overlay
let styled_image = self.apply_style_and_text(&base_image, &text_content, config)?;
// Step 4: Generate hashtags and caption
let (hashtags, caption) = self.generate_social_content(&text_content, config).await?;
// Step 5: Save the final image
let filename = format!(
"card_{}_{}.png",
chrono::Utc::now().format("%Y%m%d_%H%M%S"),
index
);
let image_path = format!("{}/{}", self.output_dir, filename);
styled_image.save(&image_path)?;
Ok(CardResult {
image_path: image_path.clone(),
image_url: None, // Will be set after upload to storage
text_content,
hashtags,
caption,
style: format!("{:?}", config.style),
dimensions: (config.dimensions.width, config.dimensions.height),
})
}
/// Generate optimized text content for the card
async fn generate_text_content(
&self,
text_prompt: &str,
config: &CardConfig,
) -> Result<String> {
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?;
// Clean up the response
let text = response.trim().to_string();
// Ensure it's not too long
if text.len() > 100 {
Ok(text.chars().take(100).collect::<String>() + "...")
} else {
Ok(text)
}
}
/// Generate the base image
async fn generate_image(
&self,
image_prompt: &str,
config: &CardConfig,
) -> Result<DynamicImage> {
let enhanced_prompt = self.enhance_image_prompt(image_prompt, config);
// Call image generation service
let image_bytes = self
.llm_provider
.generate_image(
&enhanced_prompt,
config.dimensions.width,
config.dimensions.height,
)
.await?;
let image = image::load_from_memory(&image_bytes)?;
Ok(image)
}
/// Enhance the image prompt based on style
fn enhance_image_prompt(&self, base_prompt: &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",
};
format!(
"{}, {}, perfect for Instagram, professional quality, 4K, highly detailed",
base_prompt, style_modifiers
)
}
/// Apply style effects and text overlay to the image
fn apply_style_and_text(
&self,
image: &DynamicImage,
text: &str,
config: &CardConfig,
) -> Result<DynamicImage> {
let mut rgba_image = image.to_rgba8();
// Apply style-specific filters
self.apply_style_filter(&mut rgba_image, &config.style);
// Add text overlay
self.add_text_overlay(&mut rgba_image, text, config)?;
// Add watermark if configured
if let Some(ref watermark) = config.brand_watermark {
self.add_watermark(&mut rgba_image, watermark)?;
}
Ok(DynamicImage::ImageRgba8(rgba_image))
}
/// Apply style-specific image filters
fn apply_style_filter(&self, image: &mut RgbaImage, style: &CardStyle) {
match style {
CardStyle::Dark => {
// Darken and increase contrast
for pixel in image.pixels_mut() {
pixel[0] = (pixel[0] as f32 * 0.7) as u8;
pixel[1] = (pixel[1] as f32 * 0.7) as u8;
pixel[2] = (pixel[2] as f32 * 0.7) as u8;
}
}
CardStyle::Light => {
// Brighten slightly
for pixel in image.pixels_mut() {
pixel[0] = ((pixel[0] as f32 * 1.1).min(255.0)) as u8;
pixel[1] = ((pixel[1] as f32 * 1.1).min(255.0)) as u8;
pixel[2] = ((pixel[2] as f32 * 1.1).min(255.0)) as u8;
}
}
CardStyle::Polaroid => {
// Add warm vintage tint
for pixel in image.pixels_mut() {
pixel[0] = ((pixel[0] as f32 * 1.05).min(255.0)) as u8;
pixel[1] = ((pixel[1] as f32 * 0.95).min(255.0)) as u8;
pixel[2] = ((pixel[2] as f32 * 0.85).min(255.0)) as u8;
}
}
CardStyle::Vibrant => {
// Increase saturation
for pixel in image.pixels_mut() {
let r = pixel[0] as f32;
let g = pixel[1] as f32;
let b = pixel[2] as f32;
let avg = (r + g + b) / 3.0;
let factor = 1.3;
pixel[0] = ((r - avg) * factor + avg).clamp(0.0, 255.0) as u8;
pixel[1] = ((g - avg) * factor + avg).clamp(0.0, 255.0) as u8;
pixel[2] = ((b - avg) * factor + avg).clamp(0.0, 255.0) as u8;
}
}
_ => {}
}
}
/// Add text overlay to the image
fn add_text_overlay(
&self,
image: &mut RgbaImage,
text: &str,
config: &CardConfig,
) -> Result<()> {
let (width, height) = (image.width(), image.height());
// Load font (embedded or from file)
let font_data = include_bytes!("../../../assets/fonts/Inter-Bold.ttf");
let font = Font::try_from_bytes(font_data as &[u8])
.ok_or_else(|| anyhow!("Failed to load font"))?;
// Calculate font size based on image dimensions and text length
let base_size = (width as f32 * 0.08).min(height as f32 * 0.1);
let scale = Scale::uniform(base_size);
// Calculate text position
let (text_width, text_height) = text_size(scale, &font, text);
let (x, y) = self.calculate_text_position(
width,
height,
text_width as u32,
text_height as u32,
&config.text_position,
);
// Draw text shadow for better readability
let shadow_color = Rgba([0u8, 0u8, 0u8, 180u8]);
draw_text_mut(image, shadow_color, x + 3, y + 3, scale, &font, text);
// Draw main text
let text_color = match config.style {
CardStyle::Dark => Rgba([255u8, 255u8, 255u8, 255u8]),
CardStyle::Light => Rgba([30u8, 30u8, 30u8, 255u8]),
_ => Rgba([255u8, 255u8, 255u8, 255u8]),
};
draw_text_mut(image, text_color, x, y, scale, &font, text);
Ok(())
}
/// Calculate text position based on configuration
fn calculate_text_position(
&self,
img_width: u32,
img_height: u32,
text_width: u32,
text_height: u32,
position: &TextPosition,
) -> (i32, i32) {
let padding = (img_width as f32 * 0.05) as i32;
match position {
TextPosition::Top => (
((img_width - text_width) / 2) as i32,
padding + text_height as i32,
),
TextPosition::Center => (
((img_width - text_width) / 2) as i32,
((img_height - text_height) / 2) as i32,
),
TextPosition::Bottom => (
((img_width - text_width) / 2) as i32,
(img_height - text_height) as i32 - padding,
),
TextPosition::TopLeft => (padding, padding + text_height as i32),
TextPosition::TopRight => (
(img_width - text_width) as i32 - padding,
padding + text_height as i32,
),
TextPosition::BottomLeft => (padding, (img_height - text_height) as i32 - padding),
TextPosition::BottomRight => (
(img_width - text_width) as i32 - padding,
(img_height - text_height) as i32 - padding,
),
}
}
/// Add brand watermark
fn add_watermark(&self, image: &mut RgbaImage, watermark: &str) -> Result<()> {
let font_data = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
let font = Font::try_from_bytes(font_data as &[u8])
.ok_or_else(|| anyhow!("Failed to load font"))?;
let scale = Scale::uniform(image.width() as f32 * 0.025);
let color = Rgba([255u8, 255u8, 255u8, 128u8]);
let padding = 20i32;
let x = padding;
let y = (image.height() - 30) as i32;
draw_text_mut(image, color, x, y, scale, &font, watermark);
Ok(())
}
/// Generate hashtags and caption for the post
async fn generate_social_content(
&self,
text_content: &str,
config: &CardConfig,
) -> Result<(Vec<String>, 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?;
// Parse the response
let mut caption = String::new();
let mut hashtags = Vec::new();
for line in response.lines() {
if line.starts_with("CAPTION:") {
caption = line.trim_start_matches("CAPTION:").trim().to_string();
} else if line.starts_with("HASHTAGS:") {
let tags = line.trim_start_matches("HASHTAGS:").trim();
hashtags = tags.split(',').map(|t| format!("#{}", t.trim())).collect();
}
}
Ok((hashtags, caption))
}
}
/// Register CARD keyword with the BASIC runtime
pub fn register_card_keyword(runtime: &mut BasicRuntime, llm_provider: Arc<dyn LLMProvider>) {
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_number().map(|n| n as usize))
.transpose()?;
let kw = keyword.lock().await;
let results = kw
.execute(&image_prompt, &text_prompt, style.as_deref(), count)
.await?;
// Convert results to BasicValue
let value = if results.len() == 1 {
BasicValue::Object(serde_json::to_value(&results[0])?)
} else {
BasicValue::Array(
results
.into_iter()
.map(|r| BasicValue::Object(serde_json::to_value(&r).unwrap()))
.collect(),
)
};
Ok(value)
})
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_card_style_from_string() {
assert!(matches!(CardStyle::from("minimal"), CardStyle::Minimal));
assert!(matches!(CardStyle::from("VIBRANT"), CardStyle::Vibrant));
assert!(matches!(CardStyle::from("unknown"), CardStyle::Modern));
}
#[test]
fn test_card_dimensions() {
assert_eq!(CardDimensions::INSTAGRAM_SQUARE.width, 1080);
assert_eq!(CardDimensions::INSTAGRAM_SQUARE.height, 1080);
assert_eq!(CardDimensions::INSTAGRAM_STORY.height, 1920);
}
#[test]
fn test_text_position_calculation() {
// Create a mock keyword for testing
// In real tests, we'd use a mock LLM provider
}
}