From d97741966a6c97b5db7df62873bd2ee9d99f0565 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 19 Apr 2026 23:13:06 +0300 Subject: y4: wire ComicForge CLI providers and config --- internal/comic/runner.go | 2 + internal/image/gemini.go | 42 ++++++++++++++++++ internal/text/gemini.go | 110 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 internal/text/gemini.go (limited to 'internal') diff --git a/internal/comic/runner.go b/internal/comic/runner.go index 6ade637..7e4e9bb 100644 --- a/internal/comic/runner.go +++ b/internal/comic/runner.go @@ -32,6 +32,7 @@ type RunnerConfig struct { Theme string Language string Script string + NarratorVoice string Slug string NarrateEnabled bool UltraRealistic *bool @@ -88,6 +89,7 @@ func NewRunner(cfg *RunnerConfig) *Runner { MainProvider: cfg.MainTTSProvider, ConclusionProvider: cfg.ConclusionTTSProvider, Prompts: cfg.Prompts, + VoiceName: cfg.NarratorVoice, Language: cfg.Language, Script: cfg.Script, }) diff --git a/internal/image/gemini.go b/internal/image/gemini.go index 57fa61a..85da3a8 100644 --- a/internal/image/gemini.go +++ b/internal/image/gemini.go @@ -12,6 +12,7 @@ import ( "image/png" "io" "net/http" + "os" "strings" "time" @@ -165,6 +166,47 @@ func (c *GeminiProvider) Search(ctx context.Context, opts *SearchOptions) ([]Sea return []SearchResult{result}, nil } +// IsAvailable reports whether the provider was initialized successfully. +func (c *GeminiProvider) IsAvailable() error { + return c.ensureReady() +} + +// GenerateImage renders the first generated image to outputFile. +func (c *GeminiProvider) GenerateImage(ctx context.Context, prompt, outputFile string) error { + if c == nil { + return fmt.Errorf("image provider is nil") + } + if ctx == nil { + ctx = context.Background() + } + if strings.TrimSpace(prompt) == "" { + return fmt.Errorf("prompt is required") + } + if strings.TrimSpace(outputFile) == "" { + return fmt.Errorf("output file is required") + } + results, err := c.Search(ctx, &SearchOptions{Query: prompt}) + if err != nil { + return err + } + if len(results) == 0 { + return fmt.Errorf("no image results returned") + } + rc, err := c.Download(ctx, results[0].URL) + if err != nil { + return err + } + defer rc.Close() + data, err := io.ReadAll(rc) + if err != nil { + return fmt.Errorf("read image data: %w", err) + } + if err := os.WriteFile(outputFile, data, 0o644); err != nil { + return fmt.Errorf("write image: %w", err) + } + return nil +} + // Download returns the image bytes for a data URI or a remote URL. func (c *GeminiProvider) Download(ctx context.Context, url string) (io.ReadCloser, error) { if strings.HasPrefix(url, geminiDataPrefix) { diff --git a/internal/text/gemini.go b/internal/text/gemini.go new file mode 100644 index 0000000..94a8c16 --- /dev/null +++ b/internal/text/gemini.go @@ -0,0 +1,110 @@ +package text + +import ( + "context" + "fmt" + "strings" + + "google.golang.org/genai" + + "codeberg.org/snonux/comicforge/internal/httpctx" + "codeberg.org/snonux/comicforge/internal/provider" +) + +const ( + // DefaultModel is the Gemini text model used for story generation. + DefaultModel = "gemini-2.5-flash" +) + +// GeminiConfig holds the settings needed to build a Gemini-backed text provider. +type GeminiConfig struct { + APIKey string + Model string +} + +// GeminiProvider implements TextProvider for Google Gemini text generation. +type GeminiProvider struct { + client *genai.Client + model string + err error +} + +var _ provider.TextProvider = (*GeminiProvider)(nil) + +var newGeminiClient = httpctx.NewGenAIClient +var geminiGenerateText = func(ctx context.Context, p *GeminiProvider, prompt string) (string, error) { + return p.generateText(ctx, prompt) +} + +// NewGeminiProvider creates a Gemini text provider. +func NewGeminiProvider(cfg *GeminiConfig) *GeminiProvider { + g := &GeminiProvider{model: DefaultModel} + if cfg == nil { + g.err = fmt.Errorf("text config is required") + return g + } + g.model = defaultOr(cfg.Model, DefaultModel) + if strings.TrimSpace(cfg.APIKey) == "" { + g.err = fmt.Errorf("Google API key is required for text generation") + return g + } + client, err := newGeminiClient(context.Background(), &genai.ClientConfig{ + APIKey: cfg.APIKey, + Backend: genai.BackendGeminiAPI, + }) + if err != nil { + g.err = fmt.Errorf("create Gemini client: %w", err) + return g + } + g.client = client + return g +} + +// Name returns the provider name. +func (g *GeminiProvider) Name() string { return provider.Gemini } + +// IsAvailable reports whether the provider was initialized successfully. +func (g *GeminiProvider) IsAvailable() error { + if g == nil { + return fmt.Errorf("text provider is nil") + } + return g.err +} + +// GenerateText generates a text response for the provided prompt. +func (g *GeminiProvider) GenerateText(ctx context.Context, prompt string) (string, error) { + if g == nil { + return "", fmt.Errorf("text provider is nil") + } + if ctx == nil { + ctx = context.Background() + } + if g.err != nil { + return "", g.err + } + if strings.TrimSpace(prompt) == "" { + return "", fmt.Errorf("prompt is required") + } + return geminiGenerateText(ctx, g, prompt) +} + +func (g *GeminiProvider) generateText(ctx context.Context, prompt string) (string, error) { + resp, err := g.client.Models.GenerateContent(ctx, g.model, []*genai.Content{ + genai.NewContentFromText(prompt, genai.RoleUser), + }, nil) + if err != nil { + return "", fmt.Errorf("generate text: %w", err) + } + text := strings.TrimSpace(resp.Text()) + if text == "" { + return "", fmt.Errorf("no text content returned") + } + return text, nil +} + +func defaultOr(value, fallback string) string { + if strings.TrimSpace(value) != "" { + return value + } + return fallback +} -- cgit v1.2.3