diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-21 23:12:52 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-21 23:12:52 +0300 |
| commit | 3e99f5a75e14455aa75ca3a1dc7d08bd3e6f55fd (patch) | |
| tree | 568bb781c0d71bdea58f5dc7192e6c5edec94c27 /internal/comic | |
| parent | d15846a2784d15c5043833874b6539b727555cb0 (diff) | |
u7: wire documented config knobs into runtime
Diffstat (limited to 'internal/comic')
| -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 |
5 files changed, 307 insertions, 65 deletions
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 "" |
