diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-03 19:22:33 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-03 19:22:33 +0300 |
| commit | ce8777a542b744ae8195d77c514722f7c8083459 (patch) | |
| tree | 92e9ab611ec69e4d49c0f05ad0f3894a76f22b35 | |
| parent | ec0240047da823ac47268f9abdff9bd722416265 (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.go | 196 | ||||
| -rw-r--r-- | internal/story/pdf.go | 41 | ||||
| -rw-r--r-- | internal/story/runner.go | 21 |
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 |
