diff options
Diffstat (limited to 'internal/image/gemini.go')
| -rw-r--r-- | internal/image/gemini.go | 329 |
1 files changed, 0 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 |
