summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-03 23:02:41 +0300
committerPaul Buetow <paul@buetow.org>2026-04-03 23:02:41 +0300
commit621ef41e416bdc9985a27e9a2f923435676f096e (patch)
treee82a9d367ce1ff2dba6ba6d52ec4f3be1a385841
parentb0e8ccfebc5094829054021250f08ddb93c9978e (diff)
feat: comic pipeline v0.10.0 — iterative chaining, title slugs, comics subfolder, 16:9
- Iterative image chaining: each page receives cover + prev page as pixel-level reference (multimodal) so the model copies appearance directly, not from text alone - --story-theme flag overrides random genre pick per run - Gemini now generates a short comic title (---COMIC TITLE--- separator) used as the output slug; all files named <slug>_cover.png, <slug>_page_1.png, etc. - All comic output stored under comics/<slug>/ subdirectory - Aspect ratio changed to 16:9 (ThinkPad X1 Gen 9 screen fit; 16:10 not supported) - Bulgarian language rule moved to top of every prompt for stronger compliance - Dynamic story-specific cover masthead + publisher logo badge matching story genre - --story-theme "a thrilling space..." forces genre without touching random pool Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--cmd/totalrecall/main.go1
-rw-r--r--comic_back_attribution.txt16
-rw-r--r--comic_cover_attribution.txt16
-rw-r--r--comic_page_1_attribution.txt16
-rw-r--r--comic_page_2_attribution.txt16
-rw-r--r--comic_page_3_attribution.txt16
-rw-r--r--comic_strip_attribution.txt15
-rw-r--r--internal/cli/command.go1
-rw-r--r--internal/cli/flags.go1
-rw-r--r--internal/image/nanobanana.go66
-rw-r--r--internal/image/search.go9
-rw-r--r--internal/story/artist.go175
-rw-r--r--internal/story/generator.go244
-rw-r--r--internal/story/pdf.go5
-rw-r--r--internal/story/runner.go76
-rw-r--r--internal/version.go2
16 files changed, 576 insertions, 99 deletions
diff --git a/cmd/totalrecall/main.go b/cmd/totalrecall/main.go
index 5363190..d6abcf5 100644
--- a/cmd/totalrecall/main.go
+++ b/cmd/totalrecall/main.go
@@ -68,6 +68,7 @@ func runCommand(cmd *cobra.Command, args []string, flags *cli.Flags) error {
ImageTextModel: flags.NanoBananaTextModel,
OutputDir: ".",
Style: flags.StoryStyle,
+ Theme: flags.StoryTheme,
NarratorVoice: flags.NarratorVoice,
})
return runner.Run(flags.StoryFile)
diff --git a/comic_back_attribution.txt b/comic_back_attribution.txt
new file mode 100644
index 0000000..fd16f28
--- /dev/null
+++ b/comic_back_attribution.txt
@@ -0,0 +1,16 @@
+Image generated by Google Gemini Nano Banana
+
+Model: gemini-3.1-flash-image-preview
+Text model: gemini-2.5-flash
+Aspect ratio: 4:3
+Size: 896x1200
+Result: Generated educational image for vocabulary story
+
+Prompt used:
+ЗАДЪЛЖИТЕЛНО / MANDATORY LANGUAGE RULE: This is a BULGARIAN comic book. All visible text (blurb box, labels, banners) MUST be in Bulgarian Cyrillic script. English text anywhere on the back cover is STRICTLY FORBIDDEN.
+
+Art style: ultra realistic comic strip with photographic detail and dramatic lighting.
+CHARACTER & SETTING REFERENCE (back cover — follow exactly, do NOT change clothing):
+Captain Elara, a middle-aged woman (early 40s), has dark brown hair tied back in a neat, practical ponytail, with a few loose strands framing her face. Her eyes are sharp, intelligent hazel, and her skin tone is light olive, slightly tanned. She possesses an athletic, lean, and agile build. Elara wears a functional dark grey (almost black) fitted spacesuit with subtle dark blue piping along the seams and collar. The suit features a high stand-up collar, long sleeves, and zips up the front with the zipper visible. It has no visible pockets, patches, or emblems. Her footwear consists of...
+
+Generated at: 2026-04-03 22:39:29
diff --git a/comic_cover_attribution.txt b/comic_cover_attribution.txt
new file mode 100644
index 0000000..c898574
--- /dev/null
+++ b/comic_cover_attribution.txt
@@ -0,0 +1,16 @@
+Image generated by Google Gemini Nano Banana
+
+Model: gemini-3.1-flash-image-preview
+Text model: gemini-2.5-flash
+Aspect ratio: 4:3
+Size: 896x1200
+Result: Generated educational image for vocabulary story
+
+Prompt used:
+ЗАДЪЛЖИТЕЛНО / MANDATORY LANGUAGE RULE: This is a BULGARIAN comic book. All text on the cover (cover lines, banners, labels) MUST be in Bulgarian Cyrillic script. The masthead title must also be rendered in a striking comic-book font.
+
+Art style: ultra realistic comic strip with photographic detail and dramatic lighting.
+CHARACTER & SETTING REFERENCE (cover — follow exactly, do NOT change clothing):
+Captain Elara, a middle-aged woman (early 40s), has dark brown hair tied back in a neat, practical ponytail, with a few loose strands framing her face. Her eyes are sharp, intelligent hazel, and her skin tone is light olive, slightly tanned. She possesses an athletic, lean, and agile build. Elara wears a functional dark grey (almost black) fitted spacesuit with subtle dark blue piping along the seams and collar. The suit features a high stand-up collar, long sleeves, and zips up the front with the zipper visible. It has no visible pockets, patches, or emblems. Her footwear ...
+
+Generated at: 2026-04-03 22:38:20
diff --git a/comic_page_1_attribution.txt b/comic_page_1_attribution.txt
new file mode 100644
index 0000000..b75b9e0
--- /dev/null
+++ b/comic_page_1_attribution.txt
@@ -0,0 +1,16 @@
+Image generated by Google Gemini Nano Banana
+
+Model: gemini-3.1-flash-image-preview
+Text model: gemini-2.5-flash
+Aspect ratio: 4:3
+Size: 896x1200
+Result: Generated educational image for vocabulary story
+
+Prompt used:
+ЗАДЪЛЖИТЕЛНО / MANDATORY LANGUAGE RULE: This is a BULGARIAN comic book. Every word of text inside speech bubbles, thought bubbles, caption boxes, and panel labels MUST be written in Bulgarian Cyrillic script (например: Здравей! Какво правиш? Побързай!). English text anywhere in the panels is STRICTLY FORBIDDEN — use ONLY Bulgarian.
+
+Art style: ultra realistic comic strip with photographic detail and dramatic lighting.
+CHARACTER & SETTING REFERENCE (story page 1 of 3 — follow exactly, do NOT change clothing):
+Captain Elara, a middle-aged woman (early 40s), has dark brown hair tied back in a neat, practical ponytail, with a few loose strands framing her face. Her eyes are sharp, intelligent hazel, and her skin tone is light olive, slightly tanned. She possesses an athletic, lean, and agile build. Elara wears a functional dark grey (almost black) fitted spacesuit with subtle dark blue piping along the seams and collar. The suit features...
+
+Generated at: 2026-04-03 22:38:37
diff --git a/comic_page_2_attribution.txt b/comic_page_2_attribution.txt
new file mode 100644
index 0000000..6bb716c
--- /dev/null
+++ b/comic_page_2_attribution.txt
@@ -0,0 +1,16 @@
+Image generated by Google Gemini Nano Banana
+
+Model: gemini-3.1-flash-image-preview
+Text model: gemini-2.5-flash
+Aspect ratio: 4:3
+Size: 896x1200
+Result: Generated educational image for vocabulary story
+
+Prompt used:
+ЗАДЪЛЖИТЕЛНО / MANDATORY LANGUAGE RULE: This is a BULGARIAN comic book. Every word of text inside speech bubbles, thought bubbles, caption boxes, and panel labels MUST be written in Bulgarian Cyrillic script (например: Здравей! Какво правиш? Побързай!). English text anywhere in the panels is STRICTLY FORBIDDEN — use ONLY Bulgarian.
+
+Art style: ultra realistic comic strip with photographic detail and dramatic lighting.
+CHARACTER & SETTING REFERENCE (story page 2 of 3 — follow exactly, do NOT change clothing):
+Captain Elara, a middle-aged woman (early 40s), has dark brown hair tied back in a neat, practical ponytail, with a few loose strands framing her face. Her eyes are sharp, intelligent hazel, and her skin tone is light olive, slightly tanned. She possesses an athletic, lean, and agile build. Elara wears a functional dark grey (almost black) fitted spacesuit with subtle dark blue piping along the seams and collar. The suit features...
+
+Generated at: 2026-04-03 22:38:50
diff --git a/comic_page_3_attribution.txt b/comic_page_3_attribution.txt
new file mode 100644
index 0000000..b3d7ef0
--- /dev/null
+++ b/comic_page_3_attribution.txt
@@ -0,0 +1,16 @@
+Image generated by Google Gemini Nano Banana
+
+Model: gemini-3.1-flash-image-preview
+Text model: gemini-2.5-flash
+Aspect ratio: 4:3
+Size: 896x1200
+Result: Generated educational image for vocabulary story
+
+Prompt used:
+ЗАДЪЛЖИТЕЛНО / MANDATORY LANGUAGE RULE: This is a BULGARIAN comic book. Every word of text inside speech bubbles, thought bubbles, caption boxes, and panel labels MUST be written in Bulgarian Cyrillic script (например: Здравей! Какво правиш? Побързай!). English text anywhere in the panels is STRICTLY FORBIDDEN — use ONLY Bulgarian.
+
+Art style: ultra realistic comic strip with photographic detail and dramatic lighting.
+CHARACTER & SETTING REFERENCE (story page 3 of 3 — follow exactly, do NOT change clothing):
+Captain Elara, a middle-aged woman (early 40s), has dark brown hair tied back in a neat, practical ponytail, with a few loose strands framing her face. Her eyes are sharp, intelligent hazel, and her skin tone is light olive, slightly tanned. She possesses an athletic, lean, and agile build. Elara wears a functional dark grey (almost black) fitted spacesuit with subtle dark blue piping along the seams and collar. The suit features...
+
+Generated at: 2026-04-03 22:39:11
diff --git a/comic_strip_attribution.txt b/comic_strip_attribution.txt
new file mode 100644
index 0000000..a5c09a5
--- /dev/null
+++ b/comic_strip_attribution.txt
@@ -0,0 +1,15 @@
+Image generated by Google Gemini Nano Banana
+
+Model: gemini-3.1-flash-image-preview
+Text model: gemini-2.5-flash
+Aspect ratio: 4:3
+Size: 768x1376
+Result: Generated educational image for vocabulary story
+
+Prompt used:
+Art style: ultra realistic comic strip with photographic detail and dramatic lighting.
+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:
+
+Всяка сутрин в къщата на баба Елица се събуждаше с едно приятно гъделичкане. Не беше слънцето, което я будеше, а нейната любима 1. котка, Мурка. Мурка обичаше да се катери по завивките, да мърка силно и да бута с глава ръката на Елица, докато момиченцето не отвори очи. "Добро утро, Мурка," прошепна Елица, протягайки се с усмивка. 1. Котето се изви грациозно, разтегна лапи и скочи от легл...
+
+Generated at: 2026-04-03 13:28:31
diff --git a/internal/cli/command.go b/internal/cli/command.go
index ff96009..6f0b2a2 100644
--- a/internal/cli/command.go
+++ b/internal/cli/command.go
@@ -67,6 +67,7 @@ func setupFlags(cmd *cobra.Command, flags *Flags) {
cmd.Flags().StringVar(&flags.BatchFile, "batch", "", "Process words from file (one per line)")
cmd.Flags().StringVar(&flags.StoryFile, "story", "", "Generate a vocabulary story + comic image from a batch-format file (outputs to current directory)")
cmd.Flags().StringVar(&flags.StoryStyle, "story-style", "", "Art style for comic pages (default: random). E.g. \"ultra realistic comic strip with photographic detail and dramatic lighting\"")
+ cmd.Flags().StringVar(&flags.StoryTheme, "story-theme", "", "Genre/theme for the story (default: random). E.g. \"a thrilling space adventure with aliens and spaceships\"")
cmd.Flags().StringVar(&flags.NarratorVoice, "narrator-voice", "",
"Gemini voice for cinematic story narration (default: random from cinematic pool). "+
"Valid values: Charon, Fenrir, Enceladus, Algieba, Aoede, Schedar")
diff --git a/internal/cli/flags.go b/internal/cli/flags.go
index 30e3854..b9d28c9 100644
--- a/internal/cli/flags.go
+++ b/internal/cli/flags.go
@@ -22,6 +22,7 @@ type Flags struct {
BatchFile string
StoryFile string // --story <file>: generate vocabulary story + comic image
StoryStyle string // --story-style: override the random art style (empty = random)
+ StoryTheme string // --story-theme: override the random genre pick (empty = random)
NarratorVoice string // --narrator-voice: Gemini voice for cinematic narration (empty = random)
SkipAudio bool
SkipImages bool
diff --git a/internal/image/nanobanana.go b/internal/image/nanobanana.go
index 8bcd945..befe74c 100644
--- a/internal/image/nanobanana.go
+++ b/internal/image/nanobanana.go
@@ -114,7 +114,16 @@ func (c *NanoBananaClient) Search(ctx context.Context, opts *SearchOptions) ([]S
fmt.Printf("Nano Banana Image Generation Prompt (%d chars): %s\n", len(prompt), prompt)
fmt.Printf("Nano Banana Image Generation: Using model '%s' with aspect ratio '%s'\n", c.modelName(), aspectRatio)
- imageBytes, mimeType, err := nanoBananaGenerateImage(ctx, c, prompt, aspectRatio)
+ var imageBytes []byte
+ var mimeType string
+ // Use multimodal chaining when reference images are available — this keeps
+ // character appearance consistent across pages far more reliably than
+ // injecting a text-only character bible.
+ if len(opts.ReferenceImages) > 0 {
+ imageBytes, mimeType, err = c.generateImageWithRefs(ctx, prompt, aspectRatio, opts.ReferenceImages)
+ } else {
+ imageBytes, mimeType, err = nanoBananaGenerateImage(ctx, c, prompt, aspectRatio)
+ }
if err != nil {
if searchErr, ok := err.(*SearchError); ok {
return nil, searchErr
@@ -420,6 +429,61 @@ func (c *NanoBananaClient) generateImage(ctx context.Context, prompt, aspectRati
return imageBytes, mimeType, nil
}
+// generateImageWithRefs sends reference images alongside the text prompt so the
+// model can match character appearance from the existing pages. This implements
+// the iterative chaining technique: each new page is conditioned on the visual
+// look established by previous pages rather than relying on text descriptions alone.
+func (c *NanoBananaClient) generateImageWithRefs(ctx context.Context, prompt, aspectRatio string, refs [][]byte) ([]byte, string, error) {
+ if aspectRatio == "" {
+ aspectRatio = nanoBananaAspectRatio
+ }
+ cfg := &genai.GenerateContentConfig{
+ ResponseModalities: []string{string(genai.ModalityImage)},
+ ImageConfig: &genai.ImageConfig{AspectRatio: aspectRatio},
+ }
+
+ // Build multimodal content: reference image bytes first, then the instruction
+ // + prompt text. Leading with images means they are processed before the text.
+ parts := make([]*genai.Part, 0, len(refs)+1)
+ for _, ref := range refs {
+ if len(ref) > 0 {
+ parts = append(parts, &genai.Part{
+ InlineData: &genai.Blob{MIMEType: "image/png", Data: ref},
+ })
+ }
+ }
+ refNote := fmt.Sprintf(
+ "The %d reference image(s) above show the EXACT character appearance that must be preserved. "+
+ "Every character — same face, same age, same hair, same clothing, same animal breed and markings — "+
+ "must look IDENTICAL in the new image. Now generate:\n\n",
+ len(refs),
+ )
+ parts = append(parts, &genai.Part{Text: refNote + prompt})
+
+ resp, err := c.client.Models.GenerateContent(ctx, c.modelName(),
+ []*genai.Content{{Role: string(genai.RoleUser), Parts: parts}},
+ cfg,
+ )
+ if err != nil {
+ return nil, "", &SearchError{
+ Provider: nanoBananaSource,
+ Code: "API_ERROR",
+ Message: fmt.Sprintf("failed to generate image with refs: %v", err),
+ }
+ }
+
+ imageBytes, mimeType, err := extractGeneratedImage(resp)
+ if err != nil {
+ return nil, "", &SearchError{
+ Provider: nanoBananaSource,
+ Code: "NO_RESULTS",
+ Message: err.Error(),
+ }
+ }
+
+ return imageBytes, mimeType, nil
+}
+
func extractGeneratedImage(response *genai.GenerateContentResponse) ([]byte, string, error) {
if response == nil {
return nil, "", fmt.Errorf("no response from Gemini")
diff --git a/internal/image/search.go b/internal/image/search.go
index 8de97e0..80d5e33 100644
--- a/internal/image/search.go
+++ b/internal/image/search.go
@@ -27,8 +27,13 @@ type SearchOptions struct {
Page int // Page number (1-based)
ImageType string // Type: "photo", "illustration", "vector", "all"
Orientation string // Orientation: "horizontal", "vertical", "all"
- CustomPrompt string // Custom prompt for AI image generation
- AspectRatio string // Override aspect ratio (e.g. "9:16"); empty = provider default
+ CustomPrompt string // Custom prompt for AI image generation
+ AspectRatio string // Override aspect ratio (e.g. "9:16"); empty = provider default
+ // ReferenceImages holds raw PNG bytes of previously generated images.
+ // When non-empty, the NanoBanana client sends them as multimodal content
+ // alongside the text prompt so the model can match character appearance
+ // across pages (iterative chaining technique).
+ ReferenceImages [][]byte
}
// DefaultSearchOptions returns sensible defaults for Bulgarian word searches
diff --git a/internal/story/artist.go b/internal/story/artist.go
index c76638c..e40bb6a 100644
--- a/internal/story/artist.go
+++ b/internal/story/artist.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"math/rand/v2"
+ "os"
"strings"
"time"
@@ -16,9 +17,10 @@ const (
// storyPageCount is the number of 9-panel story pages (excluding cover/back).
storyPageCount = 3
- // comicPageAspectRatio: portrait (3:4) matches a standard comic book page
- // and gives the model room for a 3×3 panel grid.
- comicPageAspectRatio = "3:4"
+ // comicPageAspectRatio: 16:9 is the closest supported widescreen ratio for
+ // the ThinkPad X1 Gen 9 (2560×1600 / 16:10), filling the display with minimal
+ // letterboxing. The API supports 16:9 but not 16:10.
+ comicPageAspectRatio = "16:9"
// comicPromptMaxChars caps each page's story excerpt in the NanoBanana prompt.
comicPromptMaxChars = 900
@@ -63,11 +65,11 @@ var comicStyles = []string{
// gemini-2.5-flash allocates its thinking budget correctly instead of returning empty.
const bibleSystemInstruction = `You are a comic-book art director producing a CHARACTER CONSISTENCY GUIDE in English for an illustrator.
-For every named HUMAN character provide: name, EXACT age (e.g. "8-year-old girl", "65-year-old woman"),
-hair (colour + style), eye colour, skin tone, build, and the EXACT clothing they wear —
-specify garment, colour, pattern, and fit.
+For every named HUMAN character provide: name, apparent age category (young child, teenager,
+young adult, middle-aged, elderly), hair (colour + style), eye colour, skin tone, build,
+and the EXACT clothing they wear — specify garment, colour, pattern, and fit.
The character's apparent age MUST NOT change across any panel, page, cover, or back cover —
-they must always look the same age. Clothing must NOT change between panels unless the story
+they must always look the same. 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.
For every named ANIMAL character provide: name, species, exact breed, fur/feather/scale colour
@@ -131,50 +133,62 @@ func NewArtist(config *ArtistConfig) *Artist {
}
// 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
+// - <titleSlug>_cover.png — full-bleed cover
+// - <titleSlug>_page_1.png … _3 — 9-panel (3×3) story pages
+// - <titleSlug>_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.
+// A character bible injected into every prompt keeps characters, clothing, and
+// setting consistent across all pages. The bible is produced by GenerateFull in
+// the same Gemini call as the story; prebuiltBible is passed in from there.
+// titleSlug is used as the file-name prefix; it must already be a safe slug.
// Returns the list of saved image paths in order.
-func (a *Artist) DrawComicPages(storyText string) ([]string, error) {
+func (a *Artist) DrawComicPages(storyText, prebuiltBible, titleSlug string) ([]string, error) {
style := a.style
if style == "" {
style = pickStyle()
}
fmt.Printf(" Comic style: %s\n", style)
- bible, blurb := a.buildHelperTexts(storyText)
+ bible, blurb := a.resolveHelperTexts(storyText, prebuiltBible)
var paths []string
+ // recentRefs holds image bytes from recently generated pages for iterative
+ // chaining: each new page receives the cover + the previous page as visual
+ // reference so the model can match character appearance directly from pixels
+ // rather than relying on text descriptions alone.
+ var recentRefs [][]byte
- // 1. Cover
+ // 1. Cover — generated without refs (it is the visual baseline).
fmt.Println(" Generating cover page...")
- if p, err := a.generateSinglePage(buildCoverPrompt(storyText, style, bible), "comic_cover"); err != nil {
+ p, coverBytes, err := a.generateSinglePage(buildCoverPrompt(storyText, style, bible), titleSlug+"_cover", nil)
+ if err != nil {
fmt.Printf(" Warning: cover generation failed: %v\n", err)
} else {
paths = append(paths, p)
+ recentRefs = appendRef(recentRefs, coverBytes) // cover becomes the anchor reference
}
- // 2. Story pages (9-panel grids)
+ // 2. Story pages (9-panel grids) — each page receives cover + previous page as refs.
sections := splitIntoSections(storyText, storyPageCount)
for i, section := range sections {
pageNum := i + 1
fmt.Printf(" Generating story page %d/%d...\n", pageNum, storyPageCount)
- p, err := a.generateSinglePage(
+ p, pageBytes, err := a.generateSinglePage(
buildStoryPagePrompt(section, pageNum, storyPageCount, style, bible),
- fmt.Sprintf("comic_page_%d", pageNum),
+ fmt.Sprintf("%s_page_%d", titleSlug, pageNum),
+ recentRefs,
)
if err != nil {
return paths, fmt.Errorf("story page %d failed: %w", pageNum, err)
}
paths = append(paths, p)
+ recentRefs = appendRef(recentRefs, pageBytes)
}
- // 3. Back cover
+ // 3. Back cover — receives the same rolling refs as the last story page.
fmt.Println(" Generating back cover...")
- if p, err := a.generateSinglePage(buildBackCoverPrompt(storyText, style, bible, blurb), "comic_back"); err != nil {
+ p, _, err = a.generateSinglePage(buildBackCoverPrompt(storyText, style, bible, blurb), titleSlug+"_back", recentRefs)
+ if err != nil {
fmt.Printf(" Warning: back cover generation failed: %v\n", err)
} else {
paths = append(paths, p)
@@ -183,38 +197,52 @@ func (a *Artist) DrawComicPages(storyText string) ([]string, error) {
return paths, nil
}
-// buildHelperTexts generates the character bible and back-cover blurb in sequence.
-// Both use gemini-2.0-flash with a single retry on empty response (rate-limit recovery).
-// Returns ("", "") on total failure — callers degrade gracefully without these.
-func (a *Artist) buildHelperTexts(storyText string) (bible, blurb string) {
- if a.apiKey == "" {
- fmt.Println(" Warning: no API key — skipping character bible and blurb")
- return "", ""
+// appendRef adds imgBytes to refs and keeps at most 2 entries (cover anchor +
+// the immediately preceding page). Larger windows inflate the multimodal
+// payload significantly without proportional consistency gains.
+func appendRef(refs [][]byte, imgBytes []byte) [][]byte {
+ if len(imgBytes) == 0 {
+ return refs
}
-
- client, err := genai.NewClient(context.Background(), &genai.ClientConfig{APIKey: a.apiKey})
- if err != nil {
- fmt.Printf(" Warning: Gemini client failed (%v); panels may vary\n", err)
- return "", ""
+ refs = append(refs, imgBytes)
+ if len(refs) > 2 {
+ // Keep the first entry (cover anchor) and the latest page only.
+ refs = [][]byte{refs[0], refs[len(refs)-1]}
}
+ return refs
+}
- bible = a.callGeminiHelper(client, bibleSystemInstruction, storyText, "character bible")
+// resolveHelperTexts returns the character bible and back-cover blurb.
+// The bible comes from prebuiltBible (produced by GenerateFull in the same
+// Gemini call as the story — no extra API call, no rate-limiting). The blurb
+// is still generated with a separate call since it is not part of story generation.
+func (a *Artist) resolveHelperTexts(storyText, prebuiltBible string) (bible, blurb string) {
+ bible = prebuiltBible
if bible != "" {
fmt.Printf(" Character bible ready (%d chars)\n", len(bible))
+ } else {
+ fmt.Println(" Warning: no character bible — characters may vary between pages")
+ }
+
+ if a.apiKey == "" {
+ return bible, ""
+ }
+
+ client, err := genai.NewClient(context.Background(), &genai.ClientConfig{APIKey: a.apiKey})
+ if err != nil {
+ fmt.Printf(" Warning: Gemini client failed for blurb (%v)\n", err)
+ return bible, ""
}
blurb = a.callGeminiHelper(client, blurbSystemInstruction, storyText, "back-cover blurb")
if blurb != "" {
fmt.Printf(" Back-cover blurb ready (%d chars)\n", len(blurb))
}
-
return bible, blurb
}
// callGeminiHelper sends one text prompt to helperModel and returns the trimmed response.
-// Uses the same SystemInstruction + user-content pattern as the story generator,
-// which is the proven approach for gemini-2.5-flash. Retries once after
-// helperRetryPause when the model returns an empty string (free-tier RPM recovery).
+// Retries once after helperRetryPause on empty response.
func (a *Artist) callGeminiHelper(client *genai.Client, systemInstruction, userPrompt, label string) string {
for attempt := 1; attempt <= 2; attempt++ {
ctx, cancel := context.WithTimeout(context.Background(), helperTimeout)
@@ -246,10 +274,14 @@ func (a *Artist) callGeminiHelper(client *genai.Client, systemInstruction, userP
}
// generateSinglePage downloads and saves one image for the given prompt.
-func (a *Artist) generateSinglePage(prompt, fileNamePattern string) (string, error) {
+// refs are optional previously generated page images passed as multimodal
+// context to the image model for iterative chaining consistency.
+// Returns the saved file path and raw PNG bytes (for use as ref in next page).
+func (a *Artist) generateSinglePage(prompt, fileNamePattern string, refs [][]byte) (string, []byte, error) {
opts := image.DefaultSearchOptions("vocabulary story")
opts.CustomPrompt = prompt
opts.AspectRatio = comicPageAspectRatio
+ opts.ReferenceImages = refs
downloader := image.NewDownloader(a.nbClient, &image.DownloadOptions{
OutputDir: a.outputDir,
@@ -260,7 +292,19 @@ func (a *Artist) generateSinglePage(prompt, fileNamePattern string) (string, err
})
_, savedPath, err := downloader.DownloadBestMatchWithOptions(context.Background(), opts)
- return savedPath, err
+ if err != nil {
+ return "", nil, err
+ }
+
+ // Read back the saved PNG so callers can pass it as a reference image to
+ // subsequent pages. Non-fatal if the read fails — we just skip the reference.
+ imgBytes, readErr := os.ReadFile(savedPath)
+ if readErr != nil {
+ fmt.Printf(" Warning: could not read back %s for chaining: %v\n", savedPath, readErr)
+ imgBytes = nil
+ }
+
+ return savedPath, imgBytes, nil
}
// buildCoverPrompt constructs the front-cover image prompt.
@@ -277,28 +321,45 @@ func buildCoverPrompt(storyText, style, bible string) string {
bibleBlock := bibleSection(bible, "cover")
return fmt.Sprintf(
- "Art style: %s.%s\n"+
+ // Bulgarian language rule placed first so the model processes it before any other instruction.
+ "ЗАДЪЛЖИТЕЛНО / MANDATORY LANGUAGE RULE: This is a BULGARIAN comic book. "+
+ "All text on the cover (cover lines, banners, labels) MUST be in Bulgarian "+
+ "Cyrillic script. The masthead title must also be rendered in a striking comic-book font.\n\n"+
+ "Art style: %s.%s\n"+
"TRADITIONAL COMIC BOOK FRONT COVER — portrait orientation, single full-bleed illustration.\n"+
"NO panel grid. NO speech bubbles.\n"+
- "MANDATORY TITLE — the most important element on this cover:\n"+
- " The title 'BULGARIAN VOCABULARY ADVENTURE' MUST appear in HUGE, dominant lettering "+
- "across the very top of the cover. Use a bold, colourful comic-book masthead font — "+
- "thick outlines, high contrast against the background, taking up the top 20%% of the image. "+
- "This title MUST be legible and unmissable.\n"+
+ "MANDATORY MASTHEAD — the most important visual element on this cover:\n"+
+ " • Invent a DRAMATIC, STORY-SPECIFIC comic book title that fits the characters and "+
+ "theme of the story teaser below (e.g. for a space story: 'ГАЛАКТИЧЕСКИ ГЕРОИ', for "+
+ "a mystery: 'ТАЙНАТА НА ГОРАТА'). The title must be in HUGE, dominant lettering "+
+ "across the very top of the cover — bold comic-book masthead font, thick outlines, "+
+ "bright contrasting colours (yellow, red, or white on dark), taking up the top 20%% "+
+ "of the image. This title MUST be legible and unmissable.\n"+
+ " • Directly below the main title, add a smaller subtitle banner: "+
+ "'BULGARIAN VOCABULARY ADVENTURE' in a contrasting accent colour.\n"+
+ " • Add a bold comic-book LOGO BUG (small circular or star-shaped badge) "+
+ "in the top-left corner — e.g. a planet, rocket, magnifying glass, sword — "+
+ "matching the story theme. The logo should feel like a real publisher imprint.\n"+
"Remaining layout rules:\n"+
- " • MAIN ART: below the title, a dramatic illustration of EXACTLY the named characters "+
+ " • MAIN ART: below the masthead, a dramatic illustration of EXACTLY the named characters "+
"from the story (as described in the reference above) — same faces, same ages, same "+
"clothing, same animals. Do NOT invent new characters or use generic stand-ins.\n"+
- " • COVER LINES: 2–3 short teaser phrases in bold display type (e.g. 'A Summer Adventure!').\n"+
+ " • COVER LINES: 2–3 short Bulgarian teaser phrases in bold display type "+
+ "(e.g. 'НЕВЕРОЯТНО ПРИКЛЮЧЕНИЕ!' or 'СРЕЩА С НЕПОЗНАТОТО!')\n"+
" • BOTTOM STRIP: price box bottom-left, issue number bottom-right — "+
"classic Silver-Age / Bronze-Age comic production design.\n"+
"IMPORTANT: only the characters named in the reference may appear on this cover. "+
- "Same age, same face, same clothing as in the interior pages. Story teaser:\n\n%s",
+ "Same age, same face, same clothing as in the interior pages. "+
+ "LANGUAGE REMINDER: all cover text in Bulgarian Cyrillic — see rule at top. "+
+ "Story teaser:\n\n%s",
style, bibleBlock, teaser,
)
}
// buildStoryPagePrompt constructs a 9-panel grid page prompt.
+// The Bulgarian language requirement is placed at the very top — before the
+// character reference and story excerpt — because image models tend to follow
+// early instructions more reliably than late ones buried in a list.
func buildStoryPagePrompt(section string, pageNum, totalPages int, style, bible string) string {
excerpt := strings.TrimSpace(section)
if len(excerpt) > comicPromptMaxChars {
@@ -311,7 +372,13 @@ func buildStoryPagePrompt(section string, pageNum, totalPages int, style, bible
bibleBlock := bibleSection(bible, fmt.Sprintf("story page %d of %d", pageNum, totalPages))
return fmt.Sprintf(
- "Art style: %s.%s\n"+
+ // Lead with the hard language constraint so it is processed first.
+ "ЗАДЪЛЖИТЕЛНО / MANDATORY LANGUAGE RULE: This is a BULGARIAN comic book. "+
+ "Every word of text inside speech bubbles, thought bubbles, caption boxes, "+
+ "and panel labels MUST be written in Bulgarian Cyrillic script "+
+ "(например: Здравей! Какво правиш? Побързай!). "+
+ "English text anywhere in the panels is STRICTLY FORBIDDEN — use ONLY Bulgarian.\n\n"+
+ "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.\n"+
"STRICT CONSISTENCY RULES — apply to every single panel:\n"+
@@ -320,6 +387,7 @@ func buildStoryPagePrompt(section string, pageNum, totalPages int, style, bible
" • Animal characters: identical breed, fur colour/pattern, markings, and eye colour — "+
"NEVER substitute a different animal or a generic version of the species.\n"+
" • Clothing changes only if this page's excerpt explicitly describes a change.\n"+
+ " • LANGUAGE: all speech, thought, and caption text — Bulgarian Cyrillic ONLY.\n"+
"Story excerpt:\n\n%s",
style, bibleBlock, pageNum, totalPages, excerpt,
)
@@ -351,7 +419,11 @@ func buildBackCoverPrompt(storyText, style, bible, blurb string) string {
bibleBlock := bibleSection(bible, "back cover")
return fmt.Sprintf(
- "Art style: %s.%s\n"+
+ // Bulgarian language rule placed first for maximum model compliance.
+ "ЗАДЪЛЖИТЕЛНО / MANDATORY LANGUAGE RULE: This is a BULGARIAN comic book. "+
+ "All visible text (blurb box, labels, banners) MUST be in Bulgarian Cyrillic script. "+
+ "English text anywhere on the back cover is STRICTLY FORBIDDEN.\n\n"+
+ "Art style: %s.%s\n"+
"TRADITIONAL COMIC BOOK BACK COVER — portrait orientation, single full-bleed illustration.\n"+
"NO panel grid. NO speech bubbles.\n"+
"Layout rules (must follow exactly):\n"+
@@ -365,6 +437,7 @@ func buildBackCoverPrompt(storyText, style, bible, blurb string) string {
"classic comic book back-cover production design.\n"+
"IMPORTANT: only the characters named in the reference may appear on this back cover. "+
"Same age, same face, same clothing, same animals as in the interior pages. "+
+ "LANGUAGE REMINDER: all text in Bulgarian Cyrillic — see rule at top. "+
"Story ending hint:\n\n%s",
style, bibleBlock, blurbBoxInstruction, ending,
)
diff --git a/internal/story/generator.go b/internal/story/generator.go
index b8254eb..4f4317e 100644
--- a/internal/story/generator.go
+++ b/internal/story/generator.go
@@ -3,6 +3,7 @@ package story
import (
"context"
"fmt"
+ "math/rand/v2"
"strings"
"time"
@@ -12,20 +13,82 @@ import (
)
const (
- storyGeminiModel = "gemini-2.5-flash"
- storyTimeout = 120 * time.Second
- // 8192 tokens gives plenty of room for both Gemini 2.5 Flash's internal
- // thinking tokens and the ~650 visible tokens of a 500-word Bulgarian story.
- // A small budget (e.g. 1024) is silently consumed by thinking before any
- // visible text is emitted, producing a truncated result.
+ storyGeminiModel = "gemini-2.5-flash"
+ storyTimeout = 120 * time.Second
+ // 8192 tokens for story-only generation (thinking + ~650-word visible story).
storyMaxTokens = int32(8192)
+ // 16384 total for the combined story+bible call, with thinking capped at 8192
+ // (see storyFullThinkingBudget). This guarantees ~8192 tokens for the visible
+ // output (story ~650 words + bible ~280 words ≈ 1300 tokens — well within budget).
+ storyFullMaxTokens = int32(16384)
+ // storyFullThinkingBudget caps the internal chain-of-thought so the model
+ // cannot consume all MaxOutputTokens with thinking and produce no visible text.
+ // Without this cap, gemini-2.5-flash exhausts all tokens on reasoning for the
+ // complex combined prompt, making resp.Text() return an empty string.
+ storyFullThinkingBudget = int32(8192)
storySystemPrompt = "You are a creative Bulgarian language teacher. Write engaging stories that naturally incorporate vocabulary words to help students learn."
+
+ // storyBibleSeparator is the exact line the model must output between the
+ // story and the character bible. Parsing splits the response on this marker.
+ storyBibleSeparator = "---CHARACTER GUIDE---"
+
+ // storyTitleSeparator is the exact line the model outputs after the bible to
+ // deliver a short English comic title. parseGenerateResult extracts it for
+ // use as the output directory name and file prefix.
+ storyTitleSeparator = "---COMIC TITLE---"
)
+// storyGenres is the pool of genres picked randomly each run to keep stories
+// varied — not always fairy tales. Realistic/slice-of-life is weighted at 40%
+// (picked when index 0 or 1 is chosen) and the rest appear equally.
+var storyGenres = []string{
+ "a warm realistic slice-of-life story",
+ "a heartfelt family drama",
+ "an exciting science-fiction adventure",
+ "a thrilling action-adventure story",
+ "a mystery with a surprising twist",
+ "a funny comedy with silly misunderstandings",
+ "a fantasy quest in a magical world",
+ "a spooky but kid-friendly horror story",
+ "a space exploration adventure",
+ "a superhero origin story",
+}
+
+// pickStoryGenre returns a random genre from the pool.
+// Realistic genres (indices 0–1) appear 40% of the time; the rest 60%.
+func pickStoryGenre() string {
+ if rand.Float64() < 0.4 {
+ return storyGenres[rand.IntN(2)]
+ }
+ return storyGenres[2+rand.IntN(len(storyGenres)-2)]
+}
+
+// resolveGenre returns theme if non-empty, otherwise picks a random genre.
+// This lets the caller override the genre via --story-theme without changing
+// the random pick logic.
+func resolveGenre(theme string) string {
+ if theme != "" {
+ return theme
+ }
+ return pickStoryGenre()
+}
+