diff options
| -rw-r--r-- | README.md | 55 | ||||
| -rw-r--r-- | cmd/totalrecall/main.go | 13 | ||||
| -rw-r--r-- | internal/cli/command.go | 1 | ||||
| -rw-r--r-- | internal/cli/flags.go | 9 | ||||
| -rw-r--r-- | internal/story/artist.go | 69 | ||||
| -rw-r--r-- | internal/story/runner.go | 20 | ||||
| -rw-r--r-- | internal/version.go | 2 |
7 files changed, 119 insertions, 50 deletions
@@ -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" |
