diff options
| -rw-r--r-- | README.md | 19 | ||||
| -rw-r--r-- | cmd/comicforge/cli.go | 9 | ||||
| -rw-r--r-- | cmd/comicforge/cli_test.go | 46 | ||||
| -rw-r--r-- | internal/comic/artist.go | 97 | ||||
| -rw-r--r-- | internal/comic/comic_test.go | 174 | ||||
| -rw-r--r-- | internal/comic/narrator.go | 7 | ||||
| -rw-r--r-- | internal/comic/runner.go | 42 | ||||
| -rw-r--r-- | internal/comic/types.go | 52 | ||||
| -rw-r--r-- | internal/config/config.go | 8 | ||||
| -rw-r--r-- | internal/config/config_test.go | 37 | ||||
| -rw-r--r-- | internal/image/gemini.go | 28 | ||||
| -rw-r--r-- | internal/image/gemini_attribution.go | 6 | ||||
| -rw-r--r-- | internal/image/gemini_test.go | 28 | ||||
| -rw-r--r-- | internal/image/registry.go | 8 | ||||
| -rw-r--r-- | internal/image/types_test.go | 1 | ||||
| -rw-r--r-- | internal/provider/provider.go | 6 |
16 files changed, 494 insertions, 74 deletions
@@ -72,6 +72,24 @@ comic: story_pages: 5 gallery_pages: 5 panels_per_page: 4 + aspect_ratio: "16:9" + prompt_max_chars: 900 + page_max_retries: 5 + page_retry_base_seconds: 15 + +story: + realistic_weight: 0.4 + +styles: + comic: + - classic comic book with bold ink outlines + - graphic novel with dramatic shadows + realistic: + - ultra-realistic DSLR photography, cinematic 35mm lens + - cinematic realism with natural light + +narration: + chunk_words: 100 prompts_dir: ./prompts ``` @@ -118,4 +136,3 @@ comicforge \ ``` The generated files are written under `out/comics/demo-comic/`. - diff --git a/cmd/comicforge/cli.go b/cmd/comicforge/cli.go index e472108..a87a226 100644 --- a/cmd/comicforge/cli.go +++ b/cmd/comicforge/cli.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "strings" + "time" "github.com/spf13/cobra" @@ -151,6 +152,8 @@ func runCommand(ctx context.Context, cmd *cobra.Command, deps commandDeps, flags Prompts: cfg, OutputDir: flags.outputDir, Style: flags.style, + ComicStyles: cfg.Styles.Comic, + RealisticStyles: cfg.Styles.Realistic, Theme: flags.theme, Language: cfg.Language.Story, Script: cfg.Language.Script, @@ -158,6 +161,12 @@ func runCommand(ctx context.Context, cmd *cobra.Command, deps commandDeps, flags Slug: flags.slug, NarrateEnabled: flags.narrateEnabled, UltraRealistic: resolveUltraRealistic(flags), + RealisticWeight: cfg.Story.RealisticWeight, + AspectRatio: cfg.Comic.AspectRatio, + PromptMaxChars: cfg.Comic.PromptMaxChars, + PageMaxRetries: cfg.Comic.PageMaxRetries, + PageRetryBase: time.Duration(cfg.Comic.PageRetryBaseSeconds) * time.Second, + ChunkWords: cfg.Narration.ChunkWords, StoryPages: cfg.Comic.StoryPages, GalleryPages: cfg.Comic.GalleryPages, PanelsPerPage: cfg.Comic.PanelsPerPage, diff --git a/cmd/comicforge/cli_test.go b/cmd/comicforge/cli_test.go index 4cf49f8..fd7904c 100644 --- a/cmd/comicforge/cli_test.go +++ b/cmd/comicforge/cli_test.go @@ -184,6 +184,52 @@ prompts_dir: ./config-prompts } } +func TestRootCommandUsesRealisticWeightWhenUltraModeUnset(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + if err := os.WriteFile(configPath, []byte(strings.TrimSpace(` +story: + realistic_weight: 0 +`)), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + var gotRunnerCfg *comic.RunnerConfig + cmd := newRootCommandWithDeps(commandDeps{ + loadConfig: func(path string) (*config.Config, error) { + return config.Load(path) + }, + newTextProvider: func(*config.Config) (provider.TextProvider, error) { return noopProvider{}, nil }, + newImageProvider: func(*config.Config) (provider.ImageProvider, error) { return noopProvider{}, nil }, + newTTSProvider: func(*config.Config, string) (provider.TTSProvider, error) { return noopProvider{}, nil }, + newRunner: func(cfg *comic.RunnerConfig) comic.StoryRunner { + gotRunnerCfg = cfg + return &recordingRunner{} + }, + }) + buf := &bytes.Buffer{} + cmd.SetOut(buf) + cmd.SetErr(buf) + vocabPath := filepath.Join(tmpDir, "vocab.txt") + if err := os.WriteFile(vocabPath, []byte("ябълка = apple\n"), 0o644); err != nil { + t.Fatalf("write vocab: %v", err) + } + cmd.SetArgs([]string{"--config", configPath, "--vocab", vocabPath}) + + if err := cmd.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v\noutput:\n%s", err, buf.String()) + } + if gotRunnerCfg == nil { + t.Fatal("runner config was not captured") + } + if got, want := gotRunnerCfg.RealisticWeight, 0.0; got != want { + t.Fatalf("realistic weight = %v, want %v", got, want) + } + if gotRunnerCfg.UltraRealistic != nil { + t.Fatalf("ultra realistic override = %#v, want nil when flags are unset", gotRunnerCfg.UltraRealistic) + } +} + func TestRootCommandVersionSkipsRequiredFlags(t *testing.T) { cmd := newRootCommandWithDeps(commandDeps{ loadConfig: func(string) (*config.Config, error) { 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/im |
