summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CLAUDE.md1
-rw-r--r--Magefile.go5
-rw-r--r--README.md12
-rw-r--r--cmd/comicforge/cli.go10
-rw-r--r--cmd/stylegrid/main.go30
-rw-r--r--config.yaml.example15
-rw-r--r--internal/comic/artist.go20
-rw-r--r--internal/comic/comic_test.go10
-rw-r--r--internal/comic/runner.go10
-rw-r--r--internal/comic/types.go82
-rw-r--r--internal/config/config.go30
-rw-r--r--internal/config/config_test.go25
-rw-r--r--internal/stylegrid/stylegrid.go254
-rw-r--r--internal/stylegrid/stylegrid_test.go34
14 files changed, 537 insertions, 1 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 04c97ae..9dbf309 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -12,6 +12,7 @@
- Keep `cmd/comicforge/main.go` small: flag parsing and command wiring only.
- Put application logic under `internal/`.
- Use `mage` for project tasks.
+- `cmd/stylegrid` + `internal/stylegrid`: montage of all configured `styles.*` lines and `story.genres` (`mage stylegrid` / `go run ./cmd/stylegrid`).
## Page shape and PDF
diff --git a/Magefile.go b/Magefile.go
index edb7ba3..34bad57 100644
--- a/Magefile.go
+++ b/Magefile.go
@@ -48,6 +48,11 @@ func Clean() error {
return sh.RunV("rm", "-f", binaryName)
}
+// Stylegrid renders stylegrid.png (all style lines + story genres from config) via Gemini; requires ImageMagick montage.
+func Stylegrid() error {
+ return sh.RunV("go", "run", "./cmd/stylegrid", "--output", "stylegrid.png")
+}
+
func resolveGOPATH() (string, error) {
if gopath := os.Getenv("GOPATH"); gopath != "" {
return gopath, nil
diff --git a/README.md b/README.md
index 819da09..daa6b05 100644
--- a/README.md
+++ b/README.md
@@ -232,3 +232,15 @@ comicforge --prompt "a robot reading a newspaper" --output out --slug manual-rob
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`, `--page-format`, `--aspect-ratio`, and the ultra-realistic flags to shape the generated image prompt (page-shape flags affect the image API and the manual prompt template; PDF flags have no effect because no PDF is built).
+
+### Style and theme reference sheet (`stylegrid`)
+
+To compare **every** line in `styles.*` plus **every** `story.genres` entry on one montage (shared test scene, header above each cell), use the helper command. It calls the configured image provider once per cell and requires ImageMagick **`montage`** as well as **`convert`**.
+
+```bash
+go run ./cmd/stylegrid --config config.yaml --output stylegrid.png
+```
+
+Flags: `--scene` (override the shared scene), `--aspect-ratio` (default `16:9`), `--timeout` (default 45m). With default `config` this produces **14** style variants + **3** genre panels = **17** images. Regenerate after editing style or genre lists in YAML.
+
+`mage stylegrid` writes `stylegrid.png` in the repo root via the same command.
diff --git a/cmd/comicforge/cli.go b/cmd/comicforge/cli.go
index 683b8c7..a6c0526 100644
--- a/cmd/comicforge/cli.go
+++ b/cmd/comicforge/cli.go
@@ -185,6 +185,11 @@ func runCommand(ctx context.Context, cmd *cobra.Command, deps commandDeps, flags
MangaStyles: cfg.Styles.Manga,
HorrorStyles: cfg.Styles.Horror,
WatercolorStyles: cfg.Styles.Watercolor,
+ PulpStyles: cfg.Styles.Pulp,
+ MechaStyles: cfg.Styles.Mecha,
+ PixelArtStyles: cfg.Styles.PixelArt,
+ InkWashStyles: cfg.Styles.InkWash,
+ ClayStyles: cfg.Styles.Clay,
Theme: flags.theme,
Language: cfg.Language.Story,
Script: cfg.Language.Script,
@@ -245,6 +250,11 @@ func runPromptCommand(ctx context.Context, cmd *cobra.Command, deps commandDeps,
MangaStyles: cfg.Styles.Manga,
HorrorStyles: cfg.Styles.Horror,
WatercolorStyles: cfg.Styles.Watercolor,
+ PulpStyles: cfg.Styles.Pulp,
+ MechaStyles: cfg.Styles.Mecha,
+ PixelArtStyles: cfg.Styles.PixelArt,
+ InkWashStyles: cfg.Styles.InkWash,
+ ClayStyles: cfg.Styles.Clay,
Theme: flags.theme,
Language: cfg.Language.Story,
Script: cfg.Language.Script,
diff --git a/cmd/stylegrid/main.go b/cmd/stylegrid/main.go
new file mode 100644
index 0000000..535bbe1
--- /dev/null
+++ b/cmd/stylegrid/main.go
@@ -0,0 +1,30 @@
+// Command stylegrid renders one comparison image for every style line and story genre in config.
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "os"
+ "time"
+
+ "codeberg.org/snonux/comicforge/internal/stylegrid"
+)
+
+func main() {
+ configPath := flag.String("config", "", "config file (default: same search paths as comicforge)")
+ outPath := flag.String("output", "stylegrid.png", "output PNG path")
+ scene := flag.String("scene", stylegrid.DefaultScene, "shared scene description for all cells")
+ aspect := flag.String("aspect-ratio", "16:9", "Gemini aspect ratio for each cell")
+ timeout := flag.Duration("timeout", 45*time.Minute, "overall timeout for all generations + montage")
+ flag.Parse()
+
+ ctx, cancel := context.WithTimeout(context.Background(), *timeout)
+ defer cancel()
+
+ if err := stylegrid.Run(ctx, *configPath, *outPath, *scene, *aspect); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ fmt.Printf("Wrote %s\n", *outPath)
+}
diff --git a/config.yaml.example b/config.yaml.example
index 98e6455..79f1246 100644
--- a/config.yaml.example
+++ b/config.yaml.example
@@ -69,6 +69,21 @@ styles:
watercolor:
- pure transparent watercolor storybook comic style, visible cold-press paper grain, watery pigment blooms, uneven wash edges, granulation, soft bleeding colors, pale layered washes, loose pencil underdrawing, no digital airbrush, no oil-paint texture, no glossy comic coloring
- hand-painted watercolor children's adventure style, translucent pastel washes, wet-on-wet skies, dry-brush texture, white paper highlights, delicate ink-and-pencil outlines, puddled pigment edges, airy negative space, gentle low-contrast rendering
+ pulp:
+ - retro pulp adventure cover style, painted poster energy, dramatic rim light, saturated inks, worn paper grain, bold composition, vintage print imperfections
+ - mid-century pulp sci-fi comic style, chromatic airbrushed highlights, atomic-age props, bold diagonals, distressed halftone texture, high adventure mood
+ mecha:
+ - high-detail mecha comic style, hard-surface armor plates, mechanical joints, cockpit glass reflections, technical line art, industrial lighting
+ - anime mecha action page style, towering robots, dynamic perspective, panel-like motion energy, sparks and smoke, crisp cel-shaded rendering
+ pixel_art:
+ - 16-bit pixel-art comic scene, limited palette, clean pixel clusters, deliberate dithering, retro game-era perspective, sharp pixel edges
+ - retro pixel-art storyboard style, chunky sprites, high-contrast palette ramps, tiled-environment texture, no anti-aliasing, classic console look
+ ink_wash:
+ - ink-wash brush painting style, sumi-e inspired strokes, expressive black ink gradients, controlled bleed on paper, minimal but dramatic composition
+ - monochrome ink-and-wash comic style, calligraphic brush lines, soft gray washes, negative-space storytelling, textured rice-paper feel
+ clay:
+ - stop-motion clay diorama style, hand-sculpted clay characters, thumbprint texture, miniature set lighting, tactile handcrafted look
+ - plasticine comic tableau style, sculpted figures, soft studio shadows, practical miniature props, playful clay surface detail
narration:
voices:
diff --git a/internal/comic/artist.go b/internal/comic/artist.go
index 8fa2aa9..b4eccdd 100644
--- a/internal/comic/artist.go
+++ b/internal/comic/artist.go
@@ -27,6 +27,11 @@ type ArtistConfig struct {
MangaStyles []string
HorrorStyles []string
WatercolorStyles []string
+ PulpStyles []string
+ MechaStyles []string
+ PixelArtStyles []string
+ InkWashStyles []string
+ ClayStyles []string
Theme string
AspectRatio string
Language string
@@ -57,6 +62,11 @@ type Artist struct {
mangaStyles []string
horrorStyles []string
watercolorStyles []string
+ pulpStyles []string
+ mechaStyles []string
+ pixelArtStyles []string
+ inkWashStyles []string
+ clayStyles []string
theme string
aspectRatio string
pageFrame string
@@ -112,6 +122,11 @@ func NewArtist(cfg *ArtistConfig) *Artist {
a.mangaStyles = append([]string(nil), cfg.MangaStyles...)
a.horrorStyles = append([]string(nil), cfg.HorrorStyles...)
a.watercolorStyles = append([]string(nil), cfg.WatercolorStyles...)
+ a.pulpStyles = append([]string(nil), cfg.PulpStyles...)
+ a.mechaStyles = append([]string(nil), cfg.MechaStyles...)
+ a.pixelArtStyles = append([]string(nil), cfg.PixelArtStyles...)
+ a.inkWashStyles = append([]string(nil), cfg.InkWashStyles...)
+ a.clayStyles = append([]string(nil), cfg.ClayStyles...)
a.theme = cfg.Theme
a.aspectRatio = orDefault(cfg.AspectRatio, comicPageAspectRatio)
a.pageFrame = DescribePageFrame(a.aspectRatio)
@@ -160,6 +175,11 @@ func (a *Artist) DrawComicPages(ctx context.Context, storyText, bible, titleSlug
a.mangaStyles,
a.horrorStyles,
a.watercolorStyles,
+ a.pulpStyles,
+ a.mechaStyles,
+ a.pixelArtStyles,
+ a.inkWashStyles,
+ a.clayStyles,
a.styleMode,
)
}
diff --git a/internal/comic/comic_test.go b/internal/comic/comic_test.go
index d2067a2..169c287 100644
--- a/internal/comic/comic_test.go
+++ b/internal/comic/comic_test.go
@@ -852,6 +852,11 @@ func TestDrawComicPagesUsesConfiguredStylePools(t *testing.T) {
{name: "manga", styleMode: styleModeManga, wantStyle: "manga-ink"},
{name: "horror", styleMode: styleModeHorror, wantStyle: "horror-ink"},
{name: "watercolor", styleMode: styleModeWatercolor, wantStyle: "watercolor-ink"},
+ {name: "pulp", styleMode: styleModePulp, wantStyle: "pulp-ink"},
+ {name: "mecha", styleMode: styleModeMecha, wantStyle: "mecha-ink"},
+ {name: "pixel-art", styleMode: styleModePixelArt, wantStyle: "pixel-ink"},
+ {name: "ink-wash", styleMode: styleModeInkWash, wantStyle: "inkwash-ink"},
+ {name: "clay", styleMode: styleModeClay, wantStyle: "clay-ink"},
}
for _, tt := range tests {
@@ -869,6 +874,11 @@ func TestDrawComicPagesUsesConfiguredStylePools(t *testing.T) {
MangaStyles: []string{"manga-ink"},
HorrorStyles: []string{"horror-ink"},
WatercolorStyles: []string{"watercolor-ink"},
+ PulpStyles: []string{"pulp-ink"},
+ MechaStyles: []string{"mecha-ink"},
+ PixelArtStyles: []string{"pixel-ink"},
+ InkWashStyles: []string{"inkwash-ink"},
+ ClayStyles: []string{"clay-ink"},
Language: "English",
Script: "Latin",
PanelsPerPage: 2,
diff --git a/internal/comic/runner.go b/internal/comic/runner.go
index 82d8d81..50195a3 100644
--- a/internal/comic/runner.go
+++ b/internal/comic/runner.go
@@ -46,6 +46,11 @@ type RunnerConfig struct {
MangaStyles []string
HorrorStyles []string
WatercolorStyles []string
+ PulpStyles []string
+ MechaStyles []string
+ PixelArtStyles []string
+ InkWashStyles []string
+ ClayStyles []string
AspectRatio string
PromptMaxChars int
PageMaxRetries int
@@ -103,6 +108,11 @@ func NewRunner(cfg *RunnerConfig) *Runner {
MangaStyles: cfg.MangaStyles,
HorrorStyles: cfg.HorrorStyles,
WatercolorStyles: cfg.WatercolorStyles,
+ PulpStyles: cfg.PulpStyles,
+ MechaStyles: cfg.MechaStyles,
+ PixelArtStyles: cfg.PixelArtStyles,
+ InkWashStyles: cfg.InkWashStyles,
+ ClayStyles: cfg.ClayStyles,
Theme: cfg.Theme,
AspectRatio: cfg.AspectRatio,
Language: cfg.Language,
diff --git a/internal/comic/types.go b/internal/comic/types.go
index 0ef3999..03f894a 100644
--- a/internal/comic/types.go
+++ b/internal/comic/types.go
@@ -111,6 +111,26 @@ var (
"hand-painted watercolor children's adventure style, translucent pastel washes, wet-on-wet skies, dry-brush texture, white paper highlights, delicate ink-and-pencil outlines, puddled pigment edges, airy negative space, gentle low-contrast rendering",
"storybook watercolor fantasy comic style, washed paper texture, transparent layered color, soft backruns and blossoms, feathered brush edges, muted handmade palette, tender expressions, dreamy illustrated settings, visibly painted on paper",
}
+ defaultPulpStyles = []string{
+ "retro pulp adventure cover style, painted poster energy, dramatic rim light, saturated inks, worn paper grain, bold composition, vintage print imperfections",
+ "mid-century pulp sci-fi comic style, chromatic airbrushed highlights, atomic-age props, bold diagonals, distressed halftone texture, high adventure mood",
+ }
+ defaultMechaStyles = []string{
+ "high-detail mecha comic style, hard-surface armor plates, mechanical joints, cockpit glass reflections, technical line art, industrial lighting",
+ "anime mecha action page style, towering robots, dynamic perspective, panel-like motion energy, sparks and smoke, crisp cel-shaded rendering",
+ }
+ defaultPixelArtStyles = []string{
+ "16-bit pixel-art comic scene, limited palette, clean pixel clusters, deliberate dithering, retro game-era perspective, sharp pixel edges",
+ "retro pixel-art storyboard style, chunky sprites, high-contrast palette ramps, tiled-environment texture, no anti-aliasing, classic console look",
+ }
+ defaultInkWashStyles = []string{
+ "ink-wash brush painting style, sumi-e inspired strokes, expressive black ink gradients, controlled bleed on paper, minimal but dramatic composition",
+ "monochrome ink-and-wash comic style, calligraphic brush lines, soft gray washes, negative-space storytelling, textured rice-paper feel",
+ }
+ defaultClayStyles = []string{
+ "stop-motion clay diorama style, hand-sculpted clay characters, thumbprint texture, miniature set lighting, tactile handcrafted look",
+ "plasticine comic tableau style, sculpted figures, soft studio shadows, practical miniature props, playful clay surface detail",
+ }
realisticStyles = defaultRealisticStyles
comicStyles = defaultComicStyles
@@ -119,6 +139,11 @@ var (
mangaStyles = defaultMangaStyles
horrorStyles = defaultHorrorStyles
watercolorStyles = defaultWatercolorStyles
+ pulpStyles = defaultPulpStyles
+ mechaStyles = defaultMechaStyles
+ pixelArtStyles = defaultPixelArtStyles
+ inkWashStyles = defaultInkWashStyles
+ clayStyles = defaultClayStyles
galleryPoses = []string{
"close-up portrait of face and shoulders, dramatic three-quarter lighting, clear gaze toward the viewer, sharp eyes and expressive face",
@@ -137,6 +162,11 @@ const (
styleModeManga = "manga"
styleModeHorror = "horror"
styleModeWatercolor = "watercolor"
+ styleModePulp = "pulp"
+ styleModeMecha = "mecha"
+ styleModePixelArt = "pixel_art"
+ styleModeInkWash = "ink_wash"
+ styleModeClay = "clay"
)
// PromptRenderer renders external or embedded prompt templates.
@@ -200,6 +230,11 @@ func pickStyleMode(forcedUltra *bool) string {
styleModeManga,
styleModeHorror,
styleModeWatercolor,
+ styleModePulp,
+ styleModeMecha,
+ styleModePixelArt,
+ styleModeInkWash,
+ styleModeClay,
}
return modes[rand.IntN(len(modes))]
}
@@ -211,7 +246,12 @@ func pickStyle(
action90sStyles,
mangaStyles,
horrorStyles,
- watercolorStyles []string,
+ watercolorStyles,
+ pulpStyles,
+ mechaStyles,
+ pixelArtStyles,
+ inkWashStyles,
+ clayStyles []string,
mode string,
) string {
switch mode {
@@ -263,6 +303,46 @@ func pickStyle(
return ""
}
return watercolorStyles[rand.IntN(len(watercolorStyles))]
+ case styleModePulp:
+ if len(pulpStyles) == 0 {
+ pulpStyles = defaultPulpStyles
+ }
+ if len(pulpStyles) == 0 {
+ return ""
+ }
+ return pulpStyles[rand.IntN(len(pulpStyles))]
+ case styleModeMecha:
+ if len(mechaStyles) == 0 {
+ mechaStyles = defaultMechaStyles
+ }
+ if len(mechaStyles) == 0 {
+ return ""
+ }
+ return mechaStyles[rand.IntN(len(mechaStyles))]
+ case styleModePixelArt:
+ if len(pixelArtStyles) == 0 {
+ pixelArtStyles = defaultPixelArtStyles
+ }
+ if len(pixelArtStyles) == 0 {
+ return ""
+ }
+ return pixelArtStyles[rand.IntN(len(pixelArtStyles))]
+ case styleModeInkWash:
+ if len(inkWashStyles) == 0 {
+ inkWashStyles = defaultInkWashStyles
+ }
+ if len(inkWashStyles) == 0 {
+ return ""
+ }
+ return inkWashStyles[rand.IntN(len(inkWashStyles))]
+ case styleModeClay:
+ if len(clayStyles) == 0 {
+ clayStyles = defaultClayStyles
+ }
+ if len(clayStyles) == 0 {
+ return ""
+ }
+ return clayStyles[rand.IntN(len(clayStyles))]
}
if len(comicStyles) == 0 {
comicStyles = defaultComicStyles
diff --git a/internal/config/config.go b/internal/config/config.go
index a79c1ce..4107d61 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -103,6 +103,11 @@ type StyleConfig struct {
Manga []string `mapstructure:"manga" yaml:"manga"`
Horror []string `mapstructure:"horror" yaml:"horror"`
Watercolor []string `mapstructure:"watercolor" yaml:"watercolor"`
+ Pulp []string `mapstructure:"pulp" yaml:"pulp"`
+ Mecha []string `mapstructure:"mecha" yaml:"mecha"`
+ PixelArt []string `mapstructure:"pixel_art" yaml:"pixel_art"`
+ InkWash []string `mapstructure:"ink_wash" yaml:"ink_wash"`
+ Clay []string `mapstructure:"clay" yaml:"clay"`
}
// NarrationConfig stores narration prompt knobs.
@@ -188,6 +193,26 @@ func DefaultConfig() *Config {
"pure transparent watercolor storybook comic style, visible cold-press paper grain, watery pigment blooms, uneven wash edges, granulation, soft bleeding colors, pale layered washes, loose pencil underdrawing, no digital airbrush, no oil-paint texture, no glossy comic coloring",
"hand-painted watercolor children's adventure style, translucent pastel washes, wet-on-wet skies, dry-brush texture, white paper highlights, delicate ink-and-pencil outlines, puddled pigment edges, airy negative space, gentle low-contrast rendering",
},
+ Pulp: []string{
+ "retro pulp adventure cover style, painted poster energy, dramatic rim light, saturated inks, worn paper grain, bold composition, vintage print imperfections",
+ "mid-century pulp sci-fi comic style, chromatic airbrushed highlights, atomic-age props, bold diagonals, distressed halftone texture, high adventure mood",
+ },
+ Mecha: []string{
+ "high-detail mecha comic style, hard-surface armor plates, mechanical joints, cockpit glass reflections, technical line art, industrial lighting",
+ "anime mecha action page style, towering robots, dynamic perspective, panel-like motion energy, sparks and smoke, crisp cel-shaded rendering",
+ },
+ PixelArt: []string{
+ "16-bit pixel-art comic scene, limited palette, clean pixel clusters, deliberate dithering, retro game-era perspective, sharp pixel edges",
+ "retro pixel-art storyboard style, chunky sprites, high-contrast palette ramps, tiled-environment texture, no anti-aliasing, classic console look",
+ },
+ InkWash: []string{
+ "ink-wash brush painting style, sumi-e inspired strokes, expressive black ink gradients, controlled bleed on paper, minimal but dramatic composition",
+ "monochrome ink-and-wash comic style, calligraphic brush lines, soft gray washes, negative-space storytelling, textured rice-paper feel",
+ },
+ Clay: []string{
+ "stop-motion clay diorama style, hand-sculpted clay characters, thumbprint texture, miniature set lighting, tactile handcrafted look",
+ "plasticine comic tableau style, sculpted figures, soft studio shadows, practical miniature props, playful clay surface detail",
+ },
},
Narration: NarrationConfig{
Voices: []string{
@@ -454,6 +479,11 @@ func setDefaults(v *viper.Viper, cfg *Config) {
v.SetDefault("styles.manga", cfg.Styles.Manga)
v.SetDefault("styles.horror", cfg.Styles.Horror)
v.SetDefault("styles.watercolor", cfg.Styles.Watercolor)
+ v.SetDefault("styles.pulp", cfg.Styles.Pulp)
+ v.SetDefault("styles.mecha", cfg.Styles.Mecha)
+ v.SetDefault("styles.pixel_art", cfg.Styles.PixelArt)
+ v.SetDefault("styles.ink_wash", cfg.Styles.InkWash)
+ v.SetDefault("styles.clay", cfg.Styles.Clay)
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 98b72db..c7aa2c8 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -58,6 +58,16 @@ styles:
- custom horror
watercolor:
- custom watercolor
+ pulp:
+ - custom pulp
+ mecha:
+ - custom mecha
+ pixel_art:
+ - custom pixel art
+ ink_wash:
+ - custom ink wash
+ clay:
+ - custom clay
narration:
chunk_words: 42
`)), 0o644); err != nil {
@@ -119,6 +129,21 @@ narration:
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 := len(cfg.Styles.Pulp), 1; got != want || cfg.Styles.Pulp[0] != "custom pulp" {
+ t.Fatalf("Styles.Pulp = %#v, want %q", cfg.Styles.Pulp, "custom pulp")
+ }
+ if got, want := len(cfg.Styles.Mecha), 1; got != want || cfg.Styles.Mecha[0] != "custom mecha" {
+ t.Fatalf("Styles.Mecha = %#v, want %q", cfg.Styles.Mecha, "custom mecha")
+ }
+ if got, want := len(cfg.Styles.PixelArt), 1; got != want || cfg.Styles.PixelArt[0] != "custom pixel art" {
+ t.Fatalf("Styles.PixelArt = %#v, want %q", cfg.Styles.PixelArt, "custom pixel art")
+ }
+ if got, want := len(cfg.Styles.InkWash), 1; got != want || cfg.Styles.InkWash[0] != "custom ink wash" {
+ t.Fatalf("Styles.InkWash = %#v, want %q", cfg.Styles.InkWash, "custom ink wash")
+ }
+ if got, want := len(cfg.Styles.Clay), 1; got != want || cfg.Styles.Clay[0] != "custom clay" {
+ t.Fatalf("Styles.Clay = %#v, want %q", cfg.Styles.Clay, "custom clay")
+ }
if got, want := cfg.Narration.ChunkWords, 42; got != want {
t.Fatalf("Narration.ChunkWords = %d, want %d", got, want)
}
diff --git a/internal/stylegrid/stylegrid.go b/internal/stylegrid/stylegrid.go
new file mode 100644
index 0000000..5aeb5f0
--- /dev/null
+++ b/internal/stylegrid/stylegrid.go
@@ -0,0 +1,254 @@
+// Package stylegrid builds a single montage image comparing all configured art styles and story themes.
+package stylegrid
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "codeberg.org/snonux/comicforge/internal/config"
+ "codeberg.org/snonux/comicforge/internal/image"
+ "codeberg.org/snonux/comicforge/internal/provider"
+)
+
+// DefaultScene is the shared composition so style differences stay comparable.
+const DefaultScene = "A lone traveler with a backpack pauses on a rocky hill at golden sunset, wind moving their coat; a distant ruined stone tower on the horizon; wide scenic shot, clear silhouette, atmospheric sky."
+
+// Job is one cell in the reference grid.
+type Job struct {
+ Kind string // "style" or "theme"
+ Header string // shown in the header bar above the image
+ Prompt string // full image generation prompt
+}
+
+// BuildJobs expands config style pools and story genres into generation jobs.
+func BuildJobs(cfg *config.Config, scene string) []Job {
+ if cfg == nil {
+ return nil
+ }
+ scene = strings.TrimSpace(scene)
+ if scene == "" {
+ scene = DefaultScene
+ }
+ base := "Single full-frame illustration only. No text, lettering, captions, logos, or watermarks inside the artwork. No borders inside the image. 16:9 composition.\n\nScene (keep this layout consistent for comparison):\n" + scene + "\n\n"
+
+ families := []struct {
+ title string
+ lines []string
+ }{
+ {"Comic", cfg.Styles.Comic},
+ {"Realistic", cfg.Styles.Realistic},
+ {"Cartoon", cfg.Styles.Cartoon},
+ {"Action 90s", cfg.Styles.Action90s},
+ {"Manga", cfg.Styles.Manga},
+ {"Horror", cfg.Styles.Horror},
+ {"Watercolor", cfg.Styles.Watercolor},
+ {"Pulp", cfg.Styles.Pulp},
+ {"Mecha", cfg.Styles.Mecha},
+ {"Pixel Art", cfg.Styles.PixelArt},
+ {"Ink Wash", cfg.Styles.InkWash},
+ {"Clay", cfg.Styles.Clay},
+ }
+
+ var jobs []Job
+ for _, fam := range families {
+ for i, line := range fam.lines {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ header := fmt.Sprintf("Style · %s (%d/%d)", fam.title, i+1, len(fam.lines))
+ prompt := base + "Rendering — apply this visual style precisely:\n" + line
+ jobs = append(jobs, Job{Kind: "style", Header: header, Prompt: prompt})
+ }
+ }
+
+ for i, g := range cfg.Story.Genres {
+ g = strings.TrimSpace(g)
+ if g == "" {
+ continue
+ }
+ header := fmt.Sprintf("Theme / genre (%d/%d): %s", i+1, len(cfg.Story.Genres), truncateRunes(g, 72))
+ prompt := base + "Rendering — interpret the same scene with mood, palette, and staging strongly guided by this story genre (illustrated comic or graphic-novel finish, readable shapes):\n" + g
+ jobs = append(jobs, Job{Kind: "theme", Header: header, Prompt: prompt})
+ }
+ return jobs
+}
+
+func truncateRunes(s string, max int) string {
+ r := []rune(s)
+ if len(r) <= max {
+ return s
+ }
+ return string(r[:max-1]) + "…"
+}
+
+// MontageOptions tunes the output sheet.
+type MontageOptions struct {
+ CellW int
+ CellH int
+ HeaderH int
+ TileX int
+ Gutter int
+ BG string
+}
+
+func defaultMontageOptions(nCells int) MontageOptions {
+ // ~5 columns for the default grid (~27 cells).
+ tileX := 5
+ if nCells <= 4 {
+ tileX = 2
+ }
+ return MontageOptions{
+ CellW: 640,
+ CellH: 360,
+ HeaderH: 56,
+ TileX: tileX,
+ Gutter: 6,
+ BG: "#1a1a1a",
+ }
+}
+
+// GenerateMontage renders each job with img, labels tiles, and runs ImageMagick montage.
+func GenerateMontage(ctx context.Context, img provider.ImageProvider, aspectRatio string, jobs []Job, outPath string, opts MontageOptions) error {
+ if len(jobs) == 0 {
+ return fmt.Errorf("no style/theme jobs (empty config lists?)")
+ }
+ if _, err := exec.LookPath("convert"); err != nil {
+ return fmt.Errorf("ImageMagick convert not found: %w", err)
+ }
+ if _, err := exec.LookPath("montage"); err != nil {
+ return fmt.Errorf("ImageMagick montage not found: %w", err)
+ }
+ arImg, ok := img.(provider.AspectRatioImageProvider)
+ if !ok {
+ return fmt.Errorf("image provider %q does not support aspect ratio", img.Name())
+ }
+ if err := img.IsAvailable(); err != nil {
+ return err
+ }
+
+ tmpDir, err := os.MkdirTemp("", "comicforge-stylegrid-*")
+ if err != nil {
+ return err
+ }
+ defer func() { _ = os.RemoveAll(tmpDir) }()
+
+ for i := range jobs {
+ raw := filepath.Join(tmpDir, fmt.Sprintf("raw_%03d.png", i))
+ if err := arImg.GenerateImageWithAspectRatio(ctx, jobs[i].Prompt, raw, aspectRatio); err != nil {
+ return fmt.Errorf("job %d (%s): %w", i, jobs[i].Header, err)
+ }
+ norm := filepath.Join(tmpDir, fmt.Sprintf("norm_%03d.png", i))
+ if err := normalizeCell(raw, norm, opts.CellW, opts.CellH); err != nil {
+ return fmt.Errorf("normalize %d: %w", i, err)
+ }
+ lblPath := filepath.Join(tmpDir, fmt.Sprintf("caption_%03d.txt", i))
+ if err := os.WriteFile(lblPath, []byte(jobs[i].Header), 0o644); err != nil {
+ return err
+ }
+ labeled := filepath.Join(tmpDir, fmt.Sprintf("labeled_%03d.png", i))
+ if err := addCaptionHeader(norm, labeled, lblPath, opts.CellW, opts.HeaderH); err != nil {
+ return fmt.Errorf("header %d: %w", i, err)
+ }
+ }
+
+ labeledPaths, err := filepath.Glob(filepath.Join(tmpDir, "labeled_*.png"))
+ if err != nil {
+ return err
+ }
+ sort.Strings(labeledPaths)
+
+ args := []string{}
+ for _, p := range labeledPaths {
+ args = append(args, p)
+ }
+ args = append(args,
+ "-tile", fmt.Sprintf("%dx", opts.TileX),
+ "-geometry", fmt.Sprintf("+%d+%d", opts.Gutter, opts.Gutter),
+ "-background", opts.BG,
+ outPath,
+ )
+ cmd := exec.CommandContext(ctx, "montage", args...)
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("montage: %w\n%s", err, strings.TrimSpace(string(out)))
+ }
+ return nil
+}
+
+func normalizeCell(src, dst string, w, h int) error {
+ // Cover crop to exact WxH.
+ args := []string{
+ src,
+ "-resize", fmt.Sprintf("%dx%d^", w, h),
+ "-gravity", "center",
+ "-extent", fmt.Sprintf("%dx%d", w, h),
+ dst,
+ }
+ cmd := exec.Command("convert", args...)
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("convert resize: %w\n%s", err, strings.TrimSpace(string(out)))
+ }
+ return nil
+}
+
+func addCaptionHeader(imagePath, outPath, captionFile string, cellW, headerH int) error {
+ // caption:@file wraps text; header bar above the art.
+ args := []string{
+ "(",
+ "-background", "#252525",
+ "-fill", "#f2f2f2",
+ "-size", fmt.Sprintf("%dx%d", cellW-20, headerH-8),
+ "caption:@" + captionFile,
+ ")",
+ imagePath,
+ "-append",
+ outPath,
+ }
+ cmd := exec.Command("convert", args...)
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("convert caption: %w\n%s", err, strings.TrimSpace(string(out)))
+ }
+ return nil
+}
+
+// Run loads config from path, builds jobs, and writes outPath.
+func Run(ctx context.Context, configPath, outPath, scene, aspectRatio string) error {
+ cfg, err := config.Load(configPath)
+ if err != nil {
+ return err
+ }
+ jobs := BuildJobs(cfg, scene)
+ opts := defaultMontageOptions(len(jobs))
+
+ img, err := buildImageProvider(cfg)
+ if err != nil {
+ return err
+ }
+ if strings.TrimSpace(aspectRatio) == "" {
+ aspectRatio = cfg.Comic.AspectRatio
+ }
+ if strings.TrimSpace(aspectRatio) == "" {
+ aspectRatio = "16:9"
+ }
+ return GenerateMontage(ctx, img, aspectRatio, jobs, outPath, opts)
+}
+
+func buildImageProvider(cfg *config.Config) (provider.ImageProvider, error) {
+ p, err := image.DefaultRegistry().NewFromConfig(cfg)
+ if err != nil {
+ return nil, err
+ }
+ ip, ok := p.(provider.ImageProvider)
+ if !ok {
+ return nil, fmt.Errorf("image provider %q does not satisfy ImageProvider", cfg.Provider.Image)
+ }
+ return ip, nil
+}
diff --git a/internal/stylegrid/stylegrid_test.go b/internal/stylegrid/stylegrid_test.go
new file mode 100644
index 0000000..1987f85
--- /dev/null
+++ b/internal/stylegrid/stylegrid_test.go
@@ -0,0 +1,34 @@
+package stylegrid
+
+import (
+ "testing"
+
+ "codeberg.org/snonux/comicforge/internal/config"
+)
+
+func TestBuildJobsDefaultConfig(t *testing.T) {
+ cfg := config.DefaultConfig()
+ jobs := BuildJobs(cfg, "test scene")
+ // 12 families × 2 lines + 3 genres = 27
+ if want := 27; len(jobs) != want {
+ t.Fatalf("len(jobs) = %d, want %d", len(jobs), want)
+ }
+ if jobs[0].Kind != "style" || jobs[0].Header == "" || jobs[0].Prompt == "" {
+ t.Fatalf("first job = %#v", jobs[0])
+ }
+ last := jobs[len(jobs)-1]
+ if last.Kind != "theme" {
+ t.Fatalf("last job kind = %q, want theme", last.Kind)
+ }
+}
+
+func TestBuildJobsSkipsEmptyStrings(t *testing.T) {
+ cfg := config.DefaultConfig()
+ cfg.Styles.Comic = []string{"", " ", "only-valid"}
+ cfg.Story.Genres = nil
+ jobs := BuildJobs(cfg, "x")
+ // 1 comic line + 22 from other families + 0 themes
+ if want := 23; len(jobs) != want {
+ t.Fatalf("len(jobs) = %d, want %d", len(jobs), want)
+ }
+}