diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-03 23:02:41 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-03 23:02:41 +0300 |
| commit | b9ee95a66b94c8dc7fca946a2d464ed5d3ad86b1 (patch) | |
| tree | 1170581406c1bd461430e85d749573152a6596c8 /internal | |
| parent | b0e8ccfebc5094829054021250f08ddb93c9978e (diff) | |
feat: comic pipeline v0.10.0 — iterative chaining, title slugs, comics subfolder, 16:9v0.10.0
- 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>
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/cli/command.go | 1 | ||||
| -rw-r--r-- | internal/cli/flags.go | 1 | ||||
| -rw-r--r-- | internal/image/nanobanana.go | 66 | ||||
| -rw-r--r-- | internal/image/search.go | 9 | ||||
| -rw-r--r-- | internal/story/artist.go | 175 | ||||
| -rw-r--r-- | internal/story/generator.go | 244 | ||||
| -rw-r--r-- | internal/story/pdf.go | 5 | ||||
| -rw-r--r-- | internal/story/runner.go | 76 | ||||
| -rw-r--r-- | internal/version.go | 2 |
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..b2442 |
