summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md6
-rw-r--r--cmd/comicforge/cli.go51
-rw-r--r--cmd/comicforge/cli_test.go5
-rw-r--r--config.yaml.example21
-rw-r--r--internal/comic/artist.go113
-rw-r--r--internal/comic/comic_test.go128
-rw-r--r--internal/comic/runner.go59
-rw-r--r--internal/comic/types.go162
-rw-r--r--internal/config/config.go46
-rw-r--r--internal/config/config_test.go36
-rw-r--r--prompts/manual_title_prompt.md11
11 files changed, 517 insertions, 121 deletions
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