diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-03 13:15:44 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-03 13:15:44 +0300 |
| commit | b819475635eec77c72ab1b3ec0793dd59f750013 (patch) | |
| tree | a2fa6459b8eacdbc7314b97f4ee459ec8e6eb291 /internal | |
| parent | 5a526a59bc650576d69c1f358449230e7751eb6d (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.go | 5 | ||||
| -rw-r--r-- | internal/cli/flags.go | 3 | ||||
| -rw-r--r-- | internal/story/artist.go | 223 | ||||
| -rw-r--r-- | internal/story/generator.go | 119 | ||||
| -rw-r--r-- | internal/story/narrator.go | 95 | ||||
| -rw-r--r-- | internal/story/runner.go | 175 | ||||
| -rw-r--r-- | internal/version.go | 2 |
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" |
