summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-03 17:14:21 +0300
committerPaul Buetow <paul@buetow.org>2026-04-03 17:14:21 +0300
commit788c0e8bdacd5525eea46d55a74d7470005307ad (patch)
treef5d47854a324decd31003d7bf4067abce8828ba2
parent59f615cdb0a0a30b702ab810013351bd97fdc408 (diff)
feat: character bible for consistent comic panel characters
- Generate a Gemini character bible before panel image generation - Bible describes each character (name, age, hair, eyes, clothing) and setting; it is prepended to every panel prompt to lock visual consistency - Revert to 3 separate comic_page_N.png files (4:3 aspect ratio) - bibleMaxTokens=2048 to avoid Gemini 2.5 Flash thinking-token starvation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--internal/story/artist.go226
-rw-r--r--internal/story/runner.go19
2 files changed, 195 insertions, 50 deletions
diff --git a/internal/story/artist.go b/internal/story/artist.go
index c406b65..77b1f19 100644
--- a/internal/story/artist.go
+++ b/internal/story/artist.go
@@ -5,18 +5,29 @@ import (
"fmt"
"math/rand/v2"
"strings"
+ "time"
+
+ "google.golang.org/genai"
"codeberg.org/snonux/totalrecall/internal/image"
)
const (
- // comicStripAspectRatio produces a tall portrait image — roughly three 4:3
- // panels stacked — giving the model room to render beginning, middle, and end
- // of the story in a single consistent scene without character drift across files.
- comicStripAspectRatio = "9:16"
+ comicPageCount = 3
+
+ // comicPromptMaxChars caps each panel's story excerpt in the NanoBanana prompt.
+ comicPromptMaxChars = 800
+
+ // bibleModel is the Gemini text model used to generate the character bible.
+ bibleModel = "gemini-2.5-flash"
+
+ // bibleTimeout gives Gemini up to 60 s to produce the character bible.
+ bibleTimeout = 60 * time.Second
- // comicPromptMaxChars caps the story excerpt embedded in the image prompt.
- comicPromptMaxChars = 1200
+ // bibleMaxTokens must be large enough to cover Gemini 2.5 Flash's internal
+ // thinking tokens plus the ~180-word visible bible output. A small budget
+ // (e.g. 512) is silently consumed by thinking before any text is emitted.
+ bibleMaxTokens = int32(2048)
)
// comicStyles is the pool from which the strip style is drawn each run.
@@ -35,85 +46,162 @@ var comicStyles = []string{
"cyberpunk neon art with glowing outlines, dark backgrounds, and electric accent colors",
}
+// characterBiblePrompt instructs Gemini to produce a concise visual reference
+// that will be prepended verbatim to every panel prompt.
+const characterBiblePrompt = `You are a comic-book art director. Read the Bulgarian story below and write a
+CHARACTER CONSISTENCY GUIDE in English for an illustrator. Cover every character that appears:
+name, age estimate, hair (colour + style), eye colour, skin tone, build, and exact clothing worn
+throughout the story. Then describe the setting (location, time of day, weather, key props) and
+the overall lighting / colour mood. Be very specific — this guide will be copy-pasted into every
+panel prompt to lock visual consistency. Maximum 180 words. No headers, just dense prose.
+
+Story:
+`
+
// ArtistConfig holds settings for comic-book image generation via NanoBanana.
type ArtistConfig struct {
- APIKey string // Google API key
+ APIKey string // Google API key (NanoBanana image + Gemini bible generation)
Model string // NanoBanana image model
TextModel string // NanoBanana text/prompt model
OutputDir string // target directory; defaults to "."
- // Style overrides the random art-style pick when non-empty.
- Style string
+ Style string // overrides the random art-style pick when non-empty
}
-// Artist generates a single tall comic-strip image that illustrates the story.
+// Artist generates comic-book pages that illustrate the story.
type Artist struct {
nbClient image.ImageClient
+ apiKey string // used for the character-bible Gemini call
outputDir string
- style string // empty = pick randomly each run
+ style string
}
// NewArtist creates an Artist backed by the NanoBanana image generator.
func NewArtist(config *ArtistConfig) *Artist {
dir := "."
- if config != nil && config.OutputDir != "" {
- dir = config.OutputDir
- }
-
+ var apiKey, style string
var nbConfig *image.NanoBananaConfig
- var style string
+
if config != nil {
+ dir = orDefault(config.OutputDir, ".")
+ apiKey = config.APIKey
+ style = config.Style
nbConfig = &image.NanoBananaConfig{
APIKey: config.APIKey,
Model: config.Model,
TextModel: config.TextModel,
}
- style = config.Style
}
return &Artist{
nbClient: image.NewNanoBananaClient(nbConfig),
+ apiKey: apiKey,
outputDir: dir,
style: style,
}
}
-// DrawComicStrip generates a single tall 9:16 comic-strip image covering the
-// whole story in one scene with consistent characters. The image is saved as
-// comic_strip.png; attribution is auto-written as comic_strip_attribution.txt
-// by the Downloader. Returns the saved image path.
-func (a *Artist) DrawComicStrip(storyText string) (string, error) {
+// DrawComicPages generates one image per story section (comicPageCount total).
+// A character bible is produced first and embedded in every panel prompt so
+// characters, clothes, and setting stay visually consistent across all pages.
+// Files are saved as comic_page_1.png … comic_page_N.png; attribution files
+// are auto-written by the Downloader. Returns the list of saved image paths.
+func (a *Artist) DrawComicPages(storyText string) ([]string, error) {
style := a.style
if style == "" {
style = pickStyle()
}
-
fmt.Printf(" Comic style: %s\n", style)
+ bible, err := a.buildCharacterBible(storyText)
+ if err != nil {
+ // Non-fatal: warn and continue without the bible rather than aborting.
+ fmt.Printf(" Warning: character bible generation failed (%v); panels may vary\n", err)
+ bible = ""
+ } else {
+ fmt.Printf(" Character bible ready (%d chars)\n", len(bible))
+ }
+
+ sections := splitIntoSections(storyText, comicPageCount)
+ var paths []string
+
+ for i, section := range sections {
+ pageNum := i + 1
+ fmt.Printf(" Generating comic page %d/%d...\n", pageNum, comicPageCount)
+
+ path, err := a.drawPage(section, pageNum, comicPageCount, style, bible)
+ if err != nil {
+ return paths, fmt.Errorf("comic page %d failed: %w", pageNum, err)
+ }
+ paths = append(paths, path)
+ }
+
+ return paths, nil
+}
+
+// buildCharacterBible calls Gemini to produce a concise visual reference card
+// describing every character and the setting. This is prepended to each panel
+// prompt to lock character appearance across all generated images.
+func (a *Artist) buildCharacterBible(storyText string) (string, error) {
+ if a.apiKey == "" {
+ return "", fmt.Errorf("no API key for character bible generation")
+ }
+
+ client, err := genai.NewClient(context.Background(), &genai.ClientConfig{
+ APIKey: a.apiKey,
+ })
+ if err != nil {
+ return "", fmt.Errorf("create genai client: %w", err)
+ }
+
+ thinkingBudget := int32(0) // disable thinking — short factual extraction, no reasoning needed
+ ctx, cancel := context.WithTimeout(context.Background(), bibleTimeout)
+ defer cancel()
+
+ resp, err := client.Models.GenerateContent(ctx, bibleModel,
+ []*genai.Content{genai.NewContentFromText(characterBiblePrompt+storyText, genai.RoleUser)},
+ &genai.GenerateContentConfig{
+ MaxOutputTokens: bibleMaxTokens,
+ ThinkingConfig: &genai.ThinkingConfig{ThinkingBudget: &thinkingBudget},
+ },
+ )
+ if err != nil {
+ return "", fmt.Errorf("gemini bible call: %w", err)
+ }
+
+ bible := strings.TrimSpace(resp.Text())
+ if bible == "" {
+ return "", fmt.Errorf("gemini returned empty character bible")
+ }
+
+ return bible, nil
+}
+
+// drawPage generates a single comic page with the given style and bible.
+func (a *Artist) drawPage(section string, pageNum, totalPages int, style, bible string) (string, error) {
opts := image.DefaultSearchOptions("vocabulary story")
- opts.CustomPrompt = buildComicStripPrompt(storyText, style)
- opts.AspectRatio = comicStripAspectRatio
+ opts.CustomPrompt = buildPagePrompt(section, pageNum, totalPages, style, bible)
downloader := image.NewDownloader(a.nbClient, &image.DownloadOptions{
OutputDir: a.outputDir,
OverwriteExisting: true,
CreateDir: true,
- FileNamePattern: "comic_strip", // → comic_strip.png + comic_strip_attribution.txt
+ FileNamePattern: fmt.Sprintf("comic_page_%d", pageNum),
MaxSizeBytes: 20 * 1024 * 1024,
})
- ctx := context.Background()
- _, savedPath, err := downloader.DownloadBestMatchWithOptions(ctx, opts)
+ _, savedPath, err := downloader.DownloadBestMatchWithOptions(context.Background(), opts)
if err != nil {
- return "", fmt.Errorf("comic strip generation failed: %w", err)
+ return "", err
}
return savedPath, nil
}
-// buildComicStripPrompt constructs a single prompt for a 3-panel tall comic
-// strip covering the whole story. The excerpt is capped at comicPromptMaxChars.
-func buildComicStripPrompt(storyText, style string) string {
- excerpt := strings.TrimSpace(storyText)
+// buildPagePrompt constructs the NanoBanana prompt for one panel.
+// The character bible is injected between the style directive and the scene
+// excerpt so the model has the visual reference before reading the scene.
+func buildPagePrompt(section string, pageNum, totalPages int, style, bible string) string {
+ excerpt := strings.TrimSpace(section)
if len(excerpt) > comicPromptMaxChars {
excerpt = excerpt[:comicPromptMaxChars]
if idx := strings.LastIndex(excerpt, " "); idx > 0 {
@@ -122,21 +210,77 @@ func buildComicStripPrompt(storyText, style string) string {
excerpt += "…"
}
+ bibleBlock := ""
+ if bible != "" {
+ bibleBlock = fmt.Sprintf("\nCHARACTER & SETTING REFERENCE (follow exactly — this is panel %d of %d):\n%s\n", pageNum, totalPages, bible)
+ }
+
return fmt.Sprintf(
- "Art style: %s.\n"+
- "A single tall comic strip with 3 vertically stacked panels showing the beginning, "+
- "middle, and end of the story. Keep all characters visually consistent across panels. "+
- "Scene based on this Bulgarian vocabulary story:\n\n%s",
- style, excerpt,
+ "Art style: %s.%s\nPanel %d of %d. Scene from a Bulgarian vocabulary story:\n\n%s",
+ style, bibleBlock, pageNum, totalPages, excerpt,
)
}
// pickStyle returns a randomly chosen art style.
-// Ultra realistic is selected 90% of the time; one of the other styles fills
-// the remaining 10% to provide occasional visual variety.
+// Ultra realistic is selected 90% of the time.
func pickStyle() string {
if rand.Float64() < 0.9 {
- return comicStyles[0] // ultra realistic
+ return comicStyles[0]
}
return comicStyles[1+rand.IntN(len(comicStyles)-1)]
}
+
+// splitIntoSections divides text into n roughly equal parts on paragraph
+// boundaries where possible, falling back to equal character splits.
+func splitIntoSections(text string, n int) []string {
+ paragraphs := splitParagraphs(text)
+ if len(paragraphs) >= n {
+ return distributeParagraphs(paragraphs, n)
+ }
+ return splitByChars(text, n)
+}
+
+func splitParagraphs(text string) []string {
+ var out []string
+ for _, p := range strings.Split(text, "\n\n") {
+ if p = strings.TrimSpace(p); p != "" {
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
+func distributeParagraphs(paragraphs []string, n int) []string {
+ sections := make([]string, n)
+ size, rem, idx := len(paragraphs)/n, len(paragraphs)%n, 0
+ for i := range n {
+ count := size
+ if i < rem {
+ count++
+ }
+ sections[i] = strings.Join(paragraphs[idx:idx+count], "\n\n")
+ idx += count
+ }
+ return sections
+}
+
+func splitByChars(text string, n int) []string {
+ size := len(text) / n
+ sections := make([]string, n)
+ for i := range n {
+ start := i * size
+ end := start + size
+ if i == n-1 {
+ end = len(text)
+ }
+ sections[i] = strings.TrimSpace(text[start:end])
+ }
+ return sections
+}
+
+func orDefault(s, def string) string {
+ if s != "" {
+ return s
+ }
+ return def
+}
diff --git a/internal/story/runner.go b/internal/story/runner.go
index 9004f8a..f9af35c 100644
--- a/internal/story/runner.go
+++ b/internal/story/runner.go
@@ -116,21 +116,22 @@ func (r *Runner) Run(batchFile string) error {
return err
}
- r.drawComicStrip(storyText)
+ r.drawComicPages(storyText)
return r.handleNarration(storyText, dir)
}
-// drawComicStrip generates a single tall comic strip image; errors are
-// non-fatal so story.txt is always accessible even when image generation fails.
-func (r *Runner) drawComicStrip(storyText string) {
- fmt.Println("Generating comic strip...")
- imagePath, err := r.artist.DrawComicStrip(storyText)
+// drawComicPages generates comicPageCount images with a shared character bible
+// for visual consistency; errors are non-fatal so story.txt is always accessible.
+func (r *Runner) drawComicPages(storyText string) {
+ fmt.Printf("Generating %d comic pages...\n", comicPageCount)
+ paths, err := r.artist.DrawComicPages(storyText)
if err != nil {
- fmt.Fprintf(os.Stderr, "Warning: comic strip generation failed: %v\n", err)
- return
+ fmt.Fprintf(os.Stderr, "Warning: comic page generation failed: %v\n", err)
+ }
+ for _, p := range paths {
+ fmt.Printf("Comic page saved: %s\n", p)
}
- fmt.Printf("Comic strip saved: %s\n", imagePath)
}
// handleNarration generates a cinematic MP3 via Gemini TTS when a narrator is