summaryrefslogtreecommitdiff
path: root/internal/image
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-01 14:55:40 +0300
committerPaul Buetow <paul@buetow.org>2026-04-01 14:55:40 +0300
commit6edc7f4f270fe814df459eedfc9d6a02e6deacf0 (patch)
tree239e36b7c9c4e24e2249208c3cb4f1ff0e2041a9 /internal/image
parentd21c7aafa128194524cc19b076e1411169cab680 (diff)
z6: add Nano Banana image provider
Diffstat (limited to 'internal/image')
-rw-r--r--internal/image/download.go4
-rw-r--r--internal/image/download_test.go19
-rw-r--r--internal/image/nanobanana.go510
-rw-r--r--internal/image/nanobanana_test.go129
-rw-r--r--internal/image/openai_test.go3
-rw-r--r--internal/image/search.go50
-rw-r--r--internal/image/styles.go8
-rw-r--r--internal/image/styles_test.go28
8 files changed, 681 insertions, 70 deletions
diff --git a/internal/image/download.go b/internal/image/download.go
index b2af843..2f04359 100644
--- a/internal/image/download.go
+++ b/internal/image/download.go
@@ -177,7 +177,9 @@ func (d *Downloader) generateFileName(word string, result *SearchResult, index i
// Determine extension from URL
ext := filepath.Ext(result.URL)
- if ext == "" || len(ext) > 5 { // Probably not a real extension
+ if strings.HasPrefix(result.URL, "data:image/png") {
+ ext = ".png"
+ } else if ext == "" || len(ext) > 5 { // Probably not a real extension
ext = ".jpg" // Default to jpg
}
diff --git a/internal/image/download_test.go b/internal/image/download_test.go
new file mode 100644
index 0000000..7b8bd93
--- /dev/null
+++ b/internal/image/download_test.go
@@ -0,0 +1,19 @@
+package image
+
+import "testing"
+
+func TestDownloaderGenerateFileName_DataURIUsesPNG(t *testing.T) {
+ t.Parallel()
+
+ d := NewDownloader(&mockSearcher{name: nanoBananaSource}, &DownloadOptions{
+ FileNamePattern: "{word}_{source}",
+ })
+ result := &SearchResult{
+ URL: "data:image/png;base64,AAAA",
+ Source: nanoBananaSource,
+ }
+
+ if got := d.generateFileName("ябълка", result, 0); got != "ябълка_nanobanana.png" {
+ t.Fatalf("generateFileName() = %q, want %q", got, "ябълка_nanobanana.png")
+ }
+}
diff --git a/internal/image/nanobanana.go b/internal/image/nanobanana.go
index f65833f..6830b00 100644
--- a/internal/image/nanobanana.go
+++ b/internal/image/nanobanana.go
@@ -1,13 +1,34 @@
package image
-import "google.golang.org/genai"
+import (
+ "bytes"
+ "context"
+ "crypto/md5"
+ "encoding/base64"
+ "encoding/hex"
+ "fmt"
+ "image"
+ "image/png"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "google.golang.org/genai"
+)
const (
- // DefaultNanoBananaModel is the model name planned for Nano Banana image generation.
+ // DefaultNanoBananaModel is the Gemini image model used for Nano Banana generation.
DefaultNanoBananaModel = "gemini-3.1-flash-image-preview"
- // DefaultNanoBananaTextModel is the text model planned for prompt and translation work.
+ // DefaultNanoBananaTextModel is the Gemini text model used for translation and scene generation.
DefaultNanoBananaTextModel = "gemini-2.5-flash"
+
+ nanoBananaAspectRatio = "4:3"
+ nanoBananaImageWidth = 800
+ nanoBananaImageHeight = 600
+ nanoBananaDataPrefix = "data:image/png;base64,"
+ nanoBananaSource = "nanobanana"
)
// NanoBananaConfig holds the settings needed to build a Gemini-backed image generator.
@@ -17,16 +38,481 @@ type NanoBananaConfig struct {
TextModel string
}
-// NewNanoBananaConfig returns a normalized Nano Banana configuration.
-func NewNanoBananaConfig(apiKey string) *NanoBananaConfig {
- return &NanoBananaConfig{
- APIKey: apiKey,
- Model: DefaultNanoBananaModel,
- TextModel: DefaultNanoBananaTextModel,
+// NanoBananaClient implements ImageSearcher for Google Nano Banana image generation.
+type NanoBananaClient struct {
+ client *genai.Client
+ initErr error
+ config *NanoBananaConfig
+ lastPrompt string
+
+ // PromptCallback is called when the prompt is generated, before the image is created.
+ PromptCallback func(prompt string)
+}
+
+var _ ImageSearcher = (*NanoBananaClient)(nil)
+
+var newNanoBananaClient = genai.NewClient
+
+// NewNanoBananaClient creates a new Nano Banana client.
+func NewNanoBananaClient(config *NanoBananaConfig) *NanoBananaClient {
+ normalized := normalizeNanoBananaConfig(config)
+ client := &NanoBananaClient{config: normalized}
+
+ if normalized.APIKey == "" {
+ return client
+ }
+
+ genaiClient, err := newNanoBananaClient(context.Background(), &genai.ClientConfig{
+ APIKey: normalized.APIKey,
+ Backend: genai.BackendGeminiAPI,
+ })
+ if err != nil {
+ client.initErr = err
+ return client
+ }
+
+ client.client = genaiClient
+ return client
+}
+
+// Search generates an educational image for the Bulgarian word using Nano Banana.
+func (c *NanoBananaClient) Search(ctx context.Context, opts *SearchOptions) ([]SearchResult, error) {
+ if err := c.ensureReady(); err != nil {
+ return nil, err
+ }
+ if opts == nil {
+ return nil, &SearchError{
+ Provider: nanoBananaSource,
+ Code: "INVALID_OPTIONS",
+ Message: "search options are required",
+ }
+ }
+
+ translatedWord, err := c.resolveTranslation(ctx, opts)
+ if err != nil {
+ return nil, err
+ }
+
+ prompt, err := c.resolvePrompt(ctx, opts, translatedWord)
+ if err != nil {
+ return nil, err
+ }
+
+ c.lastPrompt = prompt
+ if c.PromptCallback != nil {
+ c.PromptCallback(prompt)
+ }
+
+ fmt.Printf("Nano Banana Image Generation Prompt (%d chars): %s\n", len(prompt), prompt)
+ fmt.Printf("Nano Banana Image Generation: Using model '%s' with aspect ratio '%s'\n", c.modelName(), nanoBananaAspectRatio)
+
+ imageBytes, mimeType, err := c.generateImage(ctx, prompt)
+ if err != nil {
+ return nil, err
+ }
+
+ dataURL, err := encodeDataURL(imageBytes, mimeType)
+ if err != nil {
+ return nil, err
+ }
+
+ result := SearchResult{
+ ID: c.generateImageID(opts.Query),
+ URL: dataURL,
+ ThumbnailURL: dataURL,
+ Width: nanoBananaImageWidth,
+ Height: nanoBananaImageHeight,
+ Description: fmt.Sprintf("Generated educational image for %s (%s)", opts.Query, translatedWord),
+ Attribution: "Generated by Google Gemini Nano Banana",
+ Source: nanoBananaSource,
+ }
+
+ return []SearchResult{result}, nil
+}
+
+// Download returns the image bytes for either a data URI or a remote URL.
+func (c *NanoBananaClient) Download(ctx context.Context, url string) (io.ReadCloser, error) {
+ if strings.HasPrefix(url, nanoBananaDataPrefix) {
+ return decodeDataURL(url)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ if closeErr := resp.Body.Close(); closeErr != nil {
+ return nil, fmt.Errorf("HTTP %d: %s (failed to close response body: %v)", resp.StatusCode, resp.Status, closeErr)
+ }
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
+ }
+
+ return resp.Body, nil
+}
+
+// GetAttribution returns attribution text for the generated image.
+func (c *NanoBananaClient) GetAttribution(result *SearchResult) string {
+ attribution := "Image generated by Google Gemini Nano Banana\n\n"
+ attribution += fmt.Sprintf("Model: %s\n", c.modelName())
+ attribution += fmt.Sprintf("Text model: %s\n", c.textModelName())
+ attribution += fmt.Sprintf("Aspect ratio: %s\n", nanoBananaAspectRatio)
+ attribution += fmt.Sprintf("Size: %dx%d\n", nanoBananaImageWidth, nanoBananaImageHeight)
+ if result != nil && result.Description != "" {
+ attribution += fmt.Sprintf("Result: %s\n", result.Description)
+ }
+ attribution += fmt.Sprintf("\nPrompt used:\n%s\n", c.lastPrompt)
+ attribution += fmt.Sprintf("\nGenerated at: %s\n", time.Now().Format("2006-01-02 15:04:05"))
+ return attribution
+}
+
+// Name returns the name of the search provider.
+func (c *NanoBananaClient) Name() string {
+ return nanoBananaSource
+}
+
+// GetLastPrompt returns the last prompt used for image generation.
+func (c *NanoBananaClient) GetLastPrompt() string {
+ return c.lastPrompt
+}
+
+// SetPromptCallback sets a callback that runs after prompt generation.
+func (c *NanoBananaClient) SetPromptCallback(callback func(prompt string)) {
+ c.PromptCallback = callback
+}
+
+func (c *NanoBananaClient) ensureReady() error {
+ if c == nil || c.config == nil {
+ return &SearchError{
+ Provider: nanoBananaSource,
+ Code: "NO_CONFIG",
+ Message: "Nano Banana client not initialized",
+ }
+ }
+ if c.config.APIKey == "" {
+ return &SearchError{
+ Provider: nanoBananaSource,
+ Code: "NO_API_KEY",
+ Message: "Google API key not configured",
+ }
+ }
+ if c.initErr != nil {
+ return &SearchError{
+ Provider: nanoBananaSource,
+ Code: "CLIENT_INIT_FAILED",
+ Message: fmt.Sprintf("failed to initialize client: %v", c.initErr),
+ }
+ }
+ if c.client == nil {
+ return &SearchError{
+ Provider: nanoBananaSource,
+ Code: "CLIENT_NOT_READY",
+ Message: "Nano Banana client not initialized",
+ }
+ }
+
+ return nil
+}
+
+func (c *NanoBananaClient) resolveTranslation(ctx context.Context, opts *SearchOptions) (string, error) {
+ if opts.Translation != "" {
+ fmt.Printf("Using provided translation: %s -> %s\n", opts.Query, opts.Translation)
+ return opts.Translation, nil
+ }
+
+ translation, err := c.translateBulgarianToEnglish(ctx, opts.Query)
+ if err != nil {
+ fmt.Printf("Translation failed: %v, using original word\n", err)
+ return opts.Query, nil
+ }
+
+ return translation, nil
+}
+
+func (c *NanoBananaClient) resolvePrompt(ctx context.Context, opts *SearchOptions, translatedWord string) (string, error) {
+ if customPrompt := strings.TrimSpace(opts.CustomPrompt); customPrompt != "" {
+ if len(customPrompt) > 1000 {
+ customPrompt = customPrompt[:997] + "..."
+ }
+ fmt.Printf("Using custom prompt: %s\n", customPrompt)
+ return customPrompt, nil
+ }
+
+ return c.createEducationalPrompt(ctx, opts.Query, translatedWord), nil
+}
+
+func (c *NanoBananaClient) createEducationalPrompt(ctx context.Context, bulgarianWord, englishTranslation string) string {
+ scene, err := c.generateSceneDescription(ctx, bulgarianWord, englishTranslation)
+ if err != nil {
+ fmt.Printf(" Failed to generate scene: %v, using basic prompt\n", err)
+ scene = ""
+ }
+
+ selectedStyle := chooseArtisticStyle()
+ if selectedStyle == defaultArtisticStyle {
+ fmt.Printf(" No artistic styles available, using generic prompt\n")
+ }
+ fmt.Printf(" Using image style: %s\n", selectedStyle)
+
+ var prompt string
+
+ if scene != "" {
+ fullPrompt := fmt.Sprintf(
+ "Generate a %s depicting: %s. "+
+ "The image should be educational and suitable for language learning flashcards. "+
+ "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. Ensure the subject is shown from an angle that makes it immediately identifiable. "+
+ "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,
+ )
+
+ if len(fullPrompt) > 1000 {
+ prompt = fmt.Sprintf(
+ "Generate a %s depicting: %s. "+
+ "The image should be educational and suitable for language learning flashcards. "+
+ "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 len(prompt) > 1000 {
+ maxSceneLen := 1000 - len(fmt.Sprintf(
+ "Generate a %s depicting: . "+
+ "The image should be educational and suitable for language learning flashcards. "+
+ "Requirements: The main subject must be clearly visible, easily recognizable, and prominent in the image.",
+ selectedStyle,
+ ))
+ if len(scene) > maxSceneLen {
+ scene = scene[:maxSceneLen] + "..."
+ }
+ prompt = fmt.Sprintf(
+ "Generate a %s depicting: %s. "+
+ "The image should be educational and suitable for language learning flashcards. "+
+ "Requirements: The main subject must be clearly visible, easily recognizable, and prominent in the image.",
+ selectedStyle, scene,
+ )
+ }
+ } else {
+ prompt = fullPrompt
+ }
+ } else {
+ prompt = fmt.Sprintf(
+ "Generate a %s of %s. "+
+ "The image should be educational and suitable for language learning flashcards. "+
+ "Requirements: The %s must be clearly visible and easily recognizable. Show it prominently centered with excellent lighting and sharp focus.",
+ selectedStyle, englishTranslation, englishTranslation,
+ )
+ }
+
+ if len(prompt) > 1000 {
+ prompt = prompt[:997] + "..."
+ }
+
+ return prompt
+}
+
+func (c *NanoBananaClient) translateBulgarianToEnglish(ctx context.Context, word string) (string, error) {
+ fmt.Printf("Nano Banana Translation: Using model '%s' to translate '%s'\n", c.textModelName(), word)
+
+ translation, err := c.generateText(
+ ctx,
+ c.textModelName(),
+ "You are a Bulgarian language expert. Translate the Bulgarian word into English. Respond with only the English translation, nothing else.",
+ fmt.Sprintf("Translate the Bulgarian word '%s' to English. Respond with only the English translation, nothing else.", word),
+ 0.3,
+ 50,
+ )
+ if err != nil {
+ return "", fmt.Errorf("translation failed: %w", err)
+ }
+
+ fmt.Printf("Translated '%s' to '%s'\n", word, translation)
+ return translation, nil
+}
+
+func (c *NanoBananaClient) generateSceneDescription(ctx context.Context, bulgarianWord, englishTranslation string) (string, error) {
+ fmt.Printf("Nano Banana Scene Generation: Creating scene for '%s' (%s)\n", bulgarianWord, englishTranslation)
+
+ scene, err := c.generateText(
+ ctx,
+ c.textModelName(),
+ "You are helping create educational flashcards for language learning. Generate a brief, vivid scene description that incorporates the given English word in a memorable, contextual way. The scene should be visually interesting and help with memory retention. Keep it to 1-2 sentences, focusing on visual elements that can be illustrated. The subject (the English word) should be the clear focal point of the image, prominent and centered.",
+ fmt.Sprintf("Create a scene description for the English word '%s' that would make a memorable flashcard image. Make sure '%s' is the main focus and most prominent element in the scene.", englishTranslation, englishTranslation),
+ 0.7,
+ 100,
+ )
+ if err != nil {
+ return "", fmt.Errorf("scene generation failed: %w", err)
+ }
+
+ fmt.Printf("Generated scene: %s\n", scene)
+ return scene, nil
+}
+
+func (c *NanoBananaClient) generateText(ctx context.Context, model, systemPrompt, userPrompt string, temperature float32, maxOutputTokens int32) (string, error) {
+ temp := temperature
+ resp, err := c.client.Models.GenerateContent(ctx, model, []*genai.Content{
+ genai.NewContentFromText(userPrompt, genai.RoleUser),
+ }, &genai.GenerateContentConfig{
+ SystemInstruction: genai.NewContentFromText(systemPrompt, genai.RoleUser),
+ Temperature: &temp,
+ MaxOutputTokens: maxOutputTokens,
+ })
+ if err != nil {
+ return "", fmt.Errorf("Gemini API error: %w", err)
+ }
+
+ text := strings.TrimSpace(resp.Text())
+ if text == "" {
+ return "", fmt.Errorf("no response received")
+ }
+
+ return text, nil
+}
+
+func (c *NanoBananaClient) generateImage(ctx context.Context, prompt string) ([]byte, string, error) {
+ cfg := &genai.GenerateContentConfig{
+ ResponseModalities: []string{string(genai.ModalityImage)},
+ ImageConfig: &genai.ImageConfig{
+ AspectRatio: nanoBananaAspectRatio,
+ },
+ }
+
+ resp, err := c.client.Models.GenerateContent(ctx, c.modelName(), []*genai.Content{
+ genai.NewContentFromText(prompt, genai.RoleUser),
+ }, cfg)
+ if err != nil {
+ return nil, "", &SearchError{
+ Provider: nanoBananaSource,
+ Code: "API_ERROR",
+ Message: fmt.Sprintf("failed to generate image: %v", err),
+ }
+ }
+
+ imageBytes, mimeType, err := extractGeneratedImage(resp)
+ if err != nil {
+ return nil, "", &SearchError{
+ Provider: nanoBananaSource,
+ Code: "NO_RESULTS",
+ Message: err.Error(),
+ }
+ }
+
+ return imageBytes, mimeType, nil
+}
+
+func extractGeneratedImage(response *genai.GenerateContentResponse) ([]byte, string, error) {
+ if response == nil {
+ return nil, "", fmt.Errorf("no response from Gemini")
+ }
+
+ for _, candidate := range response.Candidates {
+ if candidate == nil || candidate.Content == nil {
+ continue
+ }
+
+ for _, part := range candidate.Content.Parts {
+ if part == nil || part.InlineData == nil || len(part.InlineData.Data) == 0 {
+ continue
+ }
+
+ mimeType := part.InlineData.MIMEType
+ if mimeType == "" {
+ mimeType = "image/png"
+ }
+
+ return append([]byte(nil), part.InlineData.Data...), mimeType, nil
+ }
+ }
+
+ return nil, "", fmt.Errorf("no image data returned from Gemini")
+}
+
+func encodeDataURL(imageBytes []byte, mimeType string) (string, error) {
+ if len(imageBytes) == 0 {
+ return "", fmt.Errorf("no image bytes returned")
+ }
+
+ normalizedBytes, err := normalizePNG(imageBytes, mimeType)
+ if err != nil {
+ return "", err
}
+
+ return fmt.Sprintf("data:image/png;base64,%s", base64.StdEncoding.EncodeToString(normalizedBytes)), nil
}
-// ClientConfig returns the Google GenAI client config for this provider.
-func (c *NanoBananaConfig) ClientConfig() *genai.ClientConfig {
- return &genai.ClientConfig{APIKey: c.APIKey}
+func decodeDataURL(url string) (io.ReadCloser, error) {
+ header, payload, ok := strings.Cut(url, ",")
+ if !ok || !strings.HasPrefix(header, "data:") || !strings.Contains(header, ";base64") {
+ return nil, fmt.Errorf("unsupported data URI: %s", url)
+ }
+
+ data, err := base64.StdEncoding.DecodeString(payload)
+ if err != nil {
+ return nil, fmt.Errorf("decode data URI: %w", err)
+ }
+
+ return io.NopCloser(bytes.NewReader(data)), nil
+}
+
+func normalizePNG(imageBytes []byte, mimeType string) ([]byte, error) {
+ if strings.EqualFold(strings.TrimSpace(mimeType), "image/png") {
+ return append([]byte(nil), imageBytes...), nil
+ }
+
+ img, _, err := image.Decode(bytes.NewReader(imageBytes))
+ if err != nil {
+ return nil, fmt.Errorf("decode generated image: %w", err)
+ }
+
+ var buffer bytes.Buffer
+ if err := png.Encode(&buffer, img); err != nil {
+ return nil, fmt.Errorf("encode generated image as png: %w", err)
+ }
+
+ return buffer.Bytes(), nil
+}
+
+func (c *NanoBananaClient) generateImageID(word string) string {
+ hash := md5.Sum([]byte(word))
+ return hex.EncodeToString(hash[:])[:8]
+}
+
+func (c *NanoBananaClient) modelName() string {
+ if c == nil || c.config == nil || strings.TrimSpace(c.config.Model) == "" {
+ return DefaultNanoBananaModel
+ }
+
+ return c.config.Model
+}
+
+func (c *NanoBananaClient) textModelName() string {
+ if c == nil || c.config == nil || strings.TrimSpace(c.config.TextModel) == "" {
+ return DefaultNanoBananaTextModel
+ }
+
+ return c.config.TextModel
+}
+
+func normalizeNanoBananaConfig(config *NanoBananaConfig) *NanoBananaConfig {
+ normalized := &NanoBananaConfig{}
+ if config != nil {
+ *normalized = *config
+ }
+
+ normalized.APIKey = strings.TrimSpace(normalized.APIKey)
+ normalized.Model = strings.TrimSpace(normalized.Model)
+ normalized.TextModel = strings.TrimSpace(normalized.TextModel)
+
+ if normalized.Model == "" {
+ normalized.Model = DefaultNanoBananaModel
+ }
+ if normalized.TextModel == "" {
+ normalized.TextModel = DefaultNanoBananaTextModel
+ }
+
+ return normalized
}
diff --git a/internal/image/nanobanana_test.go b/internal/image/nanobanana_test.go
index bde91a1..344dec9 100644
--- a/internal/image/nanobanana_test.go
+++ b/internal/image/nanobanana_test.go
@@ -1,26 +1,127 @@
package image
-import "testing"
+import (
+ "context"
+ "encoding/base64"
+ "io"
+ "os"
+ "strings"
+ "testing"
+)
-func TestNewNanoBananaConfig(t *testing.T) {
+func TestNewNanoBananaClient(t *testing.T) {
t.Parallel()
- cfg := NewNanoBananaConfig("test-key")
- if cfg.APIKey != "test-key" {
- t.Fatalf("expected API key to be preserved, got %q", cfg.APIKey)
+ client := NewNanoBananaClient(&NanoBananaConfig{APIKey: "test-key"})
+ if client == nil {
+ t.Fatal("expected client")
}
- if cfg.Model != DefaultNanoBananaModel {
- t.Fatalf("expected default model %q, got %q", DefaultNanoBananaModel, cfg.Model)
+ if client.config == nil {
+ t.Fatal("expected normalized config")
}
- if cfg.TextModel != DefaultNanoBananaTextModel {
- t.Fatalf("expected default text model %q, got %q", DefaultNanoBananaTextModel, cfg.TextModel)
+ if client.config.Model != DefaultNanoBananaModel {
+ t.Fatalf("expected default model %q, got %q", DefaultNanoBananaModel, client.config.Model)
}
+ if client.config.TextModel != DefaultNanoBananaTextModel {
+ t.Fatalf("expected default text model %q, got %q", DefaultNanoBananaTextModel, client.config.TextModel)
+ }
+ if client.Name() != nanoBananaSource {
+ t.Fatalf("Name() = %q, want %q", client.Name(), nanoBananaSource)
+ }
+}
+
+func TestNanoBananaClient_NoAPIKey(t *testing.T) {
+ client := NewNanoBananaClient(&NanoBananaConfig{})
- clientCfg := cfg.ClientConfig()
- if clientCfg == nil {
- t.Fatal("expected client config")
+ _, err := client.Search(context.Background(), DefaultSearchOptions("ябълка"))
+ if err == nil {
+ t.Fatal("expected error for missing API key")
}
- if clientCfg.APIKey != "test-key" {
- t.Fatalf("expected client config API key to be preserved, got %q", clientCfg.APIKey)
+
+ searchErr, ok := err.(*SearchError)
+ if !ok {
+ t.Fatalf("expected SearchError, got %T", err)
+ }
+ if searchErr.Code != "NO_API_KEY" {
+ t.Fatalf("expected NO_API_KEY error, got %s", searchErr.Code)
}
}
+
+func TestNanoBananaClient_DownloadDataURI(t *testing.T) {
+ client := &NanoBananaClient{}
+ payload := []byte("png-bytes")
+ url := "data:image/png;base64," + encodeBase64(payload)
+
+ reader, err := client.Download(context.Background(), url)
+ if err != nil {
+ t.Fatalf("Download() unexpected error: %v", err)
+ }
+ t.Cleanup(func() {
+ _ = reader.Close()
+ })
+
+ data, err := io.ReadAll(reader)
+ if err != nil {
+ t.Fatalf("ReadAll() unexpected error: %v", err)
+ }
+ if string(data) != string(payload) {
+ t.Fatalf("Download() = %q, want %q", data, payload)
+ }
+}
+
+func TestNanoBananaClient_GetAttribution(t *testing.T) {
+ client := &NanoBananaClient{
+ config: &NanoBananaConfig{
+ Model: "gemini-test-image",
+ TextModel: "gemini-test-text",
+ },
+ lastPrompt: "Generate a simple illustration of an apple.",
+ }
+
+ attr := client.GetAttribution(&SearchResult{Description: "Generated educational image for ябълка (apple)"})
+ for _, want := range []string{
+ "Google Gemini Nano Banana",
+ "gemini-test-image",
+ "gemini-test-text",
+ "Prompt used:",
+ "Generated educational image for ябълка (apple)",
+ } {
+ if !strings.Contains(attr, want) {
+ t.Fatalf("GetAttribution() = %q, missing %q", attr, want)
+ }
+ }
+}
+
+func TestNanoBananaClient_Integration(t *testing.T) {
+ if os.Getenv("TOTALRECALL_IMAGE_INTEGRATION") == "" {
+ t.Skip("TOTALRECALL_IMAGE_INTEGRATION not set, skipping integration test")
+ }
+ apiKey := os.Getenv("GOOGLE_API_KEY")
+ if apiKey == "" {
+ t.Skip("GOOGLE_API_KEY not set, skipping integration test")
+ }
+
+ client := NewNanoBananaClient(&NanoBananaConfig{
+ APIKey: apiKey,
+ Model: DefaultNanoBananaModel,
+ TextModel: DefaultNanoBananaTextModel,
+ })
+
+ results, err := client.Search(context.Background(), &SearchOptions{
+ Query: "ябълка",
+ Translation: "apple",
+ })
+ if err != nil {
+ t.Fatalf("Search() failed: %v", err)
+ }
+ if len(results) != 1 {
+ t.Fatalf("expected 1 result, got %d", len(results))
+ }
+ if !strings.HasPrefix(results[0].URL, "data:image/png;base64,") {
+ t.Fatalf("expected data URI result, got %q", results[0].URL)
+ }
+}
+
+func encodeBase64(data []byte) string {
+ return base64.StdEncoding.EncodeToString(data)
+}
diff --git a/internal/image/openai_test.go b/internal/image/openai_test.go
index 8b75f3e..ed0a026 100644
--- a/internal/image/openai_test.go
+++ b/internal/image/openai_test.go
@@ -146,6 +146,9 @@ func containsHelper(s, substr string) bool {
// Integration test (skipped by default)
func TestOpenAIClient_Search_Integration(t *testing.T) {
+ if os.Getenv("TOTALRECALL_IMAGE_INTEGRATION") == "" {
+ t.Skip("TOTALRECALL_IMAGE_INTEGRATION not set, skipping integration test")
+ }
apiKey := os.Getenv("OPENAI_API_KEY")
if apiKey == "" {
t.Skip("OPENAI_API_KEY not set, skipping integration test")
diff --git a/internal/image/search.go b/internal/image/search.go
index 540e0a1..53004c1 100644
--- a/internal/image/search.go
+++ b/internal/image/search.go
@@ -7,27 +7,27 @@ import (
// SearchResult represents a single image search result
type SearchResult struct {
- ID string // Unique identifier
- URL string // Direct URL to the image
- ThumbnailURL string // URL to thumbnail version
- Width int // Image width in pixels
- Height int // Image height in pixels
- Description string // Image description or tags
- Attribution string // Attribution text if required
- Source string // Source provider (e.g., "pixabay", "unsplash")
+ ID string // Unique identifier
+ URL string // Direct URL to the image
+ ThumbnailURL string // URL to thumbnail version
+ Width int // Image width in pixels
+ Height int // Image height in pixels
+ Description string // Image description or tags
+ Attribution string // Attribution text if required
+ Source string // Source provider (e.g., "pixabay", "unsplash")
}
// SearchOptions configures the image search
type SearchOptions struct {
- Query string // Search query (Bulgarian word)
- Translation string // English translation (if already available)
- Language string // Language code (default: "bg")
- SafeSearch bool // Enable safe search filtering
- PerPage int // Number of results per page
- 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)
+ Query string // Search query (Bulgarian word)
+ Translation string // English translation (if already available)
+ Language string // Language code (default: "bg")
+ SafeSearch bool // Enable safe search filtering
+ PerPage int // Number of results per page
+ 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
}
// DefaultSearchOptions returns sensible defaults for Bulgarian word searches
@@ -47,13 +47,13 @@ func DefaultSearchOptions(query string) *SearchOptions {
type ImageSearcher interface {
// Search performs an image search with the given options
Search(ctx context.Context, opts *SearchOptions) ([]SearchResult, error)
-
+
// Download downloads an image from the given URL
Download(ctx context.Context, url string) (io.ReadCloser, error)
-
+
// GetAttribution returns the required attribution text for an image
GetAttribution(result *SearchResult) string
-
+
// Name returns the name of the search provider
Name() string
}
@@ -71,10 +71,10 @@ func (e *SearchError) Error() string {
// RateLimitError indicates that the API rate limit has been exceeded
type RateLimitError struct {
- Provider string
- RetryAfter int // Seconds to wait before retry
- LimitPerHour int
- LimitPerDay int
+ Provider string
+ RetryAfter int // Seconds to wait before retry
+ LimitPerHour int
+ LimitPerDay int
}
func (e *RateLimitError) Error() string {
@@ -86,4 +86,4 @@ func DownloadImage(ctx context.Context, searcher ImageSearcher, url string, outp
// Implementation will be in a separate download.go file
// This is just the interface definition
return nil
-} \ No newline at end of file
+}
diff --git a/internal/image/styles.go b/internal/image/styles.go
index d4a32a9..915f286 100644
--- a/internal/image/styles.go
+++ b/internal/image/styles.go
@@ -4,8 +4,8 @@ import "math/rand"
const defaultArtisticStyle = "simple illustration"
-// artisticStyles contains the shared pool of artistic styles used for image prompts.
-var artisticStyles = []string{
+// ArtisticStyles contains the shared pool of artistic styles used for image prompts.
+var ArtisticStyles = []string{
"Photorealism", "Hyperrealism", "Surrealism", "Impressionism",
"Minimalism", "Pop Art", "Art Nouveau", "Digital Art",
"Watercolor", "Oil Painting", "Pencil Sketch", "Ink Drawing",
@@ -78,11 +78,11 @@ var artisticStyles = []string{
}
func pickArtisticStyle() string {
- if len(artisticStyles) == 0 {
+ if len(ArtisticStyles) == 0 {
return ""
}
- styles := append([]string(nil), artisticStyles...)
+ styles := append([]string(nil), ArtisticStyles...)
// Shuffle the styles to avoid bias without mutating the shared pool.
rand.Shuffle(len(styles), func(i, j int) {
diff --git a/internal/image/styles_test.go b/internal/image/styles_test.go
index 1848342..cbfa1ba 100644
--- a/internal/image/styles_test.go
+++ b/internal/image/styles_test.go
@@ -6,24 +6,24 @@ import (
)
func TestArtisticStyles(t *testing.T) {
- if len(artisticStyles) == 0 {
- t.Fatal("artisticStyles should not be empty")
+ if len(ArtisticStyles) == 0 {
+ t.Fatal("ArtisticStyles should not be empty")
}
- if !hasStyle(artisticStyles, "Photorealism") {
- t.Fatal(`artisticStyles should include "Photorealism"`)
+ if !hasStyle(ArtisticStyles, "Photorealism") {
+ t.Fatal(`ArtisticStyles should include "Photorealism"`)
}
- if !hasStyle(artisticStyles, "Candid Photography") {
- t.Fatal(`artisticStyles should include "Candid Photography"`)
+ if !hasStyle(ArtisticStyles, "Candid Photography") {
+ t.Fatal(`ArtisticStyles should include "Candid Photography"`)
}
}
func TestChooseArtisticStyle_EmptyPool(t *testing.T) {
- original := artisticStyles
- artisticStyles = nil
+ original := ArtisticStyles
+ ArtisticStyles = nil
t.Cleanup(func() {
- artisticStyles = original
+ ArtisticStyles = original
})
if got := chooseArtisticStyle(); got != defaultArtisticStyle {
@@ -32,17 +32,17 @@ func TestChooseArtisticStyle_EmptyPool(t *testing.T) {
}
func TestPickArtisticStyle_DoesNotMutateSharedPool(t *testing.T) {
- original := append([]string(nil), artisticStyles...)
+ original := append([]string(nil), ArtisticStyles...)
t.Cleanup(func() {
- artisticStyles = original
+ ArtisticStyles = original
})
- artisticStyles = []string{"Photorealism", "Surrealism", "Impressionism"}
+ ArtisticStyles = []string{"Photorealism", "Surrealism", "Impressionism"}
_ = pickArtisticStyle()
- if !reflect.DeepEqual(artisticStyles, []string{"Photorealism", "Surrealism", "Impressionism"}) {
- t.Fatalf("pickArtisticStyle() mutated shared pool: got %v", artisticStyles)
+ if !reflect.DeepEqual(ArtisticStyles, []string{"Photorealism", "Surrealism", "Impressionism"}) {
+ t.Fatalf("pickArtisticStyle() mutated shared pool: got %v", ArtisticStyles)
}
}