summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/image/gemini.go329
-rw-r--r--internal/image/gemini_api.go154
-rw-r--r--internal/image/gemini_attribution.go33
-rw-r--r--internal/image/gemini_media.go66
-rw-r--r--internal/image/gemini_prompt.go106
-rw-r--r--internal/image/gemini_test.go42
6 files changed, 401 insertions, 329 deletions
diff --git a/internal/image/gemini.go b/internal/image/gemini.go
index e04b04e..05a2e0e 100644
--- a/internal/image/gemini.go
+++ b/internal/image/gemini.go
@@ -1,24 +1,15 @@
package image
import (
- "bytes"
"context"
- "crypto/md5"
- "encoding/base64"
- "encoding/hex"
"fmt"
- "image"
- _ "image/jpeg"
- "image/png"
"io"
"net/http"
"os"
"strings"
- "time"
"google.golang.org/genai"
- "codeberg.org/snonux/comicforge/internal/apicircuit"
"codeberg.org/snonux/comicforge/internal/httpctx"
)
@@ -303,326 +294,6 @@ func (c *GeminiProvider) ensureReady() error {
return nil
}
-func (c *GeminiProvider) resolveTranslation(_ context.Context, opts *SearchOptions, translation string) (string, error) {
- if translation != "" {
- fmt.Printf("Using provided translation: %s -> %s\n", opts.Query, translation)
- return translation, nil
- }
-
- return opts.Query, nil
-}
-
-func (c *GeminiProvider) resolvePrompt(ctx context.Context, opts *SearchOptions, translatedWord string) (string, error) {
- if customPrompt := strings.TrimSpace(opts.CustomPrompt); customPrompt != "" {
- if len(customPrompt) > maxCustomPrompt {
- customPrompt = customPrompt[:maxCustomPrompt-3] + "..."
- }
- fmt.Printf("Using custom prompt: %s\n", customPrompt)
- return customPrompt, nil
- }
-
- return c.createEducationalPrompt(ctx, opts.Query, translatedWord), nil
-}
-
-func (c *GeminiProvider) buildPrompt(ctx context.Context, opts *SearchOptions) (string, string, error) {
- if opts == nil {
- return "", "", &SearchError{
- Provider: geminiSource,
- Code: "INVALID_OPTIONS",
- Message: "search options are required",
- }
- }
-
- translation := strings.TrimSpace(opts.Translation)
- if customPrompt := strings.TrimSpace(opts.CustomPrompt); customPrompt != "" {
- if len(customPrompt) > maxCustomPrompt {
- customPrompt = customPrompt[:maxCustomPrompt-3] + "..."
- }
- fmt.Printf("Using custom prompt: %s\n", customPrompt)
- return customPrompt, translation, nil
- }
-
- translatedWord, err := c.resolveTranslation(ctx, opts, translation)
- if err != nil {
- return "", "", err
- }
-
- prompt, err := c.resolvePrompt(ctx, opts, translatedWord)
- if err != nil {
- return "", "", err
- }
-
- return prompt, translatedWord, nil
-}
-
-// createEducationalPrompt generates a prompt optimized for image generation.
-func (c *GeminiProvider) createEducationalPrompt(ctx context.Context, query, translation string) string {
- subject := promptSubject(translation, query)
-
- scene, err := c.generateSceneDescription(ctx, query, translation)
- if err != nil {
- fmt.Printf(" Failed to generate scene: %v, using basic prompt\n", err)
- scene = ""
- }
- if scene != "" {
- scene = sanitizeSceneDescription(scene)
- if !usableSceneDescription(scene) {
- fmt.Printf(" Scene response was too short or generic, using basic prompt\n")
- scene = ""
- }
- }
-
- selectedStyle := chooseArtisticStyle()
- if selectedStyle == defaultArtisticStyle {
- fmt.Printf(" No artistic styles available, using generic prompt\n")
- }
- fmt.Printf(" Using image style: %s\n", selectedStyle)
-
- return buildEducationalPrompt(selectedStyle, scene, subject)
-}
-
-func (c *GeminiProvider) generateSceneDescription(ctx context.Context, query, translation string) (string, error) {
- fmt.Printf("Gemini Scene Generation: Creating scene for %q (%s)\n", query, translation)
-
- scene, err := geminiGenerateText(
- ctx,
- c,
- 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 %q that would make a memorable flashcard image. Make sure %q is the main focus and most prominent element in the scene.", translation, translation),
- 0.7,
- 100,
- )
- if err != nil {
- return "", fmt.Errorf("scene generation failed: %w", err)
- }
- scene = sanitizeSceneDescription(scene)
- if !usableSceneDescription(scene) {
- return "", fmt.Errorf("scene generation returned unusable content")
- }
-
- fmt.Printf("Generated scene: %s\n", scene)
- return scene, nil
-}
-
-func (c *GeminiProvider) generateText(ctx context.Context, model, systemPrompt, userPrompt string, temperature float32, maxOutputTokens int32) (string, error) {
- temp := temperature
- resp, err := apicircuit.Execute(nil, c.Name(), apicircuit.CapabilityText, func() (*genai.GenerateContentResponse, error) {
- return 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 *GeminiProvider) generateImage(ctx context.Context, prompt, aspectRatio string) ([]byte, string, error) {
- if aspectRatio == "" {
- aspectRatio = geminiAspectRatio
- }
-
- cfg := &genai.GenerateContentConfig{
- ResponseModalities: []string{string(genai.ModalityImage)},
- ImageConfig: &genai.ImageConfig{
- AspectRatio: aspectRatio,
- },
- }
-
- resp, err := apicircuit.Execute(nil, c.Name(), apicircuit.CapabilityImage, func() (*genai.GenerateContentResponse, error) {
- return c.client.Models.GenerateContent(ctx, c.modelName(), []*genai.Content{
- genai.NewContentFromText(prompt, genai.RoleUser),
- }, cfg)
- })
- if err != nil {
- return nil, "", &SearchError{
- Provider: geminiSource,
- Code: "API_ERROR",
- Message: fmt.Sprintf("failed to generate image: %v", err),
- }
- }
-
- imageBytes, mimeType, err := extractGeneratedImage(resp)
- if err != nil {
- return nil, "", &SearchError{
- Provider: geminiSource,
- Code: "NO_RESULTS",
- Message: err.Error(),
- }
- }
-
- return imageBytes, mimeType, nil
-}
-
-func (c *GeminiProvider) generateImageWithRefs(ctx context.Context, prompt, aspectRatio string, refs [][]byte) ([]byte, string, error) {
- if aspectRatio == "" {
- aspectRatio = geminiAspectRatio
- }
-
- cfg := &genai.GenerateContentConfig{
- ResponseModalities: []string{string(genai.ModalityImage)},
- ImageConfig: &genai.ImageConfig{AspectRatio: aspectRatio},
- }
-
- parts := make([]*genai.Part, 0, len(refs)+1)
- for _, ref := range refs {
- if len(ref) > 0 {
- parts = append(parts, &genai.Part{
- InlineData: &genai.Blob{MIMEType: "image/png", Data: ref},
- })
- }
- }
- refNote := fmt.Sprintf(
- "The %d reference image(s) above show the exact character appearance that must be preserved. "+
- "Every character, animal, or object must look identical in the new image. Now generate:\n\n",
- len(refs),
- )
- parts = append(parts, &genai.Part{Text: refNote + prompt})
-
- resp, err := apicircuit.Execute(nil, c.Name(), apicircuit.CapabilityImage, func() (*genai.GenerateContentResponse, error) {
- return c.client.Models.GenerateContent(ctx, c.modelName(), []*genai.Content{
- {
- Role: string(genai.RoleUser),
- Parts: parts,
- },
- }, cfg)
- })
- if err != nil {
- return nil, "", &SearchError{
- Provider: geminiSource,
- Code: "API_ERROR",
- Message: fmt.Sprintf("failed to generate image with refs: %v", err),
- }
- }
-
- imageBytes, mimeType, err := extractGeneratedImage(resp)
- if err != nil {
- return nil, "", &SearchError{
- Provider: geminiSource,
- 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 geminiDataPrefix + base64.StdEncoding.EncodeToString(normalizedBytes), nil
-}
-
-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 decodedImageDimensions(imageBytes []byte) (int, int, error) {
- cfg, _, err := image.DecodeConfig(bytes.NewReader(imageBytes))
- if err != nil {
- return 0, 0, fmt.Errorf("decode generated image dimensions: %w", err)
- }
-
- return cfg.Width, cfg.Height, nil
-}
-
-func (c *GeminiProvider) generateImageID(word string) string {
- hash := md5.Sum([]byte(word))
- return hex.EncodeToString(hash[:])[:8]
-}
-
-func (c *GeminiProvider) buildAttribution(result *SearchResult, prompt string) string {
- if result == nil {
- return ""
- }
-
- var attribution strings.Builder
- attribution.WriteString("Image generated by Google Gemini Nano Banana\n\n")
- fmt.Fprintf(&attribution, "Model: %s\n", c.modelName())
- fmt.Fprintf(&attribution, "Text model: %s\n", c.textModelName())
- fmt.Fprintf(&attribution, "Aspect ratio: %s\n", geminiAspectRatio)
- fmt.Fprintf(&attribution, "Size: %dx%d\n", result.Width, result.Height)
- if result.Description != "" {
- fmt.Fprintf(&attribution, "Result: %s\n", result.Description)
- }
- fmt.Fprintf(&attribution, "\nPrompt used:\n%s\n", prompt)
- fmt.Fprintf(&attribution, "\nGenerated at: %s\n", time.Now().Format("2006-01-02 15:04:05"))
- return attribution.String()
-}
-
func (c *GeminiProvider) modelName() string {
if c == nil || c.config == nil || strings.TrimSpace(c.config.Model) == "" {
return DefaultGeminiImageModel
diff --git a/internal/image/gemini_api.go b/internal/image/gemini_api.go
new file mode 100644
index 0000000..f1aa1c8
--- /dev/null
+++ b/internal/image/gemini_api.go
@@ -0,0 +1,154 @@
+package image
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "google.golang.org/genai"
+
+ "codeberg.org/snonux/comicforge/internal/apicircuit"
+)
+
+func (c *GeminiProvider) generateText(ctx context.Context, model, systemPrompt, userPrompt string, temperature float32, maxOutputTokens int32) (string, error) {
+ temp := temperature
+ resp, err := apicircuit.Execute(nil, c.Name(), apicircuit.CapabilityText, func() (*genai.GenerateContentResponse, error) {
+ return 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 geminiImageConfig(aspectRatio string) *genai.GenerateContentConfig {
+ if aspectRatio == "" {
+ aspectRatio = geminiAspectRatio
+ }
+
+ return &genai.GenerateContentConfig{
+ ResponseModalities: []string{string(genai.ModalityImage)},
+ ImageConfig: &genai.ImageConfig{
+ AspectRatio: aspectRatio,
+ },
+ }
+}
+
+func geminiReferenceParts(prompt string, refs [][]byte) []*genai.Part {
+ parts := make([]*genai.Part, 0, len(refs)+1)
+ for _, ref := range refs {
+ if len(ref) == 0 {
+ continue
+ }
+ parts = append(parts, &genai.Part{
+ InlineData: &genai.Blob{MIMEType: "image/png", Data: ref},
+ })
+ }
+
+ refNote := fmt.Sprintf(
+ "The %d reference image(s) above show the exact character appearance that must be preserved. "+
+ "Every character, animal, or object must look identical in the new image. Now generate:\n\n",
+ len(refs),
+ )
+ parts = append(parts, &genai.Part{Text: refNote + prompt})
+ return parts
+}
+
+func (c *GeminiProvider) generateImage(ctx context.Context, prompt, aspectRatio string) ([]byte, string, error) {
+ cfg := geminiImageConfig(aspectRatio)
+
+ resp, err := apicircuit.Execute(nil, c.Name(), apicircuit.CapabilityImage, func() (*genai.GenerateContentResponse, error) {
+ return c.client.Models.GenerateContent(ctx, c.modelName(), []*genai.Content{
+ genai.NewContentFromText(prompt, genai.RoleUser),
+ }, cfg)
+ })
+ if err != nil {
+ return nil, "", &SearchError{
+ Provider: geminiSource,
+ Code: "API_ERROR",
+ Message: fmt.Sprintf("failed to generate image: %v", err),
+ }
+ }
+
+ imageBytes, mimeType, err := extractGeneratedImage(resp)
+ if err != nil {
+ return nil, "", &SearchError{
+ Provider: geminiSource,
+ Code: "NO_RESULTS",
+ Message: err.Error(),
+ }
+ }
+
+ return imageBytes, mimeType, nil
+}
+
+func (c *GeminiProvider) generateImageWithRefs(ctx context.Context, prompt, aspectRatio string, refs [][]byte) ([]byte, string, error) {
+ cfg := geminiImageConfig(aspectRatio)
+ parts := geminiReferenceParts(prompt, refs)
+
+ resp, err := apicircuit.Execute(nil, c.Name(), apicircuit.CapabilityImage, func() (*genai.GenerateContentResponse, error) {
+ return c.client.Models.GenerateContent(ctx, c.modelName(), []*genai.Content{
+ {
+ Role: string(genai.RoleUser),
+ Parts: parts,
+ },
+ }, cfg)
+ })
+ if err != nil {
+ return nil, "", &SearchError{
+ Provider: geminiSource,
+ Code: "API_ERROR",
+ Message: fmt.Sprintf("failed to generate image with refs: %v", err),
+ }
+ }
+
+ imageBytes, mimeType, err := extractGeneratedImage(resp)
+ if err != nil {
+ return nil, "", &SearchError{
+ Provider: geminiSource,
+ 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")
+}
diff --git a/internal/image/gemini_attribution.go b/internal/image/gemini_attribution.go
new file mode 100644
index 0000000..2aefeac
--- /dev/null
+++ b/internal/image/gemini_attribution.go
@@ -0,0 +1,33 @@
+package image
+
+import (
+ "crypto/md5"
+ "encoding/hex"
+ "fmt"
+ "strings"
+ "time"
+)
+
+func (c *GeminiProvider) generateImageID(word string) string {
+ hash := md5.Sum([]byte(word))
+ return hex.EncodeToString(hash[:])[:8]
+}
+
+func (c *GeminiProvider) buildAttribution(result *SearchResult, prompt string) string {
+ if result == nil {
+ return ""
+ }
+
+ var attribution strings.Builder
+ attribution.WriteString("Image generated by Google Gemini Nano Banana\n\n")
+ fmt.Fprintf(&attribution, "Model: %s\n", c.modelName())
+ fmt.Fprintf(&attribution, "Text model: %s\n", c.textModelName())
+ fmt.Fprintf(&attribution, "Aspect ratio: %s\n", geminiAspectRatio)
+ fmt.Fprintf(&attribution, "Size: %dx%d\n", result.Width, result.Height)
+ if result.Description != "" {
+ fmt.Fprintf(&attribution, "Result: %s\n", result.Description)
+ }
+ fmt.Fprintf(&attribution, "\nPrompt used:\n%s\n", prompt)
+ fmt.Fprintf(&attribution, "\nGenerated at: %s\n", time.Now().Format("2006-01-02 15:04:05"))
+ return attribution.String()
+}
diff --git a/internal/image/gemini_media.go b/internal/image/gemini_media.go
new file mode 100644
index 0000000..f04ddd3
--- /dev/null
+++ b/internal/image/gemini_media.go
@@ -0,0 +1,66 @@
+package image
+
+import (
+ "bytes"
+ "encoding/base64"
+ "fmt"
+ "image"
+ _ "image/jpeg"
+ "image/png"
+ "io"
+ "strings"
+)
+
+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 geminiDataPrefix + base64.StdEncoding.EncodeToString(normalizedBytes), nil
+}
+
+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 decodedImageDimensions(imageBytes []byte) (int, int, error) {
+ cfg, _, err := image.DecodeConfig(bytes.NewReader(imageBytes))
+ if err != nil {
+ return 0, 0, fmt.Errorf("decode generated image dimensions: %w", err)
+ }
+
+ return cfg.Width, cfg.Height, nil
+}
diff --git a/internal/image/gemini_prompt.go b/internal/image/gemini_prompt.go
new file mode 100644
index 0000000..09c54c7
--- /dev/null
+++ b/internal/image/gemini_prompt.go
@@ -0,0 +1,106 @@
+package image
+
+import (
+ "context"
+ "fmt"
+ "strings"
+)
+
+func normalizeCustomPrompt(prompt string) string {
+ prompt = strings.TrimSpace(prompt)
+ if len(prompt) > maxCustomPrompt {
+ prompt = prompt[:maxCustomPrompt-3] + "..."
+ }
+ return prompt
+}
+
+func (c *GeminiProvider) resolveTranslation(_ context.Context, opts *SearchOptions, translation string) (string, error) {
+ if translation != "" {
+ fmt.Printf("Using provided translation: %s -> %s\n", opts.Query, translation)
+ return translation, nil
+ }
+
+ return opts.Query, nil
+}
+
+func (c *GeminiProvider) resolvePrompt(ctx context.Context, opts *SearchOptions, translatedWord string) (string, error) {
+ if customPrompt := normalizeCustomPrompt(opts.CustomPrompt); customPrompt != "" {
+ fmt.Printf("Using custom prompt: %s\n", customPrompt)
+ return customPrompt, nil
+ }
+
+ return c.createEducationalPrompt(ctx, opts.Query, translatedWord), nil
+}
+
+func (c *GeminiProvider) buildPrompt(ctx context.Context, opts *SearchOptions) (string, string, error) {
+ if opts == nil {
+ return "", "", &SearchError{
+ Provider: geminiSource,
+ Code: "INVALID_OPTIONS",
+ Message: "search options are required",
+ }
+ }
+
+ translation := strings.TrimSpace(opts.Translation)
+ translatedWord, err := c.resolveTranslation(ctx, opts, translation)
+ if err != nil {
+ return "", "", err
+ }
+
+ prompt, err := c.resolvePrompt(ctx, opts, translatedWord)
+ if err != nil {
+ return "", "", err
+ }
+
+ return prompt, translatedWord, nil
+}
+
+// createEducationalPrompt generates a prompt optimized for image generation.
+func (c *GeminiProvider) createEducationalPrompt(ctx context.Context, query, translation string) string {
+ subject := promptSubject(translation, query)
+
+ scene, err := c.generateSceneDescription(ctx, query, translation)
+ if err != nil {
+ fmt.Printf(" Failed to generate scene: %v, using basic prompt\n", err)
+ scene = ""
+ }
+ if scene != "" {
+ scene = sanitizeSceneDescription(scene)
+ if !usableSceneDescription(scene) {
+ fmt.Printf(" Scene response was too short or generic, using basic prompt\n")
+ scene = ""
+ }
+ }
+
+ selectedStyle := chooseArtisticStyle()
+ if selectedStyle == defaultArtisticStyle {
+ fmt.Printf(" No artistic styles available, using generic prompt\n")
+ }
+ fmt.Printf(" Using image style: %s\n", selectedStyle)
+
+ return buildEducationalPrompt(selectedStyle, scene, subject)
+}
+
+func (c *GeminiProvider) generateSceneDescription(ctx context.Context, query, translation string) (string, error) {
+ fmt.Printf("Gemini Scene Generation: Creating scene for %q (%s)\n", query, translation)
+
+ scene, err := geminiGenerateText(
+ ctx,
+ c,
+ 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 %q that would make a memorable flashcard image. Make sure %q is the main focus and most prominent element in the scene.", translation, translation),
+ 0.7,
+ 100,
+ )
+ if err != nil {
+ return "", fmt.Errorf("scene generation failed: %w", err)
+ }
+ scene = sanitizeSceneDescription(scene)
+ if !usableSceneDescription(scene) {
+ return "", fmt.Errorf("scene generation returned unusable content")
+ }
+
+ fmt.Printf("Generated scene: %s\n", scene)
+ return scene, nil
+}
diff --git a/internal/image/gemini_test.go b/internal/image/gemini_test.go
index 54480fc..70bd424 100644
--- a/internal/image/gemini_test.go
+++ b/internal/image/gemini_test.go
@@ -114,6 +114,48 @@ func TestGeminiProvider_Search_CustomPromptSkipsTextGeneration(t *testing.T) {
}
}
+func TestGeminiProvider_Search_CustomPromptIsTruncated(t *testing.T) {
+ originalText := geminiGenerateText
+ originalImage := geminiGenerateImage
+ t.Cleanup(func() {
+ geminiGenerateText = originalText
+ geminiGenerateImage = originalImage
+ })
+
+ geminiGenerateText = func(context.Context, *GeminiProvider, string, string, string, float32, int32) (string, error) {
+ t.Fatal("unexpected text generation for truncated prompt")
+ return "", nil
+ }
+
+ var gotPrompt string
+ geminiGenerateImage = func(_ context.Context, _ *GeminiProvider, prompt, _ string) ([]byte, string, error) {
+ gotPrompt = prompt
+ return mustJPEGBytes(t), "image/jpeg", nil
+ }
+
+ client := NewGeminiProvider(&GeminiConfig{APIKey: "test-key"})
+ longPrompt := strings.Repeat("a", maxCustomPrompt+128)
+ results, err := client.Search(context.Background(), &SearchOptions{
+ Query: "ябълка",
+ CustomPrompt: longPrompt,
+ })
+ if err != nil {
+ t.Fatalf("Search() unexpected error: %v", err)
+ }
+ if len(gotPrompt) != maxCustomPrompt {
+ t.Fatalf("prompt length = %d, want %d", len(gotPrompt), maxCustomPrompt)
+ }
+ if !strings.HasSuffix(gotPrompt, "...") {
+ t.Fatalf("prompt = %q, want ellipsis suffix", gotPrompt)
+ }
+ if client.LastPrompt() != gotPrompt {
+ t.Fatalf("LastPrompt() = %q, want %q", client.LastPrompt(), gotPrompt)
+ }
+ if len(results) != 1 {
+ t.Fatalf("expected 1 result, got %d", len(results))
+ }
+}
+
func TestGeminiProvider_GenerateImage_UsesCustomPrompt(t *testing.T) {
originalText := geminiGenerateText
originalImage := geminiGenerateImage