summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-04 09:34:28 +0300
committerPaul Buetow <paul@buetow.org>2026-04-04 09:34:28 +0300
commita34789fd2f7c0e2a7292535dbd3cdc68d3f00675 (patch)
tree6077d20fbd740909c7066f4c99b75d8d9ddf6b8a
parent3b26916eed21c5c4c3d2ef40f19a1ef6e47b5662 (diff)
feat: 5 story pages, 3 gallery pages, cover retry, adult characters, shorter TTS chunksv0.11.0
- Increase story pages from 3 to 5 (10 total with cover/gallery/back) - Add 3 text-free gallery pages with close-up heroine poses before back cover - Add retry logic to cover and back cover generation (same as story pages) - Enforce adult (18+) characters in generator prompt to avoid content policy blocks - Reduce TTS chunk size from 200 to 100 words to stay under 1-minute quality threshold - Increase TTS timeout from 2 to 3 minutes to avoid spurious deadline exceeded errors Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--internal/image/nanobanana.go8
-rw-r--r--internal/story/artist.go150
-rw-r--r--internal/story/generator.go6
-rw-r--r--internal/story/narrator.go306
-rw-r--r--internal/story/runner.go2
-rw-r--r--internal/version.go2
6 files changed, 392 insertions, 82 deletions
diff --git a/internal/image/nanobanana.go b/internal/image/nanobanana.go
index befe74c..ac55f3b 100644
--- a/internal/image/nanobanana.go
+++ b/internal/image/nanobanana.go
@@ -277,8 +277,8 @@ func (c *NanoBananaClient) resolveTranslation(_ context.Context, opts *SearchOpt
func (c *NanoBananaClient) resolvePrompt(ctx context.Context, opts *SearchOptions, translatedWord string) (string, error) {
if customPrompt := strings.TrimSpace(opts.CustomPrompt); customPrompt != "" {
- if len(customPrompt) > 1000 {
- customPrompt = customPrompt[:997] + "..."
+ if len(customPrompt) > 4000 {
+ customPrompt = customPrompt[:3997] + "..."
}
fmt.Printf("Using custom prompt: %s\n", customPrompt)
return customPrompt, nil
@@ -299,8 +299,8 @@ func (c *NanoBananaClient) buildPrompt(ctx context.Context, opts *SearchOptions)
translation := strings.TrimSpace(opts.Translation)
if customPrompt := strings.TrimSpace(opts.CustomPrompt); customPrompt != "" {
- if len(customPrompt) > 1000 {
- customPrompt = customPrompt[:997] + "..."
+ if len(customPrompt) > 4000 {
+ customPrompt = customPrompt[:3997] + "..."
}
fmt.Printf("Using custom prompt: %s\n", customPrompt)
return customPrompt, translation, nil
diff --git a/internal/story/artist.go b/internal/story/artist.go
index 8e7e5c7..fa62511 100644
--- a/internal/story/artist.go
+++ b/internal/story/artist.go
@@ -15,9 +15,23 @@ import (
)
const (
- // storyPageCount is the number of story pages (excluding cover/back).
+ // storyPageCount is the number of story pages (excluding cover/back/gallery).
// Each page uses a 2×2 grid of 4 panels in landscape (16:9) format.
- storyPageCount = 3
+ // cover + 5 story pages + 3 gallery pages + back cover = 10 total.
+ storyPageCount = 5
+
+ // galleryPageCount is the number of text-free close-up character art pages
+ // inserted between the story pages and the back cover. Each is a full-bleed
+ // single illustration of the hero/heroine in a distinct dramatic pose.
+ galleryPageCount = 3
+
+ // pageMaxRetries is the number of times a story page generation is retried
+ // before being skipped. Gemini image generation occasionally returns no data
+ // due to transient safety filter hits or API hiccups; a retry usually succeeds.
+ pageMaxRetries = 3
+
+ // pageRetryPause is the wait between story page retries.
+ pageRetryPause = 10 * time.Second
// comicPageAspectRatio: 16:9 is the closest supported widescreen ratio for
// the ThinkPad X1 Gen 9 (2560×1600 / 16:10), filling the display with minimal
@@ -163,44 +177,100 @@ func (a *Artist) DrawComicPages(storyText, prebuiltBible, titleSlug string, entr
var recentRefs [][]byte
// 1. Cover — generated without refs (it is the visual baseline).
- fmt.Println(" Generating cover page...")
- 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 {
+ // Retried up to pageMaxRetries times; failure is non-fatal but the cover
+ // is omitted from the PDF and no anchor reference is established.
+ p, coverBytes := a.generatePageWithRetry(buildCoverPrompt(storyText, style, bible), titleSlug+"_cover", nil, "cover page")
+ if p != "" {
paths = append(paths, p)
recentRefs = appendRef(recentRefs, coverBytes) // cover becomes the anchor reference
}
- // 2. Story pages (7-panel landscape: 3+4 rows) — each receives cover + previous page as refs.
+ // 2. Story pages — each receives cover + previous page as refs.
+ // Failures are non-fatal: up to pageMaxRetries attempts per page, then a
+ // warning is logged and generation continues with the next page so the PDF
+ // always contains as many pages as the API manages to produce.
sections := splitIntoSections(storyText, storyPageCount)
for i, section := range sections {
pageNum := i + 1
- fmt.Printf(" Generating story page %d/%d...\n", pageNum, storyPageCount)
- p, pageBytes, err := a.generateSinglePage(
- buildStoryPagePrompt(section, pageNum, storyPageCount, style, bible, entries),
- fmt.Sprintf("%s_page_%d", titleSlug, pageNum),
- recentRefs,
- )
- if err != nil {
- return paths, fmt.Errorf("story page %d failed: %w", pageNum, err)
+ prompt := buildStoryPagePrompt(section, pageNum, storyPageCount, style, bible, entries)
+ fileName := fmt.Sprintf("%s_page_%d", titleSlug, pageNum)
+ p, pageBytes := a.generateStoryPage(prompt, fileName, pageNum, recentRefs)
+ if p != "" {
+ paths = append(paths, p)
+ recentRefs = appendRef(recentRefs, pageBytes)
}
- paths = append(paths, p)
- recentRefs = appendRef(recentRefs, pageBytes)
}
- // 3. Back cover — receives the same rolling refs as the last story page.
- fmt.Println(" Generating back cover...")
- 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 {
+ // 3. Gallery pages — text-free close-up character art pages, one per pose.
+ // Each is a full-bleed single illustration; no panels, no text, no speech bubbles.
+ // They act as alternative covers and use the accumulated refs for consistency.
+ for i := range galleryPageCount {
+ galleryNum := i + 1
+ prompt := buildGalleryPagePrompt(style, bible, galleryNum)
+ fileName := fmt.Sprintf("%s_gallery_%d", titleSlug, galleryNum)
+ gp, galleryBytes := a.generatePageWithRetry(prompt, fileName, recentRefs,
+ fmt.Sprintf("gallery page %d/%d", galleryNum, galleryPageCount))
+ if gp != "" {
+ paths = append(paths, gp)
+ recentRefs = appendRef(recentRefs, galleryBytes)
+ }
+ }
+
+ // 4. Back cover — receives the same rolling refs as the last gallery page.
+ // Retried up to pageMaxRetries times; failure is non-fatal.
+ p, _ = a.generatePageWithRetry(buildBackCoverPrompt(storyText, style, bible, blurb), titleSlug+"_back", recentRefs, "back cover")
+ if p != "" {
paths = append(paths, p)
}
return paths, nil
}
+// generateStoryPage attempts to generate a single story page up to pageMaxRetries
+// times. It returns the saved path and image bytes on success, or empty strings
+// after all retries are exhausted (non-fatal — the caller continues with the next
+// page so the PDF is never aborted by a single transient API failure).
+func (a *Artist) generateStoryPage(prompt, fileName string, pageNum int, refs [][]byte) (string, []byte) {
+ fmt.Printf(" Generating story page %d/%d...\n", pageNum, storyPageCount)
+ for attempt := 1; attempt <= pageMaxRetries; attempt++ {
+ p, pageBytes, err := a.generateSinglePage(prompt, fileName, refs)
+ if err == nil {
+ return p, pageBytes
+ }
+ if attempt < pageMaxRetries {
+ fmt.Printf(" Story page %d attempt %d failed (%v), retrying in %s...\n",
+ pageNum, attempt, err, pageRetryPause)
+ time.Sleep(pageRetryPause)
+ } else {
+ fmt.Printf(" Warning: story page %d failed after %d attempts: %v\n",
+ pageNum, pageMaxRetries, err)
+ }
+ }
+ return "", nil
+}
+
+// generatePageWithRetry attempts to generate a single comic page (cover or back
+// cover) up to pageMaxRetries times. Returns the saved path and image bytes on
+// success, or ("", nil) after all retries are exhausted (non-fatal).
+func (a *Artist) generatePageWithRetry(prompt, fileName string, refs [][]byte, label string) (string, []byte) {
+ fmt.Printf(" Generating %s...\n", label)
+ for attempt := 1; attempt <= pageMaxRetries; attempt++ {
+ p, imgBytes, err := a.generateSinglePage(prompt, fileName, refs)
+ if err == nil {
+ return p, imgBytes
+ }
+ if attempt < pageMaxRetries {
+ fmt.Printf(" Warning: %s attempt %d/%d failed (%v), retrying in %s...\n",
+ label, attempt, pageMaxRetries, err, pageRetryPause)
+ time.Sleep(pageRetryPause)
+ } else {
+ fmt.Printf(" Warning: %s failed after %d attempts: %v\n",
+ label, pageMaxRetries, err)
+ }
+ }
+ return "", nil
+}
+
// 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.
@@ -385,8 +455,8 @@ func buildStoryPagePrompt(section string, pageNum, totalPages int, style, bible
"and panel labels MUST be written in Bulgarian Cyrillic script "+
"(например: Здравей! Какво правиш? Побързай!). "+
"English text anywhere in the panels is STRICTLY FORBIDDEN — use ONLY Bulgarian.\n\n"+
+ "%s"+ // vocabulary block — before art style so it is never truncated
"Art style: %s.%s\n"+
- "%s"+ // vocabulary block
"Comic book story page %d of %d. "+
"MANDATORY PANEL LAYOUT — divide the image into exactly 4 panels in a 2×2 grid:\n"+
" • TOP-LEFT panel: scene 1 from the excerpt\n"+
@@ -404,7 +474,7 @@ func buildStoryPagePrompt(section string, pageNum, totalPages int, style, bible
" • 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, vocabBlock, pageNum, totalPages, excerpt,
+ vocabBlock, style, bibleBlock, pageNum, totalPages, excerpt,
)
}
@@ -480,6 +550,34 @@ func buildBackCoverPrompt(storyText, style, bible, blurb string) string {
)
}
+// galleryPoses are the close-up compositions cycled across the 3 gallery pages.
+// Each is a distinct dramatic framing so the pages feel like variant cover art.
+var galleryPoses = []string{
+ "extreme close-up portrait: face and shoulders filling the entire frame, dramatic three-quarter lighting, intense gaze directly at the viewer, fine detail on eyes and expression",
+ "dynamic action pose: full body, low-angle shot looking up at the heroine against the sky or setting backdrop, confident stance, hair and clothing caught in motion",
+ "atmospheric mid-shot: waist-up, the heroine silhouetted or lit by the ambient environment (bioluminescence, sunset, neon glow), looking off into the distance with a sense of wonder or resolve",
+}
+
+// buildGalleryPagePrompt constructs a text-free close-up character art page prompt.
+// galleryNum (1-based) selects the pose from galleryPoses so each page is distinct.
+// No text, no panels, no speech bubbles — pure full-bleed illustration.
+func buildGalleryPagePrompt(style, bible string, galleryNum int) string {
+ pose := galleryPoses[(galleryNum-1)%len(galleryPoses)]
+ bibleBlock := bibleSection(bible, fmt.Sprintf("gallery page %d", galleryNum))
+ return fmt.Sprintf(
+ "Art style: %s.%s\n"+
+ "FULL-BLEED CHARACTER ART PAGE — portrait orientation, single illustration.\n"+
+ "NO text of any kind. NO title. NO labels. NO speech bubbles. NO panel borders. NO UI elements.\n"+
+ "This is a text-free variant cover / gallery page. Pure art only.\n\n"+
+ "Composition: %s\n\n"+
+ "The subject MUST be EXACTLY the main heroine described in the reference above — "+
+ "same face, same age, same clothing, same companion animal if naturally present. "+
+ "Do NOT invent new characters. Do NOT add any text overlays.\n"+
+ "Background: the story's setting rendered with full cinematic atmosphere and colour mood.",
+ style, bibleBlock, pose,
+ )
+}
+
// bibleSection formats the character bible as a labelled block for the prompt.
// Returns empty string when bible is empty.
func bibleSection(bible, context string) string {
diff --git a/internal/story/generator.go b/internal/story/generator.go
index 896782b..77525f1 100644
--- a/internal/story/generator.go
+++ b/internal/story/generator.go
@@ -267,8 +267,10 @@ func buildStoryPromptFull(entries []batch.WordEntry, theme string) string {
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("IMPORTANT: all human characters must be adults (18 years or older). ")
+ sb.WriteString("Do NOT describe any character as a child, teenager, or minor.\n")
+ sb.WriteString("For every named HUMAN character: name, apparent age as a young adult or older ")
+ sb.WriteString("(e.g. young adult, adult, 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 ")
diff --git a/internal/story/narrator.go b/internal/story/narrator.go
index 39fa456..be3c21c 100644
--- a/internal/story/narrator.go
+++ b/internal/story/narrator.go
@@ -16,15 +16,27 @@ import (
)
const (
- // narratorTimeout gives the TTS API up to 2 minutes per chunk.
- // Chunks are much shorter than the full story, so this is generous.
- narratorTimeout = 2 * time.Minute
+ // narratorTimeout gives the TTS API up to 3 minutes per chunk.
+ // Gemini TTS can be slow under load; 3 minutes avoids spurious timeouts
+ // while still bounding runaway requests.
+ narratorTimeout = 3 * time.Minute
// narratorChunkWords is the target word count per TTS chunk.
- // Gemini TTS degrades in quality and voice consistency for long texts;
- // splitting at ~200 words keeps each call short and the voice stable.
+ // Gemini TTS degrades in quality and voice consistency after ~1 minute;
+ // ~100 words at typical Bulgarian speech rate (~120 words/min) keeps each
+ // call to ~50 seconds, safely under the 1-minute quality threshold.
// Chunks are split at paragraph boundaries whenever possible.
- narratorChunkWords = 200
+ narratorChunkWords = 100
+
+ // introSystemInstruction directs Gemini to write a short cinematic teaser
+ // in Bulgarian — roughly 30–40 words (≈15 seconds of narration) — that hooks
+ // the listener before the main story begins.
+ introSystemInstruction = `You are a dramatic cinematic narrator writing an opening teaser for a Bulgarian story.
+Write a SHORT opening teaser in the BULGARIAN language (NOT Russian — Bulgarian uses Cyrillic but
+is a distinct language with different phonology, vocabulary, and grammar).
+Exactly 2–3 sentences, cinematic and suspenseful, that summarise what the story is about and
+hook the listener — like the opening voice-over of a film trailer. Do NOT spoil the ending.
+Output only the Bulgarian teaser text, nothing else.`
// conclusionSystemInstruction directs Gemini to write a short cinematic
// epilogue in Bulgarian — roughly 40–60 words (≈15–30 seconds of narration).
@@ -74,16 +86,21 @@ type NarratorConfig struct {
Voice string // empty → random pick from cinematicVoices each run
}
-// Narrator wraps a Gemini TTS Provider and generates cinematic MP3 narrations.
+// Narrator wraps two Gemini TTS providers: one for the main story and a
+// different voice for the epilogue, so the moral/conclusion is clearly
+// distinguished from the narrative.
type Narrator struct {
- provider audio.Provider
- apiKey string // stored for the conclusion text-generation call
- voice string // resolved voice name, stored for progress logging
+ provider audio.Provider
+ conclusionProvider audio.Provider // different voice for the epilogue
+ apiKey string // stored for the conclusion text-generation call
+ voice string // main voice name, for progress logging
+ conclusionVoice string // epilogue voice name, for progress logging
}
-// NewNarrator wires a GeminiProvider with the cinematic voice and returns a
-// Narrator ready to call. Returns an error if the API key is missing or the
-// provider cannot be initialised.
+// NewNarrator wires a main GeminiProvider (story) and a second provider with a
+// different voice (epilogue). Returns an error if the API key is missing or the
+// main provider cannot be initialised. Epilogue provider failure is non-fatal —
+// it falls back to the main voice.
func NewNarrator(config *NarratorConfig) (*Narrator, error) {
if config == nil || config.APIKey == "" {
return nil, fmt.Errorf("Google API key is required for story narration")
@@ -104,14 +121,36 @@ func NewNarrator(config *NarratorConfig) (*Narrator, error) {
return nil, fmt.Errorf("narrator: initialise Gemini TTS: %w", err)
}
- return &Narrator{provider: provider, apiKey: config.APIKey, voice: voice}, nil
+ // Pick a different voice for the epilogue so it sounds distinct from
+ // the main narrative — signals to the listener that it is a separate segment.
+ conclusionVoice := pickDifferentVoice(voice)
+ conclusionProvider, err := audio.NewProvider(&audio.Config{
+ Provider: "gemini",
+ OutputFormat: "mp3",
+ GoogleAPIKey: config.APIKey,
+ GeminiVoice: conclusionVoice,
+ })
+ if err != nil {
+ // Non-fatal — fall back to the main voice.
+ fmt.Printf(" Warning: epilogue voice init failed (%v), using main voice\n", err)
+ conclusionProvider = provider
+ conclusionVoice = voice
+ }
+
+ return &Narrator{
+ provider: provider,
+ conclusionProvider: conclusionProvider,
+ apiKey: config.APIKey,
+ voice: voice,
+ conclusionVoice: conclusionVoice,
+ }, nil
}
// Narrate generates a cinematic MP3 narration of storyText and saves it to
-// outputFile. The story is split into short paragraph-aligned chunks before
-// calling the TTS API so the voice quality stays high throughout (Gemini TTS
-// degrades on long single-call texts). A Gemini-generated cinematic epilogue
-// is always appended as a final 15–30 second concluding segment.
+// outputFile. Structure:
+// 1. Intro (conclusion voice + ambient music): 15-second teaser summarising the story
+// 2. Main story (main voice): split into ~200-word chunks for consistent quality
+// 3. Epilogue (conclusion voice + ambient music): cinematic moral/outro
func (n *Narrator) Narrate(storyText, outputFile string) error {
tmpDir, err := os.MkdirTemp("", "totalrecall-narration-*")
if err != nil {
@@ -119,6 +158,38 @@ func (n *Narrator) Narrate(storyText, outputFile string) error {
}
defer os.RemoveAll(tmpDir)
+ // Intro and epilogue share the conclusion voice and ambient music so they
+ // frame the main story as distinct cinematic bookends.
+ var allPaths []string
+ if introPath, ok := n.narrateIntro(storyText, tmpDir); ok {
+ allPaths = append(allPaths, introPath)
+ }
+
+ chunkPaths, err := n.narrateMainStory(storyText, tmpDir)
+ if err != nil {
+ return err
+ }
+ allPaths = append(allPaths, chunkPaths...)
+
+ if conclusionPath, ok := n.narrateConclusion(storyText, tmpDir); ok {
+ allPaths = append(allPaths, conclusionPath)
+ }
+
+ // Merge intro + story + epilogue into one file, then widen to stereo.
+ combinedPath := filepath.Join(tmpDir, "combined.mp3")
+ if len(allPaths) == 1 {
+ combinedPath = allPaths[0]
+ } else {
+ if err := concatenateMP3s(allPaths, combinedPath, tmpDir); err != nil {
+ return err
+ }
+ }
+ return convertToStereo(combinedPath, outputFile)
+}
+
+// narrateMainStory splits the story into chunks and narrates each with the
+// main voice. Returns the list of chunk MP3 paths.
+func (n *Narrator) narrateMainStory(storyText, tmpDir string) ([]string, error) {
chunks := splitIntoNarrationChunks(storyText, narratorChunkWords)
fmt.Printf(" Splitting narration into %d chunks for consistent voice quality...\n", len(chunks))
@@ -126,51 +197,114 @@ func (n *Narrator) Narrate(storyText, outputFile string) error {
for i, chunk := range chunks {
chunkPath := filepath.Join(tmpDir, fmt.Sprintf("chunk_%03d.mp3", i+1))
fmt.Printf(" Narrating chunk %d/%d...\n", i+1, len(chunks))
- if err := n.narrateChunk(cinematicInstruction+chunk, chunkPath); err != nil {
- return fmt.Errorf("narrate chunk %d: %w", i+1, err)
+ if err := n.narrateChunkWith(n.provider, cinematicInstruction+chunk, chunkPath); err != nil {
+ return nil, fmt.Errorf("narrate chunk %d: %w", i+1, err)
}
chunkPaths = append(chunkPaths, chunkPath)
}
+ return chunkPaths, nil
+}
+
+// narrateIntro generates a short Bulgarian cinematic teaser (~15 s) via Gemini
+// text, narrates it in the conclusion voice with ambient music, and returns the
+// path. Non-fatal — main narration continues if intro fails.
+func (n *Narrator) narrateIntro(storyText, tmpDir string) (string, bool) {
+ intro := n.buildIntro(storyText)
+ if intro == "" {
+ return "", false
+ }
- // Generate and append a cinematic epilogue as the final segment.
- if conclusionPath, ok := n.narrateConclusion(storyText, tmpDir); ok {
- chunkPaths = append(chunkPaths, conclusionPath)
+ fmt.Printf(" Narrating intro teaser (voice: %s)...\n", n.conclusionVoice)
+ introRaw := filepath.Join(tmpDir, "intro_narration.mp3")
+ if err := n.narrateChunkWith(n.conclusionProvider, cinematicInstruction+intro, introRaw); err != nil {
+ fmt.Printf(" Warning: intro narration failed: %v\n", err)
+ return "", false
}
- // Merge all segments into a single file, then widen to stereo.
- // Gemini TTS produces mono audio; convertToStereo duplicates the channel so
- // the result plays correctly on headphones without audio only in one ear.
- combinedPath := filepath.Join(tmpDir, "combined.mp3")
- if len(chunkPaths) == 1 {
- combinedPath = chunkPaths[0]
- } else {
- if err := concatenateMP3s(chunkPaths, combinedPath, tmpDir); err != nil {
- return err
- }
+ introWithMusic := filepath.Join(tmpDir, "intro_with_music.mp3")
+ if err := mixAmbientMusic(introRaw, introWithMusic, tmpDir); err != nil {
+ fmt.Printf(" Warning: intro music mix failed (%v) — using narration only\n", err)
+ return introRaw, true
}
- return convertToStereo(combinedPath, outputFile)
+ return introWithMusic, true
+}
+
+// buildIntro calls Gemini to produce a short Bulgarian cinematic opening teaser
+// (~30–40 words, ≈15 s). Returns empty string on failure.
+func (n *Narrator) buildIntro(storyText string) string {
+ if n.apiKey == "" {
+ return ""
+ }
+ client, err := genai.NewClient(context.Background(), &genai.ClientConfig{APIKey: n.apiKey})
+ if err != nil {
+ fmt.Printf(" Warning: intro text generation failed: %v\n", err)
+ return ""
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), helperTimeout)
+ defer cancel()
+ resp, err := client.Models.GenerateContent(ctx, helperModel,
+ []*genai.Content{genai.NewContentFromText(storyText, genai.RoleUser)},
+ &genai.GenerateContentConfig{
+ SystemInstruction: &genai.Content{
+ Parts: []*genai.Part{{Text: introSystemInstruction}},
+ },
+ MaxOutputTokens: helperMaxTokens,
+ },
+ )
+ if err != nil {
+ fmt.Printf(" Warning: intro text generation failed: %v\n", err)
+ return ""
+ }
+ text := strings.TrimSpace(resp.Text())
+ if text == "" {
+ fmt.Println(" Warning: intro text generation returned empty response")
+ }
+ return text
}
-// narrateConclusion generates a short Bulgarian cinematic epilogue via Gemini text,
-// then narrates it as an MP3 written to tmpDir. Returns the path and true on success,
-// or empty string and false on any failure (non-fatal — the main narration still saves).
+// narrateConclusion generates a short Bulgarian cinematic epilogue via Gemini
+// text, splits it into chunks, narrates each with the epilogue voice, then
+// mixes in ambient background music. Returns the final path and true on success.
+// Non-fatal on any failure — the main narration always saves.
func (n *Narrator) narrateConclusion(storyText, tmpDir string) (string, bool) {
conclusion := n.buildConclusion(storyText)
if conclusion == "" {
return "", false
}
- fmt.Println(" Narrating concluding epilogue...")
- conclusionPath := filepath.Join(tmpDir, "conclusion.mp3")
- if err := n.narrateChunk(cinematicInstruction+conclusion, conclusionPath); err != nil {
- fmt.Printf(" Warning: conclusion narration failed: %v\n", err)
- return "", false
+ fmt.Printf(" Narrating concluding epilogue (voice: %s)...\n", n.conclusionVoice)
+
+ chunks := splitIntoNarrationChunks(conclusion, narratorChunkWords)
+ var chunkPaths []string
+ for i, chunk := range chunks {
+ chunkPath := filepath.Join(tmpDir, fmt.Sprintf("conclusion_%03d.mp3", i+1))
+ if err := n.narrateChunkWith(n.conclusionProvider, cinematicInstruction+chunk, chunkPath); err != nil {
+ fmt.Printf(" Warning: conclusion narration failed: %v\n", err)
+ return "", false
+ }
+ chunkPaths = append(chunkPaths, chunkPath)
+ }
+
+ // Join conclusion chunks if there is more than one.
+ conclusionNarration := filepath.Join(tmpDir, "conclusion_narration.mp3")
+ if len(chunkPaths) == 1 {
+ conclusionNarration = chunkPaths[0]
+ } else if err := concatenateMP3s(chunkPaths, conclusionNarration, tmpDir); err != nil {
+ fmt.Printf(" Warning: conclusion concat failed: %v\n", err)
+ return chunkPaths[len(chunkPaths)-1], true
}
- return conclusionPath, true
+
+ // Mix ambient background music under the epilogue for a cinematic feel.
+ conclusionWithMusic := filepath.Join(tmpDir, "conclusion_with_music.mp3")
+ if err := mixAmbientMusic(conclusionNarration, conclusionWithMusic, tmpDir); err != nil {
+ fmt.Printf(" Warning: background music mix failed (%v) — using narration only\n", err)
+ return conclusionNarration, true
+ }
+ return conclusionWithMusic, true
}
-// buildConclusion calls Gemini text to produce a short Bulgarian cinematic epilogue
-// (≈40–60 words, ~15–30 s of narration). Returns empty string on failure.
+// buildConclusion calls Gemini text to produce a short Bulgarian cinematic
+// epilogue (≈40–60 words, ~15–30 s of narration). Returns empty string on failure.
func (n *Narrator) buildConclusion(storyText string) string {
if n.apiKey == "" {
return ""
@@ -206,11 +340,72 @@ func (n *Narrator) buildConclusion(storyText string) string {
return text
}
-// narrateChunk calls the TTS provider for a single text segment.
-func (n *Narrator) narrateChunk(text, outputFile string) error {
+// narrateChunkWith calls the given TTS provider for a single text segment.
+func (n *Narrator) narrateChunkWith(provider audio.Provider, text, outputFile string) error {
ctx, cancel := context.WithTimeout(context.Background(), narratorTimeout)
defer cancel()
- return n.provider.GenerateAudio(ctx, text, outputFile)
+ return provider.GenerateAudio(ctx, text, outputFile)
+}
+
+// mixAmbientMusic generates a soft cinematic ambient pad using ffmpeg's built-in
+// signal generators (bass drone sine waves + quiet pink noise) and mixes it under
+// narrationFile at low volume. The music fades in over 4 seconds and is cut to
+// exactly the length of the narration. Falls back gracefully when ffmpeg is absent.
+func mixAmbientMusic(narrationFile, outputFile, tmpDir string) error {
+ ffmpegPath, err := exec.LookPath("ffmpeg")
+ if err != nil {
+ return fmt.Errorf("ffmpeg not found")
+ }
+
+ musicPath := filepath.Join(tmpDir, "ambient_pad.mp3")
+ if err := generateAmbientPad(ffmpegPath, musicPath); err != nil {
+ return err
+ }
+
+ // Mix: narration at full weight, ambient pad at 30% weight.
+ // duration=first stops when narration (first input) ends.
+ cmd := exec.Command(ffmpegPath,
+ "-nostdin", "-hide_banner", "-loglevel", "error", "-y",
+ "-i", narrationFile,
+ "-i", musicPath,
+ "-filter_complex", "[0:a][1:a]amix=inputs=2:weights=1 0.3:duration=first[aout]",
+ "-map", "[aout]",
+ "-ac", "2",
+ "-codec:a", "libmp3lame", "-q:a", "2",
+ outputFile,
+ )
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("background music mix failed: %w\n%s", err, strings.TrimSpace(string(out)))
+ }
+ return nil
+}
+
+// generateAmbientPad creates a 5-minute ambient cinematic pad MP3 using ffmpeg's
+// aevalsrc filter. The pad consists of a C-major bass drone (C2+G2+C3) mixed with
+// quiet pink noise for atmosphere, with a 4-second fade-in. The long duration
+// ensures it always outlasts the epilogue narration; mixing uses duration=first
+// to cut it cleanly at the end of the voice track.
+func generateAmbientPad(ffmpegPath, outputFile string) error {
+ // Bass drone: C2 (65 Hz), G2 (98 Hz), C3 (130 Hz) at low amplitudes.
+ // Pink noise adds cinematic texture without dominating the voice.
+ droneExpr := "0.04*sin(65*2*PI*t)+0.03*sin(98*2*PI*t)+0.02*sin(130*2*PI*t)"
+ cmd := exec.Command(ffmpegPath,
+ "-nostdin", "-hide_banner", "-loglevel", "error", "-y",
+ "-f", "lavfi",
+ "-i", fmt.Sprintf("aevalsrc=%s:sample_rate=44100", droneExpr),
+ "-f", "lavfi", "-i", "anoisesrc=color=pink:amplitude=0.008",
+ "-filter_complex", "[0:a][1:a]amix=inputs=2:duration=first[mixed];[mixed]afade=t=in:st=0:d=4[aout]",
+ "-map", "[aout]",
+ "-t", "300", // 5 minutes — always longer than the epilogue
+ "-ac", "2",
+ outputFile,
+ )
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("ambient pad generation failed: %w\n%s", err, strings.TrimSpace(string(out)))
+ }
+ return nil
}
// concatenateMP3s joins chunkPaths into outputFile using ffmpeg's concat demuxer.
@@ -312,3 +507,18 @@ func splitIntoNarrationChunks(text string, targetWords int) []string {
func pickCinematicVoice() string {
return cinematicVoices[rand.IntN(len(cinematicVoices))]
}
+
+// pickDifferentVoice returns a random voice from the pool that is not currentVoice.
+// Used to ensure the epilogue sounds distinct from the main narration.
+func pickDifferentVoice(currentVoice string) string {
+ pool := make([]string, 0, len(cinematicVoices)-1)
+ for _, v := range cinematicVoices {
+ if !strings.EqualFold(v, currentVoice) {
+ pool = append(pool, v)
+ }
+ }
+ if len(pool) == 0 {
+ return currentVoice
+ }
+ return pool[rand.IntN(len(pool))]
+}
diff --git a/internal/story/runner.go b/internal/story/runner.go
index 871cb0e..f69b2f4 100644
--- a/internal/story/runner.go
+++ b/internal/story/runner.go
@@ -152,7 +152,7 @@ func (r *Runner) Run(batchFile string) error {
// entries carries the vocabulary words so panels can visually feature and label them.
// Errors are non-fatal — story.txt is always accessible regardless of image failures.
func (r *Runner) drawComicPages(storyText, bible, titleSlug string, entries []batch.WordEntry) {
- fmt.Printf("Generating %d comic pages...\n", storyPageCount+2) // 2 = cover + back cover
+ fmt.Printf("Generating %d comic pages...\n", storyPageCount+galleryPageCount+2) // cover + story + gallery + back
paths, err := r.artist.DrawComicPages(storyText, bible, titleSlug, entries)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: comic page generation failed: %v\n", err)
diff --git a/internal/version.go b/internal/version.go
index 502f343..39b6037 100644
--- a/internal/version.go
+++ b/internal/version.go
@@ -1,3 +1,3 @@
package internal
-const Version = "0.10.0"
+const Version = "0.11.0"