summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-03 13:22:19 +0300
committerPaul Buetow <paul@buetow.org>2026-04-03 13:22:19 +0300
commit59f615cdb0a0a30b702ab810013351bd97fdc408 (patch)
tree3f7498986c9605166851b554d9bba5ce3d923352
parentb819475635eec77c72ab1b3ec0793dd59f750013 (diff)
feat: single tall comic strip with consistent characters and more dramatic narration
- Replace 3 separate comic pages with one 9:16 tall comic_strip.png - Prompt explicitly requests 3 vertically stacked panels with consistent characters - Add AspectRatio field to SearchOptions; NanoBanana uses it when set - Make cinematic narration instruction more dramatic (movie trailer narrator style) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--internal/image/nanobanana.go21
-rw-r--r--internal/image/nanobanana_test.go14
-rw-r--r--internal/image/search.go1
-rw-r--r--internal/story/artist.go137
-rw-r--r--internal/story/narrator.go12
-rw-r--r--internal/story/runner.go19
6 files changed, 67 insertions, 137 deletions
diff --git a/internal/image/nanobanana.go b/internal/image/nanobanana.go
index 4e99dfa..8bcd945 100644
--- a/internal/image/nanobanana.go
+++ b/internal/image/nanobanana.go
@@ -56,8 +56,8 @@ var newNanoBananaClient = genai.NewClient
var nanoBananaGenerateText = func(ctx context.Context, c *NanoBananaClient, model, systemPrompt, userPrompt string, temperature float32, maxOutputTokens int32) (string, error) {
return c.generateText(ctx, model, systemPrompt, userPrompt, temperature, maxOutputTokens)
}
-var nanoBananaGenerateImage = func(ctx context.Context, c *NanoBananaClient, prompt string) ([]byte, string, error) {
- return c.generateImage(ctx, prompt)
+var nanoBananaGenerateImage = func(ctx context.Context, c *NanoBananaClient, prompt, aspectRatio string) ([]byte, string, error) {
+ return c.generateImage(ctx, prompt, aspectRatio)
}
// NewNanoBananaClient creates a new Nano Banana client.
@@ -105,10 +105,16 @@ func (c *NanoBananaClient) Search(ctx context.Context, opts *SearchOptions) ([]S
c.PromptCallback(prompt)
}
+ // Resolve aspect ratio: use caller override if provided, else the default.
+ aspectRatio := nanoBananaAspectRatio
+ if opts.AspectRatio != "" {
+ aspectRatio = opts.AspectRatio
+ }
+
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(), nanoBananaAspectRatio)
+ fmt.Printf("Nano Banana Image Generation: Using model '%s' with aspect ratio '%s'\n", c.modelName(), aspectRatio)
- imageBytes, mimeType, err := nanoBananaGenerateImage(ctx, c, prompt)
+ imageBytes, mimeType, err := nanoBananaGenerateImage(ctx, c, prompt, aspectRatio)
if err != nil {
if searchErr, ok := err.(*SearchError); ok {
return nil, searchErr
@@ -380,11 +386,14 @@ func (c *NanoBananaClient) generateText(ctx context.Context, model, systemPrompt
return text, nil
}
-func (c *NanoBananaClient) generateImage(ctx context.Context, prompt string) ([]byte, string, error) {
+func (c *NanoBananaClient) generateImage(ctx context.Context, prompt, aspectRatio string) ([]byte, string, error) {
+ if aspectRatio == "" {
+ aspectRatio = nanoBananaAspectRatio
+ }
cfg := &genai.GenerateContentConfig{
ResponseModalities: []string{string(genai.ModalityImage)},
ImageConfig: &genai.ImageConfig{
- AspectRatio: nanoBananaAspectRatio,
+ AspectRatio: aspectRatio,
},
}
diff --git a/internal/image/nanobanana_test.go b/internal/image/nanobanana_test.go
index 88cc43c..c9cfb61 100644
--- a/internal/image/nanobanana_test.go
+++ b/internal/image/nanobanana_test.go
@@ -71,7 +71,7 @@ func TestNanoBananaClient_Search_CustomPromptSkipsTextGeneration(t *testing.T) {
}
var gotPrompt string
- nanoBananaGenerateImage = func(_ context.Context, _ *NanoBananaClient, prompt string) ([]byte, string, error) {
+ nanoBananaGenerateImage = func(_ context.Context, _ *NanoBananaClient, prompt, _ string) ([]byte, string, error) {
gotPrompt = prompt
return mustPNGBytes(t), "image/png", nil
}
@@ -149,7 +149,7 @@ func TestNanoBananaClient_Search_GeneratedPromptFlow(t *testing.T) {
return "", nil
}
- nanoBananaGenerateImage = func(_ context.Context, _ *NanoBananaClient, prompt string) ([]byte, string, error) {
+ nanoBananaGenerateImage = func(_ context.Context, _ *NanoBananaClient, prompt, _ string) ([]byte, string, error) {
gotPrompt = prompt
return mustJPEGBytes(t), "image/jpeg", nil
}
@@ -249,7 +249,7 @@ func TestNanoBananaClient_Search_TranslationFailureFallsBackToQuery(t *testing.T
return "", nil
}
- nanoBananaGenerateImage = func(_ context.Context, _ *NanoBananaClient, prompt string) ([]byte, string, error) {
+ nanoBananaGenerateImage = func(_ context.Context, _ *NanoBananaClient, prompt, _ string) ([]byte, string, error) {
return mustPNGBytes(t), "image/png", nil
}
@@ -297,7 +297,7 @@ func TestNanoBananaClient_Search_TrivialSceneFallsBackToSubjectPrompt(t *testing
}
var gotPrompt string
- nanoBananaGenerateImage = func(_ context.Context, _ *NanoBananaClient, prompt string) ([]byte, string, error) {
+ nanoBananaGenerateImage = func(_ context.Context, _ *NanoBananaClient, prompt, _ string) ([]byte, string, error) {
gotPrompt = prompt
return mustPNGBytes(t), "image/png", nil
}
@@ -342,7 +342,7 @@ func TestNanoBananaClient_Search_ImageGenerationError(t *testing.T) {
return "", nil
}
- nanoBananaGenerateImage = func(_ context.Context, _ *NanoBananaClient, _ string) ([]byte, string, error) {
+ nanoBananaGenerateImage = func(_ context.Context, _ *NanoBananaClient, _, _ string) ([]byte, string, error) {
return nil, "", fmt.Errorf("image generation failed")
}
@@ -376,7 +376,7 @@ func TestNanoBananaClient_Search_CustomPromptPreservesTranslationMetadata(t *tes
t.Fatal("unexpected text generation for custom prompt")
return "", nil
}
- nanoBananaGenerateImage = func(_ context.Context, _ *NanoBananaClient, _ string) ([]byte, string, error) {
+ nanoBananaGenerateImage = func(_ context.Context, _ *NanoBananaClient, _, _ string) ([]byte, string, error) {
return mustPNGBytes(t), "image/png", nil
}
@@ -409,7 +409,7 @@ func TestNanoBananaClient_Search_CustomPromptIgnoresWhitespaceTranslation(t *tes
t.Fatal("unexpected text generation for custom prompt")
return "", nil
}
- nanoBananaGenerateImage = func(_ context.Context, _ *NanoBananaClient, _ string) ([]byte, string, error) {
+ nanoBananaGenerateImage = func(_ context.Context, _ *NanoBananaClient, _, _ string) ([]byte, string, error) {
return mustPNGBytes(t), "image/png", nil
}
diff --git a/internal/image/search.go b/internal/image/search.go
index be20731..8de97e0 100644
--- a/internal/image/search.go
+++ b/internal/image/search.go
@@ -28,6 +28,7 @@ type SearchOptions struct {
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
}
// DefaultSearchOptions returns sensible defaults for Bulgarian word searches
diff --git a/internal/story/artist.go b/internal/story/artist.go
index 34d89ed..c406b65 100644
--- a/internal/story/artist.go
+++ b/internal/story/artist.go
@@ -10,17 +10,18 @@ import (
)
const (
- // comicPageCount is the number of comic pages generated per story.
- comicPageCount = 3
+ // comicStripAspectRatio produces a tall portrait image — roughly three 4:3
+ // panels stacked — giving the model room to render beginning, middle, and end
+ // of the story in a single consistent scene without character drift across files.
+ comicStripAspectRatio = "9:16"
- // comicPromptMaxChars caps each page's story excerpt in the NanoBanana prompt
- // so it stays well within the model's context window.
- comicPromptMaxChars = 800
+ // comicPromptMaxChars caps the story excerpt embedded in the image prompt.
+ comicPromptMaxChars = 1200
)
-// comicStyles is the pool from which page styles are drawn without replacement.
-// "Ultra realistic" is always included; the remaining slots are randomised so
-// each run produces a different visual mix.
+// comicStyles is the pool from which the strip style is drawn each run.
+// Ultra realistic is selected 90% of the time; the remaining 10% comes from
+// the other styles to provide occasional visual variety.
var comicStyles = []string{
"ultra realistic comic strip with photographic detail and dramatic lighting",
"classic American comic book with bold ink outlines, halftone dots, and primary colors",
@@ -44,7 +45,7 @@ type ArtistConfig struct {
Style string
}
-// Artist generates comic-book-style images that illustrate the story.
+// Artist generates a single tall comic-strip image that illustrates the story.
type Artist struct {
nbClient image.ImageClient
outputDir string
@@ -59,16 +60,13 @@ func NewArtist(config *ArtistConfig) *Artist {
}
var nbConfig *image.NanoBananaConfig
+ var style string
if config != nil {
nbConfig = &image.NanoBananaConfig{
APIKey: config.APIKey,
Model: config.Model,
TextModel: config.TextModel,
}
- }
-
- var style string
- if config != nil {
style = config.Style
}
@@ -79,63 +77,43 @@ func NewArtist(config *ArtistConfig) *Artist {
}
}
-// DrawComicPages splits the story into comicPageCount sections and generates
-// one image per section. A single art style is chosen at random for the whole
-// comic so all pages look visually consistent. Files are saved as
-// comic_page_1.png … comic_page_N.png; attribution files are auto-written by
-// the Downloader. Returns the list of saved image paths.
-func (a *Artist) DrawComicPages(storyText string) ([]string, error) {
- sections := splitIntoSections(storyText, comicPageCount)
- // Use the configured style override, or pick one at random.
+// DrawComicStrip generates a single tall 9:16 comic-strip image covering the
+// whole story in one scene with consistent characters. The image is saved as
+// comic_strip.png; attribution is auto-written as comic_strip_attribution.txt
+// by the Downloader. Returns the saved image path.
+func (a *Artist) DrawComicStrip(storyText string) (string, error) {
style := a.style
if style == "" {
style = pickStyle()
}
- var paths []string
fmt.Printf(" Comic style: %s\n", style)
- for i, section := range sections {
- pageNum := i + 1
- fmt.Printf(" Generating comic page %d/%d...\n", pageNum, comicPageCount)
-
- path, err := a.drawPage(section, pageNum, len(sections), style)
- if err != nil {
- return paths, fmt.Errorf("comic page %d failed: %w", pageNum, err)
- }
- paths = append(paths, path)
- }
-
- return paths, nil
-}
-
-// drawPage generates a single comic page with the given style.
-// fileNamePattern comic_page_N → comic_page_N.png + comic_page_N_attribution.txt.
-func (a *Artist) drawPage(section string, pageNum, totalPages int, style string) (string, error) {
opts := image.DefaultSearchOptions("vocabulary story")
- opts.CustomPrompt = buildComicPrompt(section, pageNum, totalPages, style)
+ opts.CustomPrompt = buildComicStripPrompt(storyText, style)
+ opts.AspectRatio = comicStripAspectRatio
downloader := image.NewDownloader(a.nbClient, &image.DownloadOptions{
OutputDir: a.outputDir,
OverwriteExisting: true,
CreateDir: true,
- FileNamePattern: fmt.Sprintf("comic_page_%d", pageNum),
+ FileNamePattern: "comic_strip", // → comic_strip.png + comic_strip_attribution.txt
MaxSizeBytes: 20 * 1024 * 1024,
})
ctx := context.Background()
_, savedPath, err := downloader.DownloadBestMatchWithOptions(ctx, opts)
if err != nil {
- return "", err
+ return "", fmt.Errorf("comic strip generation failed: %w", err)
}
return savedPath, nil
}
-// buildComicPrompt constructs the NanoBanana prompt for one comic page.
-// The story excerpt is capped at comicPromptMaxChars.
-func buildComicPrompt(section string, pageNum, totalPages int, style string) string {
- excerpt := strings.TrimSpace(section)
+// buildComicStripPrompt constructs a single prompt for a 3-panel tall comic
+// strip covering the whole story. The excerpt is capped at comicPromptMaxChars.
+func buildComicStripPrompt(storyText, style string) string {
+ excerpt := strings.TrimSpace(storyText)
if len(excerpt) > comicPromptMaxChars {
excerpt = excerpt[:comicPromptMaxChars]
if idx := strings.LastIndex(excerpt, " "); idx > 0 {
@@ -146,9 +124,10 @@ func buildComicPrompt(section string, pageNum, totalPages int, style string) str
return fmt.Sprintf(
"Art style: %s.\n"+
- "This is page %d of %d of a comic strip. "+
- "Scene based on this part of a Bulgarian vocabulary story:\n\n%s",
- style, pageNum, totalPages, excerpt,
+ "A single tall comic strip with 3 vertically stacked panels showing the beginning, "+
+ "middle, and end of the story. Keep all characters visually consistent across panels. "+
+ "Scene based on this Bulgarian vocabulary story:\n\n%s",
+ style, excerpt,
)
}
@@ -159,65 +138,5 @@ func pickStyle() string {
if rand.Float64() < 0.9 {
return comicStyles[0] // ultra realistic
}
- // Pick from the non-ultra-realistic styles (index 1 onwards).
return comicStyles[1+rand.IntN(len(comicStyles)-1)]
}
-
-// splitIntoSections divides text into n roughly equal parts on paragraph
-// boundaries where possible, falling back to equal character splits.
-func splitIntoSections(text string, n int) []string {
- paragraphs := splitParagraphs(text)
-
- // If there are enough paragraphs, distribute them evenly across pages.
- if len(paragraphs) >= n {
- return distributeParagraphs(paragraphs, n)
- }
-
- // Fallback: split by characters when the text has fewer paragraphs than pages.
- return splitByChars(text, n)
-}
-
-// splitParagraphs splits text on blank lines, discarding empty entries.
-func splitParagraphs(text string) []string {
- var out []string
- for _, p := range strings.Split(text, "\n\n") {
- if p = strings.TrimSpace(p); p != "" {
- out = append(out, p)
- }
- }
- return out
-}
-
-// distributeParagraphs assigns paragraphs to n buckets as evenly as possible.
-func distributeParagraphs(paragraphs []string, n int) []string {
- sections := make([]string, n)
- size := len(paragraphs) / n
- rem := len(paragraphs) % n
- idx := 0
-
- for i := range n {
- count := size
- if i < rem {
- count++ // distribute remainder one-per-bucket from the front
- }
- sections[i] = strings.Join(paragraphs[idx:idx+count], "\n\n")
- idx += count
- }
-
- return sections
-}
-
-// splitByChars splits text into n roughly equal character-based sections.
-func splitByChars(text string, n int) []string {
- size := len(text) / n
- sections := make([]string, n)
- for i := range n {
- start := i * size
- end := start + size
- if i == n-1 {
- end = len(text)
- }
- sections[i] = strings.TrimSpace(text[start:end])
- }
- return sections
-}
diff --git a/internal/story/narrator.go b/internal/story/narrator.go
index 25ca9cb..fec50d4 100644
--- a/internal/story/narrator.go
+++ b/internal/story/narrator.go
@@ -19,11 +19,13 @@ const (
// Gemini TTS reads style instructions from the user-turn prompt, so embedding
// the directive here (rather than as a SystemInstruction) is the supported way
// to control voice style, pacing, and emotional delivery.
- cinematicInstruction = `Read the following Bulgarian story as a cinematic narrator.
-Use dramatic pacing, natural pauses between sentences, and a warm authoritative tone.
-Speak with emotional depth appropriate to the scene — slow down for tender moments,
-build energy for exciting passages. This is a language-learning story; pronounce
-Bulgarian words clearly and with expressive intonation.
+ cinematicInstruction = `You are a dramatic cinematic narrator performing a Bulgarian story.
+Deliver this as a professional movie trailer narrator would: deep, resonant, and commanding.
+Use long dramatic pauses before key moments. Build tension with slower, deliberate pacing,
+then accelerate through action. Drop your voice low and gravelly for mysterious or serious
+passages; let warmth and energy rise for joyful or triumphant ones. Breathe life into every
+sentence — this should sound like an epic film, not a reading exercise. Pronounce all
+Bulgarian words with authentic clarity and expressive intonation.
`
)
diff --git a/internal/story/runner.go b/internal/story/runner.go
index 4e06a53..9004f8a 100644
--- a/internal/story/runner.go
+++ b/internal/story/runner.go
@@ -116,22 +116,21 @@ func (r *Runner) Run(batchFile string) error {
return err
}
- r.drawComicPages(storyText)
+ r.drawComicStrip(storyText)
return r.handleNarration(storyText, dir)
}
-// drawComicPages generates the comic strip pages; errors are non-fatal so
-// story.txt is always accessible even when image generation fails.
-func (r *Runner) drawComicPages(storyText string) {
- fmt.Printf("Generating %d comic pages...\n", comicPageCount)
- imagePaths, err := r.artist.DrawComicPages(storyText)
+// drawComicStrip generates a single tall comic strip image; errors are
+// non-fatal so story.txt is always accessible even when image generation fails.
+func (r *Runner) drawComicStrip(storyText string) {
+ fmt.Println("Generating comic strip...")
+ imagePath, err := r.artist.DrawComicStrip(storyText)
if err != nil {
- fmt.Fprintf(os.Stderr, "Warning: comic image generation failed: %v\n", err)
- }
- for _, p := range imagePaths {
- fmt.Printf("Comic page saved: %s\n", p)
+ fmt.Fprintf(os.Stderr, "Warning: comic strip generation failed: %v\n", err)
+ return
}
+ fmt.Printf("Comic strip saved: %s\n", imagePath)
}
// handleNarration generates a cinematic MP3 via Gemini TTS when a narrator is