summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-03 19:22:33 +0300
committerPaul Buetow <paul@buetow.org>2026-04-03 19:22:33 +0300
commitce8777a542b744ae8195d77c514722f7c8083459 (patch)
tree92e9ab611ec69e4d49c0f05ad0f3894a76f22b35
parentec0240047da823ac47268f9abdff9bd722416265 (diff)
feat: traditional comic covers, animal consistency, back-cover blurb, PDF output
Cover/back improvements: - Front cover: MANDATORY TITLE instruction makes 'BULGARIAN VOCABULARY ADVENTURE' dominate the top 20% of the cover in bold masthead lettering - Back cover: Gemini generates a 2-3 sentence English marketing blurb that is embedded verbatim in the blurb-box instruction (italic display type) - Both covers now specify Silver-Age / Bronze-Age comic production layout (price box, barcode strip, cover lines) Animal consistency: - Character bible prompt now treats animals as named characters — specifies exact breed, fur colour/pattern, markings, and eye colour; instructs model never to substitute a generic animal or change markings between pages - Story page prompts add explicit animal consistency rule alongside human rules Bible/blurb reliability: - Switch helper calls to same SystemInstruction + user-content pattern that the story generator uses successfully with gemini-2.5-flash - Increase MaxOutputTokens to 8192 and timeout to 90s to match story generator - Retry pause extended to 15s for free-tier RPM recovery between rapid calls - Removed deprecated gemini-2.0-flash model references PDF generation (new internal/story/pdf.go): - AssembleComicPDF() combines all 5 images into comic.pdf via ImageMagick convert - Pages are in reading order: cover → story pages → back cover - Non-fatal: warns if ImageMagick is not installed, never blocks the pipeline Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--internal/story/artist.go196
-rw-r--r--internal/story/pdf.go41
-rw-r--r--internal/story/runner.go21
3 files changed, 192 insertions, 66 deletions
diff --git a/internal/story/artist.go b/internal/story/artist.go
index 5ba925a..79b4c03 100644
--- a/internal/story/artist.go
+++ b/internal/story/artist.go
@@ -23,16 +23,22 @@ const (
// comicPromptMaxChars caps each page's story excerpt in the NanoBanana prompt.
comicPromptMaxChars = 900
- // bibleModel is the Gemini text model used to generate the character bible.
- bibleModel = "gemini-2.5-flash"
-
- // bibleTimeout gives Gemini up to 90 s to produce the character bible.
- bibleTimeout = 90 * time.Second
-
- // bibleMaxTokens matches the story generator's proven budget.
- // No ThinkingConfig is set — the model manages token allocation itself,
- // which is the same approach used by the working story generator.
- bibleMaxTokens = int32(8192)
+ // helperModel matches the story generator's proven model (gemini-2.5-flash).
+ // Both the bible and blurb use the same SystemInstruction + user-content pattern
+ // that the story generator uses successfully.
+ helperModel = "gemini-2.5-flash"
+
+ // helperTimeout gives Gemini up to 90 s per helper call; thinking tokens
+ // within gemini-2.5-flash need more time than a plain text model.
+ helperTimeout = 90 * time.Second
+
+ // helperMaxTokens must be large enough to cover internal thinking tokens
+ // (gemini-2.5-flash) plus the visible output. 8192 matches the story generator.
+ helperMaxTokens = int32(8192)
+
+ // helperRetryPause waits before retrying when the model returns an empty
+ // response — typically caused by free-tier RPM exhaustion between rapid calls.
+ helperRetryPause = 15 * time.Second
)
// comicStyles is the pool from which the page style is drawn each run.
@@ -52,22 +58,33 @@ var comicStyles = []string{
// characterBiblePrompt instructs Gemini to produce a strict visual reference
// prepended verbatim to every panel, cover, and back-cover prompt.
-const characterBiblePrompt = `You are a comic-book art director. Read the Bulgarian story below and write a
-CHARACTER CONSISTENCY GUIDE in English for an illustrator.
+// bibleSystemInstruction is the SystemInstruction role for the character bible call.
+// Matching the story generator's proven SystemInstruction + user-content split ensures
+// 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 character provide: name, age estimate, hair (colour + style), eye colour,
+For every named HUMAN character provide: name, age estimate, hair (colour + style), eye colour,
skin tone, build, and the EXACT clothing they wear — specify garment, colour, pattern, and fit.
Clothing must NOT change between panels unless the story explicitly describes a change;
if no change is described, list the same outfit for all appearances.
+For every named ANIMAL character provide: name, species, exact breed, fur/feather/scale colour
+and pattern, eye colour, size, any distinctive markings, and typical body posture.
+The animal must look IDENTICAL on every page — same breed, same markings, same eye colour.
+Do NOT substitute a generic animal; if the story says Persian cat, every panel must show a
+Persian cat with the exact described colouring.
+
Also describe: the setting (location, time of day, weather, key props) and overall
lighting / colour mood.
Be extremely specific — this guide will be copy-pasted into every panel prompt to lock visual
-consistency. Maximum 220 words. No headers, just dense descriptive prose.
+consistency. Maximum 280 words. No headers, just dense descriptive prose.`
-Story:
-`
+// blurbSystemInstruction is the SystemInstruction role for the back-cover blurb call.
+const blurbSystemInstruction = `You are a comic-book editor writing back-cover marketing copy.
+Rules: write exactly 2–3 sentences in English; exciting and enticing; do NOT spoil the ending;
+use present-tense second-person (e.g. "Join Eli as she discovers…").
+Output only the blurb text — no quotes, no labels, no extra commentary.`
// ArtistConfig holds settings for comic-book image generation via NanoBanana.
type ArtistConfig struct {
@@ -126,13 +143,7 @@ func (a *Artist) DrawComicPages(storyText string) ([]string, error) {
}
fmt.Printf(" Comic style: %s\n", style)
- bible, err := a.buildCharacterBible(storyText)
- if err != nil {
- fmt.Printf(" Warning: character bible failed (%v); panels may vary\n", err)
- bible = ""
- } else {
- fmt.Printf(" Character bible ready (%d chars)\n", len(bible))
- }
+ bible, blurb := a.buildHelperTexts(storyText)
var paths []string
@@ -161,7 +172,7 @@ func (a *Artist) DrawComicPages(storyText string) ([]string, error) {
// 3. Back cover
fmt.Println(" Generating back cover...")
- if p, err := a.generateSinglePage(buildBackCoverPrompt(storyText, style, bible), "comic_back"); err != nil {
+ if p, err := a.generateSinglePage(buildBackCoverPrompt(storyText, style, bible, blurb), "comic_back"); err != nil {
fmt.Printf(" Warning: back cover generation failed: %v\n", err)
} else {
paths = append(paths, p)
@@ -170,43 +181,66 @@ func (a *Artist) DrawComicPages(storyText string) ([]string, error) {
return paths, nil
}
-// buildCharacterBible calls Gemini to produce a strict visual reference card.
-// No ThinkingConfig is set — same pattern as the working story generator.
-func (a *Artist) buildCharacterBible(storyText string) (string, error) {
+// 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 == "" {
- return "", fmt.Errorf("no API key for character bible generation")
+ fmt.Println(" Warning: no API key — skipping character bible and blurb")
+ return "", ""
}
- client, err := genai.NewClient(context.Background(), &genai.ClientConfig{
- APIKey: a.apiKey,
- })
+ client, err := genai.NewClient(context.Background(), &genai.ClientConfig{APIKey: a.apiKey})
if err != nil {
- return "", fmt.Errorf("create genai client: %w", err)
+ fmt.Printf(" Warning: Gemini client failed (%v); panels may vary\n", err)
+ return "", ""
}
- ctx, cancel := context.WithTimeout(context.Background(), bibleTimeout)
- defer cancel()
+ bible = a.callGeminiHelper(client, bibleSystemInstruction, storyText, "character bible")
+ if bible != "" {
+ fmt.Printf(" Character bible ready (%d chars)\n", len(bible))
+ }
- resp, err := client.Models.GenerateContent(ctx, bibleModel,
- []*genai.Content{genai.NewContentFromText(characterBiblePrompt+storyText, genai.RoleUser)},
- &genai.GenerateContentConfig{
- MaxOutputTokens: bibleMaxTokens,
- },
- )
- if err != nil {
- return "", fmt.Errorf("gemini bible call: %w", err)
+ blurb = a.callGeminiHelper(client, blurbSystemInstruction, storyText, "back-cover blurb")
+ if blurb != "" {
+ fmt.Printf(" Back-cover blurb ready (%d chars)\n", len(blurb))
}
- bible := strings.TrimSpace(resp.Text())
- if bible == "" {
- reason := "unknown"
- if len(resp.Candidates) > 0 {
- reason = string(resp.Candidates[0].FinishReason)
+ 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).
+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)
+ resp, err := client.Models.GenerateContent(ctx, helperModel,
+ []*genai.Content{genai.NewContentFromText(userPrompt, genai.RoleUser)},
+ &genai.GenerateContentConfig{
+ SystemInstruction: &genai.Content{
+ Parts: []*genai.Part{{Text: systemInstruction}},
+ },
+ MaxOutputTokens: helperMaxTokens,
+ },
+ )
+ cancel()
+
+ if err != nil {
+ fmt.Printf(" Warning: %s attempt %d failed: %v\n", label, attempt, err)
+ } else if text := strings.TrimSpace(resp.Text()); text != "" {
+ return text
+ } else {
+ fmt.Printf(" Warning: %s attempt %d returned empty response\n", label, attempt)
}
- return "", fmt.Errorf("empty response (finish reason: %s)", reason)
- }
- return bible, nil
+ if attempt < 2 {
+ fmt.Printf(" Retrying %s in %s...\n", label, helperRetryPause)
+ time.Sleep(helperRetryPause)
+ }
+ }
+ return ""
}
// generateSinglePage downloads and saves one image for the given prompt.
@@ -242,11 +276,20 @@ func buildCoverPrompt(storyText, style, bible string) string {
bibleBlock := bibleSection(bible, "cover")
return fmt.Sprintf(
"Art style: %s.%s\n"+
- "COMIC BOOK FRONT COVER — single full-bleed illustration, no panel grid. "+
- "Large bold title text at the top: \"BULGARIAN VOCABULARY ADVENTURE\". "+
- "Show the main character(s) in a dynamic, eye-catching pose with the story setting "+
- "behind them. Dramatic, inviting, professional comic cover composition. "+
- "Story teaser:\n\n%s",
+ "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"+
+ "Remaining layout rules:\n"+
+ " • MAIN ART: below the title, a single dramatic illustration of the main character(s) "+
+ "and any animals in a dynamic pose, richly detailed story setting behind them.\n"+
+ " • COVER LINES: 2–3 short teaser phrases in bold display type (e.g. 'A Summer Adventure!').\n"+
+ " • BOTTOM STRIP: price box bottom-left, issue number bottom-right — "+
+ "classic Silver-Age / Bronze-Age comic production design.\n"+
+ "Characters and animals MUST match the reference exactly. Story teaser:\n\n%s",
style, bibleBlock, teaser,
)
}
@@ -266,16 +309,22 @@ func buildStoryPagePrompt(section string, pageNum, totalPages int, style, bible
return fmt.Sprintf(
"Art style: %s.%s\n"+
"Comic book story page %d of %d. Layout: a 3×3 grid of 9 panels filling the page, "+
- "each panel showing a distinct moment from the excerpt below. "+
- "All characters MUST look identical across every panel — same face, hair, and clothing "+
- "as described in the reference above. Story excerpt:\n\n%s",
+ "each panel showing a distinct moment from the excerpt below.\n"+
+ "STRICT CONSISTENCY RULES — apply to every single panel:\n"+
+ " • Human characters: identical face, hair colour/style, and clothing to the reference.\n"+
+ " • 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"+
+ "Story excerpt:\n\n%s",
style, bibleBlock, pageNum, totalPages, excerpt,
)
}
// buildBackCoverPrompt constructs the back-cover image prompt.
-func buildBackCoverPrompt(storyText, style, bible string) string {
- // Use the last ~200 chars of the story as the resolution hint.
+// blurb is an English marketing summary generated by Gemini; when non-empty it is
+// embedded verbatim in the blurb-box instruction so the image model renders it.
+func buildBackCoverPrompt(storyText, style, bible, blurb string) string {
+ // Use the last ~200 chars of the story as a visual hint for the scene.
ending := strings.TrimSpace(storyText)
if len(ending) > 200 {
ending = ending[len(ending)-200:]
@@ -284,14 +333,33 @@ func buildBackCoverPrompt(storyText, style, bible string) string {
}
}
+ // Build the blurb box instruction: use the generated blurb if available,
+ // otherwise ask the model to leave a styled empty box.
+ blurbBoxInstruction := "a rectangular text box (white or cream background, thin black border) " +
+ "near the bottom — styled like a classic back-cover synopsis box, box shape required."
+ if blurb != "" {
+ blurbBoxInstruction = fmt.Sprintf(
+ "a rectangular text box (white or cream background, thin black border) "+
+ "near the bottom displaying this blurb text in italic type:\n"+
+ " \"%s\"", blurb)
+ }
+
bibleBlock := bibleSection(bible, "back cover")
return fmt.Sprintf(
"Art style: %s.%s\n"+
- "COMIC BOOK BACK COVER — single full-bleed illustration, no panel grid. "+
- "A calm, warm, conclusive scene from the story's ending. "+
- "Small text area at the bottom for a short blurb (leave space). "+
+ "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"+
+ " • MAIN ART: a calm, warm, resolved scene filling the upper 60%% of the cover — "+
+ "the main character(s) and any animals in a peaceful or triumphant ending moment, "+
+ "with the full story setting behind them.\n"+
+ " • BLURB BOX: %s\n"+
+ " • BOTTOM STRIP: barcode box bottom-left (black-and-white barcode graphic), "+
+ "series title 'BULGARIAN VOCABULARY ADVENTURE' bottom-right — "+
+ "classic comic book back-cover production design.\n"+
+ "Characters and animals MUST match the reference above exactly. "+
"Story ending hint:\n\n%s",
- style, bibleBlock, ending,
+ style, bibleBlock, blurbBoxInstruction, ending,
)
}
diff --git a/internal/story/pdf.go b/internal/story/pdf.go
new file mode 100644
index 0000000..fd717dc
--- /dev/null
+++ b/internal/story/pdf.go
@@ -0,0 +1,41 @@
+package story
+
+import (
+ "fmt"
+ "os/exec"
+ "path/filepath"
+ "strings"
+)
+
+// AssembleComicPDF combines the 5 comic images (cover, 3 story pages, back cover)
+// into a single portrait PDF using ImageMagick's convert command.
+// 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) {
+ if len(imagePaths) == 0 {
+ return "", fmt.Errorf("no comic images to assemble into PDF")
+ }
+
+ if _, err := exec.LookPath("convert"); err != nil {
+ return "", fmt.Errorf("ImageMagick 'convert' not found — install ImageMagick to generate the PDF")
+ }
+
+ pdfPath := filepath.Join(outputDir, "comic.pdf")
+
+ // Build the convert command:
+ // convert -density 150 page1.png page2.png ... output.pdf
+ // -density 150 gives a reasonable print resolution without huge file sizes.
+ // Each PNG is added as a separate PDF page in the order provided.
+ args := []string{"-density", "150"}
+ args = append(args, imagePaths...)
+ args = append(args, pdfPath)
+
+ cmd := exec.Command("convert", args...)
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ return "", fmt.Errorf("convert failed: %w\n%s", err, strings.TrimSpace(string(out)))
+ }
+
+ return pdfPath, nil
+}
diff --git a/internal/story/runner.go b/internal/story/runner.go
index 8820e71..74d1104 100644
--- a/internal/story/runner.go
+++ b/internal/story/runner.go
@@ -121,9 +121,14 @@ func (r *Runner) Run(batchFile string) error {
return r.handleNarration(storyText, dir)
}
-// drawComicPages generates comicPageCount images with a shared character bible
-// for visual consistency; errors are non-fatal so story.txt is always accessible.
+// drawComicPages generates the 5 comic images and assembles them into a PDF.
+// Errors are non-fatal — story.txt is always accessible regardless of image failures.
func (r *Runner) drawComicPages(storyText string) {
+ dir := "."
+ if r.config != nil && r.config.OutputDir != "" {
+ dir = r.config.OutputDir
+ }
+
fmt.Printf("Generating %d comic pages...\n", storyPageCount+2) // 2 = cover + back cover
paths, err := r.artist.DrawComicPages(storyText)
if err != nil {
@@ -132,6 +137,18 @@ func (r *Runner) drawComicPages(storyText string) {
for _, p := range paths {
fmt.Printf("Comic page saved: %s\n", p)
}
+
+ if len(paths) == 0 {
+ return
+ }
+
+ // Assemble all generated pages into a single PDF in reading order.
+ pdfPath, err := AssembleComicPDF(dir, paths)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Warning: PDF assembly failed: %v\n", err)
+ return
+ }
+ fmt.Printf("Comic PDF saved: %s\n", pdfPath)
}
// handleNarration generates a cinematic MP3 via Gemini TTS when a narrator is