summaryrefslogtreecommitdiff
path: root/internal/image
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-07-16 20:38:22 +0300
committerPaul Buetow <paul@buetow.org>2025-07-16 20:38:22 +0300
commitd46669426aa6b0ece71d0d05d0b6f2966686b17a (patch)
tree9f927c3a8bc763943764ad63e3badafe8a9a7f62 /internal/image
parente49ecfe601c924fa68671477331a860acf8a62f7 (diff)
feat: add custom image prompt support and keyboard shortcuts
- Add text area next to image display for custom image generation prompts - Users can specify their own prompts or leave empty for auto-generation - Display the used prompt in the text area after generation - Load prompts from attribution files when navigating to existing cards - Add keyboard shortcuts for all GUI buttons: - G: Generate, N: New Word, I: Regenerate Image, A: Regenerate Audio - R: Regenerate All, D: Delete, P: Play audio - Left/Right arrows: Navigate between words - Y/N: Confirm/cancel delete dialog - Update UI layout with equal 50/50 split between image and prompt - Enable text wrapping in prompt text area - Add 25% chance to ask OpenAI for creative photo style suggestions - Fix concurrent processing to properly use custom prompts 🤖 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/download.go77
-rw-r--r--internal/image/openai.go68
-rw-r--r--internal/image/search.go1
3 files changed, 144 insertions, 2 deletions
diff --git a/internal/image/download.go b/internal/image/download.go
index f684260..7083a6f 100644
--- a/internal/image/download.go
+++ b/internal/image/download.go
@@ -241,4 +241,81 @@ func (d *Downloader) DownloadMultiple(ctx context.Context, query string, count i
}
return downloaded, nil
+}
+
+// DownloadBestMatchWithOptions downloads the best matching image for given search options
+func (d *Downloader) DownloadBestMatchWithOptions(ctx context.Context, opts *SearchOptions) (*SearchResult, string, error) {
+ // Search for images
+ searchOpts := *opts // Copy to avoid modifying original
+ searchOpts.PerPage = 5 // Get top 5 results
+
+ results, err := d.searcher.Search(ctx, &searchOpts)
+ if err != nil {
+ return nil, "", fmt.Errorf("search failed: %w", err)
+ }
+
+ if len(results) == 0 {
+ return nil, "", fmt.Errorf("no images found for query: %s", opts.Query)
+ }
+
+ // Try to download the first available image
+ for i, result := range results {
+ // Generate filename
+ filename := d.generateFileName(opts.Query, &result, i)
+ outputPath := filepath.Join(d.options.OutputDir, filename)
+
+ // Try to download
+ err := d.DownloadImage(ctx, &result, outputPath)
+ if err == nil {
+ return &result, outputPath, nil
+ }
+
+ // Log error and try next
+ fmt.Fprintf(os.Stderr, "Warning: failed to download image %d: %v\n", i+1, err)
+ }
+
+ return nil, "", fmt.Errorf("failed to download any images for query: %s", opts.Query)
+}
+
+// DownloadMultipleWithOptions downloads multiple images for given search options
+func (d *Downloader) DownloadMultipleWithOptions(ctx context.Context, opts *SearchOptions, count int) ([]string, error) {
+ // Search for images
+ searchOpts := *opts // Copy to avoid modifying original
+ searchOpts.PerPage = count * 2 // Get extra in case some fail
+
+ results, err := d.searcher.Search(ctx, &searchOpts)
+ if err != nil {
+ return nil, fmt.Errorf("search failed: %w", err)
+ }
+
+ if len(results) == 0 {
+ return nil, fmt.Errorf("no images found for query: %s", opts.Query)
+ }
+
+ // Download up to 'count' images
+ var downloaded []string
+ for i, result := range results {
+ if len(downloaded) >= count {
+ break
+ }
+
+ // Generate filename
+ filename := d.generateFileName(opts.Query, &result, i)
+ outputPath := filepath.Join(d.options.OutputDir, filename)
+
+ // Try to download
+ err := d.DownloadImage(ctx, &result, outputPath)
+ if err == nil {
+ downloaded = append(downloaded, outputPath)
+ } else {
+ // Log error and continue
+ fmt.Fprintf(os.Stderr, "Warning: failed to download image %d: %v\n", i+1, err)
+ }
+ }
+
+ if len(downloaded) == 0 {
+ return nil, fmt.Errorf("failed to download any images for query: %s", opts.Query)
+ }
+
+ return downloaded, nil
} \ No newline at end of file
diff --git a/internal/image/openai.go b/internal/image/openai.go
index c4b2e9d..add1c96 100644
--- a/internal/image/openai.go
+++ b/internal/image/openai.go
@@ -123,8 +123,14 @@ func (c *OpenAIClient) Search(ctx context.Context, opts *SearchOptions) ([]Searc
translatedWord = opts.Query
}
- // Create educational prompt
- prompt := c.createEducationalPrompt(opts.Query, translatedWord)
+ // Create prompt - use custom if provided, otherwise generate educational prompt
+ var prompt string
+ if opts.CustomPrompt != "" {
+ prompt = opts.CustomPrompt
+ fmt.Printf("Using custom prompt: %s\n", prompt)
+ } else {
+ prompt = c.createEducationalPrompt(opts.Query, translatedWord)
+ }
// Store the prompt for attribution
c.lastPrompt = prompt
@@ -243,8 +249,28 @@ func (c *OpenAIClient) Name() string {
return "openai"
}
+// GetLastPrompt returns the last prompt used for image generation
+func (c *OpenAIClient) GetLastPrompt() string {
+ return c.lastPrompt
+}
+
// createEducationalPrompt generates a prompt optimized for language learning
func (c *OpenAIClient) createEducationalPrompt(bulgarianWord, englishTranslation string) string {
+ // 25% chance to ask OpenAI for a creative style
+ if rand.Float32() < 0.25 {
+ if creativeStyle := c.getCreativeStyleFromOpenAI(context.Background(), englishTranslation); creativeStyle != "" {
+ fmt.Printf(" Using OpenAI-suggested style: %s\n", creativeStyle)
+ return fmt.Sprintf(
+ "Generate a %s of: %s. "+
+ "This is for the Bulgarian word '%s' which means %s. "+
+ "The image should be educational and suitable for language learning flashcards. "+
+ "Requirements: single main subject, plain background, clear and recognizable. "+
+ "IMPORTANT: No text whatsoever. Do not include any words, letters, typography, labels, captions, or writing of any kind. Image only, without any text elements.",
+ creativeStyle, englishTranslation, bulgarianWord, englishTranslation,
+ )
+ }
+ }
+
// Define different art styles for variety (42 styles total)
styles := []string{
// Original styles (1-10)
@@ -435,4 +461,42 @@ func (c *OpenAIClient) getSizeHeight() int {
default:
return 512
}
+}
+
+// getCreativeStyleFromOpenAI asks OpenAI for a creative photo style suggestion
+func (c *OpenAIClient) getCreativeStyleFromOpenAI(ctx context.Context, subject string) string {
+ fmt.Printf(" Asking OpenAI for creative style suggestion for '%s'...\n", subject)
+
+ req := openai.ChatCompletionRequest{
+ Model: openai.GPT4oMini,
+ Messages: []openai.ChatCompletionMessage{
+ {
+ Role: openai.ChatMessageRoleSystem,
+ Content: "You are a creative art director. Suggest unique, interesting photo/art styles for educational flashcard images. Be creative and varied. Respond with ONLY the style description, nothing else. Keep it concise (max 15 words).",
+ },
+ {
+ Role: openai.ChatMessageRoleUser,
+ Content: fmt.Sprintf("Suggest a creative visual style for an educational image of: %s", subject),
+ },
+ },
+ Temperature: 0.9, // Higher temperature for more creativity
+ MaxTokens: 30,
+ }
+
+ resp, err := c.client.CreateChatCompletion(ctx, req)
+ if err != nil {
+ fmt.Printf(" Failed to get creative style: %v\n", err)
+ return ""
+ }
+
+ if len(resp.Choices) == 0 || resp.Choices[0].Message.Content == "" {
+ return ""
+ }
+
+ style := strings.TrimSpace(resp.Choices[0].Message.Content)
+ // Remove any trailing punctuation
+ style = strings.TrimSuffix(style, ".")
+ style = strings.TrimSuffix(style, "!")
+
+ return style
} \ No newline at end of file
diff --git a/internal/image/search.go b/internal/image/search.go
index acc9dc8..800a114 100644
--- a/internal/image/search.go
+++ b/internal/image/search.go
@@ -26,6 +26,7 @@ type SearchOptions struct {
Page int // Page number (1-based)
ImageType string // Type: "photo", "illustration", "vector", "all"
Orientation string // Orientation: "horizontal", "vertical", "all"
+ CustomPrompt string // Custom prompt for AI image generation (OpenAI)
}
// DefaultSearchOptions returns sensible defaults for Bulgarian word searches