diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-21 10:39:39 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-21 10:39:39 +0300 |
| commit | 45efacae81503ab2266502e1ea32513e35fb88d7 (patch) | |
| tree | cf86559b6d3dece24da06dccfe7cb90b0352d4a6 /internal | |
| parent | 6165b2689dc139a410c3c8aeee0d0e8841d7b0ad (diff) | |
refactor image Gemini provider for r7
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/image/gemini.go | 329 | ||||
| -rw-r--r-- | internal/image/gemini_api.go | 154 | ||||
| -rw-r--r-- | internal/image/gemini_attribution.go | 33 | ||||
| -rw-r--r-- | internal/image/gemini_media.go | 66 | ||||
| -rw-r--r-- | internal/image/gemini_prompt.go | 106 | ||||
| -rw-r--r-- | internal/image/gemini_test.go | 42 |
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 |
