diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-03 17:14:21 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-03 17:14:21 +0300 |
| commit | 788c0e8bdacd5525eea46d55a74d7470005307ad (patch) | |
| tree | f5d47854a324decd31003d7bf4067abce8828ba2 | |
| parent | 59f615cdb0a0a30b702ab810013351bd97fdc408 (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.go | 226 | ||||
| -rw-r--r-- | internal/story/runner.go | 19 |
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 |
