summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-03 13:15:44 +0300
committerPaul Buetow <paul@buetow.org>2026-04-03 13:15:44 +0300
commitb819475635eec77c72ab1b3ec0793dd59f750013 (patch)
treea2fa6459b8eacdbc7314b97f4ee459ec8e6eb291 /internal
parent5a526a59bc650576d69c1f358449230e7751eb6d (diff)
feat: add --story cinematic narration and comic strip generation
- Generates a ~500-word Bulgarian vocabulary story from a batch file - Produces 3 comic pages via NanoBanana (90% ultra-realistic style) - Art style chosen randomly per run; override with --story-style - Cinematic Gemini TTS narration saved as story_narration.mp3 - Random voice from curated pool (Charon, Fenrir, Enceladus, Algieba, Aoede, Schedar) - Override narrator voice with --narrator-voice - Falls back to story_tts_todo.txt if narration fails - No new API key required — reuses existing GOOGLE_API_KEY - Bump version to 0.9.4 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal')
-rw-r--r--internal/cli/command.go5
-rw-r--r--internal/cli/flags.go3
-rw-r--r--internal/story/artist.go223
-rw-r--r--internal/story/generator.go119
-rw-r--r--internal/story/narrator.go95
-rw-r--r--internal/story/runner.go175
-rw-r--r--internal/version.go2
7 files changed, 621 insertions, 1 deletions
diff --git a/internal/cli/command.go b/internal/cli/command.go
index 09c1758..ff96009 100644
--- a/internal/cli/command.go
+++ b/internal/cli/command.go
@@ -65,6 +65,11 @@ func setupFlags(cmd *cobra.Command, flags *Flags) {
cmd.Flags().StringVarP(&flags.AudioFormat, "format", "f", flags.AudioFormat, "Audio format (wav or mp3; Gemini TTS writes wav natively and auto-converts to mp3 with ffmpeg, which is now the default)")
cmd.Flags().StringVar(&flags.ImageAPI, "image-api", flags.ImageAPI, "Image source for explicit CLI runs (default: Nano Banana; use openai to switch, config file image.provider also applies when unset)")
cmd.Flags().StringVar(&flags.BatchFile, "batch", "", "Process words from file (one per line)")
+ cmd.Flags().StringVar(&flags.StoryFile, "story", "", "Generate a vocabulary story + comic image from a batch-format file (outputs to current directory)")
+ cmd.Flags().StringVar(&flags.StoryStyle, "story-style", "", "Art style for comic pages (default: random). E.g. \"ultra realistic comic strip with photographic detail and dramatic lighting\"")
+ cmd.Flags().StringVar(&flags.NarratorVoice, "narrator-voice", "",
+ "Gemini voice for cinematic story narration (default: random from cinematic pool). "+
+ "Valid values: Charon, Fenrir, Enceladus, Algieba, Aoede, Schedar")
cmd.Flags().BoolVar(&flags.SkipAudio, "skip-audio", false, "Skip audio generation")
cmd.Flags().BoolVar(&flags.SkipImages, "skip-images", false, "Skip image download")
cmd.Flags().BoolVar(&flags.GenerateAnki, "anki", false, "Generate Anki import file (APKG format by default, use --anki-csv for legacy CSV)")
diff --git a/internal/cli/flags.go b/internal/cli/flags.go
index 0dad679..30e3854 100644
--- a/internal/cli/flags.go
+++ b/internal/cli/flags.go
@@ -20,6 +20,9 @@ type Flags struct {
ImageAPI string
ImageAPISpecified bool
BatchFile string
+ StoryFile string // --story <file>: generate vocabulary story + comic image
+ StoryStyle string // --story-style: override the random art style (empty = random)
+ NarratorVoice string // --narrator-voice: Gemini voice for cinematic narration (empty = random)
SkipAudio bool
SkipImages bool
GenerateAnki bool
diff --git a/internal/story/artist.go b/internal/story/artist.go
new file mode 100644
index 0000000..34d89ed
--- /dev/null
+++ b/internal/story/artist.go
@@ -0,0 +1,223 @@
+package story
+
+import (
+ "context"
+ "fmt"
+ "math/rand/v2"
+ "strings"
+
+ "codeberg.org/snonux/totalrecall/internal/image"
+)
+
+const (
+ // comicPageCount is the number of comic pages generated per story.
+ comicPageCount = 3
+
+ // comicPromptMaxChars caps each page's story excerpt in the NanoBanana prompt
+ // so it stays well within the model's context window.
+ comicPromptMaxChars = 800
+)
+
+// 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.
+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",
+ "Japanese manga with clean linework, expressive eyes, and speed lines",
+ "retro 1960s pop art in the style of Roy Lichtenstein with thick outlines and Ben-Day dots",
+ "watercolor illustration with soft washes, delicate linework, and pastel tones",
+ "European bande dessinée with detailed backgrounds, clear lines, and rich flat colors",
+ "noir black-and-white graphic novel with heavy shadows and high contrast",
+ "children's picture book with bright, friendly illustrations and thick outlines",
+ "painterly oil-on-canvas comic with loose brushwork and vivid impressionist colors",
+ "cyberpunk neon art with glowing outlines, dark backgrounds, and electric accent colors",
+}
+
+// ArtistConfig holds settings for comic-book image generation via NanoBanana.
+type ArtistConfig struct {
+ APIKey string // Google API key
+ Model string // NanoBanana image model
+ TextModel string // NanoBanana text/prompt model
+ OutputDir string // target directory; defaults to "."
+ // Style overrides the random art-style pick when non-empty.
+ Style string
+}
+
+// Artist generates comic-book-style images that illustrate the story.
+type Artist struct {
+ nbClient image.ImageClient
+ outputDir string
+ style string // empty = pick randomly each run
+}
+
+// NewArtist creates an Artist backed by the NanoBanana image generator.
+func NewArtist(config *ArtistConfig) *Artist {
+ dir := "."
+ if config != nil && config.OutputDir != "" {
+ dir = config.OutputDir
+ }
+
+ var nbConfig *image.NanoBananaConfig
+ if config != nil {
+ nbConfig = &image.NanoBananaConfig{
+ APIKey: config.APIKey,
+ Model: config.Model,
+ TextModel: config.TextModel,
+ }
+ }
+
+ var style string
+ if config != nil {
+ style = config.Style
+ }
+
+ return &Artist{
+ nbClient: image.NewNanoBananaClient(nbConfig),
+ outputDir: dir,
+ style: style,
+ }
+}
+
+// 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.
+ 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)
+
+ downloader := image.NewDownloader(a.nbClient, &image.DownloadOptions{
+ OutputDir: a.outputDir,
+ OverwriteExisting: true,
+ CreateDir: true,
+ FileNamePattern: fmt.Sprintf("comic_page_%d", pageNum),
+ MaxSizeBytes: 20 * 1024 * 1024,
+ })
+
+ ctx := context.Background()
+ _, savedPath, err := downloader.DownloadBestMatchWithOptions(ctx, opts)
+ if err != nil {
+ return "", 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)
+ if len(excerpt) > comicPromptMaxChars {
+ excerpt = excerpt[:comicPromptMaxChars]
+ if idx := strings.LastIndex(excerpt, " "); idx > 0 {
+ excerpt = excerpt[:idx]
+ }
+ excerpt += "…"
+ }
+
+ 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,
+ )
+}
+
+// pickStyle returns a randomly chosen art style.
+// Ultra realistic is selected 90% of the time; one of the other styles fills
+// the remaining 10% to provide occasional visual variety.
+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/generator.go b/internal/story/generator.go
new file mode 100644
index 0000000..b8254eb
--- /dev/null
+++ b/internal/story/generator.go
@@ -0,0 +1,119 @@
+package story
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ "google.golang.org/genai"
+
+ "codeberg.org/snonux/totalrecall/internal/batch"
+)
+
+const (
+ storyGeminiModel = "gemini-2.5-flash"
+ storyTimeout = 120 * time.Second
+ // 8192 tokens gives plenty of room for both Gemini 2.5 Flash's internal
+ // thinking tokens and the ~650 visible tokens of a 500-word Bulgarian story.
+ // A small budget (e.g. 1024) is silently consumed by thinking before any
+ // visible text is emitted, producing a truncated result.
+ storyMaxTokens = int32(8192)
+ storySystemPrompt = "You are a creative Bulgarian language teacher. Write engaging stories that naturally incorporate vocabulary words to help students learn."
+)
+
+// Config holds generator settings and API credentials.
+type Config struct {
+ APIKey string
+ TextModel string // defaults to storyGeminiModel
+}
+
+// Generator uses Gemini to produce vocabulary-based stories.
+type Generator struct {
+ client *genai.Client
+ initErr error
+ textModel string
+}
+
+// var seam for test injection, mirrors the phonetic/fetcher.go pattern.
+var generateStoryText = func(ctx context.Context, client *genai.Client, model, prompt string) (string, error) {
+ resp, err := client.Models.GenerateContent(ctx, model, []*genai.Content{
+ genai.NewContentFromText(prompt, genai.RoleUser),
+ }, &genai.GenerateContentConfig{
+ SystemInstruction: &genai.Content{
+ Parts: []*genai.Part{{Text: storySystemPrompt}},
+ },
+ MaxOutputTokens: storyMaxTokens,
+ })
+ if err != nil {
+ return "", fmt.Errorf("gemini API error: %w", err)
+ }
+
+ text := strings.TrimSpace(resp.Text())
+ if text == "" {
+ return "", fmt.Errorf("no story content returned from Gemini")
+ }
+
+ return text, nil
+}
+
+// NewGenerator creates a Generator that calls Gemini with the given API key.
+// If the API key is empty, Generate will return an error.
+func NewGenerator(config *Config) *Generator {
+ g := &Generator{
+ textModel: storyGeminiModel,
+ }
+
+ if config == nil || config.APIKey == "" {
+ g.initErr = fmt.Errorf("Google API key is required for story generation")
+ return g
+ }
+
+ if config.TextModel != "" {
+ g.textModel = config.TextModel
+ }
+
+ client, err := genai.NewClient(context.Background(), &genai.ClientConfig{
+ APIKey: config.APIKey,
+ })
+ if err != nil {
+ g.initErr = fmt.Errorf("failed to create Gemini client: %w", err)
+ return g
+ }
+
+ g.client = client
+ return g
+}
+
+// Generate builds a ~500-word story that uses every word in entries naturally
+// and returns the raw story text.
+func (g *Generator) Generate(entries []batch.WordEntry) (string, error) {
+ if g.initErr != nil {
+ return "", g.initErr
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), storyTimeout)
+ defer cancel()
+
+ prompt := buildStoryPrompt(entries)
+ return generateStoryText(ctx, g.client, g.textModel, prompt)
+}
+
+// buildStoryPrompt creates the Gemini prompt from the word list.
+func buildStoryPrompt(entries []batch.WordEntry) string {
+ var sb strings.Builder
+ sb.WriteString("Write a ~500-word story in Bulgarian that naturally uses all of the following words.\n")
+ sb.WriteString("Number each word as shown below. Return ONLY the story text — no title, no header, no explanation.\n\n")
+ sb.WriteString("Words to include:\n")
+
+ for i, e := range entries {
+ word := e.Bulgarian
+ if e.Translation != "" {
+ sb.WriteString(fmt.Sprintf("%d. %s (%s)\n", i+1, word, e.Translation))
+ } else {
+ sb.WriteString(fmt.Sprintf("%d. %s\n", i+1, word))
+ }
+ }
+
+ return sb.String()
+}
diff --git a/internal/story/narrator.go b/internal/story/narrator.go
new file mode 100644
index 0000000..25ca9cb
--- /dev/null
+++ b/internal/story/narrator.go
@@ -0,0 +1,95 @@
+package story
+
+import (
+ "context"
+ "fmt"
+ "math/rand/v2"
+ "time"
+
+ "codeberg.org/snonux/totalrecall/internal/audio"
+)
+
+const (
+ // narratorTimeout gives the TTS API up to 3 minutes to narrate a full story.
+ // A ~500-word story is much longer than a flashcard word, so the generous
+ // timeout prevents premature cancellation on slow API responses.
+ narratorTimeout = 3 * time.Minute
+
+ // cinematicInstruction is prepended to the story text before the TTS call.
+ // 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.
+
+`
+)
+
+// cinematicVoices is a curated subset of Gemini voices chosen for their
+// narrative quality. A voice is picked randomly each run so repeated story
+// generations sound different. Users can override with --narrator-voice.
+var cinematicVoices = []string{
+ "Charon", // deep, measured — authoritative narrator feel
+ "Fenrir", // strong, resonant — good for dramatic pacing
+ "Enceladus", // breathy, intimate — cinematic closeness
+ "Algieba", // smooth, warm — classic storytelling tone
+ "Aoede", // breezy, expressive — light narrative energy
+ "Schedar", // steady, grounded presence — suits long-form stories
+}
+
+// NarratorConfig holds credentials and voice preferences for Gemini TTS narration.
+type NarratorConfig struct {
+ APIKey string // Google API key — the same GOOGLE_API_KEY already used by the project
+ Voice string // empty → random pick from cinematicVoices each run
+}
+
+// Narrator wraps a Gemini TTS Provider and generates cinematic MP3 narrations.
+type Narrator struct {
+ provider audio.Provider
+ voice string // resolved voice name, stored 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.
+func NewNarrator(config *NarratorConfig) (*Narrator, error) {
+ if config == nil || config.APIKey == "" {
+ return nil, fmt.Errorf("Google API key is required for story narration")
+ }
+
+ voice := config.Voice
+ if voice == "" {
+ voice = pickCinematicVoice()
+ }
+
+ provider, err := audio.NewProvider(&audio.Config{
+ Provider: "gemini",
+ OutputFormat: "mp3",
+ GoogleAPIKey: config.APIKey,
+ GeminiVoice: voice,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("narrator: initialise Gemini TTS: %w", err)
+ }
+
+ return &Narrator{provider: provider, voice: voice}, nil
+}
+
+// Narrate generates a cinematic MP3 narration of storyText and saves it to
+// outputFile. The cinematic instruction is prepended to the text so the TTS
+// model applies dramatic pacing and expressive intonation.
+func (n *Narrator) Narrate(storyText, outputFile string) error {
+ ctx, cancel := context.WithTimeout(context.Background(), narratorTimeout)
+ defer cancel()
+
+ cinematicText := cinematicInstruction + storyText
+ return n.provider.GenerateAudio(ctx, cinematicText, outputFile)
+}
+
+// pickCinematicVoice returns a random voice from the cinematicVoices pool.
+func pickCinematicVoice() string {
+ return cinematicVoices[rand.IntN(len(cinematicVoices))]
+}
diff --git a/internal/story/runner.go b/internal/story/runner.go
new file mode 100644
index 0000000..4e06a53
--- /dev/null
+++ b/internal/story/runner.go
@@ -0,0 +1,175 @@
+package story
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "codeberg.org/snonux/totalrecall/internal/batch"
+)
+
+// ttsTodoContent is written to story_tts_todo.txt as a fallback when Gemini TTS
+// narration fails or no API key is available. It documents the original
+// ElevenLabs integration placeholder for reference.
+const ttsTodoContent = `# Story Narration — Fallback Placeholder
+#
+# Gemini TTS narration was not produced (missing API key or generation error).
+#
+# To generate narration manually, run again with GOOGLE_API_KEY set, or
+# use the ElevenLabs TTS API as an alternative:
+# POST https://api.elevenlabs.io/v1/text-to-speech/{voice_id}
+# with the contents of story.txt and save the result as story_narration.mp3
+#
+# Reference: https://elevenlabs.io/docs/api-reference/text-to-speech
+`
+
+// RunnerConfig holds all settings required to orchestrate story generation.
+type RunnerConfig struct {
+ APIKey string // Google API key (Gemini text + NanoBanana image + Gemini TTS)
+ TextModel string // Gemini text model for Generator (empty → default)
+ ImageModel string // NanoBanana image model for Artist
+ ImageTextModel string // NanoBanana text model for Artist
+ OutputDir string // directory for output files; defaults to "."
+ // Style overrides the random art-style pick when non-empty.
+ // Accepts any free-form description; it is passed verbatim to the image model.
+ Style string
+ // NarratorVoice picks a specific Gemini cinematic voice for narration.
+ // Empty → random pick from the curated cinematic pool each run.
+ NarratorVoice string
+}
+
+// Runner orchestrates the full pipeline: text → image → narration.
+type Runner struct {
+ config *RunnerConfig
+ generator *Generator
+ artist *Artist
+ narrator *Narrator // nil when API key is absent or init fails
+}
+
+// NewRunner wires together a Generator, Artist, and Narrator from the given config.
+func NewRunner(config *RunnerConfig) *Runner {
+ dir := "."
+ if config != nil && config.OutputDir != "" {
+ dir = config.OutputDir
+ }
+
+ var apiKey, textModel, imageModel, imageTextModel, style, narratorVoice string
+ if config != nil {
+ apiKey = config.APIKey
+ textModel = config.TextModel
+ imageModel = config.ImageModel
+ imageTextModel = config.ImageTextModel
+ style = config.Style
+ narratorVoice = config.NarratorVoice
+ }
+
+ // Narrator init failure (e.g. missing key) is non-fatal — handleNarration
+ // falls back to writing story_tts_todo.txt when narrator is nil.
+ narrator, err := NewNarrator(&NarratorConfig{
+ APIKey: apiKey,
+ Voice: narratorVoice,
+ })
+ if err != nil {
+ narrator = nil
+ }
+
+ return &Runner{
+ config: config,
+ generator: NewGenerator(&Config{
+ APIKey: apiKey,
+ TextModel: textModel,
+ }),
+ artist: NewArtist(&ArtistConfig{
+ APIKey: apiKey,
+ Model: imageModel,
+ TextModel: imageTextModel,
+ OutputDir: dir,
+ Style: style,
+ }),
+ narrator: narrator,
+ }
+}
+
+// Run reads the batch file, generates a story, draws comic pages, narrates the
+// story, and writes all output files to the configured OutputDir.
+func (r *Runner) Run(batchFile string) error {
+ dir := "."
+ if r.config != nil && r.config.OutputDir != "" {
+ dir = r.config.OutputDir
+ }
+
+ entries, err := batch.ReadBatchFile(batchFile)
+ if err != nil {
+ return fmt.Errorf("failed to read batch file: %w", err)
+ }
+ if len(entries) == 0 {
+ return fmt.Errorf("batch file %q contains no words", batchFile)
+ }
+
+ fmt.Printf("Generating story for %d words...\n", len(entries))
+ storyText, err := r.generator.Generate(entries)
+ if err != nil {
+ return fmt.Errorf("story generation failed: %w", err)
+ }
+
+ if err := r.saveStoryText(storyText, dir); err != nil {
+ return err
+ }
+
+ r.drawComicPages(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)
+ 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)
+ }
+}
+
+// handleNarration generates a cinematic MP3 via Gemini TTS when a narrator is
+// available, or falls back to writing story_tts_todo.txt. Narration failure is
+// non-fatal: the placeholder is written instead so the pipeline always finishes.
+func (r *Runner) handleNarration(storyText, dir string) error {
+ if r.narrator == nil {
+ return r.saveTTSPlaceholder(dir)
+ }
+
+ mp3Path := filepath.Join(dir, "story_narration.mp3")
+ fmt.Printf("Generating cinematic narration (voice: %s)...\n", r.narrator.voice)
+ if err := r.narrator.Narrate(storyText, mp3Path); err != nil {
+ fmt.Fprintf(os.Stderr, "Warning: narration failed: %v\n", err)
+ return r.saveTTSPlaceholder(dir)
+ }
+
+ fmt.Printf("Narration saved: %s\n", mp3Path)
+ return nil
+}
+
+// saveStoryText writes the generated story to story.txt in dir.
+func (r *Runner) saveStoryText(text, dir string) error {
+ path := filepath.Join(dir, "story.txt")
+ if err := os.WriteFile(path, []byte(text+"\n"), 0644); err != nil {
+ return fmt.Errorf("failed to write story.txt: %w", err)
+ }
+ fmt.Printf("Story saved: %s\n", path)
+ return nil
+}
+
+// saveTTSPlaceholder writes story_tts_todo.txt as a fallback when narration
+// is unavailable or fails.
+func (r *Runner) saveTTSPlaceholder(dir string) error {
+ path := filepath.Join(dir, "story_tts_todo.txt")
+ if err := os.WriteFile(path, []byte(ttsTodoContent), 0644); err != nil {
+ return fmt.Errorf("failed to write story_tts_todo.txt: %w", err)
+ }
+ fmt.Printf("TTS placeholder saved: %s\n", path)
+ return nil
+}
diff --git a/internal/version.go b/internal/version.go
index 8b8a36b..ced4ea8 100644
--- a/internal/version.go
+++ b/internal/version.go
@@ -1,3 +1,3 @@
package internal
-const Version = "0.9.2"
+const Version = "0.9.4"