diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-03 18:57:20 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-03 18:57:20 +0300 |
| commit | ec0240047da823ac47268f9abdff9bd722416265 (patch) | |
| tree | ef33442df88b1a801d29b0415b4f55de29779d47 | |
| parent | 788c0e8bdacd5525eea46d55a74d7470005307ad (diff) | |
feat: 5-page comic with cover/back cover, 9-panel grid, and character bible
- Add comic_cover.png and comic_back.png flanking the 3 story pages
- Switch story pages to 3:4 portrait aspect ratio for standard comic proportions
- Request 9-panel (3×3) grid layout in every story page prompt
- Generate a Gemini character bible before image generation to lock characters,
clothing, and setting across all 5 pages — clothing only changes if the story
explicitly directs it
- Fix nil-writer panic in cover warning path
- Fix undefined comicPageCount reference in runner.go
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | internal/story/artist.go | 198 | ||||
| -rw-r--r-- | internal/story/runner.go | 2 |
2 files changed, 141 insertions, 59 deletions
diff --git a/internal/story/artist.go b/internal/story/artist.go index 77b1f19..5ba925a 100644 --- a/internal/story/artist.go +++ b/internal/story/artist.go @@ -13,26 +13,30 @@ import ( ) const ( - comicPageCount = 3 + // storyPageCount is the number of 9-panel story pages (excluding cover/back). + storyPageCount = 3 - // comicPromptMaxChars caps each panel's story excerpt in the NanoBanana prompt. - comicPromptMaxChars = 800 + // comicPageAspectRatio: portrait (3:4) matches a standard comic book page + // and gives the model room for a 3×3 panel grid. + comicPageAspectRatio = "3:4" + + // comicPromptMaxChars caps each page's story excerpt in the NanoBanana prompt. + comicPromptMaxChars = 900 // 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 + // bibleTimeout gives Gemini up to 90 s to produce the character bible. + bibleTimeout = 90 * time.Second - // 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) + // bibleMaxTokens matches the story generator's proven budget. + // No ThinkingConfig is set — the model manages token allocation itself, + // which is the same approach used by the working story generator. + bibleMaxTokens = int32(8192) ) -// comicStyles is the pool from which the strip style is drawn each run. -// Ultra realistic is selected 90% of the time; the remaining 10% comes from -// the other styles to provide occasional visual variety. +// comicStyles is the pool from which the page style is drawn each run. +// Ultra realistic is selected 90% of the time. var comicStyles = []string{ "ultra realistic comic strip with photographic detail and dramatic lighting", "classic American comic book with bold ink outlines, halftone dots, and primary colors", @@ -46,14 +50,21 @@ 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. +// characterBiblePrompt instructs Gemini to produce a strict visual reference +// prepended verbatim to every panel, cover, and back-cover 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. +CHARACTER CONSISTENCY GUIDE in English for an illustrator. + +For every named character provide: name, age estimate, hair (colour + style), eye colour, +skin tone, build, and the EXACT clothing they wear — specify garment, colour, pattern, and fit. +Clothing must NOT change between panels unless the story explicitly describes a change; +if no change is described, list the same outfit for all appearances. + +Also describe: the setting (location, time of day, weather, key props) and overall +lighting / colour mood. + +Be extremely specific — this guide will be copy-pasted into every panel prompt to lock visual +consistency. Maximum 220 words. No headers, just dense descriptive prose. Story: ` @@ -100,11 +111,14 @@ func NewArtist(config *ArtistConfig) *Artist { } } -// 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. +// DrawComicPages generates 5 images total: +// - comic_cover.png — full-bleed cover +// - comic_page_1.png … comic_page_3.png — 9-panel (3×3) story pages +// - comic_back.png — back cover +// +// A character bible is built first and injected into every prompt so +// characters, clothing, and setting stay consistent across all pages. +// Returns the list of saved image paths in order. func (a *Artist) DrawComicPages(storyText string) ([]string, error) { style := a.style if style == "" { @@ -114,33 +128,50 @@ func (a *Artist) DrawComicPages(storyText string) ([]string, error) { 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) + fmt.Printf(" Warning: character bible 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 + // 1. Cover + fmt.Println(" Generating cover page...") + if p, err := a.generateSinglePage(buildCoverPrompt(storyText, style, bible), "comic_cover"); err != nil { + fmt.Printf(" Warning: cover generation failed: %v\n", err) + } else { + paths = append(paths, p) + } + + // 2. Story pages (9-panel grids) + sections := splitIntoSections(storyText, storyPageCount) 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) + fmt.Printf(" Generating story page %d/%d...\n", pageNum, storyPageCount) + p, err := a.generateSinglePage( + buildStoryPagePrompt(section, pageNum, storyPageCount, style, bible), + fmt.Sprintf("comic_page_%d", pageNum), + ) if err != nil { - return paths, fmt.Errorf("comic page %d failed: %w", pageNum, err) + return paths, fmt.Errorf("story page %d failed: %w", pageNum, err) } - paths = append(paths, path) + paths = append(paths, p) + } + + // 3. Back cover + fmt.Println(" Generating back cover...") + if p, err := a.generateSinglePage(buildBackCoverPrompt(storyText, style, bible), "comic_back"); err != nil { + fmt.Printf(" Warning: back cover generation failed: %v\n", err) + } else { + paths = append(paths, p) } 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. +// buildCharacterBible calls Gemini to produce a strict visual reference card. +// No ThinkingConfig is set — same pattern as the working story generator. func (a *Artist) buildCharacterBible(storyText string) (string, error) { if a.apiKey == "" { return "", fmt.Errorf("no API key for character bible generation") @@ -153,7 +184,6 @@ func (a *Artist) buildCharacterBible(storyText string) (string, error) { 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() @@ -161,7 +191,6 @@ func (a *Artist) buildCharacterBible(storyText string) (string, error) { []*genai.Content{genai.NewContentFromText(characterBiblePrompt+storyText, genai.RoleUser)}, &genai.GenerateContentConfig{ MaxOutputTokens: bibleMaxTokens, - ThinkingConfig: &genai.ThinkingConfig{ThinkingBudget: &thinkingBudget}, }, ) if err != nil { @@ -170,37 +199,60 @@ func (a *Artist) buildCharacterBible(storyText string) (string, error) { bible := strings.TrimSpace(resp.Text()) if bible == "" { - return "", fmt.Errorf("gemini returned empty character bible") + reason := "unknown" + if len(resp.Candidates) > 0 { + reason = string(resp.Candidates[0].FinishReason) + } + return "", fmt.Errorf("empty response (finish reason: %s)", reason) } 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) { +// generateSinglePage downloads and saves one image for the given prompt. +func (a *Artist) generateSinglePage(prompt, fileNamePattern string) (string, error) { opts := image.DefaultSearchOptions("vocabulary story") - opts.CustomPrompt = buildPagePrompt(section, pageNum, totalPages, style, bible) + opts.CustomPrompt = prompt + opts.AspectRatio = comicPageAspectRatio downloader := image.NewDownloader(a.nbClient, &image.DownloadOptions{ OutputDir: a.outputDir, OverwriteExisting: true, CreateDir: true, - FileNamePattern: fmt.Sprintf("comic_page_%d", pageNum), + FileNamePattern: fileNamePattern, MaxSizeBytes: 20 * 1024 * 1024, }) _, savedPath, err := downloader.DownloadBestMatchWithOptions(context.Background(), opts) - if err != nil { - return "", err + return savedPath, err +} + +// buildCoverPrompt constructs the front-cover image prompt. +func buildCoverPrompt(storyText, style, bible string) string { + // Use a short excerpt as a teaser on the cover prompt. + teaser := strings.TrimSpace(storyText) + if len(teaser) > 300 { + teaser = teaser[:300] + if idx := strings.LastIndex(teaser, " "); idx > 0 { + teaser = teaser[:idx] + } + teaser += "…" } - return savedPath, nil + bibleBlock := bibleSection(bible, "cover") + return fmt.Sprintf( + "Art style: %s.%s\n"+ + "COMIC BOOK FRONT COVER — single full-bleed illustration, no panel grid. "+ + "Large bold title text at the top: \"BULGARIAN VOCABULARY ADVENTURE\". "+ + "Show the main character(s) in a dynamic, eye-catching pose with the story setting "+ + "behind them. Dramatic, inviting, professional comic cover composition. "+ + "Story teaser:\n\n%s", + style, bibleBlock, teaser, + ) } -// 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 { +// buildStoryPagePrompt constructs a 9-panel grid page prompt. +func buildStoryPagePrompt(section string, pageNum, totalPages int, style, bible string) string { excerpt := strings.TrimSpace(section) if len(excerpt) > comicPromptMaxChars { excerpt = excerpt[:comicPromptMaxChars] @@ -210,19 +262,51 @@ func buildPagePrompt(section string, pageNum, totalPages int, style, bible strin excerpt += "…" } - bibleBlock := "" - if bible != "" { - bibleBlock = fmt.Sprintf("\nCHARACTER & SETTING REFERENCE (follow exactly — this is panel %d of %d):\n%s\n", pageNum, totalPages, bible) + bibleBlock := bibleSection(bible, fmt.Sprintf("story page %d of %d", pageNum, totalPages)) + return fmt.Sprintf( + "Art style: %s.%s\n"+ + "Comic book story page %d of %d. Layout: a 3×3 grid of 9 panels filling the page, "+ + "each panel showing a distinct moment from the excerpt below. "+ + "All characters MUST look identical across every panel — same face, hair, and clothing "+ + "as described in the reference above. Story excerpt:\n\n%s", + style, bibleBlock, pageNum, totalPages, excerpt, + ) +} + +// buildBackCoverPrompt constructs the back-cover image prompt. +func buildBackCoverPrompt(storyText, style, bible string) string { + // Use the last ~200 chars of the story as the resolution hint. + ending := strings.TrimSpace(storyText) + if len(ending) > 200 { + ending = ending[len(ending)-200:] + if idx := strings.Index(ending, " "); idx > 0 { + ending = ending[idx+1:] + } } + bibleBlock := bibleSection(bible, "back cover") return fmt.Sprintf( - "Art style: %s.%s\nPanel %d of %d. Scene from a Bulgarian vocabulary story:\n\n%s", - style, bibleBlock, pageNum, totalPages, excerpt, + "Art style: %s.%s\n"+ + "COMIC BOOK BACK COVER — single full-bleed illustration, no panel grid. "+ + "A calm, warm, conclusive scene from the story's ending. "+ + "Small text area at the bottom for a short blurb (leave space). "+ + "Story ending hint:\n\n%s", + style, bibleBlock, ending, + ) +} + +// bibleSection formats the character bible as a labelled block for the prompt. +// Returns empty string when bible is empty. +func bibleSection(bible, context string) string { + if bible == "" { + return "" + } + return fmt.Sprintf( + "\nCHARACTER & SETTING REFERENCE (%s — follow exactly, do NOT change clothing):\n%s\n", + context, bible, ) } -// pickStyle returns a randomly chosen art style. -// Ultra realistic is selected 90% of the time. func pickStyle() string { if rand.Float64() < 0.9 { return comicStyles[0] @@ -230,8 +314,6 @@ func pickStyle() string { 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 { diff --git a/internal/story/runner.go b/internal/story/runner.go index f9af35c..8820e71 100644 --- a/internal/story/runner.go +++ b/internal/story/runner.go @@ -124,7 +124,7 @@ func (r *Runner) Run(batchFile string) error { // 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) + fmt.Printf("Generating %d comic pages...\n", storyPageCount+2) // 2 = cover + back cover paths, err := r.artist.DrawComicPages(storyText) if err != nil { fmt.Fprintf(os.Stderr, "Warning: comic page generation failed: %v\n", err) |
