summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-03 23:36:30 +0300
committerPaul Buetow <paul@buetow.org>2026-04-03 23:36:30 +0300
commit3b26916eed21c5c4c3d2ef40f19a1ef6e47b5662 (patch)
tree9ec98bde2124a7f10f79736d319d2b205e61934f /internal
parent621ef41e416bdc9985a27e9a2f923435676f096e (diff)
fix
Diffstat (limited to 'internal')
-rw-r--r--internal/story/artist.go63
-rw-r--r--internal/story/generator.go4
-rw-r--r--internal/story/narrator.go60
-rw-r--r--internal/story/runner.go11
4 files changed, 108 insertions, 30 deletions
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:
// - <titleSlug>_cover.png — full-bleed cover
-// - <titleSlug>_page_1.png … _3 — 9-panel (3×3) story pages
+// - <titleSlug>_page_1.png … _3 — 4-panel (2×2 grid) landscape story pages
// - <titleSlug>_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)
}