From 3e99f5a75e14455aa75ca3a1dc7d08bd3e6f55fd Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Tue, 21 Apr 2026 23:12:52 +0300 Subject: u7: wire documented config knobs into runtime --- internal/comic/artist.go | 97 ++++++++++++------- internal/comic/comic_test.go | 174 ++++++++++++++++++++++++++++++++++- internal/comic/narrator.go | 7 +- internal/comic/runner.go | 42 ++++++--- internal/comic/types.go | 52 +++++++---- internal/config/config.go | 8 ++ internal/config/config_test.go | 37 ++++++++ internal/image/gemini.go | 28 +++++- internal/image/gemini_attribution.go | 6 +- internal/image/gemini_test.go | 28 ++++++ internal/image/registry.go | 8 +- internal/image/types_test.go | 1 + internal/provider/provider.go | 6 ++ 13 files changed, 421 insertions(+), 73 deletions(-) (limited to 'internal') diff --git a/internal/comic/artist.go b/internal/comic/artist.go index aa1dd5f..a54c01a 100644 --- a/internal/comic/artist.go +++ b/internal/comic/artist.go @@ -14,41 +14,57 @@ import ( // ArtistConfig configures comic page generation. type ArtistConfig struct { - ImageProvider provider.ImageProvider - TextProvider provider.TextProvider - Prompts PromptRenderer - OutputDir string - Style string - Theme string - Language string - Script string - UltraRealistic bool - StoryPages int - GalleryPages int - PanelsPerPage int + ImageProvider provider.ImageProvider + TextProvider provider.TextProvider + Prompts PromptRenderer + OutputDir string + Style string + ComicStyles []string + RealisticStyles []string + Theme string + AspectRatio string + Language string + Script string + UltraRealistic bool + StoryPages int + GalleryPages int + PanelsPerPage int + PromptMaxChars int + PageMaxRetries int + PageRetryBase time.Duration } // Artist generates comic-book pages. type Artist struct { - imageProvider provider.ImageProvider - textProvider provider.TextProvider - prompts PromptRenderer - outputDir string - style string - theme string - language string - script string - ultraRealistic bool - storyPages int - galleryPages int - panelsPerPage int - initErr error + imageProvider provider.ImageProvider + textProvider provider.TextProvider + prompts PromptRenderer + outputDir string + style string + comicStyles []string + realisticStyles []string + theme string + aspectRatio string + language string + script string + ultraRealistic bool + storyPages int + galleryPages int + panelsPerPage int + promptMaxChars int + pageMaxRetries int + pageRetryBase time.Duration + initErr error } type referenceImageGenerator interface { GenerateImageWithReferences(context.Context, string, string, [][]byte) error } +type referenceAspectRatioImageGenerator interface { + GenerateImageWithReferencesAndAspectRatio(context.Context, string, string, [][]byte, string) error +} + var sleep = time.Sleep // NewArtist creates an Artist. @@ -72,7 +88,10 @@ func NewArtist(cfg *ArtistConfig) *Artist { a.prompts = cfg.Prompts a.outputDir = orDefault(cfg.OutputDir, a.outputDir) a.style = cfg.Style + a.comicStyles = append([]string(nil), cfg.ComicStyles...) + a.realisticStyles = append([]string(nil), cfg.RealisticStyles...) a.theme = cfg.Theme + a.aspectRatio = orDefault(cfg.AspectRatio, comicPageAspectRatio) a.language = orDefault(cfg.Language, a.language) a.script = orDefault(cfg.Script, a.script) a.ultraRealistic = cfg.UltraRealistic @@ -85,6 +104,13 @@ func NewArtist(cfg *ArtistConfig) *Artist { if cfg.PanelsPerPage > 0 { a.panelsPerPage = cfg.PanelsPerPage } + a.promptMaxChars = normalizePositive(cfg.PromptMaxChars, comicPromptMaxChars) + a.pageMaxRetries = normalizePositive(cfg.PageMaxRetries, pageMaxRetries) + if cfg.PageRetryBase > 0 { + a.pageRetryBase = cfg.PageRetryBase + } else { + a.pageRetryBase = pageRetryBase + } if a.imageProvider == nil { a.initErr = fmt.Errorf("%w: image provider", ErrMissingProvider) @@ -102,7 +128,7 @@ func (a *Artist) DrawComicPages(ctx context.Context, storyText, bible, titleSlug } style := a.style if style == "" { - style = pickStyle(nil, a.ultraRealistic) + style = pickStyle(a.comicStyles, a.realisticStyles, a.ultraRealistic) } fmt.Printf(" Comic style: %s\n", style) @@ -184,7 +210,7 @@ func (a *Artist) renderPage(ctx context.Context, fileName, templateName string, } func (a *Artist) generateWithRetry(ctx context.Context, prompt, outputFile, label string, refs [][]byte) error { - attempts := pageMaxRetries + attempts := a.pageMaxRetries for attempt := 1; attempt <= attempts; attempt++ { callCtx, cancel := withTimeout(ctx, helperTimeout) err := a.generateImage(callCtx, prompt, outputFile, refs) @@ -199,7 +225,7 @@ func (a *Artist) generateWithRetry(ctx context.Context, prompt, outputFile, labe return nil } if attempt < attempts { - pause := pageRetryBase * time.Duration(attempt) + pause := a.pageRetryBase * time.Duration(attempt) fmt.Printf(" Warning: %s attempt %d/%d failed (%v), retrying in %s...\n", label, attempt, attempts, err, pause) sleep(pause) continue @@ -210,11 +236,17 @@ func (a *Artist) generateWithRetry(ctx context.Context, prompt, outputFile, labe } func (a *Artist) generateImage(ctx context.Context, prompt, outputFile string, refs [][]byte) error { + if withRefs, ok := a.imageProvider.(referenceAspectRatioImageGenerator); ok { + return withRefs.GenerateImageWithReferencesAndAspectRatio(ctx, prompt, outputFile, refs, a.aspectRatio) + } if len(refs) > 0 { if withRefs, ok := a.imageProvider.(referenceImageGenerator); ok { return withRefs.GenerateImageWithReferences(ctx, prompt, outputFile, refs) } } + if withAspectRatio, ok := a.imageProvider.(provider.AspectRatioImageProvider); ok { + return withAspectRatio.GenerateImageWithAspectRatio(ctx, prompt, outputFile, a.aspectRatio) + } return a.imageProvider.GenerateImage(ctx, prompt, outputFile) } @@ -299,7 +331,7 @@ func (a *Artist) storyPagePromptData(section string, pageNum int, style, bible s "RequiredDialoguePanels": requiredDialoguePanels(a.panelsPerPage), "TotalPanels": a.storyPages * a.panelsPerPage, "PanelLabelsText": panelLabelsText(a.panelsPerPage), - "PanelLayout": buildPanelLayout(section, pageScriptForPage(panelScript, pageNum-1), a.panelsPerPage), + "PanelLayout": buildPanelLayout(section, pageScriptForPage(panelScript, pageNum-1), a.panelsPerPage, a.promptMaxChars), "RenderingRequirement": a.renderingRequirement(), "RenderingRequirementEnd": a.renderingRequirementEnd(), } @@ -362,7 +394,7 @@ func pageScriptForPage(panelScript [][]string, idx int) []string { return panelScript[idx] } -func buildPanelLayout(section string, pagePanels []string, panelCount int) string { +func buildPanelLayout(section string, pagePanels []string, panelCount, promptMaxChars int) string { panelCount = normalizePositive(panelCount, defaultStoryPanelsPerPage) if len(pagePanels) == panelCount && allPanelsPresent(pagePanels) { var sb strings.Builder @@ -378,8 +410,9 @@ func buildPanelLayout(section string, pagePanels []string, panelCount int) strin } excerpt := strings.TrimSpace(section) - if utf8.RuneCountInString(excerpt) > comicPromptMaxChars { - excerpt = string([]rune(excerpt)[:comicPromptMaxChars]) + promptMaxChars = normalizePositive(promptMaxChars, comicPromptMaxChars) + if utf8.RuneCountInString(excerpt) > promptMaxChars { + excerpt = string([]rune(excerpt)[:promptMaxChars]) if idx := strings.LastIndex(excerpt, " "); idx > 0 { excerpt = excerpt[:idx] } diff --git a/internal/comic/comic_test.go b/internal/comic/comic_test.go index a4c50f2..0714769 100644 --- a/internal/comic/comic_test.go +++ b/internal/comic/comic_test.go @@ -47,7 +47,7 @@ func TestParseGenerateResult(t *testing.T) { func TestBuildPanelLayoutUsesFallbackExcerpt(t *testing.T) { t.Parallel() - got := buildPanelLayout("one two three four five", nil, 2) + got := buildPanelLayout("one two three four five", nil, 2, 900) if !strings.Contains(got, "exactly 2 distinct panels") { t.Fatalf("buildPanelLayout() = %q, want 2-panel layout instruction", got) } @@ -78,7 +78,7 @@ func TestBuildPanelLayoutTruncatesCyrillicOnRuneBoundaries(t *testing.T) { t.Parallel() text := strings.Repeat("a", 899) + "б" + strings.Repeat("c", 100) - got := buildPanelLayout(text, nil, 2) + got := buildPanelLayout(text, nil, 2, 900) if !utf8.ValidString(got) { t.Fatalf("buildPanelLayout() returned invalid UTF-8: %q", got) } @@ -90,6 +90,18 @@ func TestBuildPanelLayoutTruncatesCyrillicOnRuneBoundaries(t *testing.T) { } } +func TestBuildPanelLayoutRespectsPromptMaxChars(t *testing.T) { + t.Parallel() + + got := buildPanelLayout(strings.Repeat("a", 80), nil, 2, 12) + if !strings.Contains(got, "…") { + t.Fatalf("buildPanelLayout() = %q, want truncation ellipsis", got) + } + if strings.Contains(got, strings.Repeat("a", 20)) { + t.Fatalf("buildPanelLayout() = %q, want configured truncation limit", got) + } +} + func TestParseGenerateResultUsesConfiguredDimensions(t *testing.T) { t.Parallel() @@ -402,6 +414,60 @@ func TestArtistAndRunnerEndToEndWithFakes(t *testing.T) { } } +func TestNewRunnerUsesRealisticWeightWhenUltraRealisticUnset(t *testing.T) { + t.Parallel() + + t.Run("comic", func(t *testing.T) { + runner := NewRunner(&RunnerConfig{ + TextProvider: fakeTextProvider{text: "story"}, + ImageProvider: fakeImageProvider{t: t}, + Prompts: fakePromptRenderer{}, + RealisticWeight: 0, + StoryPages: 1, + GalleryPages: 0, + PanelsPerPage: 1, + ComicStyles: []string{"comic-ink"}, + RealisticStyles: []string{"photo-real"}, + AspectRatio: "1:1", + PromptMaxChars: 12, + PageMaxRetries: 1, + PageRetryBase: time.Second, + ChunkWords: 2, + }) + if runner.artist == nil { + t.Fatal("artist is nil") + } + if runner.artist.ultraRealistic { + t.Fatal("ultraRealistic = true, want false when weight is 0") + } + }) + + t.Run("realistic", func(t *testing.T) { + runner := NewRunner(&RunnerConfig{ + TextProvider: fakeTextProvider{text: "story"}, + ImageProvider: fakeImageProvider{t: t}, + Prompts: fakePromptRenderer{}, + RealisticWeight: 1, + StoryPages: 1, + GalleryPages: 0, + PanelsPerPage: 1, + ComicStyles: []string{"comic-ink"}, + RealisticStyles: []string{"photo-real"}, + AspectRatio: "1:1", + PromptMaxChars: 12, + PageMaxRetries: 1, + PageRetryBase: time.Second, + ChunkWords: 2, + }) + if runner.artist == nil { + t.Fatal("artist is nil") + } + if !runner.artist.ultraRealistic { + t.Fatal("ultraRealistic = false, want true when weight is 1") + } + }) +} + func TestRunnerPropagatesRenderFailures(t *testing.T) { originalSleep := sleep sleep = func(time.Duration) {} @@ -506,6 +572,56 @@ func TestDrawComicPagesUsesOneStyleAcrossTheWholePDF(t *testing.T) { } } +func TestDrawComicPagesUsesConfiguredStylePools(t *testing.T) { + originalLeakValidation := validateImagePromptLeakageFn + validateImagePromptLeakageFn = func(context.Context, string, string, string) error { return nil } + t.Cleanup(func() { + validateImagePromptLeakageFn = originalLeakValidation + }) + + tests := []struct { + name string + ultraRealistic bool + wantStyle string + }{ + {name: "comic", ultraRealistic: false, wantStyle: "comic-ink"}, + {name: "realistic", ultraRealistic: true, wantStyle: "photo-real"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + renderer := &recordingPromptRenderer{} + artist := NewArtist(&ArtistConfig{ + ImageProvider: fakeImageProvider{t: t}, + Prompts: renderer, + OutputDir: t.TempDir(), + UltraRealistic: tt.ultraRealistic, + ComicStyles: []string{"comic-ink"}, + RealisticStyles: []string{"photo-real"}, + Language: "English", + Script: "Latin", + PanelsPerPage: 2, + }) + + if _, err := artist.DrawComicPages(context.Background(), "story", "bible", "slug", []WordEntry{{Word: "ябълка"}}, nil); err != nil { + t.Fatalf("DrawComicPages() error = %v", err) + } + + var gotStyle string + for _, call := range renderer.calls { + if call.name != storyPagePromptTemplate { + continue + } + gotStyle, _ = call.data["Style"].(string) + break + } + if gotStyle != tt.wantStyle { + t.Fatalf("Style prompt = %q, want %q", gotStyle, tt.wantStyle) + } + }) + } +} + func TestDrawComicPagesChainsReferenceImages(t *testing.T) { originalLeakValidation := validateImagePromptLeakageFn validateImagePromptLeakageFn = func(context.Context, string, string, string) error { return nil } @@ -538,6 +654,37 @@ func TestDrawComicPagesChainsReferenceImages(t *testing.T) { } } +func TestGenerateWithRetryUsesConfiguredRetriesAndBackoff(t *testing.T) { + originalSleep := sleep + var pauses []time.Duration + sleep = func(d time.Duration) { + pauses = append(pauses, d) + } + t.Cleanup(func() { + sleep = originalSleep + }) + + artist := NewArtist(&ArtistConfig{ + ImageProvider: failingImageProvider{}, + Prompts: fakePromptRenderer{}, + OutputDir: t.TempDir(), + UltraRealistic: false, + PageMaxRetries: 3, + PageRetryBase: 2 * time.Second, + }) + + err := artist.generateWithRetry(context.Background(), "prompt", filepath.Join(t.TempDir(), "out.png"), "cover page", nil) + if err == nil { + t.Fatal("generateWithRetry() error = nil, want failure") + } + if got, want := len(pauses), 2; got != want { + t.Fatalf("sleep calls = %d, want %d", got, want) + } + if pauses[0] != 2*time.Second || pauses[1] != 4*time.Second { + t.Fatalf("sleep pauses = %v, want [2s 4s]", pauses) + } +} + func TestConvertToStereoFallsBackToCopyWhenFFmpegMissing(t *testing.T) { originalLookPath := lookPath lookPath = func(string) (string, error) { @@ -598,6 +745,29 @@ func TestNarrateConclusionDoesNotAcceptPartialConcatFallback(t *testing.T) { } } +func TestNarratorUsesConfiguredChunkWords(t *testing.T) { + t.Parallel() + + text := strings.Join([]string{ + "едно две", + "три четири", + "пет шест", + }, "\n\n") + + n := NewNarrator(&NarratorConfig{ + MainProvider: fakeTTSProvider{}, + Prompts: fakePromptRenderer{}, + ChunkWords: 2, + }) + paths, err := n.narrateMainStory(context.Background(), text, t.TempDir()) + if err != nil { + t.Fatalf("narrateMainStory() error = %v", err) + } + if got, want := len(paths), 3; got != want { + t.Fatalf("chunk count = %d, want %d", got, want) + } +} + type fakePromptRenderer struct{} func (fakePromptRenderer) RenderPrompt(name string, data any) (string, error) { diff --git a/internal/comic/narrator.go b/internal/comic/narrator.go index 24a523b..d5d8867 100644 --- a/internal/comic/narrator.go +++ b/internal/comic/narrator.go @@ -22,6 +22,7 @@ type NarratorConfig struct { VoiceName string Language string Script string + ChunkWords int } // Narrator generates intro, story, and conclusion narration. @@ -33,6 +34,7 @@ type Narrator struct { voiceName string language string script string + chunkWords int initErr error } @@ -53,6 +55,7 @@ func NewNarrator(cfg *NarratorConfig) *Narrator { n.voiceName = cfg.VoiceName n.language = orDefault(cfg.Language, n.language) n.script = orDefault(cfg.Script, n.script) + n.chunkWords = normalizePositive(cfg.ChunkWords, narratorChunkWords) if n.conclusionProvider == nil { n.conclusionProvider = n.mainProvider } @@ -112,7 +115,7 @@ func (n *Narrator) ready() error { } func (n *Narrator) narrateMainStory(ctx context.Context, storyText, tmpDir string) ([]string, error) { - chunks := splitIntoNarrationChunks(storyText, narratorChunkWords) + chunks := splitIntoNarrationChunks(storyText, n.chunkWords) fmt.Printf(" Splitting narration into %d chunks for consistent voice quality...\n", len(chunks)) var paths []string @@ -156,7 +159,7 @@ func (n *Narrator) narrateConclusion(ctx context.Context, storyText, tmpDir stri return "", false } - chunks := splitIntoNarrationChunks(conclusion, narratorChunkWords) + chunks := splitIntoNarrationChunks(conclusion, n.chunkWords) var paths []string for i, chunk := range chunks { path := filepath.Join(tmpDir, fmt.Sprintf("conclusion_%03d.mp3", i+1)) diff --git a/internal/comic/runner.go b/internal/comic/runner.go index 0b47a52..cc45e53 100644 --- a/internal/comic/runner.go +++ b/internal/comic/runner.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "strings" + "time" "codeberg.org/snonux/comicforge/internal/provider" "codeberg.org/snonux/comicforge/internal/vocab" @@ -36,6 +37,14 @@ type RunnerConfig struct { Slug string NarrateEnabled bool UltraRealistic *bool + RealisticWeight float64 + ComicStyles []string + RealisticStyles []string + AspectRatio string + PromptMaxChars int + PageMaxRetries int + PageRetryBase time.Duration + ChunkWords int StoryPages int GalleryPages int PanelsPerPage int @@ -58,7 +67,7 @@ func NewRunner(cfg *RunnerConfig) *Runner { return r } - ultra := pickUltraRealistic() + ultra := pickUltraRealistic(cfg.RealisticWeight) if cfg.UltraRealistic != nil { ultra = *cfg.UltraRealistic } @@ -73,18 +82,24 @@ func NewRunner(cfg *RunnerConfig) *Runner { PanelsPerPage: cfg.PanelsPerPage, }) r.artist = NewArtist(&ArtistConfig{ - ImageProvider: cfg.ImageProvider, - TextProvider: cfg.TextProvider, - Prompts: cfg.Prompts, - OutputDir: cfg.OutputDir, - Style: cfg.Style, - Theme: cfg.Theme, - Language: cfg.Language, - Script: cfg.Script, - UltraRealistic: ultra, - StoryPages: cfg.StoryPages, - GalleryPages: cfg.GalleryPages, - PanelsPerPage: cfg.PanelsPerPage, + ImageProvider: cfg.ImageProvider, + TextProvider: cfg.TextProvider, + Prompts: cfg.Prompts, + OutputDir: cfg.OutputDir, + Style: cfg.Style, + ComicStyles: cfg.ComicStyles, + RealisticStyles: cfg.RealisticStyles, + Theme: cfg.Theme, + AspectRatio: cfg.AspectRatio, + Language: cfg.Language, + Script: cfg.Script, + UltraRealistic: ultra, + PromptMaxChars: cfg.PromptMaxChars, + PageMaxRetries: cfg.PageMaxRetries, + PageRetryBase: cfg.PageRetryBase, + StoryPages: cfg.StoryPages, + GalleryPages: cfg.GalleryPages, + PanelsPerPage: cfg.PanelsPerPage, }) r.narrator = NewNarrator(&NarratorConfig{ TextProvider: cfg.TextProvider, @@ -94,6 +109,7 @@ func NewRunner(cfg *RunnerConfig) *Runner { VoiceName: cfg.NarratorVoice, Language: cfg.Language, Script: cfg.Script, + ChunkWords: cfg.ChunkWords, }) return r } diff --git a/internal/comic/types.go b/internal/comic/types.go index e5072fb..2f9f03a 100644 --- a/internal/comic/types.go +++ b/internal/comic/types.go @@ -65,13 +65,13 @@ var ( "a superhero origin story", } - realisticStyles = []string{ + defaultRealisticStyles = []string{ "ultra-realistic DSLR photography, cinematic 35mm lens, natural lighting, hyper-detailed textures", "cinematic still photography, golden-hour lighting, shallow depth of field, photojournalism quality", "hyper-realistic photography, studio-quality lighting, sharp focus, true-to-life colours and textures", } - comicStyles = []string{ + defaultComicStyles = []string{ "classic American comic book with bold ink outlines, halftone dots, and primary colors", "Japanese manga with clean linework, expressive eyes, and speed lines", "retro 1960s pop art in the style of Roy Lichtenstein with thick outlines and Ben-Day dots", @@ -83,6 +83,9 @@ var ( "cyberpunk neon art with glowing outlines, dark backgrounds, and electric accent colors", } + realisticStyles = defaultRealisticStyles + comicStyles = defaultComicStyles + galleryPoses = []string{ "close-up portrait of face and shoulders, dramatic three-quarter lighting, clear gaze toward the viewer, sharp eyes and expressive face", "dynamic full-body action pose, low camera angle looking up at the hero against the sky or environment, confident stance, hair and clothing in motion", @@ -136,28 +139,34 @@ func resolveGenre(theme string, genres []string) string { return pickStoryGenre(genres) } -func pickUltraRealistic() bool { - return rand.Float64() < 0.5 +func pickUltraRealistic(weight float64) bool { + weight = clampRealisticWeight(weight) + return rand.Float64() < weight } -func pickStyle(styles []string, ultraRealistic bool) string { - if len(styles) == 0 { - if ultraRealistic { - styles = realisticStyles - } else { - styles = comicStyles +func pickStyle(comicStyles, realisticStyles []string, ultraRealistic bool) string { + if ultraRealistic { + if len(realisticStyles) == 0 { + realisticStyles = defaultRealisticStyles + } + if len(realisticStyles) == 0 { + return "" } + return realisticStyles[rand.IntN(len(realisticStyles))] } - if ultraRealistic { - return styles[rand.IntN(len(styles))] + if len(comicStyles) == 0 { + comicStyles = defaultComicStyles } - if len(styles) == 1 { - return styles[0] + if len(comicStyles) == 0 { + return "" } if rand.Float64() < 0.9 { - return styles[0] + return comicStyles[0] + } + if len(comicStyles) == 1 { + return comicStyles[0] } - return styles[1+rand.IntN(len(styles)-1)] + return comicStyles[1+rand.IntN(len(comicStyles)-1)] } func parseGenerateResult(combined string) GenerateResult { @@ -278,6 +287,17 @@ func normalizePositive(value, fallback int) int { return fallback } +func clampRealisticWeight(weight float64) float64 { + switch { + case weight < 0: + return 0 + case weight > 1: + return 1 + default: + return weight + } +} + func panelLabel(idx int) string { if idx < 0 { return "" diff --git a/internal/config/config.go b/internal/config/config.go index 5b4ab18..f778637 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -246,6 +246,14 @@ func (c *Config) ImageTextModel() string { return c.Models.ImageText } +// ComicAspectRatio returns the configured comic image aspect ratio. +func (c *Config) ComicAspectRatio() string { + if c == nil { + return "" + } + return c.Comic.AspectRatio +} + // TTSModel returns the configured text-to-speech model name. func (c *Config) TTSModel() string { if c == nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0a2734c..e27906d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -36,6 +36,19 @@ prompts_dir: ./custom-prompts comic: story_pages: 7 panels_per_page: 2 + aspect_ratio: "1:1" + prompt_max_chars: 321 + page_max_retries: 2 + page_retry_base_seconds: 9 +story: + realistic_weight: 0.8 +styles: + comic: + - custom comic + realistic: + - custom realistic +narration: + chunk_words: 42 `)), 0o644); err != nil { t.Fatalf("write config: %v", err) } @@ -59,6 +72,30 @@ comic: if got, want := cfg.Comic.PanelsPerPage, 2; got != want { t.Fatalf("Comic.PanelsPerPage = %d, want %d", got, want) } + if got, want := cfg.Comic.AspectRatio, "1:1"; got != want { + t.Fatalf("Comic.AspectRatio = %q, want %q", got, want) + } + if got, want := cfg.Comic.PromptMaxChars, 321; got != want { + t.Fatalf("Comic.PromptMaxChars = %d, want %d", got, want) + } + if got, want := cfg.Comic.PageMaxRetries, 2; got != want { + t.Fatalf("Comic.PageMaxRetries = %d, want %d", got, want) + } + if got, want := cfg.Comic.PageRetryBaseSeconds, 9; got != want { + t.Fatalf("Comic.PageRetryBaseSeconds = %d, want %d", got, want) + } + if got, want := cfg.Story.RealisticWeight, 0.8; got != want { + t.Fatalf("Story.RealisticWeight = %v, want %v", got, want) + } + if got, want := len(cfg.Styles.Comic), 1; got != want || cfg.Styles.Comic[0] != "custom comic" { + t.Fatalf("Styles.Comic = %#v, want %q", cfg.Styles.Comic, "custom comic") + } + if got, want := len(cfg.Styles.Realistic), 1; got != want || cfg.Styles.Realistic[0] != "custom realistic" { + t.Fatalf("Styles.Realistic = %#v, want %q", cfg.Styles.Realistic, "custom realistic") + } + if got, want := cfg.Narration.ChunkWords, 42; got != want { + t.Fatalf("Narration.ChunkWords = %d, want %d", got, want) + } if got, want := cfg.PromptsDir, "./custom-prompts"; got != want { t.Fatalf("PromptsDir = %q, want %q", got, want) } diff --git a/internal/image/gemini.go b/internal/image/gemini.go index 05a2e0e..bd418d5 100644 --- a/internal/image/gemini.go +++ b/internal/image/gemini.go @@ -28,9 +28,10 @@ const ( // GeminiConfig holds the settings needed to build a Gemini-backed image provider. type GeminiConfig struct { - APIKey string - Model string - TextModel string + APIKey string + Model string + TextModel string + AspectRatio string } // GeminiProvider implements ImageProvider for Google Gemini image generation. @@ -104,6 +105,9 @@ func (c *GeminiProvider) Search(ctx context.Context, opts *SearchOptions) ([]Sea } aspectRatio := geminiAspectRatio + if c.config != nil && strings.TrimSpace(c.config.AspectRatio) != "" { + aspectRatio = strings.TrimSpace(c.config.AspectRatio) + } if opts.AspectRatio != "" { aspectRatio = opts.AspectRatio } @@ -164,13 +168,26 @@ func (c *GeminiProvider) IsAvailable() error { // GenerateImage renders the first generated image to outputFile. func (c *GeminiProvider) GenerateImage(ctx context.Context, prompt, outputFile string) error { - return c.GenerateImageWithReferences(ctx, prompt, outputFile, nil) + return c.GenerateImageWithAspectRatio(ctx, prompt, outputFile, "") +} + +// GenerateImageWithAspectRatio renders the first generated image to outputFile +// using the configured aspect ratio or the supplied override. +func (c *GeminiProvider) GenerateImageWithAspectRatio(ctx context.Context, prompt, outputFile string, aspectRatio string) error { + return c.GenerateImageWithReferencesAndAspectRatio(ctx, prompt, outputFile, nil, aspectRatio) } // GenerateImageWithReferences renders the first generated image to outputFile, // optionally conditioning the model on prior page images so comic pages stay // visually consistent across the full PDF. func (c *GeminiProvider) GenerateImageWithReferences(ctx context.Context, prompt, outputFile string, refs [][]byte) error { + return c.GenerateImageWithReferencesAndAspectRatio(ctx, prompt, outputFile, refs, "") +} + +// GenerateImageWithReferencesAndAspectRatio renders the first generated image +// to outputFile, optionally conditioning on reference images and overriding the +// aspect ratio. +func (c *GeminiProvider) GenerateImageWithReferencesAndAspectRatio(ctx context.Context, prompt, outputFile string, refs [][]byte, aspectRatio string) error { if c == nil { return fmt.Errorf("image provider is nil") } @@ -187,6 +204,9 @@ func (c *GeminiProvider) GenerateImageWithReferences(ctx context.Context, prompt if len(refs) > 0 { opts.ReferenceImages = refs } + if strings.TrimSpace(aspectRatio) != "" { + opts.AspectRatio = strings.TrimSpace(aspectRatio) + } results, err := c.Search(ctx, opts) if err != nil { return err diff --git a/internal/image/gemini_attribution.go b/internal/image/gemini_attribution.go index 2aefeac..15d1bb1 100644 --- a/internal/image/gemini_attribution.go +++ b/internal/image/gemini_attribution.go @@ -22,7 +22,11 @@ func (c *GeminiProvider) buildAttribution(result *SearchResult, prompt string) s 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) + aspectRatio := geminiAspectRatio + if c != nil && c.config != nil && strings.TrimSpace(c.config.AspectRatio) != "" { + aspectRatio = strings.TrimSpace(c.config.AspectRatio) + } + fmt.Fprintf(&attribution, "Aspect ratio: %s\n", aspectRatio) fmt.Fprintf(&attribution, "Size: %dx%d\n", result.Width, result.Height) if result.Description != "" { fmt.Fprintf(&attribution, "Result: %s\n", result.Description) diff --git a/internal/image/gemini_test.go b/internal/image/gemini_test.go index 91e0ce4..b605f7a 100644 --- a/internal/image/gemini_test.go +++ b/internal/image/gemini_test.go @@ -115,6 +115,34 @@ func TestGeminiProvider_Search_CustomPromptSkipsTextGeneration(t *testing.T) { } } +func TestGeminiProvider_UsesConfiguredAspectRatio(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 configured aspect ratio") + return "", nil + } + + var gotAspectRatio string + geminiGenerateImage = func(_ context.Context, _ *GeminiProvider, prompt, aspectRatio string) ([]byte, string, error) { + gotAspectRatio = aspectRatio + return mustJPEGBytes(t), "image/jpeg", nil + } + + client := NewGeminiProvider(&GeminiConfig{APIKey: "test-key", AspectRatio: "1:1"}) + if err := client.GenerateImage(context.Background(), "Art style: comic.", t.TempDir()+"/out.png"); err != nil { + t.Fatalf("GenerateImage() unexpected error: %v", err) + } + if got, want := gotAspectRatio, "1:1"; got != want { + t.Fatalf("aspect ratio = %q, want %q", got, want) + } +} + func TestGeminiProvider_Search_CustomPromptWithoutTranslationKeepsDescriptionSimple(t *testing.T) { originalText := geminiGenerateText originalImage := geminiGenerateImage diff --git a/internal/image/registry.go b/internal/image/registry.go index 2d446e8..abf7917 100644 --- a/internal/image/registry.go +++ b/internal/image/registry.go @@ -20,6 +20,7 @@ type GeminiRegistryConfig interface { GoogleAPIKey() string ImageModel() string ImageTextModel() string + ComicAspectRatio() string } // Registry resolves provider names to factories. @@ -96,9 +97,10 @@ func DefaultRegistry() *Registry[GeminiRegistryConfig] { registry := NewRegistry[GeminiRegistryConfig]() registry.Register(Gemini, func(cfg GeminiRegistryConfig) (ImageProvider, error) { geminiProvider := NewGeminiProvider(&GeminiConfig{ - APIKey: cfg.GoogleAPIKey(), - Model: cfg.ImageModel(), - TextModel: cfg.ImageTextModel(), + APIKey: cfg.GoogleAPIKey(), + Model: cfg.ImageModel(), + TextModel: cfg.ImageTextModel(), + AspectRatio: cfg.ComicAspectRatio(), }) if err := geminiProvider.IsAvailable(); err != nil { return nil, err diff --git a/internal/image/types_test.go b/internal/image/types_test.go index 5916389..3b83a50 100644 --- a/internal/image/types_test.go +++ b/internal/image/types_test.go @@ -162,6 +162,7 @@ func (f fakeConfig) ImageProviderName() string { return f.name } func (f fakeConfig) GoogleAPIKey() string { return f.apiKey } func (f fakeConfig) ImageModel() string { return f.model } func (f fakeConfig) ImageTextModel() string { return f.textModel } +func (f fakeConfig) ComicAspectRatio() string { return "" } type customConfig struct { name string diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 256dc4c..e8fedab 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -32,6 +32,12 @@ type ImageProvider interface { GenerateImage(ctx context.Context, prompt string, outputFile string) error } +// AspectRatioImageProvider generates images with an explicit aspect ratio. +type AspectRatioImageProvider interface { + ImageProvider + GenerateImageWithAspectRatio(ctx context.Context, prompt string, outputFile string, aspectRatio string) error +} + // TTSProvider generates audio from text. type TTSProvider interface { Name() string -- cgit v1.2.3