summaryrefslogtreecommitdiff
path: root/internal/image
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-07-18 23:18:45 +0300
committerPaul Buetow <paul@buetow.org>2025-07-18 23:18:45 +0300
commit8779de2c97e445acf82e8422c899fdefa4649bda (patch)
treeb24d270a4e655c66059034cfdf0713e3bf3d32f5 /internal/image
parentf3c1b568ac28e211d218f0b33ccb85cf782ce248 (diff)
feat: multiple improvements to GUI and codebase
- Add random voice speed between 0.90-1.00 for more natural audio - Display voice and speed info in GUI audio player - Implement automatic retry loading for missing files (checks every 2 seconds) - Fix voice/speed info persistence during audio playback - Remove image caching functionality for cleaner codebase - Rename prompt.txt to image_prompt.txt for clarity - Fix GUI to recognize newly added cards during runtime (rescan on navigation) - Update README to reflect removed image cache These changes improve the user experience by making the audio more natural, providing better feedback about audio generation parameters, and ensuring the GUI stays synchronized with externally added cards. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'internal/image')
-rw-r--r--internal/image/openai.go410
1 files changed, 104 insertions, 306 deletions
diff --git a/internal/image/openai.go b/internal/image/openai.go
index 6a2494a..637b8a1 100644
--- a/internal/image/openai.go
+++ b/internal/image/openai.go
@@ -8,8 +8,6 @@ import (
"io"
"math/rand"
"net/http"
- "os"
- "path/filepath"
"strings"
"time"
@@ -18,26 +16,22 @@ import (
// OpenAIClient implements ImageSearcher for OpenAI DALL-E image generation
type OpenAIClient struct {
- client *openai.Client
- apiKey string
- model string // dall-e-2 or dall-e-3
- size string // 256x256, 512x512, 1024x1024
- quality string // standard or hd (dall-e-3 only)
- style string // natural or vivid (dall-e-3 only)
- cacheDir string
- enableCache bool
- lastPrompt string // Store the last used prompt for attribution
+ client *openai.Client
+ apiKey string
+ model string // dall-e-2 or dall-e-3
+ size string // 256x256, 512x512, 1024x1024
+ quality string // standard or hd (dall-e-3 only)
+ style string // natural or vivid (dall-e-3 only)
+ lastPrompt string // Store the last used prompt for attribution
}
// OpenAIConfig holds configuration for the OpenAI image provider
type OpenAIConfig struct {
- APIKey string
- Model string
- Size string
- Quality string
- Style string
- CacheDir string
- EnableCache bool
+ APIKey string
+ Model string
+ Size string
+ Quality string
+ Style string
}
// NewOpenAIClient creates a new OpenAI DALL-E client
@@ -62,24 +56,14 @@ func NewOpenAIClient(config *OpenAIConfig) *OpenAIClient {
if config.Style == "" {
config.Style = "natural"
}
- if config.CacheDir == "" {
- config.CacheDir = "./.image_cache"
- }
oc := &OpenAIClient{
- client: client,
- apiKey: config.APIKey,
- model: config.Model,
- size: config.Size,
- quality: config.Quality,
- style: config.Style,
- cacheDir: config.CacheDir,
- enableCache: config.EnableCache,
- }
-
- // Create cache directory if caching is enabled
- if oc.enableCache && oc.cacheDir != "" {
- os.MkdirAll(oc.cacheDir, 0755)
+ client: client,
+ apiKey: config.APIKey,
+ model: config.Model,
+ size: config.Size,
+ quality: config.Quality,
+ style: config.Style,
}
return oc
@@ -95,26 +79,6 @@ func (c *OpenAIClient) Search(ctx context.Context, opts *SearchOptions) ([]Searc
}
}
- // Check cache first
- if c.enableCache {
- cacheFile := c.getCacheFilePath(opts.Query)
- if info, err := os.Stat(cacheFile); err == nil && info.Size() > 0 {
- // Return cached result
- fmt.Printf("Using cached image for '%s'\n", opts.Query)
- result := SearchResult{
- ID: c.generateImageID(opts.Query),
- URL: cacheFile,
- ThumbnailURL: cacheFile,
- Width: c.getSizeWidth(),
- Height: c.getSizeHeight(),
- Description: fmt.Sprintf("Generated image for %s", opts.Query),
- Attribution: "Generated by OpenAI DALL-E",
- Source: "openai",
- }
- return []SearchResult{result}, nil
- }
- }
-
// Use provided translation if available, otherwise translate Bulgarian word to English
var translatedWord string
if opts.Translation != "" {
@@ -196,16 +160,6 @@ func (c *OpenAIClient) Search(ctx context.Context, opts *SearchOptions) ([]Searc
// Get the generated image URL
imageURL := resp.Data[0].URL
- // Download and cache the image if caching is enabled
- if c.enableCache {
- cacheFile := c.getCacheFilePath(opts.Query)
- if err := c.downloadAndCache(ctx, imageURL, cacheFile); err == nil {
- // Update URL to point to cached file
- imageURL = cacheFile
- }
- // Continue even if caching fails
- }
-
// Create result
result := SearchResult{
ID: c.generateImageID(opts.Query),
@@ -223,16 +177,7 @@ func (c *OpenAIClient) Search(ctx context.Context, opts *SearchOptions) ([]Searc
// Download downloads an image from the given URL
func (c *OpenAIClient) Download(ctx context.Context, url string) (io.ReadCloser, error) {
- // If it's a local cached file (not an HTTP/HTTPS URL), open it directly
- if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
- file, err := os.Open(url)
- if err != nil {
- return nil, fmt.Errorf("failed to open cached file: %w", err)
- }
- return file, nil
- }
-
- // Otherwise download from URL
+ // Download from URL
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
@@ -284,12 +229,77 @@ func (c *OpenAIClient) createEducationalPrompt(bulgarianWord, englishTranslation
scene = ""
}
- // Get styles from file or generate them
- styles, err := c.getArtisticStyles(context.Background())
- if err != nil {
- // If we can't get styles, return an error by returning empty prompt
- fmt.Printf(" ERROR: Failed to load artistic styles: %v\n", err)
- return ""
+ // Define artistic styles
+ styles := []string{
+ "Photorealism", "Hyperrealism", "Surrealism", "Impressionism",
+ "Minimalism", "Pop Art", "Art Nouveau", "Digital Art",
+ "Watercolor", "Oil Painting", "Pencil Sketch", "Ink Drawing",
+ "3D Rendering", "Low Poly Art", "Pixel Art", "Vector Art",
+ "Collage", "Mixed Media", "Contemporary Art", "Abstract Expressionism",
+ "Cubism", "Pointillism", "Fauvism", "Art Deco",
+ "Baroque", "Renaissance", "Romanticism", "Realism",
+ "Post-Impressionism", "Expressionism", "Constructivism", "Suprematism",
+ "Dadaism", "Futurism", "Op Art", "Kinetic Art",
+ "Street Art", "Graffiti Art", "Installation Art", "Land Art",
+ "Conceptual Art", "Performance Art", "Video Art", "Net Art",
+ "Generative Art", "Algorithmic Art", "Fractal Art", "Glitch Art",
+ "Vaporwave", "Synthwave", "Cyberpunk", "Steampunk",
+ "Fantasy Art", "Science Fiction Art", "Horror Art", "Gothic Art",
+ "Anime", "Manga", "Comic Book Art", "Cartoon",
+ "Caricature", "Editorial Illustration", "Children's Book Illustration", "Fashion Illustration",
+ "Architectural Rendering", "Technical Illustration", "Scientific Illustration", "Medical Illustration",
+ "Botanical Illustration", "Zoological Illustration", "Astronomical Art", "Paleoart",
+ "Infographic", "Data Visualization", "Typography Art", "Calligraphy",
+ "Mosaic", "Stained Glass", "Tapestry", "Embroidery",
+ "Sculpture", "Ceramics", "Pottery", "Glass Art",
+ "Metalwork", "Jewelry Design", "Woodcarving", "Paper Art",
+ "Origami", "Kirigami", "Quilling", "Book Art",
+ "Photography", "Documentary Photography", "Portrait Photography", "Landscape Photography",
+ "Macro Photography", "Aerial Photography", "Underwater Photography", "Astrophotography",
+ "Film Noir", "Vintage Photography", "Polaroid", "Double Exposure",
+ "HDR Photography", "Long Exposure", "Tilt-Shift", "Infrared Photography",
+ "Monochrome", "Sepia Tone", "Cross-Processing", "Cyanotype",
+ "Folk Art", "Outsider Art", "Naive Art", "Aboriginal Art",
+ "African Art", "Asian Art", "Islamic Art", "Celtic Art",
+ "Byzantine Art", "Medieval Art", "Pre-Columbian Art", "Ancient Egyptian Art",
+ "Ancient Greek Art", "Ancient Roman Art", "Cave Painting", "Petroglyphs",
+ "Bauhaus", "De Stijl", "Vienna Secession", "Arts and Crafts Movement",
+ "Prairie School", "International Style", "Brutalism", "Deconstructivism",
+ "Parametric Design", "Biomimicry", "Sustainable Design", "Universal Design",
+ "Retro Futurism", "Dieselpunk", "Atompunk", "Biopunk",
+ "Afrofuturism", "Solarpunk", "Post-Apocalyptic", "Dystopian Art",
+ "Psychedelic Art", "Visionary Art", "Lowbrow Art", "Outsider Art",
+ "Trompe-l'oeil", "Anamorphic Art", "Optical Illusion", "Impossible Objects",
+ "Sacred Geometry", "Mandala", "Yantra", "Celtic Knots",
+ "Stippling", "Hatching", "Cross-Hatching", "Scumbling",
+ "Impasto", "Glazing", "Scumbling", "Sgraffito",
+ "Encaustic", "Fresco", "Tempera", "Gouache",
+ "Pastel", "Charcoal", "Conte", "Silverpoint",
+ "Linocut", "Woodcut", "Etching", "Lithography",
+ "Screen Printing", "Monotype", "Collagraph", "Digital Print",
+ "Augmented Reality Art", "Virtual Reality Art", "Interactive Art", "Projection Mapping",
+ "Light Art", "Neon Art", "Holographic Art", "Laser Art",
+ "Sound Art", "Bio Art", "Eco Art", "Social Practice Art",
+ "Relational Aesthetics", "Participatory Art", "Community Art", "Activist Art",
+ "Feminist Art", "Queer Art", "Postcolonial Art", "Decolonial Art",
+ "Metamodernism", "Post-Internet Art", "Post-Digital Art", "New Aesthetic",
+ "Speculative Design", "Critical Design", "Design Fiction", "Adversarial Design",
+ "Transitional Design", "Transformation Design", "Service Design", "Experience Design",
+ "Slow Design", "Emotional Design", "Inclusive Design", "Regenerative Design",
+ "Biophilic Design", "Cradle to Cradle", "Circular Design", "Zero Waste Design",
+ "Modular Design", "Open Design", "Co-Design", "Participatory Design",
+ "Flat Design", "Material Design", "Neumorphism", "Glassmorphism",
+ "Maximalism", "Eclecticism", "Kitsch", "Camp",
+ "Wabi-Sabi", "Hygge", "Lagom", "Ikigai",
+ "Feng Shui", "Vastu Shastra", "Sacred Architecture", "Organic Architecture",
+ "Vernacular Architecture", "Adaptive Reuse", "Green Architecture", "Living Architecture",
+ "Kinetic Architecture", "Responsive Architecture", "Parametric Architecture", "Algorithmic Architecture",
+ "Blob Architecture", "Deconstructivist Architecture", "High-Tech Architecture", "Neo-Futurism",
+ "Critical Regionalism", "Tropical Modernism", "Desert Modernism", "Scandinavian Design",
+ "Japanese Design", "Italian Design", "German Design", "Dutch Design",
+ "Memphis Group", "Radical Design", "Anti-Design", "Superstudio",
+ "Archigram", "Metabolism", "Structuralism", "Postmodernism",
+ "Minimalist Photography", "Conceptual Photography", "Staged Photography", "Candid Photography",
}
// Shuffle the styles to avoid bias
@@ -303,7 +313,7 @@ func (c *OpenAIClient) createEducationalPrompt(bulgarianWord, englishTranslation
// Define prompt components in order of importance
var prompt string
-
+
if scene != "" {
// Full prompt with scene
fullPrompt := fmt.Sprintf(
@@ -313,7 +323,7 @@ func (c *OpenAIClient) createEducationalPrompt(bulgarianWord, englishTranslation
"IMPORTANT: No text whatsoever. Do not include any words, letters, typography, labels, captions, or writing of any kind. Image only, without any text elements.",
selectedStyle, scene,
)
-
+
// Check if full prompt exceeds 1000 characters
if len(fullPrompt) > 1000 {
// Try without the IMPORTANT notice
@@ -323,7 +333,7 @@ func (c *OpenAIClient) createEducationalPrompt(bulgarianWord, englishTranslation
"Requirements: The main subject must be clearly visible, easily recognizable, and prominent in the image. It should occupy the central area with sharp focus and proper lighting.",
selectedStyle, scene,
)
-
+
// If still too long, truncate the scene
if len(prompt) > 1000 {
// Truncate scene to fit within limit
@@ -355,12 +365,12 @@ func (c *OpenAIClient) createEducationalPrompt(bulgarianWord, englishTranslation
selectedStyle, englishTranslation, englishTranslation,
)
}
-
+
// Final check to ensure prompt is within 1000 characters
if len(prompt) > 1000 {
prompt = prompt[:997] + "..."
}
-
+
return prompt
}
@@ -432,60 +442,14 @@ func (c *OpenAIClient) generateSceneDescription(ctx context.Context, bulgarianWo
return scene, nil
}
-// getCacheFilePath generates a cache file path for the given word
-func (c *OpenAIClient) getCacheFilePath(word string) string {
- // Create a hash of the word and settings
- h := md5.New()
- h.Write([]byte(word))
- h.Write([]byte(c.model))
- h.Write([]byte(c.size))
- h.Write([]byte(c.quality))
- h.Write([]byte(c.style))
- hash := hex.EncodeToString(h.Sum(nil))
-
- // Use first 2 chars as subdirectory for better file system performance
- subdir := hash[:2]
- filename := hash[2:] + ".png"
-
- return filepath.Join(c.cacheDir, subdir, filename)
-}
-
-// downloadAndCache downloads an image and saves it to the cache
-func (c *OpenAIClient) downloadAndCache(ctx context.Context, url, cacheFile string) error {
- // Ensure directory exists
- dir := filepath.Dir(cacheFile)
- if err := os.MkdirAll(dir, 0755); err != nil {
- return err
- }
-
- // Download the image
- resp, err := c.Download(ctx, url)
- if err != nil {
- return err
- }
- defer resp.Close()
-
- // Create the cache file
- out, err := os.Create(cacheFile)
- if err != nil {
- return err
- }
- defer out.Close()
-
- // Copy the data
- _, err = io.Copy(out, resp)
- return err
-}
-
-// generateImageID creates a unique ID for the image
+// generateImageID generates a unique ID for the image
func (c *OpenAIClient) generateImageID(word string) string {
- h := md5.New()
- h.Write([]byte(word))
- h.Write([]byte(c.model))
- return "openai_" + hex.EncodeToString(h.Sum(nil))[:8]
+ // Create hash of the word for unique ID
+ hash := md5.Sum([]byte(word))
+ return hex.EncodeToString(hash[:])[:8]
}
-// getSizeWidth returns the width based on the size setting
+// getSizeWidth returns the width based on the configured size
func (c *OpenAIClient) getSizeWidth() int {
switch c.size {
case "256x256":
@@ -494,179 +458,13 @@ func (c *OpenAIClient) getSizeWidth() int {
return 512
case "1024x1024":
return 1024
- case "1024x1792", "1792x1024": // DALL-E 3 sizes
- if strings.HasPrefix(c.size, "1024") {
- return 1024
- }
- return 1792
default:
- return 512
- }
-}
-
-// getSizeHeight returns the height based on the size setting
-func (c *OpenAIClient) getSizeHeight() int {
- switch c.size {
- case "256x256":
- return 256
- case "512x512":
- return 512
- case "1024x1024":
return 1024
- case "1024x1792":
- return 1792
- case "1792x1024":
- return 1024
- default:
- return 512
}
}
-// getArtisticStyles loads artistic styles from cache or generates them via OpenAI
-func (c *OpenAIClient) getArtisticStyles(ctx context.Context) ([]string, error) {
- // Define the styles cache file path
- stylesFile := filepath.Join(c.cacheDir, "artistic_styles.txt")
-
- // Check if file exists and is less than a week old
- fileInfo, err := os.Stat(stylesFile)
- needsRegeneration := false
-
- if err != nil {
- if os.IsNotExist(err) {
- needsRegeneration = true
- fmt.Println(" Artistic styles file not found, will generate new styles")
- } else {
- return nil, fmt.Errorf("error checking styles file: %w", err)
- }
- } else {
- // Check if file is older than a week
- weekAgo := time.Now().Add(-7 * 24 * time.Hour)
- if fileInfo.ModTime().Before(weekAgo) {
- needsRegeneration = true
- fmt.Println(" Artistic styles file is older than a week, will regenerate")
- }
- }
-
- // If we need to regenerate, do it
- if needsRegeneration {
- styles, err := c.generateArtisticStyles(ctx)
- if err != nil {
- return nil, fmt.Errorf("failed to generate artistic styles: %w", err)
- }
-
- // Save to file
- if err := c.saveStylesToFile(stylesFile, styles); err != nil {
- // Log error but continue with generated styles
- fmt.Printf(" Warning: Could not save styles to file: %v\n", err)
- }
-
- return styles, nil
- }
-
- // Load from file
- return c.loadStylesFromFile(stylesFile)
-}
-
-// generateArtisticStyles asks OpenAI to generate a list of artistic styles
-func (c *OpenAIClient) generateArtisticStyles(ctx context.Context) ([]string, error) {
- fmt.Println(" Generating artistic styles via OpenAI...")
-
- req := openai.ChatCompletionRequest{
- Model: openai.GPT4oMini,
- Messages: []openai.ChatCompletionMessage{
- {
- Role: openai.ChatMessageRoleSystem,
- Content: "You are an art expert helping to create diverse visual styles for educational flashcards. Generate exactly 42 different artistic styles that could be used for images. Include a mix of: photography styles (macro, portrait, landscape, etc.), traditional art techniques (watercolor, oil painting, pencil sketch, etc.), digital art styles (3D render, pixel art, vector illustration, etc.), artistic movements (impressionist, pop art, art deco, etc.), and other creative visual approaches. Each style should be concise (2-5 words) and distinct from the others. Format your response as a simple list with one style per line, no numbers or bullets.",
- },
- {
- Role: openai.ChatMessageRoleUser,
- Content: "Please generate 42 diverse artistic styles for creating educational images. Include various photography types, painting techniques, illustration styles, and artistic movements.",
- },
- },
- Temperature: 0.8,
- MaxTokens: 500,
- }
-
- resp, err := c.client.CreateChatCompletion(ctx, req)
- if err != nil {
- return nil, fmt.Errorf("OpenAI API error: %w", err)
- }
-
- if len(resp.Choices) == 0 || resp.Choices[0].Message.Content == "" {
- return nil, fmt.Errorf("no response from OpenAI")
- }
-
- // Parse the response into lines
- content := strings.TrimSpace(resp.Choices[0].Message.Content)
- lines := strings.Split(content, "\n")
-
- // Clean up and filter valid styles
- var styles []string
- for _, line := range lines {
- style := strings.TrimSpace(line)
- // Remove any numbering or bullets
- style = strings.TrimPrefix(style, "- ")
- style = strings.TrimPrefix(style, "* ")
- style = strings.TrimPrefix(style, "• ")
- // Remove numbers like "1. " or "42. "
- if idx := strings.Index(style, ". "); idx > 0 && idx <= 3 {
- style = style[idx+2:]
- }
- style = strings.TrimSpace(style)
-
- if style != "" && len(style) <= 50 { // Reasonable length check
- styles = append(styles, style)
- }
- }
-
- // Ensure we have at least some styles
- if len(styles) < 10 {
- return nil, fmt.Errorf("insufficient styles generated (got %d, need at least 10)", len(styles))
- }
-
- fmt.Printf(" Generated %d artistic styles\n", len(styles))
- return styles, nil
-}
-
-// saveStylesToFile saves the styles to a file
-func (c *OpenAIClient) saveStylesToFile(filename string, styles []string) error {
- // Ensure directory exists
- dir := filepath.Dir(filename)
- if err := os.MkdirAll(dir, 0755); err != nil {
- return fmt.Errorf("failed to create directory: %w", err)
- }
-
- // Write styles to file
- content := strings.Join(styles, "\n")
- if err := os.WriteFile(filename, []byte(content), 0644); err != nil {
- return fmt.Errorf("failed to write file: %w", err)
- }
-
- fmt.Printf(" Saved %d styles to %s\n", len(styles), filename)
- return nil
-}
-
-// loadStylesFromFile loads styles from a file
-func (c *OpenAIClient) loadStylesFromFile(filename string) ([]string, error) {
- data, err := os.ReadFile(filename)
- if err != nil {
- return nil, fmt.Errorf("failed to read file: %w", err)
- }
-
- // Parse lines
- lines := strings.Split(string(data), "\n")
- var styles []string
- for _, line := range lines {
- style := strings.TrimSpace(line)
- if style != "" {
- styles = append(styles, style)
- }
- }
-
- if len(styles) == 0 {
- return nil, fmt.Errorf("no styles found in file")
- }
-
- fmt.Printf(" Loaded %d styles from cache\n", len(styles))
- return styles, nil
+// getSizeHeight returns the height based on the configured size
+func (c *OpenAIClient) getSizeHeight() int {
+ // All DALL-E sizes are square
+ return c.getSizeWidth()
}