diff options
| -rw-r--r-- | internal/image/nanobanana.go | 8 | ||||
| -rw-r--r-- | internal/story/artist.go | 150 | ||||
| -rw-r--r-- | internal/story/generator.go | 6 | ||||
| -rw-r--r-- | internal/story/narrator.go | 306 | ||||
| -rw-r--r-- | internal/story/runner.go | 2 | ||||
| -rw-r--r-- | internal/version.go | 2 |
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" |
