diff options
| author | Paul Buetow <paul@buetow.org> | 2025-07-16 20:38:22 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-07-16 20:38:22 +0300 |
| commit | d46669426aa6b0ece71d0d05d0b6f2966686b17a (patch) | |
| tree | 9f927c3a8bc763943764ad63e3badafe8a9a7f62 /internal/image | |
| parent | e49ecfe601c924fa68671477331a860acf8a62f7 (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.go | 77 | ||||
| -rw-r--r-- | internal/image/openai.go | 68 | ||||
| -rw-r--r-- | internal/image/search.go | 1 |
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 |
