summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-04 11:25:11 +0300
committerPaul Buetow <paul@buetow.org>2026-04-04 11:25:11 +0300
commit03f2ca528a34a7e099f1db8d5a931136af2a4652 (patch)
tree9a6f79220e85e3bacbb0e693ad7504529269ceae
parent793d7b3c61a403a8c03f1eb44df741c6951444e8 (diff)
feat: random ultra-realistic/comic style, --no-ultra-realistic flag, updated READMEv0.13.0
- Random 50/50 rendering mode per run (ultra-realistic or standard comic style) - --no-ultra-realistic flag forces standard comic style when set - renderingRequirement const centralised and applied to all image prompts via DRY renderReq() method - Log rendering mode chosen at generation time - Update README: correct page count (10), output filenames, all --story flags documented - Bump version to 0.13.0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--README.md55
-rw-r--r--cmd/totalrecall/main.go13
-rw-r--r--internal/cli/command.go1
-rw-r--r--internal/cli/flags.go9
-rw-r--r--internal/story/artist.go69
-rw-r--r--internal/story/runner.go20
-rw-r--r--internal/version.go2
7 files changed, 119 insertions, 50 deletions
diff --git a/README.md b/README.md
index db673fa..25a2e57 100644
--- a/README.md
+++ b/README.md
@@ -36,13 +36,19 @@ It has mainly been vibe coded using Claude Code CLI.
- Scene generation creates memorable contexts for each word
- Batch processing of multiple words
- **Vocabulary story generation** (`--story`):
- - Generates a ~500-word Bulgarian story that naturally uses every word in a batch file
- - Produces 3 comic-book-style pages via Nano Banana image generation
- - Art style is chosen randomly per run (90% ultra-realistic; 10% from a curated pool of styles such as manga, watercolor, noir, pop art, etc.)
- - Override with `--story-style` for a specific look
- - **Cinematic narration** via Gemini TTS (`story_narration.mp3`) — dramatic pacing, expressive intonation, random voice from a curated cinematic pool (Charon, Fenrir, Enceladus, Algieba, Aoede, Schedar); override with `--narrator-voice`
- - Outputs `story.txt`, `comic_page_1–3.png`, `story_narration.mp3` to the current directory
- - Falls back to `story_tts_todo.txt` if narration fails
+ - Generates a ~250-word Bulgarian story that naturally uses every word in a batch file
+ - All human characters are adults; story genre/setting driven by `--story-theme`
+ - Produces **10 pages** per comic: cover + 5 story pages (2×2 panel grid) + 3 gallery pages (close-up character art) + back cover
+ - All output saved under `comics/<title-slug>/` with files named `<slug>_*.png`
+ - Art style chosen randomly per run (90% ultra-realistic, 10% curated pool: manga, watercolor, noir, pop art, etc.); override with `--story-style`
+ - **Rendering mode** chosen randomly 50/50 each run: ultra-realistic (photorealistic panels) or standard comic style; force standard with `--no-ultra-realistic`
+ - **Iterative character consistency**: each page is generated with the cover + previous page as pixel references so characters stay visually consistent
+ - **Character bible**: Gemini generates a detailed visual guide (age, clothing, colours) used in every prompt
+ - **Cinematic narration** via Gemini TTS (`<slug>_narration.mp3`) — Bulgarian phonology, dramatic pacing, random voice from a curated pool; override with `--narrator-voice`
+ - Intro teaser (15 s, different voice) + main story chunks (~100 words each) + epilogue with ambient music
+ - Falls back to `<slug>_tts_todo.txt` if narration fails
+ - **Vocabulary learning file** (`<slug>_comic_vocabulary.txt`) — word list with translations + full story text
+ - **Theme file** (`<slug>_theme.txt`) — records the `--story-theme` used for easy reproduction
- Anki-compatible export
- Random voice variants and speech speed
@@ -169,27 +175,36 @@ Key features:
totalrecall --archive # Archives cards to ~/.local/state/totalrecall/archive/cards-TIMESTAMP
```
-6. Generate a vocabulary story + comic strip from a batch file:
+6. Generate a vocabulary story + comic book from a batch file:
```bash
totalrecall --story words.txt
```
- Outputs to the current directory:
- - `story.txt` — ~500-word Bulgarian story using every word naturally
- - `comic_page_1.png`, `comic_page_2.png`, `comic_page_3.png` — three comic-book pages illustrating the story arc
- - `comic_page_N_attribution.txt` — attribution for each image
- - `story_narration.mp3` — cinematic Gemini TTS narration (random voice from Charon, Fenrir, Enceladus, Algieba, Aoede, Schedar)
- - `story_tts_todo.txt` — written instead of `story_narration.mp3` only if narration fails
-
- The art style is chosen randomly each run (90% ultra-realistic, 10% other styles). Override with `--story-style`:
+ Outputs to `comics/<title-slug>/`:
+ - `<slug>_story.txt` — ~250-word Bulgarian story using every word naturally
+ - `<slug>_cover.png` — traditional comic book front cover with Bulgarian title
+ - `<slug>_page_1.png` … `<slug>_page_5.png` — five 2×2-panel story pages (16:9)
+ - `<slug>_gallery_1.png` … `<slug>_gallery_3.png` — three close-up character gallery pages
+ - `<slug>_back.png` — back cover with blurb
+ - `<slug>.pdf` — all pages assembled into a single PDF
+ - `<slug>_narration.mp3` — cinematic Gemini TTS narration with intro, story chunks, and epilogue
+ - `<slug>_comic_vocabulary.txt` — vocabulary words + full story text for learning
+ - `<slug>_theme.txt` — records `--story-theme` for easy reproduction
+ - `<slug>_tts_todo.txt` — written instead of MP3 only if narration fails
+
+ Customise the story:
```bash
- totalrecall --story words.txt --story-style "ultra realistic comic strip with photographic detail and dramatic lighting"
+ # Set a specific theme/setting
+ totalrecall --story words.txt --story-theme "a Wonder Woman inspired heroine in a futuristic city"
+
+ # Override art style
totalrecall --story words.txt --story-style "Japanese manga with clean linework and speed lines"
totalrecall --story words.txt --story-style "retro 1960s pop art in the style of Roy Lichtenstein"
- ```
- The narrator voice is chosen randomly from a cinematic pool each run. Override with `--narrator-voice`:
- ```bash
+ # Force standard comic style (default is random 50/50 between ultra-realistic and standard)
+ totalrecall --story words.txt --no-ultra-realistic
+
+ # Choose narrator voice (default: random from pool)
totalrecall --story words.txt --narrator-voice Charon # deep, authoritative
totalrecall --story words.txt --narrator-voice Fenrir # strong, resonant
totalrecall --story words.txt --narrator-voice Enceladus # breathy, intimate
diff --git a/cmd/totalrecall/main.go b/cmd/totalrecall/main.go
index d6abcf5..f3a84f9 100644
--- a/cmd/totalrecall/main.go
+++ b/cmd/totalrecall/main.go
@@ -69,6 +69,7 @@ func runCommand(cmd *cobra.Command, args []string, flags *cli.Flags) error {
OutputDir: ".",
Style: flags.StoryStyle,
Theme: flags.StoryTheme,
+ UltraRealistic: storyUltraRealistic(flags.StoryNoUltraRealistic),
NarratorVoice: flags.NarratorVoice,
})
return runner.Run(flags.StoryFile)
@@ -139,3 +140,15 @@ func runGUIMode(proc *processor.Processor, flags *cli.Flags) error {
return nil
}
+
+// storyUltraRealistic converts the --no-ultra-realistic bool flag into a *bool
+// for RunnerConfig. When noUltraRealistic is true, returns a pointer to false
+// (forcing standard comic style). When false (flag not set), returns nil so
+// the runner picks randomly 50/50 each run.
+func storyUltraRealistic(noUltraRealistic bool) *bool {
+ if noUltraRealistic {
+ v := false
+ return &v
+ }
+ return nil // nil → random pick in NewRunner
+}
diff --git a/internal/cli/command.go b/internal/cli/command.go
index 6f0b2a2..a1168b5 100644
--- a/internal/cli/command.go
+++ b/internal/cli/command.go
@@ -68,6 +68,7 @@ func setupFlags(cmd *cobra.Command, flags *Flags) {
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.StoryTheme, "story-theme", "", "Genre/theme for the story (default: random). E.g. \"a thrilling space adventure with aliens and spaceships\"")
+ cmd.Flags().BoolVar(&flags.StoryNoUltraRealistic, "no-ultra-realistic", false, "Disable photorealistic rendering requirement; produces standard comic-book style output")
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")
diff --git a/internal/cli/flags.go b/internal/cli/flags.go
index b9d28c9..104f721 100644
--- a/internal/cli/flags.go
+++ b/internal/cli/flags.go
@@ -20,10 +20,11 @@ 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)
- StoryTheme string // --story-theme: override the random genre pick (empty = random)
- NarratorVoice string // --narrator-voice: Gemini voice for cinematic narration (empty = random)
+ StoryFile string // --story <file>: generate vocabulary story + comic image
+ StoryStyle string // --story-style: override the random art style (empty = random)
+ StoryTheme string // --story-theme: override the random genre pick (empty = random)
+ StoryNoUltraRealistic bool // --no-ultra-realistic: disable photorealistic rendering requirement
+ 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
index 6e69435..fd04107 100644
--- a/internal/story/artist.go
+++ b/internal/story/artist.go
@@ -61,6 +61,7 @@ const (
// renderingRequirement is appended to every image prompt (cover, story pages,
// back cover, gallery) to push the model toward photorealistic output even
// within a comic grid layout. Centralised here so it is easy to tune.
+ // Omitted when Artist.ultraRealistic is false (--no-ultra-realistic flag).
renderingRequirement = "RENDERING REQUIREMENT: every panel and illustration must look " +
"like a real photograph — photorealistic skin texture, fabric detail, lighting, " +
"and environment. NOT a drawing, painting, or illustration. Real-world photo quality.\n"
@@ -120,26 +121,33 @@ type ArtistConfig struct {
TextModel string // NanoBanana text/prompt model
OutputDir string // target directory; defaults to "."
Style string // overrides the random art-style pick when non-empty
+ // UltraRealistic controls whether renderingRequirement is injected into every
+ // prompt. Default true (ultra-realistic). Set false via --no-ultra-realistic
+ // to produce standard comic-book style output without the photo requirement.
+ UltraRealistic bool
}
// Artist generates comic-book pages that illustrate the story.
type Artist struct {
- nbClient image.ImageClient
- apiKey string // used for the character-bible Gemini call
- outputDir string
- style string
+ nbClient image.ImageClient
+ apiKey string // used for the character-bible Gemini call
+ outputDir string
+ style string
+ ultraRealistic bool // false → omit renderingRequirement from all prompts
}
// NewArtist creates an Artist backed by the NanoBanana image generator.
func NewArtist(config *ArtistConfig) *Artist {
dir := "."
var apiKey, style string
+ ultraRealistic := true // default on
var nbConfig *image.NanoBananaConfig
if config != nil {
dir = orDefault(config.OutputDir, ".")
apiKey = config.APIKey
style = config.Style
+ ultraRealistic = config.UltraRealistic
nbConfig = &image.NanoBananaConfig{
APIKey: config.APIKey,
Model: config.Model,
@@ -148,10 +156,11 @@ func NewArtist(config *ArtistConfig) *Artist {
}
return &Artist{
- nbClient: image.NewNanoBananaClient(nbConfig),
- apiKey: apiKey,
- outputDir: dir,
- style: style,
+ nbClient: image.NewNanoBananaClient(nbConfig),
+ apiKey: apiKey,
+ outputDir: dir,
+ style: style,
+ ultraRealistic: ultraRealistic,
}
}
@@ -173,6 +182,11 @@ func (a *Artist) DrawComicPages(storyText, prebuiltBible, titleSlug string, entr
style = pickStyle()
}
fmt.Printf(" Comic style: %s\n", style)
+ if a.ultraRealistic {
+ fmt.Println(" Rendering mode: ultra-realistic (photorealistic panels)")
+ } else {
+ fmt.Println(" Rendering mode: standard comic style")
+ }
bible, blurb := a.resolveHelperTexts(storyText, prebuiltBible)
@@ -186,7 +200,7 @@ func (a *Artist) DrawComicPages(storyText, prebuiltBible, titleSlug string, entr
// 1. Cover — generated without refs (it is the visual baseline).
// 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")
+ p, coverBytes := a.generatePageWithRetry(buildCoverPrompt(storyText, style, bible, a.renderReq()), titleSlug+"_cover", nil, "cover page")
if p != "" {
paths = append(paths, p)
recentRefs = appendRef(recentRefs, coverBytes) // cover becomes the anchor reference
@@ -199,7 +213,7 @@ func (a *Artist) DrawComicPages(storyText, prebuiltBible, titleSlug string, entr
sections := splitIntoSections(storyText, storyPageCount)
for i, section := range sections {
pageNum := i + 1
- prompt := buildStoryPagePrompt(section, pageNum, storyPageCount, style, bible, entries)
+ prompt := buildStoryPagePrompt(section, pageNum, storyPageCount, style, bible, entries, a.renderReq())
fileName := fmt.Sprintf("%s_page_%d", titleSlug, pageNum)
p, pageBytes := a.generateStoryPage(prompt, fileName, pageNum, recentRefs)
if p != "" {
@@ -213,7 +227,7 @@ func (a *Artist) DrawComicPages(storyText, prebuiltBible, titleSlug string, entr
// They act as alternative covers and use the accumulated refs for consistency.
for i := range galleryPageCount {
galleryNum := i + 1
- prompt := buildGalleryPagePrompt(style, bible, galleryNum)
+ prompt := buildGalleryPagePrompt(style, bible, galleryNum, a.renderReq())
fileName := fmt.Sprintf("%s_gallery_%d", titleSlug, galleryNum)
gp, galleryBytes := a.generatePageWithRetry(prompt, fileName, recentRefs,
fmt.Sprintf("gallery page %d/%d", galleryNum, galleryPageCount))
@@ -225,7 +239,7 @@ func (a *Artist) DrawComicPages(storyText, prebuiltBible, titleSlug string, entr
// 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")
+ p, _ = a.generatePageWithRetry(buildBackCoverPrompt(storyText, style, bible, blurb, a.renderReq()), titleSlug+"_back", recentRefs, "back cover")
if p != "" {
paths = append(paths, p)
}
@@ -388,8 +402,17 @@ func (a *Artist) generateSinglePage(prompt, fileNamePattern string, refs [][]byt
return savedPath, imgBytes, nil
}
+// renderReq returns the renderingRequirement string when ultraRealistic is true,
+// or an empty string when --no-ultra-realistic is set. Used in all prompt builders.
+func (a *Artist) renderReq() string {
+ if a.ultraRealistic {
+ return renderingRequirement
+ }
+ return ""
+}
+
// buildCoverPrompt constructs the front-cover image prompt.
-func buildCoverPrompt(storyText, style, bible string) string {
+func buildCoverPrompt(storyText, style, bible, renderReq string) string {
// Use a short excerpt as a teaser on the cover prompt.
teaser := strings.TrimSpace(storyText)
if len(teaser) > 300 {
@@ -407,7 +430,7 @@ func buildCoverPrompt(storyText, style, bible string) string {
"All text on the cover (cover lines, banners, labels) MUST be in Bulgarian "+
"Cyrillic script. The masthead title must also be rendered in a striking comic-book font.\n\n"+
"Art style: %s.%s\n"+
- renderingRequirement+
+ renderReq+
"TRADITIONAL COMIC BOOK FRONT COVER — portrait orientation, single full-bleed illustration.\n"+
"NO panel grid. NO speech bubbles.\n"+
"MANDATORY MASTHEAD — the most important visual element on this cover:\n"+
@@ -444,7 +467,7 @@ func buildCoverPrompt(storyText, style, bible string) string {
// 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 {
+func buildStoryPagePrompt(section string, pageNum, totalPages int, style, bible string, entries []batch.WordEntry, renderReq string) string {
excerpt := strings.TrimSpace(section)
if len(excerpt) > comicPromptMaxChars {
excerpt = excerpt[:comicPromptMaxChars]
@@ -474,7 +497,7 @@ func buildStoryPagePrompt(section string, pageNum, totalPages int, style, bible
"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"+
- renderingRequirement+
+ renderReq+
"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"+
@@ -512,7 +535,7 @@ func buildVocabBlock(entries []batch.WordEntry) 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.
-func buildBackCoverPrompt(storyText, style, bible, blurb string) string {
+func buildBackCoverPrompt(storyText, style, bible, blurb, renderReq string) string {
// Use the last ~200 chars of the story as a visual hint for the scene.
ending := strings.TrimSpace(storyText)
if len(ending) > 200 {
@@ -540,7 +563,7 @@ func buildBackCoverPrompt(storyText, style, bible, blurb string) string {
"All visible text (blurb box, labels, banners) MUST be in Bulgarian Cyrillic script. "+
"English text anywhere on the back cover is STRICTLY FORBIDDEN.\n\n"+
"Art style: %s.%s\n"+
- renderingRequirement+
+ renderReq+
"TRADITIONAL COMIC BOOK BACK COVER — portrait orientation, single full-bleed illustration.\n"+
"NO panel grid. NO speech bubbles.\n"+
"Layout rules (must follow exactly):\n"+
@@ -571,12 +594,12 @@ var galleryPoses = []string{
// 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 {
+func buildGalleryPagePrompt(style, bible string, galleryNum int, renderReq string) string {
pose := galleryPoses[(galleryNum-1)%len(galleryPoses)]
bibleBlock := bibleSection(bible, fmt.Sprintf("gallery page %d", galleryNum))
return fmt.Sprintf(
"Art style: %s.%s\n"+
- renderingRequirement+
+ renderReq+
"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"+
@@ -608,6 +631,12 @@ func pickStyle() string {
return comicStyles[1+rand.IntN(len(comicStyles)-1)]
}
+// pickUltraRealistic returns true (photorealistic) or false (comic style) with
+// equal probability, giving each run a 50/50 chance of either look.
+func pickUltraRealistic() bool {
+ return rand.Float64() < 0.5
+}
+
func splitIntoSections(text string, n int) []string {
paragraphs := splitParagraphs(text)
if len(paragraphs) >= n {
diff --git a/internal/story/runner.go b/internal/story/runner.go
index 107e584..3299d59 100644
--- a/internal/story/runner.go
+++ b/internal/story/runner.go
@@ -40,6 +40,10 @@ type RunnerConfig struct {
// adventure with aliens and spaceships"). Passed verbatim as the genre line in the
// Gemini story prompt so the model writes in that genre instead of a random one.
Theme string
+ // UltraRealistic controls whether the renderingRequirement photorealistic
+ // instruction is injected into every image prompt.
+ // nil → random 50/50 each run; true → always on; false → always off (--no-ultra-realistic).
+ UltraRealistic *bool
// NarratorVoice picks a specific Gemini cinematic voice for narration.
// Empty → random pick from the curated cinematic pool each run.
NarratorVoice string
@@ -61,6 +65,8 @@ func NewRunner(config *RunnerConfig) *Runner {
}
var apiKey, textModel, imageModel, imageTextModel, style, theme, narratorVoice string
+ // Resolve ultraRealistic: nil config or nil pointer → random 50/50 pick each run.
+ ultraRealistic := pickUltraRealistic()
if config != nil {
apiKey = config.APIKey
textModel = config.TextModel
@@ -69,6 +75,9 @@ func NewRunner(config *RunnerConfig) *Runner {
style = config.Style
theme = config.Theme
narratorVoice = config.NarratorVoice
+ if config.UltraRealistic != nil {
+ ultraRealistic = *config.UltraRealistic
+ }
}
// Narrator init failure (e.g. missing key) is non-fatal — handleNarration
@@ -89,11 +98,12 @@ func NewRunner(config *RunnerConfig) *Runner {
Theme: theme,
}),
artist: NewArtist(&ArtistConfig{
- APIKey: apiKey,
- Model: imageModel,
- TextModel: imageTextModel,
- OutputDir: dir,
- Style: style,
+ APIKey: apiKey,
+ Model: imageModel,
+ TextModel: imageTextModel,
+ OutputDir: dir,
+ Style: style,
+ UltraRealistic: ultraRealistic,
}),
narrator: narrator,
}
diff --git a/internal/version.go b/internal/version.go
index e65f021..d6c413b 100644
--- a/internal/version.go
+++ b/internal/version.go
@@ -1,3 +1,3 @@
package internal
-const Version = "0.12.0"
+const Version = "0.13.0"