From 3b26916eed21c5c4c3d2ef40f19a1ef6e47b5662 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 3 Apr 2026 23:36:30 +0300 Subject: fix --- internal/story/artist.go | 63 +++++++++++++++++++++++++++++++++++---------- internal/story/generator.go | 4 +-- internal/story/narrator.go | 60 +++++++++++++++++++++++++++++++++++------- internal/story/runner.go | 11 ++++---- 4 files changed, 108 insertions(+), 30 deletions(-) (limited to 'internal') diff --git a/internal/story/artist.go b/internal/story/artist.go index e40bb6a..8e7e5c7 100644 --- a/internal/story/artist.go +++ b/internal/story/artist.go @@ -10,11 +10,13 @@ import ( "google.golang.org/genai" + "codeberg.org/snonux/totalrecall/internal/batch" "codeberg.org/snonux/totalrecall/internal/image" ) const ( - // storyPageCount is the number of 9-panel story pages (excluding cover/back). + // storyPageCount is the number of story pages (excluding cover/back). + // Each page uses a 2×2 grid of 4 panels in landscape (16:9) format. storyPageCount = 3 // comicPageAspectRatio: 16:9 is the closest supported widescreen ratio for @@ -134,15 +136,17 @@ func NewArtist(config *ArtistConfig) *Artist { // DrawComicPages generates 5 images total: // - _cover.png — full-bleed cover -// - _page_1.png … _3 — 9-panel (3×3) story pages +// - _page_1.png … _3 — 4-panel (2×2 grid) landscape story pages // - _back.png — back cover // // A character bible injected into every prompt keeps characters, clothing, and // setting consistent across all pages. The bible is produced by GenerateFull in // the same Gemini call as the story; prebuiltBible is passed in from there. +// entries are the vocabulary words from input.txt — they are injected into every +// story page prompt so the image model visually features and labels them in panels. // titleSlug is used as the file-name prefix; it must already be a safe slug. // Returns the list of saved image paths in order. -func (a *Artist) DrawComicPages(storyText, prebuiltBible, titleSlug string) ([]string, error) { +func (a *Artist) DrawComicPages(storyText, prebuiltBible, titleSlug string, entries []batch.WordEntry) ([]string, error) { style := a.style if style == "" { style = pickStyle() @@ -168,13 +172,13 @@ func (a *Artist) DrawComicPages(storyText, prebuiltBible, titleSlug string) ([]s recentRefs = appendRef(recentRefs, coverBytes) // cover becomes the anchor reference } - // 2. Story pages (9-panel grids) — each page receives cover + previous page as refs. + // 2. Story pages (7-panel landscape: 3+4 rows) — each receives cover + previous page as refs. 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), + buildStoryPagePrompt(section, pageNum, storyPageCount, style, bible, entries), fmt.Sprintf("%s_page_%d", titleSlug, pageNum), recentRefs, ) @@ -356,11 +360,13 @@ func buildCoverPrompt(storyText, style, bible string) string { ) } -// buildStoryPagePrompt constructs a 9-panel grid page prompt. -// The Bulgarian language requirement is placed at the very top — before the -// character reference and story excerpt — because image models tend to follow -// early instructions more reliably than late ones buried in a list. -func buildStoryPagePrompt(section string, pageNum, totalPages int, style, bible string) string { +// buildStoryPagePrompt constructs a landscape comic page prompt. +// Layout uses a 2×2 grid of 4 panels optimised for the 16:9 aspect ratio. +// entries are injected as a vocabulary block so the image model features and +// labels each word visually inside the panels — making each page a learning tool. +// The Bulgarian language requirement is placed at the very top so it is processed +// before all other instructions. +func buildStoryPagePrompt(section string, pageNum, totalPages int, style, bible string, entries []batch.WordEntry) string { excerpt := strings.TrimSpace(section) if len(excerpt) > comicPromptMaxChars { excerpt = excerpt[:comicPromptMaxChars] @@ -371,6 +377,7 @@ func buildStoryPagePrompt(section string, pageNum, totalPages int, style, bible } bibleBlock := bibleSection(bible, fmt.Sprintf("story page %d of %d", pageNum, totalPages)) + vocabBlock := buildVocabBlock(entries) return fmt.Sprintf( // Lead with the hard language constraint so it is processed first. "ЗАДЪЛЖИТЕЛНО / MANDATORY LANGUAGE RULE: This is a BULGARIAN comic book. "+ @@ -379,8 +386,16 @@ func buildStoryPagePrompt(section string, pageNum, totalPages int, style, bible "(например: Здравей! Какво правиш? Побързай!). "+ "English text anywhere in the panels is STRICTLY FORBIDDEN — use ONLY Bulgarian.\n\n"+ "Art style: %s.%s\n"+ - "Comic book story page %d of %d. Layout: a 3×3 grid of 9 panels filling the page, "+ - "each panel showing a distinct moment from the excerpt below.\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"+ + " • TOP-RIGHT panel: scene 2 from the excerpt\n"+ + " • BOTTOM-LEFT panel: scene 3 from the excerpt\n"+ + " • BOTTOM-RIGHT panel: scene 4 from the excerpt\n"+ + "Each panel is separated by a thin black gutter line. "+ + "All 4 panels must be clearly distinct scenes — NOT one continuous image. "+ + "The full image area must be covered by the 4 panels with no empty space.\n"+ "STRICT CONSISTENCY RULES — apply to every single panel:\n"+ " • Human characters: identical face, AGE APPEARANCE, hair colour/style, and clothing "+ "to the reference — a child must never look older or younger than defined.\n"+ @@ -389,10 +404,32 @@ 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, pageNum, totalPages, excerpt, + style, bibleBlock, vocabBlock, pageNum, totalPages, excerpt, ) } +// buildVocabBlock formats the vocabulary entries as a mandatory visual instruction +// block. Each word must appear as a clearly labelled object or element in at least +// one panel — making the comic page a vocabulary learning tool as well as a story page. +func buildVocabBlock(entries []batch.WordEntry) string { + if len(entries) == 0 { + return "" + } + var sb strings.Builder + sb.WriteString("VOCABULARY WORDS — each word below MUST appear as a clearly visible, labelled\n") + sb.WriteString("object or element in at least one panel. Show the object in the scene and add a\n") + sb.WriteString("small Bulgarian label directly on it (bold text, contrasting colour, easy to read):\n") + for _, e := range entries { + if e.Translation != "" { + sb.WriteString(fmt.Sprintf(" • %s (%s)\n", e.Bulgarian, e.Translation)) + } else { + sb.WriteString(fmt.Sprintf(" • %s\n", e.Bulgarian)) + } + } + sb.WriteString("\n") + return sb.String() +} + // buildBackCoverPrompt constructs the back-cover image prompt. // blurb is an English marketing summary generated by Gemini; when non-empty it is // embedded verbatim in the blurb-box instruction so the image model renders it. diff --git a/internal/story/generator.go b/internal/story/generator.go index 4f4317e..896782b 100644 --- a/internal/story/generator.go +++ b/internal/story/generator.go @@ -244,7 +244,7 @@ func parseGenerateResult(combined string) GenerateResult { func buildStoryPrompt(entries []batch.WordEntry, theme string) string { genre := resolveGenre(theme) header := fmt.Sprintf( - "Write a ~500-word story in Bulgarian that naturally uses all of the following words.\n"+ + "Write a ~250-word story in Bulgarian that naturally uses all of the following words.\n"+ "The story must be %s — do NOT write a generic fairy tale.\n"+ "Number each word as shown below. Return ONLY the story text — no title, no header, no explanation.\n\n", genre, @@ -259,7 +259,7 @@ func buildStoryPrompt(entries []batch.WordEntry, theme string) string { func buildStoryPromptFull(entries []batch.WordEntry, theme string) string { genre := resolveGenre(theme) var sb strings.Builder - sb.WriteString("Write a ~500-word story in Bulgarian that naturally uses all of the following words.\n") + sb.WriteString("Write a ~250-word story in Bulgarian that naturally uses all of the following words.\n") sb.WriteString(fmt.Sprintf("The story must be %s — do NOT write a generic fairy tale.\n", genre)) sb.WriteString("Number each word as shown below.\n\n") sb.WriteString(buildWordList(entries, "")) diff --git a/internal/story/narrator.go b/internal/story/narrator.go index 6e9fb70..39fa456 100644 --- a/internal/story/narrator.go +++ b/internal/story/narrator.go @@ -29,22 +29,29 @@ const ( // conclusionSystemInstruction directs Gemini to write a short cinematic // epilogue in Bulgarian — roughly 40–60 words (≈15–30 seconds of narration). conclusionSystemInstruction = `You are a dramatic cinematic narrator writing a closing epilogue for a Bulgarian story. -Write a SHORT closing epilogue in Bulgarian: exactly 3–4 sentences, cinematic and poetic, -with a warm and conclusive tone — like the final voice-over of a film that leaves the audience -with a sense of wonder and completion. Do NOT summarise the plot; instead reflect on the deeper -meaning or emotion of the story. Output only the Bulgarian epilogue text, nothing else.` +Write a SHORT closing epilogue in the BULGARIAN language (NOT Russian — Bulgarian uses Cyrillic but +is a distinct language with different phonology, vocabulary, and grammar). +Exactly 3–4 sentences, cinematic and poetic, with a warm and conclusive tone — like the final +voice-over of a film that leaves the audience with a sense of wonder and completion. +Do NOT summarise the plot; instead reflect on the deeper meaning or emotion of the story. +Output only the Bulgarian epilogue text, nothing else.` // cinematicInstruction is prepended to every chunk 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 = `You are a dramatic cinematic narrator performing a Bulgarian story. + // The language must be stated explicitly: Gemini TTS can confuse Bulgarian with + // Russian (both use Cyrillic) and apply Slavic Russian phonology by default. + cinematicInstruction = `You are a dramatic cinematic narrator performing a story written in BULGARIAN. +IMPORTANT: This text is in the BULGARIAN language — NOT Russian, NOT Serbian, NOT any other Slavic language. +Pronounce every word using authentic BULGARIAN phonology and accent. Bulgarian vowels are clear and distinct; +do not apply Russian stress patterns or Russian vowel reduction. The letter 'ъ' in Bulgarian is a mid-central +vowel (like the 'u' in "but"), not the Russian reduced schwa. Deliver this as a professional movie trailer narrator would: deep, resonant, and commanding. Use long dramatic pauses before key moments. Build tension with slower, deliberate pacing, then accelerate through action. Drop your voice low and gravelly for mysterious or serious passages; let warmth and energy rise for joyful or triumphant ones. Breathe life into every -sentence — this should sound like an epic film, not a reading exercise. Pronounce all -Bulgarian words with authentic clarity and expressive intonation. +sentence — this should sound like an epic Bulgarian film, not a reading exercise. ` ) @@ -130,11 +137,18 @@ func (n *Narrator) Narrate(storyText, outputFile string) error { chunkPaths = append(chunkPaths, conclusionPath) } + // 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 { - // Only one segment (short story, no conclusion) — move directly. - return os.Rename(chunkPaths[0], outputFile) + combinedPath = chunkPaths[0] + } else { + if err := concatenateMP3s(chunkPaths, combinedPath, tmpDir); err != nil { + return err + } } - return concatenateMP3s(chunkPaths, outputFile, tmpDir) + return convertToStereo(combinedPath, outputFile) } // narrateConclusion generates a short Bulgarian cinematic epilogue via Gemini text, @@ -232,6 +246,32 @@ func concatenateMP3s(chunkPaths []string, outputFile, tmpDir string) error { return nil } +// convertToStereo re-encodes a mono MP3 to stereo by duplicating the single +// channel into both left and right. Gemini TTS always outputs mono; without this +// step the audio plays only in one ear on headphones. +func convertToStereo(inputFile, outputFile string) error { + ffmpegPath, err := exec.LookPath("ffmpeg") + if err != nil { + // ffmpeg absent — fall back to a plain copy so narration still saves. + fmt.Println(" Warning: ffmpeg not found, narration will be mono") + return os.Rename(inputFile, outputFile) + } + + cmd := exec.Command(ffmpegPath, + "-nostdin", "-hide_banner", "-loglevel", "error", + "-y", + "-i", inputFile, + "-ac", "2", // duplicate mono channel into stereo + "-codec:a", "libmp3lame", "-q:a", "2", + outputFile, + ) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("ffmpeg stereo conversion failed: %w\n%s", err, strings.TrimSpace(string(out))) + } + return nil +} + // splitIntoNarrationChunks divides text into chunks of at most targetWords words, // splitting at paragraph boundaries (double newline) whenever possible. // Each chunk is trimmed and non-empty. diff --git a/internal/story/runner.go b/internal/story/runner.go index b2442a1..871cb0e 100644 --- a/internal/story/runner.go +++ b/internal/story/runner.go @@ -8,6 +8,8 @@ import ( "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. @@ -141,18 +143,17 @@ func (r *Runner) Run(batchFile string) error { return err } - r.drawComicPages(result.StoryText, result.Bible, slug) + r.drawComicPages(result.StoryText, result.Bible, slug, entries) return r.handleNarration(result.StoryText, slug, comicsDir) } // drawComicPages generates the 5 comic images and assembles them into a PDF. -// The pre-built bible (from story generation) is passed to DrawComicPages so -// no extra Gemini call is needed for character consistency. +// 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) { +func (r *Runner) drawComicPages(storyText, bible, titleSlug string, entries []batch.WordEntry) { fmt.Printf("Generating %d comic pages...\n", storyPageCount+2) // 2 = cover + back cover - paths, err := r.artist.DrawComicPages(storyText, bible, titleSlug) + paths, err := r.artist.DrawComicPages(storyText, bible, titleSlug, entries) if err != nil { fmt.Fprintf(os.Stderr, "Warning: comic page generation failed: %v\n", err) } -- cgit v1.2.3