From 59f615cdb0a0a30b702ab810013351bd97fdc408 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 3 Apr 2026 13:22:19 +0300 Subject: 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 --- internal/image/nanobanana.go | 21 ++++-- internal/image/nanobanana_test.go | 14 ++-- internal/image/search.go | 1 + internal/story/artist.go | 137 ++++++++------------------------------ internal/story/narrator.go | 12 ++-- internal/story/runner.go | 19 +++--- 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 -- cgit v1.2.3