summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-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
9 files changed, 480 insertions, 99 deletions
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()
+}
+
+// GenerateResult holds the story text, character bible, and comic title from a
+// single combined Gemini call. All three are produced in the same request —
+// no second API call, no rate limits.
+type GenerateResult struct {
+ StoryText string // Bulgarian vocabulary story
+ Bible string // English character consistency guide for illustrators
+ Title string // Short English comic title (2-4 words), used as filename slug
+}
+
// Config holds generator settings and API credentials.
type Config struct {
APIKey string
TextModel string // defaults to storyGeminiModel
+ // Theme overrides the random genre pick when non-empty.
+ // Passed verbatim as the genre phrase in the story prompt.
+ Theme string
}
// Generator uses Gemini to produce vocabulary-based stories.
@@ -33,6 +96,7 @@ type Generator struct {
client *genai.Client
initErr error
textModel string
+ theme string // overrides random genre pick when non-empty
}
// var seam for test injection, mirrors the phonetic/fetcher.go pattern.
@@ -62,6 +126,7 @@ var generateStoryText = func(ctx context.Context, client *genai.Client, model, p
func NewGenerator(config *Config) *Generator {
g := &Generator{
textModel: storyGeminiModel,
+ theme: config.Theme,
}
if config == nil || config.APIKey == "" {
@@ -95,25 +160,174 @@ func (g *Generator) Generate(entries []batch.WordEntry) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), storyTimeout)
defer cancel()
- prompt := buildStoryPrompt(entries)
+ prompt := buildStoryPrompt(entries, g.theme)
return generateStoryText(ctx, g.client, g.textModel, prompt)
}
-// buildStoryPrompt creates the Gemini prompt from the word list.
-func buildStoryPrompt(entries []batch.WordEntry) string {
+// GenerateFull generates the Bulgarian story and the character consistency bible
+// in a single Gemini call, eliminating the separate bible API call that was
+// failing due to thinking-token exhaustion. Uses storyFullMaxTokens (65536) —
+// the model maximum — because the combined task requires more thinking budget
+// than story generation alone.
+func (g *Generator) GenerateFull(entries []batch.WordEntry) (GenerateResult, error) {
+ if g.initErr != nil {
+ return GenerateResult{}, g.initErr
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), storyTimeout)
+ defer cancel()
+
+ // Call Gemini directly rather than through the generateStoryText seam so we
+ // can apply a ThinkingBudget cap. Without it, gemini-2.5-flash uses all
+ // available tokens on internal chain-of-thought for the complex combined
+ // prompt, leaving nothing for visible text (resp.Text() returns "").
+ thinkingBudget := storyFullThinkingBudget
+ resp, err := g.client.Models.GenerateContent(ctx, g.textModel,
+ []*genai.Content{genai.NewContentFromText(buildStoryPromptFull(entries, g.theme), genai.RoleUser)},
+ &genai.GenerateContentConfig{
+ SystemInstruction: &genai.Content{
+ Parts: []*genai.Part{{Text: storySystemPrompt}},
+ },
+ MaxOutputTokens: storyFullMaxTokens,
+ ThinkingConfig: &genai.ThinkingConfig{ThinkingBudget: &thinkingBudget},
+ },
+ )
+ if err != nil {
+ return GenerateResult{}, fmt.Errorf("gemini API error: %w", err)
+ }
+
+ combined := strings.TrimSpace(resp.Text())
+ if combined == "" {
+ return GenerateResult{}, fmt.Errorf("no content returned from Gemini")
+ }
+
+ return parseGenerateResult(combined), nil
+}
+
+// parseGenerateResult splits the combined model output on the two separators.
+// Format expected:
+//
+// <story text>
+// ---CHARACTER GUIDE---
+// <bible>
+// ---COMIC TITLE---
+// <title>
+//
+// If a separator is missing, the corresponding field is left empty and parsing
+// is best-effort so the pipeline can still proceed without all three sections.
+func parseGenerateResult(combined string) GenerateResult {
+ bibleIdx := strings.Index(combined, storyBibleSeparator)
+ if bibleIdx < 0 {
+ return GenerateResult{StoryText: strings.TrimSpace(combined)}
+ }
+
+ story := strings.TrimSpace(combined[:bibleIdx])
+ afterBible := strings.TrimSpace(combined[bibleIdx+len(storyBibleSeparator):])
+
+ titleIdx := strings.Index(afterBible, storyTitleSeparator)
+ if titleIdx < 0 {
+ return GenerateResult{StoryText: story, Bible: afterBible}
+ }
+
+ bible := strings.TrimSpace(afterBible[:titleIdx])
+ title := strings.TrimSpace(afterBible[titleIdx+len(storyTitleSeparator):])
+ // Keep only the first line of the title in case the model adds a blank line.
+ if nl := strings.IndexByte(title, '\n'); nl >= 0 {
+ title = strings.TrimSpace(title[:nl])
+ }
+
+ return GenerateResult{StoryText: story, Bible: bible, Title: title}
+}
+
+// buildStoryPrompt creates the simple story-only prompt used by Generate.
+// theme overrides the random genre when non-empty.
+func buildStoryPrompt(entries []batch.WordEntry, theme string) string {
+ genre := resolveGenre(theme)
+ header := fmt.Sprintf(
+ "Write a ~500-word story in Bulgarian that naturally uses all of the following words.\n"+
+ "The story must be %s — do NOT write a generic fairy tale.\n"+
+ "Number each word as shown below. Return ONLY the story text — no title, no header, no explanation.\n\n",
+ genre,
+ )
+ return buildWordList(entries, header)
+}
+
+// buildStoryPromptFull creates the extended prompt used by GenerateFull that
+// requests both the Bulgarian story and the character bible in one response.
+// theme overrides the random genre when non-empty.
+// The separator line lets parseGenerateResult split them reliably.
+func buildStoryPromptFull(entries []batch.WordEntry, theme string) string {
+ genre := resolveGenre(theme)
var sb strings.Builder
sb.WriteString("Write a ~500-word story in Bulgarian that naturally uses all of the following words.\n")
- sb.WriteString("Number each word as shown below. Return ONLY the story text — no title, no header, no explanation.\n\n")
- sb.WriteString("Words to include:\n")
+ sb.WriteString(fmt.Sprintf("The story must be %s — do NOT write a generic fairy tale.\n", genre))
+ sb.WriteString("Number each word as shown below.\n\n")
+ sb.WriteString(buildWordList(entries, ""))
+
+ sb.WriteString("\nAfter the story text, write exactly this line by itself (nothing else on that line):\n")
+ sb.WriteString(storyBibleSeparator)
+ sb.WriteString("\n\nThen write a CHARACTER CONSISTENCY GUIDE in English for an illustrator.\n")
+ sb.WriteString("For every named HUMAN character: name, apparent age description (e.g. child, ")
+ sb.WriteString("teenager, middle-aged, elderly), hair (colour + style), eye colour, skin tone, ")
+ sb.WriteString("build, and EXACT clothing (garment, colour, pattern, fit). ")
+ sb.WriteString("Apparent age and clothing must NOT change — list the same for all appearances.\n")
+ sb.WriteString("For every named ANIMAL character: name, species, exact breed, fur colour ")
+ sb.WriteString("and pattern, eye colour, size, distinctive markings, body posture. Must ")
+ sb.WriteString("look IDENTICAL on every page — same breed, same markings, same eye colour.\n")
+ sb.WriteString("Also describe: setting (location, time of day, weather, key props) and ")
+ sb.WriteString("overall lighting/colour mood.\n")
+ sb.WriteString("Maximum 280 words for the guide. No headers, just dense descriptive prose.\n")
+ // Request a short title after the bible for use as the output folder/file name.
+ sb.WriteString("\nAfter the character guide, write exactly this line by itself:\n")
+ sb.WriteString(storyTitleSeparator)
+ sb.WriteString("\n\nThen write a short comic book title in English: 2-4 words that capture the ")
+ sb.WriteString("story's theme and characters (e.g. 'Stardust Explorers', 'The Clockwork Dragon', ")
+ sb.WriteString("'Mystery at Midnight'). Output only the title — no quotes, no punctuation, no explanation.\n")
+
+ return sb.String()
+}
+
+// slugify converts a comic title into a safe directory/file name component.
+// It lowercases, replaces whitespace with hyphens, and removes everything that
+// is not an ASCII letter, digit, or hyphen. Falls back to "comic" if the result
+// would be empty.
+func slugify(title string) string {
+ title = strings.ToLower(strings.TrimSpace(title))
+ var b strings.Builder
+ prevHyphen := false
+ for _, r := range title {
+ switch {
+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
+ b.WriteRune(r)
+ prevHyphen = false
+ case r == ' ' || r == '-' || r == '_':
+ if !prevHyphen && b.Len() > 0 {
+ b.WriteByte('-')
+ prevHyphen = true
+ }
+ }
+ }
+ slug := strings.TrimRight(b.String(), "-")
+ if slug == "" {
+ return "comic"
+ }
+ return slug
+}
+
+// buildWordList formats the vocabulary entries as a numbered list.
+func buildWordList(entries []batch.WordEntry, header string) string {
+ var sb strings.Builder
+ sb.WriteString(header)
+ if header != "" {
+ sb.WriteString("Words to include:\n")
+ }
for i, e := range entries {
- word := e.Bulgarian
if e.Translation != "" {
- sb.WriteString(fmt.Sprintf("%d. %s (%s)\n", i+1, word, e.Translation))
+ sb.WriteString(fmt.Sprintf("%d. %s (%s)\n", i+1, e.Bulgarian, e.Translation))
} else {
- sb.WriteString(fmt.Sprintf("%d. %s\n", i+1, word))
+ sb.WriteString(fmt.Sprintf("%d. %s\n", i+1, e.Bulgarian))
}
}
-
return sb.String()
}
diff --git a/internal/story/pdf.go b/internal/story/pdf.go
index fd717dc..26497cb 100644
--- a/internal/story/pdf.go
+++ b/internal/story/pdf.go
@@ -9,10 +9,11 @@ import (
// AssembleComicPDF combines the 5 comic images (cover, 3 story pages, back cover)
// into a single portrait PDF using ImageMagick's convert command.
+// The PDF is named <titleSlug>.pdf and placed in outputDir.
// The PDF pages are in reading order: front cover → story pages → back cover.
// Returns the path to the written PDF, or an error if ImageMagick is not available
// or any of the required source images are missing.
-func AssembleComicPDF(outputDir string, imagePaths []string) (string, error) {
+func AssembleComicPDF(outputDir, titleSlug string, imagePaths []string) (string, error) {
if len(imagePaths) == 0 {
return "", fmt.Errorf("no comic images to assemble into PDF")
}
@@ -21,7 +22,7 @@ func AssembleComicPDF(outputDir string, imagePaths []string) (string, error) {
return "", fmt.Errorf("ImageMagick 'convert' not found — install ImageMagick to generate the PDF")
}
- pdfPath := filepath.Join(outputDir, "comic.pdf")
+ pdfPath := filepath.Join(outputDir, titleSlug+".pdf")
// Build the convert command:
// convert -density 150 page1.png page2.png ... output.pdf
diff --git a/internal/story/runner.go b/internal/story/runner.go
index 74d1104..b2442a1 100644
--- a/internal/story/runner.go
+++ b/internal/story/runner.go
@@ -33,6 +33,10 @@ type RunnerConfig struct {
// Style overrides the random art-style pick when non-empty.
// Accepts any free-form description; it is passed verbatim to the image model.
Style string
+ // Theme overrides the random story genre when non-empty (e.g. "a thrilling space
+ // adventure with aliens and spaceships"). Passed verbatim as the genre line in the
+ // Gemini story prompt so the model writes in that genre instead of a random one.
+ Theme string
// NarratorVoice picks a specific Gemini cinematic voice for narration.
// Empty → random pick from the curated cinematic pool each run.
NarratorVoice string
@@ -53,13 +57,14 @@ func NewRunner(config *RunnerConfig) *Runner {
dir = config.OutputDir
}
- var apiKey, textModel, imageModel, imageTextModel, style, narratorVoice string
+ var apiKey, textModel, imageModel, imageTextModel, style, theme, narratorVoice string
if config != nil {
apiKey = config.APIKey
textModel = config.TextModel
imageModel = config.ImageModel
imageTextModel = config.ImageTextModel
style = config.Style
+ theme = config.Theme
narratorVoice = config.NarratorVoice
}
@@ -78,6 +83,7 @@ func NewRunner(config *RunnerConfig) *Runner {
generator: NewGenerator(&am