From 664c7d300c4a9326e5aebbd84a20cee5af829a0b Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 22 Apr 2026 09:51:43 +0300 Subject: Improve comic style generation --- README.md | 6 +- cmd/comicforge/cli.go | 51 +++++++++---- cmd/comicforge/cli_test.go | 5 -- config.yaml.example | 21 ++++++ internal/comic/artist.go | 113 ++++++++++++++++++---------- internal/comic/comic_test.go | 128 +++++++++++++++++++++++++------- internal/comic/runner.go | 59 +++++++++------ internal/comic/types.go | 162 ++++++++++++++++++++++++++++++++++++++--- internal/config/config.go | 46 +++++++++++- internal/config/config_test.go | 36 +++++++++ prompts/manual_title_prompt.md | 11 +++ 11 files changed, 517 insertions(+), 121 deletions(-) create mode 100644 prompts/manual_title_prompt.md diff --git a/README.md b/README.md index b823ba0..da21d90 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ComicForge turns a vocabulary file into a generated comic package. It uses Gemin ## What It Does -ComicForge reads a vocabulary list, generates a story from those words, renders comic pages, and saves supporting files alongside the comic. By default it uses Gemini for text, image, and text-to-speech generation. +ComicForge reads a vocabulary list, generates a story from those words, renders comic pages, and saves supporting files alongside the comic. By default it uses Gemini for text, image, and text-to-speech generation. When no explicit style is provided, it randomly chooses between photorealistic, classic comic, rubber-hose cartoon, 90s action, manga, cyberpunk, golden-age superhero, horror, and watercolor storybook style families. It also supports a manual prompt mode for generating a single image directly from a prompt, without the vocabulary/story pipeline. @@ -127,7 +127,7 @@ Useful flags: - `--narrator-voice` picks the Gemini narration voice - `--text-provider`, `--image-provider`, `--tts-provider` override provider names - `--text-model`, `--image-model`, `--image-text-model`, `--tts-model` override model IDs -- `--ultra-realistic` and `--no-ultra-realistic` control the rendering mode for both story and manual prompt mode +- `--ultra-realistic` and `--no-ultra-realistic` force photorealistic or classic comic rendering; without either flag ComicForge randomly chooses from the configured style families - `--version` prints the application version Example: @@ -149,6 +149,6 @@ For manual prompt mode: comicforge --prompt "a robot reading a newspaper" --output out --slug manual-robot ``` -This writes a single image to `out/comics/assets/manual-robot/prompt.png`. +This writes a single image to `out/comics/assets/manual-robot/prompt.png`. Without `--slug`, ComicForge asks the text model for a short title and uses that as the directory slug, with a short deterministic fallback if title generation fails. Manual prompt mode can be combined with `--style`, `--theme`, and the ultra-realistic flags to shape the generated image prompt. diff --git a/cmd/comicforge/cli.go b/cmd/comicforge/cli.go index 47ec2df..168395b 100644 --- a/cmd/comicforge/cli.go +++ b/cmd/comicforge/cli.go @@ -165,6 +165,13 @@ func runCommand(ctx context.Context, cmd *cobra.Command, deps commandDeps, flags Style: flags.style, ComicStyles: cfg.Styles.Comic, RealisticStyles: cfg.Styles.Realistic, + CartoonStyles: cfg.Styles.Cartoon, + Action90sStyles: cfg.Styles.Action90s, + MangaStyles: cfg.Styles.Manga, + CyberpunkStyles: cfg.Styles.Cyberpunk, + GoldenAgeStyles: cfg.Styles.GoldenAge, + HorrorStyles: cfg.Styles.Horror, + WatercolorStyles: cfg.Styles.Watercolor, Theme: flags.theme, Language: cfg.Language.Story, Script: cfg.Language.Script, @@ -197,28 +204,40 @@ func runPromptCommand(ctx context.Context, cmd *cobra.Command, deps commandDeps, } applyConfigOverrides(cmd, cfg, flags) + textProvider, err := deps.newTextProvider(cfg) + if err != nil { + return fmt.Errorf("build text provider: %w", err) + } imageProvider, err := deps.newImageProvider(cfg) if err != nil { return fmt.Errorf("build image provider: %w", err) } runner := deps.newRunner(&comic.RunnerConfig{ - ImageProvider: imageProvider, - 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, - Slug: flags.slug, - 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, + TextProvider: textProvider, + ImageProvider: imageProvider, + Prompts: cfg, + OutputDir: flags.outputDir, + Style: flags.style, + ComicStyles: cfg.Styles.Comic, + RealisticStyles: cfg.Styles.Realistic, + CartoonStyles: cfg.Styles.Cartoon, + Action90sStyles: cfg.Styles.Action90s, + MangaStyles: cfg.Styles.Manga, + CyberpunkStyles: cfg.Styles.Cyberpunk, + GoldenAgeStyles: cfg.Styles.GoldenAge, + HorrorStyles: cfg.Styles.Horror, + WatercolorStyles: cfg.Styles.Watercolor, + Theme: flags.theme, + Language: cfg.Language.Story, + Script: cfg.Language.Script, + Slug: flags.slug, + 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, }) return runner.RunPrompt(ctx, flags.prompt) diff --git a/cmd/comicforge/cli_test.go b/cmd/comicforge/cli_test.go index 54c5ea8..27dc09c 100644 --- a/cmd/comicforge/cli_test.go +++ b/cmd/comicforge/cli_test.go @@ -404,7 +404,6 @@ func TestRootCommandSkipsTTSProviderWhenNarrationDisabled(t *testing.T) { func TestRootCommandPromptModeSkipsVocabFlow(t *testing.T) { tmpDir := t.TempDir() - var textCalled bool var ttsCalled bool var gotRunnerCfg *comic.RunnerConfig runner := &recordingRunner{} @@ -414,7 +413,6 @@ func TestRootCommandPromptModeSkipsVocabFlow(t *testing.T) { return config.DefaultConfig(), nil }, newTextProvider: func(*config.Config) (provider.TextProvider, error) { - textCalled = true return noopProvider{}, nil }, newImageProvider: func(cfg *config.Config) (provider.ImageProvider, error) { @@ -445,9 +443,6 @@ func TestRootCommandPromptModeSkipsVocabFlow(t *testing.T) { if err := cmd.ExecuteContext(context.Background()); err != nil { t.Fatalf("ExecuteContext() error = %v\noutput:\n%s", err, buf.String()) } - if textCalled { - t.Fatal("newTextProvider was called, want prompt mode to skip story flow") - } if ttsCalled { t.Fatal("newTTSProvider was called, want prompt mode to skip narration setup") } diff --git a/config.yaml.example b/config.yaml.example index 95d12dc..42ea975 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -50,6 +50,27 @@ styles: realistic: - ultra-realistic DSLR photography, cinematic 35mm lens - cinematic realism with natural light + cartoon: + - extremely cartoonish 1930s rubber-hose funny-animal comic style, original mouse-and-duck-era mascot energy, pie-cut eyes, button noses or beaks, white gloves, oversized shoes, noodle arms, squash-and-stretch bodies, round heads, huge expressions, flat candy colors, absolutely no realism + - vintage theatrical funny-animal cartoon comic style, redesign people as original anthropomorphic mascot characters with beaks or round snouts, pie-cut eyes, white gloves, oversized shoes, elastic limbs, slapstick poses, no realistic anatomy + action_90s: + - 1990s superhero comic splash-page style like a bold caped hero punching a masked villain in a city, huge muscles, spandex costumes, explosions, halftone print texture + - classic 90s action superhero cover style, oversized yellow masthead energy, caped spandex hero, armored villain, city skyline, speech balloons, Ben-Day dots + manga: + - authentic black-and-white shonen manga page style, screentone shading, manga speed lines, large expressive eyes, sharp angular hair, impact bursts, minimal western color + - high-energy manga action style, monochrome ink, dense screentones, exaggerated emotional close-ups, chibi reaction inset, diagonal panel rhythm + cyberpunk: + - cyberpunk neon comic style, rain-slick streets, glowing signage, chrome technology + - tech-noir cyberpunk comic style, dense futuristic cityscapes, holograms, saturated neon rim lighting + golden_age: + - golden age superhero comic style, clean heroic poses, bright primary colors, optimistic mid-century adventure energy + - vintage golden age comic book style, bold flat colors, classic hero staging, retro print texture + horror: + - vintage horror comic style, eerie shadows, gothic atmosphere, unsettling monsters + - creepy supernatural horror comic style, fog, haunted architecture, heavy inks + watercolor: + - watercolor storybook comic style, soft washes, gentle ink outlines, luminous paper texture + - delicate watercolor children's adventure style, airy backgrounds, pastel palette, soft brush edges narration: voices: diff --git a/internal/comic/artist.go b/internal/comic/artist.go index e1717d3..5aeccac 100644 --- a/internal/comic/artist.go +++ b/internal/comic/artist.go @@ -14,47 +14,63 @@ import ( // ArtistConfig configures comic page generation. type ArtistConfig struct { - 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 + ImageProvider provider.ImageProvider + TextProvider provider.TextProvider + Prompts PromptRenderer + OutputDir string + Style string + StyleMode string + ComicStyles []string + RealisticStyles []string + CartoonStyles []string + Action90sStyles []string + MangaStyles []string + CyberpunkStyles []string + GoldenAgeStyles []string + HorrorStyles []string + WatercolorStyles []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 - 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 + imageProvider provider.ImageProvider + textProvider provider.TextProvider + prompts PromptRenderer + outputDir string + style string + styleMode string + comicStyles []string + realisticStyles []string + cartoonStyles []string + action90sStyles []string + mangaStyles []string + cyberpunkStyles []string + goldenAgeStyles []string + horrorStyles []string + watercolorStyles []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 { @@ -88,8 +104,16 @@ func NewArtist(cfg *ArtistConfig) *Artist { a.prompts = cfg.Prompts a.outputDir = orDefault(cfg.OutputDir, a.outputDir) a.style = cfg.Style + a.styleMode = orDefault(cfg.StyleMode, styleModeComic) a.comicStyles = append([]string(nil), cfg.ComicStyles...) a.realisticStyles = append([]string(nil), cfg.RealisticStyles...) + a.cartoonStyles = append([]string(nil), cfg.CartoonStyles...) + a.action90sStyles = append([]string(nil), cfg.Action90sStyles...) + a.mangaStyles = append([]string(nil), cfg.MangaStyles...) + a.cyberpunkStyles = append([]string(nil), cfg.CyberpunkStyles...) + a.goldenAgeStyles = append([]string(nil), cfg.GoldenAgeStyles...) + a.horrorStyles = append([]string(nil), cfg.HorrorStyles...) + a.watercolorStyles = append([]string(nil), cfg.WatercolorStyles...) a.theme = cfg.Theme a.aspectRatio = orDefault(cfg.AspectRatio, comicPageAspectRatio) a.language = orDefault(cfg.Language, a.language) @@ -98,7 +122,7 @@ func NewArtist(cfg *ArtistConfig) *Artist { if cfg.StoryPages > 0 { a.storyPages = cfg.StoryPages } - if cfg.GalleryPages > 0 { + if cfg.GalleryPages >= 0 { a.galleryPages = cfg.GalleryPages } if cfg.PanelsPerPage > 0 { @@ -128,7 +152,18 @@ func (a *Artist) DrawComicPages(ctx context.Context, storyText, bible, titleSlug } style := a.style if style == "" { - style = pickStyle(a.comicStyles, a.realisticStyles, a.ultraRealistic) + style = pickStyle( + a.comicStyles, + a.realisticStyles, + a.cartoonStyles, + a.action90sStyles, + a.mangaStyles, + a.cyberpunkStyles, + a.goldenAgeStyles, + a.horrorStyles, + a.watercolorStyles, + a.styleMode, + ) } fmt.Printf(" Comic style: %s\n", style) diff --git a/internal/comic/comic_test.go b/internal/comic/comic_test.go index 65343e6..336de78 100644 --- a/internal/comic/comic_test.go +++ b/internal/comic/comic_test.go @@ -23,6 +23,23 @@ func TestSlugify(t *testing.T) { } } +func TestShortPromptSlugFromText(t *testing.T) { + t.Parallel() + + longPrompt := "generate a logo for ComicForge which is a comic book generator displaying a superhero fighting a bad guy and speech bubbles" + + got := shortPromptSlugFromText(longPrompt) + if got == "" { + t.Fatal("shortPromptSlugFromText() returned empty slug") + } + if len(got) > maxPromptSlugLength { + t.Fatalf("shortPromptSlugFromText() length = %d, want <= %d (%q)", len(got), maxPromptSlugLength, got) + } + if strings.Contains(got, " ") { + t.Fatalf("shortPromptSlugFromText() = %q, want path-safe slug", got) + } +} + func TestParseGenerateResult(t *testing.T) { t.Parallel() @@ -371,8 +388,6 @@ func TestDrawComicPagesReturnsErrorWhenRenderFails(t *testing.T) { } func TestArtistAndRunnerEndToEndWithFakes(t *testing.T) { - t.Parallel() - originalSleep := sleep sleep = func(time.Duration) {} t.Cleanup(func() { @@ -415,6 +430,7 @@ func TestArtistAndRunnerEndToEndWithFakes(t *testing.T) { OutputDir: tmpDir, Slug: "forced-slug", NarrateEnabled: true, + GalleryPages: 1, }) runner.assemblePDF = func(outputDir, titleSlug string, imagePaths []string) (string, error) { path := filepath.Join(outputDir, titleSlug+".pdf") @@ -444,8 +460,6 @@ func TestArtistAndRunnerEndToEndWithFakes(t *testing.T) { } func TestRunnerRunPromptWritesSingleAsset(t *testing.T) { - t.Parallel() - originalLeakValidation := validateImagePromptLeakageFn validateImagePromptLeakageFn = func(context.Context, string, string, string) error { return nil } t.Cleanup(func() { @@ -455,9 +469,9 @@ func TestRunnerRunPromptWritesSingleAsset(t *testing.T) { tmpDir := t.TempDir() runner := NewRunner(&RunnerConfig{ ImageProvider: fakeImageProvider{t: t}, + TextProvider: fakeTextProvider{text: "ComicForge Superhero Logo"}, Prompts: fakePromptRenderer{}, OutputDir: tmpDir, - Slug: "manual-robot", UltraRealistic: boolPtr(false), PageMaxRetries: 1, PageRetryBase: time.Second, @@ -466,7 +480,7 @@ func TestRunnerRunPromptWritesSingleAsset(t *testing.T) { if err := runner.RunPrompt(context.Background(), "a robot reading a newspaper"); err != nil { t.Fatalf("RunPrompt() error = %v", err) } - if _, err := os.Stat(filepath.Join(tmpDir, "comics", "assets", "manual-robot", "prompt.png")); err != nil { + if _, err := os.Stat(filepath.Join(tmpDir, "comics", "assets", "comicforge-superhero-logo", "prompt.png")); err != nil { t.Fatalf("prompt image missing: %v", err) } if _, err := os.Stat(filepath.Join(tmpDir, "comics", "PDF")); !os.IsNotExist(err) { @@ -477,6 +491,44 @@ func TestRunnerRunPromptWritesSingleAsset(t *testing.T) { } } +func TestRunnerRunPromptUsesShortFallbackSlugWithoutTextProvider(t *testing.T) { + originalLeakValidation := validateImagePromptLeakageFn + validateImagePromptLeakageFn = func(context.Context, string, string, string) error { return nil } + t.Cleanup(func() { + validateImagePromptLeakageFn = originalLeakValidation + }) + + tmpDir := t.TempDir() + runner := NewRunner(&RunnerConfig{ + ImageProvider: fakeImageProvider{t: t}, + Prompts: fakePromptRenderer{}, + OutputDir: tmpDir, + UltraRealistic: boolPtr(false), + PageMaxRetries: 1, + PageRetryBase: time.Second, + }) + + longPrompt := "generate a logo for ComicForge which is a comic book generator displaying a superhero fighting a bad guy and speech bubbles" + if err := runner.RunPrompt(context.Background(), longPrompt); err != nil { + t.Fatalf("RunPrompt() error = %v", err) + } + + entries, err := os.ReadDir(filepath.Join(tmpDir, "comics", "assets")) + if err != nil { + t.Fatalf("read assets dir: %v", err) + } + if got, want := len(entries), 1; got != want { + t.Fatalf("asset dirs = %d, want %d", got, want) + } + slug := entries[0].Name() + if len(slug) > maxPromptSlugLength { + t.Fatalf("prompt slug length = %d, want <= %d (%q)", len(slug), maxPromptSlugLength, slug) + } + if _, err := os.Stat(filepath.Join(tmpDir, "comics", "assets", slug, "prompt.png")); err != nil { + t.Fatalf("prompt image missing: %v", err) + } +} + func TestRunnerRunPromptValidatesImageOutput(t *testing.T) { originalLeakValidation := validateImagePromptLeakageFn defer func() { @@ -603,15 +655,16 @@ func TestRunnerRunPromptAppliesStyleThemeAndUltraContext(t *testing.T) { } } -func TestNewRunnerUsesRealisticWeightWhenUltraRealisticUnset(t *testing.T) { +func TestNewRunnerUsesUltraFlagsForStyleMode(t *testing.T) { t.Parallel() t.Run("comic", func(t *testing.T) { + ultra := false runner := NewRunner(&RunnerConfig{ TextProvider: fakeTextProvider{text: "story"}, ImageProvider: fakeImageProvider{t: t}, Prompts: fakePromptRenderer{}, - RealisticWeight: 0, + UltraRealistic: &ultra, StoryPages: 1, GalleryPages: 0, PanelsPerPage: 1, @@ -627,16 +680,20 @@ func TestNewRunnerUsesRealisticWeightWhenUltraRealisticUnset(t *testing.T) { t.Fatal("artist is nil") } if runner.artist.ultraRealistic { - t.Fatal("ultraRealistic = true, want false when weight is 0") + t.Fatal("ultraRealistic = true, want false when explicitly disabled") + } + if got, want := runner.artist.styleMode, styleModeComic; got != want { + t.Fatalf("styleMode = %q, want %q", got, want) } }) t.Run("realistic", func(t *testing.T) { + ultra := true runner := NewRunner(&RunnerConfig{ TextProvider: fakeTextProvider{text: "story"}, ImageProvider: fakeImageProvider{t: t}, Prompts: fakePromptRenderer{}, - RealisticWeight: 1, + UltraRealistic: &ultra, StoryPages: 1, GalleryPages: 0, PanelsPerPage: 1, @@ -652,7 +709,10 @@ func TestNewRunnerUsesRealisticWeightWhenUltraRealisticUnset(t *testing.T) { t.Fatal("artist is nil") } if !runner.artist.ultraRealistic { - t.Fatal("ultraRealistic = false, want true when weight is 1") + t.Fatal("ultraRealistic = false, want true when explicitly enabled") + } + if got, want := runner.artist.styleMode, styleModeRealistic; got != want { + t.Fatalf("styleMode = %q, want %q", got, want) } }) } @@ -769,27 +829,41 @@ func TestDrawComicPagesUsesConfiguredStylePools(t *testing.T) { }) tests := []struct { - name string - ultraRealistic bool - wantStyle string + name string + styleMode string + wantStyle string }{ - {name: "comic", ultraRealistic: false, wantStyle: "comic-ink"}, - {name: "realistic", ultraRealistic: true, wantStyle: "photo-real"}, + {name: "comic", styleMode: styleModeComic, wantStyle: "comic-ink"}, + {name: "realistic", styleMode: styleModeRealistic, wantStyle: "photo-real"}, + {name: "cartoon", styleMode: styleModeCartoon, wantStyle: "cartoon-ink"}, + {name: "action90s", styleMode: styleModeAction90s, wantStyle: "action-ink"}, + {name: "manga", styleMode: styleModeManga, wantStyle: "manga-ink"}, + {name: "cyberpunk", styleMode: styleModeCyberpunk, wantStyle: "cyberpunk-ink"}, + {name: "goldenAge", styleMode: styleModeGoldenAge, wantStyle: "golden-age-ink"}, + {name: "horror", styleMode: styleModeHorror, wantStyle: "horror-ink"}, + {name: "watercolor", styleMode: styleModeWatercolor, wantStyle: "watercolor-ink"}, } 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, + ImageProvider: fakeImageProvider{t: t}, + Prompts: renderer, + OutputDir: t.TempDir(), + StyleMode: tt.styleMode, + ComicStyles: []string{"comic-ink"}, + RealisticStyles: []string{"photo-real"}, + CartoonStyles: []string{"cartoon-ink"}, + Action90sStyles: []string{"action-ink"}, + MangaStyles: []string{"manga-ink"}, + CyberpunkStyles: []string{"cyberpunk-ink"}, + GoldenAgeStyles: []string{"golden-age-ink"}, + HorrorStyles: []string{"horror-ink"}, + WatercolorStyles: []string{"watercolor-ink"}, + Language: "English", + Script: "Latin", + PanelsPerPage: 2, }) if _, err := artist.DrawComicPages(context.Background(), "story", "bible", "slug", []WordEntry{{Word: "ябълка"}}, nil); err != nil { @@ -971,6 +1045,8 @@ func (fakePromptRenderer) RenderPrompt(name string, data any) (string, error) { return "FINAL STYLE LOCK — PHOTOREALISM: the entire image must look camera-captured. If anything looks drawn or painted, the result is wrong. Do not drift toward comic art between panels or in gallery images.", nil case manualPromptTemplate: return renderManualPromptForTest(data), nil + case manualTitlePromptTemplate: + return "short title prompt", nil case coverPromptTemplate, storyPagePromptTemplate, galleryPagePromptTemplate, backCoverPromptTemplate: return "image prompt", nil case blurbSystemTemplate, introSystemTemplate, conclusionSystemTemplate: @@ -1010,6 +1086,8 @@ func (r *recordingPromptRenderer) RenderPrompt(name string, data any) (string, e return "FINAL STYLE LOCK — PHOTOREALISM: the entire image must look camera-captured. If anything looks drawn or painted, the result is wrong. Do not drift toward comic art between panels or in gallery images.", nil case manualPromptTemplate: return renderManualPromptForTest(data), nil + case manualTitlePromptTemplate: + return "short title prompt", nil case coverPromptTemplate, storyPagePromptTemplate, galleryPagePromptTemplate, backCoverPromptTemplate: return "image prompt", nil case blurbSystemTemplate, introSystemTemplate, conclusionSystemTemplate: diff --git a/internal/comic/runner.go b/internal/comic/runner.go index a9aa3ef..500350c 100644 --- a/internal/comic/runner.go +++ b/internal/comic/runner.go @@ -30,6 +30,7 @@ type RunnerConfig struct { Prompts PromptRenderer OutputDir string Style string + StyleMode string Theme string Language string Script string @@ -40,6 +41,13 @@ type RunnerConfig struct { RealisticWeight float64 ComicStyles []string RealisticStyles []string + CartoonStyles []string + Action90sStyles []string + MangaStyles []string + CyberpunkStyles []string + GoldenAgeStyles []string + HorrorStyles []string + WatercolorStyles []string AspectRatio string PromptMaxChars int PageMaxRetries int @@ -67,10 +75,11 @@ func NewRunner(cfg *RunnerConfig) *Runner { return r } - ultra := pickUltraRealistic(cfg.RealisticWeight) - if cfg.UltraRealistic != nil { - ultra = *cfg.UltraRealistic + styleMode := strings.TrimSpace(cfg.StyleMode) + if styleMode == "" { + styleMode = pickStyleMode(cfg.UltraRealistic) } + ultra := styleMode == styleModeRealistic r.generator = NewGenerator(&GeneratorConfig{ TextProvider: cfg.TextProvider, @@ -82,24 +91,32 @@ 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, - 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, + ImageProvider: cfg.ImageProvider, + TextProvider: cfg.TextProvider, + Prompts: cfg.Prompts, + OutputDir: cfg.OutputDir, + Style: cfg.Style, + StyleMode: styleMode, + ComicStyles: cfg.ComicStyles, + RealisticStyles: cfg.RealisticStyles, + CartoonStyles: cfg.CartoonStyles, + Action90sStyles: cfg.Action90sStyles, + MangaStyles: cfg.MangaStyles, + CyberpunkStyles: cfg.CyberpunkStyles, + GoldenAgeStyles: cfg.GoldenAgeStyles, + HorrorStyles: cfg.HorrorStyles, + WatercolorStyles: cfg.WatercolorStyles, + 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, diff --git a/internal/comic/types.go b/internal/comic/types.go index 0c94a85..5a3a111 100644 --- a/internal/comic/types.go +++ b/internal/comic/types.go @@ -75,18 +75,64 @@ var ( 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", - "watercolor illustration with soft washes, delicate linework, and pastel tones", "European bande dessinée with detailed backgrounds, clear lines, and rich flat colors", "noir black-and-white graphic novel with heavy shadows and high contrast", "children's picture book with bright, friendly illustrations and thick outlines", "painterly oil-on-canvas comic with loose brushwork and vivid impressionist colors", - "cyberpunk neon art with glowing outlines, dark backgrounds, and electric accent colors", } - realisticStyles = defaultRealisticStyles - comicStyles = defaultComicStyles + defaultCartoonStyles = []string{ + "extremely cartoonish 1930s rubber-hose funny-animal comic style, original mouse-and-duck-era mascot energy, pie-cut eyes, button noses or beaks, white gloves, oversized shoes, noodle arms, squash-and-stretch bodies, round heads, huge expressions, flat candy colors, simple gag staging, absolutely no realism", + "vintage theatrical funny-animal cartoon comic style, redesign people as original anthropomorphic mascot characters with beaks or round snouts, pie-cut eyes, white gloves, oversized shoes, elastic limbs, cheerful slapstick poses, bold clean outlines, bright flat backgrounds, no realistic anatomy", + "old black-and-white short cartoon style translated into comic panels, original rubber-hose mascot characters, pie eyes, rubber limbs, bouncing motion arcs, simple rounded shapes, comic mischief, exaggerated face acting, hand-inked animation cels", + } + + defaultAction90sStyles = []string{ + "1990s superhero comic splash-page style like a bold caped hero punching a masked villain in a city, huge muscles, spandex costumes, flowing cape, explosive impact burst, speed lines, smoke, rubble, halftone print texture, heavy black inks, saturated primary colors", + "classic 90s action superhero cover style, oversized yellow masthead energy, caped spandex hero, armored villain, clenched fists, dramatic foreshortening, city skyline, explosions, speech balloons, thick ink outlines, Ben-Day dots", + "extreme 1990s superhero action comic style, heroic anatomy, masked villain, motion streaks, lightning impact shapes, cross-hatching, smoky city destruction, glossy saturated colors, bold comic-book lettering spaces", + } + + defaultMangaStyles = []string{ + "authentic black-and-white shonen manga page style, right-to-left manga energy, screentone shading, manga speed lines, large expressive eyes, sharp angular hair, sweat drops, impact bursts, Japanese comic panel language, minimal western color", + "high-energy manga action style, monochrome ink, dense screentones, exaggerated emotional close-ups, chibi reaction inset where appropriate, motion blur lines, diagonal panel rhythm, dramatic sound-effect shapes", + "modern manga fantasy comic style, elegant anime-inspired character designs, big eyes, spiky hair silhouettes, black ink linework, grey screentone gradients, explosive action frames, cinematic close-ups", + } + + defaultCyberpunkStyles = []string{ + "cyberpunk neon comic style, rain-slick streets, glowing signage, chrome technology, high contrast shadows, electric cyan and magenta accents", + "tech-noir cyberpunk comic style, dense futuristic cityscapes, holograms, cybernetic details, saturated neon rim lighting", + "futuristic cyberpunk action comic style, laser-lit alleys, augmented heroes, digital glitches, saturated night colors, sharp ink silhouettes", + } + + defaultGoldenAgeStyles = []string{ + "golden age superhero comic style, clean heroic poses, bright primary colors, simple confident linework, optimistic mid-century adventure energy", + "vintage golden age comic book style, bold flat colors, classic caped hero staging, simple dramatic compositions, retro print texture", + "1940s superhero serial comic style, square-jawed heroes, clear action, hand-lettered poster energy, bright optimistic palette", + } + + defaultHorrorStyles = []string{ + "vintage horror comic style, eerie shadows, gothic atmosphere, unsettling monsters, dramatic candlelit contrast, suspenseful panel staging", + "creepy supernatural horror comic style, fog, haunted architecture, anxious expressions, heavy inks, sickly green and crimson accents", + "old anthology horror comic style, graveyard mist, warped shadows, tense faces, scratchy ink texture, ominous colored lighting", + } + + defaultWatercolorStyles = []string{ + "watercolor storybook comic style, soft washes, gentle ink outlines, luminous paper texture, warm hand-painted colors, whimsical atmosphere", + "delicate watercolor children's adventure style, airy backgrounds, expressive simple characters, pastel palette, soft brush edges", + "storybook watercolor fantasy comic style, transparent layered color, soft paper grain, tender expressions, dreamy illustrated settings", + } + + realisticStyles = defaultRealisticStyles + comicStyles = defaultComicStyles + cartoonStyles = defaultCartoonStyles + action90sStyles = defaultAction90sStyles + mangaStyles = defaultMangaStyles + cyberpunkStyles = defaultCyberpunkStyles + goldenAgeStyles = defaultGoldenAgeStyles + horrorStyles = defaultHorrorStyles + watercolorStyles = defaultWatercolorStyles galleryPoses = []string{ "close-up portrait of face and shoulders, dramatic three-quarter lighting, clear gaze toward the viewer, sharp eyes and expressive face", @@ -97,6 +143,18 @@ var ( } ) +const ( + styleModeRealistic = "realistic" + styleModeComic = "comic" + styleModeCartoon = "cartoon" + styleModeAction90s = "action_90s" + styleModeManga = "manga" + styleModeCyberpunk = "cyberpunk" + styleModeGoldenAge = "golden_age" + styleModeHorror = "horror" + styleModeWatercolor = "watercolor" +) + // PromptRenderer renders external or embedded prompt templates. type PromptRenderer interface { RenderPrompt(name string, data any) (string, error) @@ -143,13 +201,41 @@ func resolveGenre(theme string, genres []string) string { return pickStoryGenre(genres) } -func pickUltraRealistic(weight float64) bool { - weight = clampRealisticWeight(weight) - return rand.Float64() < weight +func pickStyleMode(forcedUltra *bool) string { + if forcedUltra != nil { + if *forcedUltra { + return styleModeRealistic + } + return styleModeComic + } + modes := []string{ + styleModeRealistic, + styleModeComic, + styleModeCartoon, + styleModeAction90s, + styleModeManga, + styleModeCyberpunk, + styleModeGoldenAge, + styleModeHorror, + styleModeWatercolor, + } + return modes[rand.IntN(len(modes))] } -func pickStyle(comicStyles, realisticStyles []string, ultraRealistic bool) string { - if ultraRealistic { +func pickStyle( + comicStyles, + realisticStyles, + cartoonStyles, + action90sStyles, + mangaStyles, + cyberpunkStyles, + goldenAgeStyles, + horrorStyles, + watercolorStyles []string, + mode string, +) string { + switch mode { + case styleModeRealistic: if len(realisticStyles) == 0 { realisticStyles = defaultRealisticStyles } @@ -157,6 +243,62 @@ func pickStyle(comicStyles, realisticStyles []string, ultraRealistic bool) strin return "" } return realisticStyles[rand.IntN(len(realisticStyles))] + case styleModeCartoon: + if len(cartoonStyles) == 0 { + cartoonStyles = defaultCartoonStyles + } + if len(cartoonStyles) == 0 { + return "" + } + return cartoonStyles[rand.IntN(len(cartoonStyles))] + case styleModeAction90s: + if len(action90sStyles) == 0 { + action90sStyles = defaultAction90sStyles + } + if len(action90sStyles) == 0 { + return "" + } + return action90sStyles[rand.IntN(len(action90sStyles))] + case styleModeManga: + if len(mangaStyles) == 0 { + mangaStyles = defaultMangaStyles + } + if len(mangaStyles) == 0 { + return "" + } + return mangaStyles[rand.IntN(len(mangaStyles))] + case styleModeCyberpunk: + if len(cyberpunkStyles) == 0 { + cyberpunkStyles = defaultCyberpunkStyles + } + if len(cyberpunkStyles) == 0 { + return "" + } + return cyberpunkStyles[rand.IntN(len(cyberpunkStyles))] + case styleModeGoldenAge: + if len(goldenAgeStyles) == 0 { + goldenAgeStyles = defaultGoldenAgeStyles + } + if len(goldenAgeStyles) == 0 { + return "" + } + return goldenAgeStyles[rand.IntN(len(goldenAgeStyles))] + case styleModeHorror: + if len(horrorStyles) == 0 { + horrorStyles = defaultHorrorStyles + } + if len(horrorStyles) == 0 { + return "" + } + return horrorStyles[rand.IntN(len(horrorStyles))] + case styleModeWatercolor: + if len(watercolorStyles) == 0 { + watercolorStyles = defaultWatercolorStyles + } + if len(watercolorStyles) == 0 { + return "" + } + return watercolorStyles[rand.IntN(len(watercolorStyles))] } if len(comicStyles) == 0 { comicStyles = defaultComicStyles diff --git a/internal/config/config.go b/internal/config/config.go index 754c816..d7e7d49 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -95,8 +95,15 @@ type StoryConfig struct { // StyleConfig stores prompt style pools. type StyleConfig struct { - Comic []string `mapstructure:"comic" yaml:"comic"` - Realistic []string `mapstructure:"realistic" yaml:"realistic"` + Comic []string `mapstructure:"comic" yaml:"comic"` + Realistic []string `mapstructure:"realistic" yaml:"realistic"` + Cartoon []string `mapstructure:"cartoon" yaml:"cartoon"` + Action90s []string `mapstructure:"action_90s" yaml:"action_90s"` + Manga []string `mapstructure:"manga" yaml:"manga"` + Cyberpunk []string `mapstructure:"cyberpunk" yaml:"cyberpunk"` + GoldenAge []string `mapstructure:"golden_age" yaml:"golden_age"` + Horror []string `mapstructure:"horror" yaml:"horror"` + Watercolor []string `mapstructure:"watercolor" yaml:"watercolor"` } // NarrationConfig stores narration prompt knobs. @@ -152,6 +159,34 @@ func DefaultConfig() *Config { "ultra-realistic DSLR photography, cinematic 35mm lens", "cinematic realism with natural light", }, + Cartoon: []string{ + "very cartoonish 1930s rubber-hose animation comic style, pie-cut eyes, white gloves, noodle arms, squash-and-stretch bodies, round heads, huge expressions, flat candy colors, simple gag staging, no realism", + "vintage theatrical funny-animal cartoon comic style, beaks and round snouts, white gloves, oversized shoes, elastic limbs, cheerful slapstick poses, bold clean outlines, bright flat backgrounds, no realistic anatomy", + }, + Action90s: []string{ + "1990s superhero comic splash-page style like a bold caped hero punching a masked villain in a city, huge muscles, spandex costumes, flowing cape, explosive impact burst, speed lines, smoke, rubble, halftone print texture, heavy black inks, saturated primary colors", + "classic 90s action superhero cover style, oversized yellow masthead energy, caped spandex hero, armored villain, clenched fists, dramatic foreshortening, city skyline, explosions, speech balloons, thick ink outlines, Ben-Day dots", + }, + Manga: []string{ + "authentic black-and-white shonen manga page style, right-to-left manga energy, screentone shading, manga speed lines, large expressive eyes, sharp angular hair, sweat drops, impact bursts, Japanese comic panel language, minimal western color", + "high-energy manga action style, monochrome ink, dense screentones, exaggerated emotional close-ups, chibi reaction inset where appropriate, motion blur lines, diagonal panel rhythm, dramatic sound-effect shapes", + }, + Cyberpunk: []string{ + "cyberpunk neon comic style, rain-slick streets, glowing signage, chrome technology, high contrast shadows, electric cyan and magenta accents", + "tech-noir cyberpunk comic style, dense futuristic cityscapes, holograms, cybernetic details, saturated neon rim lighting", + }, + GoldenAge: []string{ + "golden age superhero comic style, clean heroic poses, bright primary colors, simple confident linework, optimistic mid-century adventure energy", + "vintage golden age comic book style, bold flat colors, classic caped hero staging, simple dramatic compositions, retro print texture", + }, + Horror: []string{ + "vintage horror comic style, eerie shadows, gothic atmosphere, unsettling monsters, dramatic candlelit contrast, suspenseful panel staging", + "creepy supernatural horror comic style, fog, haunted architecture, anxious expressions, heavy inks, sickly green and crimson accents", + }, + Watercolor: []string{ + "watercolor storybook comic style, soft washes, gentle ink outlines, luminous paper texture, warm hand-painted colors, whimsical atmosphere", + "delicate watercolor children's adventure style, airy backgrounds, expressive simple characters, pastel palette, soft brush edges", + }, }, Narration: NarrationConfig{ Voices: []string{ @@ -388,6 +423,13 @@ func setDefaults(v *viper.Viper, cfg *Config) { v.SetDefault("styles.comic", cfg.Styles.Comic) v.SetDefault("styles.realistic", cfg.Styles.Realistic) + v.SetDefault("styles.cartoon", cfg.Styles.Cartoon) + v.SetDefault("styles.action_90s", cfg.Styles.Action90s) + v.SetDefault("styles.manga", cfg.Styles.Manga) + v.SetDefault("styles.cyberpunk", cfg.Styles.Cyberpunk) + v.SetDefault("styles.golden_age", cfg.Styles.GoldenAge) + v.SetDefault("styles.horror", cfg.Styles.Horror) + v.SetDefault("styles.watercolor", cfg.Styles.Watercolor) v.SetDefault("narration.voices", cfg.Narration.Voices) v.SetDefault("narration.chunk_words", cfg.Narration.ChunkWords) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1a53486..1ae967c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -47,6 +47,20 @@ styles: - custom comic realistic: - custom realistic + cartoon: + - custom cartoon + action_90s: + - custom 90s action + manga: + - custom manga + cyberpunk: + - custom cyberpunk + golden_age: + - custom golden age + horror: + - custom horror + watercolor: + - custom watercolor narration: chunk_words: 42 `)), 0o644); err != nil { @@ -93,6 +107,27 @@ narration: 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 := len(cfg.Styles.Cartoon), 1; got != want || cfg.Styles.Cartoon[0] != "custom cartoon" { + t.Fatalf("Styles.Cartoon = %#v, want %q", cfg.Styles.Cartoon, "custom cartoon") + } + if got, want := len(cfg.Styles.Action90s), 1; got != want || cfg.Styles.Action90s[0] != "custom 90s action" { + t.Fatalf("Styles.Action90s = %#v, want %q", cfg.Styles.Action90s, "custom 90s action") + } + if got, want := len(cfg.Styles.Manga), 1; got != want || cfg.Styles.Manga[0] != "custom manga" { + t.Fatalf("Styles.Manga = %#v, want %q", cfg.Styles.Manga, "custom manga") + } + if got, want := len(cfg.Styles.Cyberpunk), 1; got != want || cfg.Styles.Cyberpunk[0] != "custom cyberpunk" { + t.Fatalf("Styles.Cyberpunk = %#v, want %q", cfg.Styles.Cyberpunk, "custom cyberpunk") + } + if got, want := len(cfg.Styles.GoldenAge), 1; got != want || cfg.Styles.GoldenAge[0] != "custom golden age" { + t.Fatalf("Styles.GoldenAge = %#v, want %q", cfg.Styles.GoldenAge, "custom golden age") + } + if got, want := len(cfg.Styles.Horror), 1; got != want || cfg.Styles.Horror[0] != "custom horror" { + t.Fatalf("Styles.Horror = %#v, want %q", cfg.Styles.Horror, "custom horror") + } + if got, want := len(cfg.Styles.Watercolor), 1; got != want || cfg.Styles.Watercolor[0] != "custom watercolor" { + t.Fatalf("Styles.Watercolor = %#v, want %q", cfg.Styles.Watercolor, "custom watercolor") + } if got, want := cfg.Narration.ChunkWords, 42; got != want { t.Fatalf("Narration.ChunkWords = %d, want %d", got, want) } @@ -260,6 +295,7 @@ func TestEmbeddedPromptTemplatesRender(t *testing.T) { "rendering_requirement.md", "rendering_requirement_end.md", "manual_prompt.md", + "manual_title_prompt.md", } { t.Run(name, func(t *testing.T) { got, err := cfg.RenderPrompt(name, data) diff --git a/prompts/manual_title_prompt.md b/prompts/manual_title_prompt.md new file mode 100644 index 0000000..12f1200 --- /dev/null +++ b/prompts/manual_title_prompt.md @@ -0,0 +1,11 @@ +Summarize this image prompt into a short directory title. + +Rules: +- Output only the title, with no explanation. +- Use English ASCII words only. +- Use 2 to 5 words. +- Do not use punctuation, quotes, emojis, slashes, or file extensions. +- Make it specific enough to identify the image. + +Prompt: +{{.Prompt}} -- cgit v1.2.3