summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-19 23:13:06 +0300
committerPaul Buetow <paul@buetow.org>2026-04-19 23:13:06 +0300
commitd97741966a6c97b5db7df62873bd2ee9d99f0565 (patch)
tree9d6bd23b89463bbb98b4965a4c9e0bff438b1b71 /internal
parentb714194839f4245a5a8fb4ec1282453ea7b77e62 (diff)
y4: wire ComicForge CLI providers and config
Diffstat (limited to 'internal')
-rw-r--r--internal/comic/runner.go2
-rw-r--r--internal/image/gemini.go42
-rw-r--r--internal/text/gemini.go110
3 files changed, 154 insertions, 0 deletions
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
+}