summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-20 19:07:56 +0300
committerPaul Buetow <paul@buetow.org>2026-04-20 19:07:56 +0300
commitd773e26b19dcea2f4a1917ebb6edaa4f5721693f (patch)
treecc38b381c0d367a84ad802c16bf7fcb0c67bc8ad
parent7bb500b8cd567e45634fc01c97e8dcd5a1a9db22 (diff)
Remove story feature for task 05
-rw-r--r--README.md106
-rw-r--r--cmd/totalrecall/main.go23
-rw-r--r--cmd/totalrecall/story.go40
-rw-r--r--internal/cli/flags.go54
-rw-r--r--internal/cli/flags_test.go4
-rw-r--r--internal/cli/video_runner.go3
-rw-r--r--internal/httpctx/httpctx.go4
-rw-r--r--internal/story/artist.go849
-rw-r--r--internal/story/gallery_copy_test.go32
-rw-r--r--internal/story/generator.go420
-rw-r--r--internal/story/narrator.go525
-rw-r--r--internal/story/pdf.go42
-rw-r--r--internal/story/runner.go353
-rw-r--r--internal/video/gallery_prompt.go247
-rw-r--r--internal/video/gallery_prompt_test.go313
-rw-r--r--internal/video/generate_selected.go3
-rw-r--r--internal/video/story_run.go30
-rw-r--r--internal/video/veo.go4
18 files changed, 23 insertions, 3029 deletions
diff --git a/README.md b/README.md
index 7fb5f1d..1afa0de 100644
--- a/README.md
+++ b/README.md
@@ -35,23 +35,6 @@ It has mainly been vibe coded using Claude Code CLI.
- **Config-driven selection**: Set `image.provider` to `openai` or `nanobanana`
- Scene generation creates memorable contexts for each word
- Batch processing of multiple words
-- **Vocabulary story generation** (`--story`):
- - 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 **12 pages** per comic: cover + 5 story pages (2×2 panel grid) + 5 gallery pages (close-up character art) + back cover
- - All output saved under `comics/<title-slug>/` with files named `<slug>_*.png`
- - **Rendering mode** chosen randomly 50/50 each run; force with `--ultra-realistic` or `--no-ultra-realistic`:
- - *Ultra-realistic*: photography-language style prompt (DSLR, cinematic stills, hyper-realistic) + mandatory photorealistic rendering requirement — produces near-photographic panels
- - *Standard comic*: comic art style pool (90% "ultra realistic comic strip", 10% manga / watercolor / noir / pop-art etc.)
- - Art style override: `--story-style` replaces the random pick for both modes
- - **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 (opt-in with `--narrate`; off by default to save quota)
- - Output: `<slug>_narration.mp3` — Bulgarian phonology, dramatic pacing, random voice from a curated pool
- - Override voice with `--narrator-voice`; intro teaser + main story chunks (~100 words each) + epilogue
- - 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
@@ -178,95 +161,6 @@ Key features:
totalrecall --archive # Archives cards to ~/.local/state/totalrecall/archive/cards-TIMESTAMP
```
-6. Generate a vocabulary story + comic book from a batch file:
- ```bash
- totalrecall --story words.txt
- ```
-
- 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_5.png` — five 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 (only when `--narrate` is passed)
- - `<slug>_tts_todo.txt` — written if `--narrate` was given but narration failed
- - `<slug>_comic_vocabulary.txt` — vocabulary words + full story text for learning
- - `<slug>_theme.txt` — records `--story-theme` for easy reproduction
-
- Customise the story:
- ```bash
- # 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"
-
- # Force ultra-realistic photorealistic mode
- totalrecall --story words.txt --ultra-realistic
-
- # Force standard comic style (default is random 50/50 between ultra-realistic and standard)
- totalrecall --story words.txt --no-ultra-realistic
-
- # Enable cinematic narration (default: off — use --narrate to opt in)
- totalrecall --story words.txt --narrate
-
- # Choose narrator voice (default: random from pool; only relevant with --narrate)
- totalrecall --story words.txt --narrate --narrator-voice Charon # deep, authoritative
- totalrecall --story words.txt --narrate --narrator-voice Fenrir # strong, resonant
- totalrecall --story words.txt --narrate --narrator-voice Enceladus # breathy, intimate
- totalrecall --story words.txt --narrate --narrator-voice Algieba # smooth, warm
- totalrecall --story words.txt --narrate --narrator-voice Aoede # breezy, expressive
- totalrecall --story words.txt --narrate --narrator-voice Schedar # steady, grounded
-
- # Repair a partial run (skip existing pages, regenerate missing ones)
- totalrecall --story words.txt --story-slug my-comic-slug
- ```
-
-#### Optional Gallery Videos (Veo)
-
-After `--story` comic generation completes, the CLI prompts you to generate short MP4 videos from the gallery close-up images using **Google Veo**.
-
-**How it works:**
-
-1. Once the comic pages, PDF, and narration are written to disk the CLI automatically searches `comics/<slug>/` for gallery PNG files.
-2. It lists the found images and asks:
- ```
- Found gallery pages:
- comics/my-story/my-story_gallery_1.png
- comics/my-story/my-story_gallery_2.png
- ...
- Generate videos for these gallery pages? [y/N]:
- ```
-3. If you confirm, you are asked which pages to animate:
- ```
- Which pages? (e.g. 1,3,5 or all) [all]:
- ```
-4. Each selected gallery PNG is sent to the Veo API and the resulting 8-second MP4 is saved alongside the PNG:
- ```
- comics/<slug>/<slug>_gallery_1.mp4
- comics/<slug>/<slug>_gallery_2.mp4
- ...
- ```
-
-**Model:** `veo-2.0-generate-001`
-
-**Cost note:** Veo is a paid Google Cloud feature. Your `GOOGLE_API_KEY` must have billing enabled and the Veo API activated in your Google Cloud project. The prompt will not appear if you pass `--video=false`.
-
-**CLI usage:**
-
-```bash
-# Default: prompt appears after story generation (answer y/N interactively)
-totalrecall --story words.txt
-
-# Skip the video prompt entirely
-totalrecall --story words.txt --video=false
-```
-
-Video generation failures are non-fatal — any errors are printed as warnings and the already-generated comic, PDF, and narration remain intact on disk.
-
#### Batch file format
Create a text file with Bulgarian words, optionally with English translations or Bulgarian definitions. The tool supports five flexible formats:
diff --git a/cmd/totalrecall/main.go b/cmd/totalrecall/main.go
index 30b8dc3..05fc74d 100644
--- a/cmd/totalrecall/main.go
+++ b/cmd/totalrecall/main.go
@@ -14,16 +14,13 @@ import (
"codeberg.org/snonux/totalrecall/internal/gui"
"codeberg.org/snonux/totalrecall/internal/models"
"codeberg.org/snonux/totalrecall/internal/processor"
- "codeberg.org/snonux/totalrecall/internal/story"
- "codeberg.org/snonux/totalrecall/internal/video"
)
// runDeps holds injectable implementations for composition-root wiring (DIP).
type runDeps struct {
- Archiver archive.Archiver
- NewLister func(openAIKey, geminiKey string, out io.Writer) models.ModelLister
- NewStoryRunner func(flags *cli.Flags) story.StoryRunner
- NewGUI func(*gui.Config) gui.App
+ Archiver archive.Archiver
+ NewLister func(openAIKey, geminiKey string, out io.Writer) models.ModelLister
+ NewGUI func(*gui.Config) gui.App
}
func defaultRunDeps() runDeps {
@@ -32,8 +29,7 @@ func defaultRunDeps() runDeps {
NewLister: func(oa, g string, w io.Writer) models.ModelLister {
return models.NewLister(oa, g, w)
},
- NewStoryRunner: newStoryRunner,
- NewGUI: gui.New,
+ NewGUI: gui.New,
}
}
@@ -78,17 +74,6 @@ func runCommand(cmd *cobra.Command, args []string, flags *cli.Flags, deps runDep
return lister.ListAvailableModels()
}
- // Handle --story flag: generate a vocabulary story + comic image into CWD.
- // This is deliberately placed before processor creation because it does not
- // need the full processor pipeline (no Anki cards, no per-word audio).
- if flags.StoryFile != "" {
- runner := deps.NewStoryRunner(flags)
- if err := runner.Run(flags.StoryFile); err != nil {
- return err
- }
- return video.RunStoryVideos(flags.VideoEnabled, ".", cli.GetGoogleAPIKey())
- }
-
// Auto-adjust image size for DALL-E 3
if flags.OpenAIImageModel == "dall-e-3" && !cmd.Flags().Changed("openai-image-size") {
// If user didn't explicitly set size, use 1024x1024 for DALL-E 3
diff --git a/cmd/totalrecall/story.go b/cmd/totalrecall/story.go
deleted file mode 100644
index 2977984..0000000
--- a/cmd/totalrecall/story.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package main
-
-import (
- "codeberg.org/snonux/totalrecall/internal/cli"
- "codeberg.org/snonux/totalrecall/internal/story"
-)
-
-// newStoryRunner wires a story.Runner from CLI flags and API keys.
-func newStoryRunner(flags *cli.Flags) story.StoryRunner {
- return story.NewRunner(&story.RunnerConfig{
- APIKey: cli.GetGoogleAPIKey(),
- TextModel: flags.NanoBananaTextModel,
- ImageModel: flags.NanoBananaModel,
- ImageTextModel: flags.NanoBananaTextModel,
- OutputDir: ".",
- Style: flags.StoryStyle,
- Theme: flags.StoryTheme,
- UltraRealistic: storyUltraRealistic(flags.StoryNoUltraRealistic, flags.StoryUltraRealistic),
- NarratorVoice: flags.NarratorVoice,
- NarrateEnabled: flags.NarrateEnabled,
- Slug: flags.StorySlug,
- })
-}
-
-// storyUltraRealistic converts the --ultra-realistic / --no-ultra-realistic
-// bool flags into a *bool for RunnerConfig.
-// - --ultra-realistic → pointer to true (force photorealistic panels)
-// - --no-ultra-realistic → pointer to false (force standard comic style)
-// - neither flag set → nil (runner picks randomly 50/50 each run)
-func storyUltraRealistic(noUltraRealistic, ultraRealistic bool) *bool {
- if ultraRealistic {
- v := true
- return &v
- }
- if noUltraRealistic {
- v := false
- return &v
- }
- return nil // nil → random pick in NewRunner
-}
diff --git a/internal/cli/flags.go b/internal/cli/flags.go
index bdcc88b..0df3ffd 100644
--- a/internal/cli/flags.go
+++ b/internal/cli/flags.go
@@ -23,29 +23,20 @@ type Flags struct {
// AudioFormatSpecified records whether the audio format was explicitly set on the CLI.
AudioFormatSpecified bool
// AudioProvider selects the text-to-speech backend ("gemini" or "openai").
- AudioProvider string
- 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)
- StoryNoUltraRealistic bool // --no-ultra-realistic: disable photorealistic rendering requirement
- StoryUltraRealistic bool // --ultra-realistic: force photorealistic rendering (overrides random 50/50)
- StorySlug string // --story-slug: force a specific output slug/directory (empty = auto from title)
- NarratorVoice string // --narrator-voice: Gemini voice for cinematic narration (empty = random)
- NarrateEnabled bool // --narrate: generate cinematic MP3 narration after --story (default false)
- VideoEnabled bool // --video: whether to prompt for Veo video generation after --story completes
- SkipAudio bool
- SkipImages bool
- RetryFailedAssets bool
- GenerateAnki bool
- AnkiCSV bool
- DeckName string
- ListModels bool
- AllVoices bool
- NoAutoPlay bool
- Archive bool
+ AudioProvider string
+ ImageAPI string
+ ImageAPISpecified bool
+ BatchFile string
+ SkipAudio bool
+ SkipImages bool
+ RetryFailedAssets bool
+ GenerateAnki bool
+ AnkiCSV bool
+ DeckName string
+ ListModels bool
+ AllVoices bool
+ NoAutoPlay bool
+ Archive bool
// OpenAI flags
OpenAIModel string
@@ -83,7 +74,6 @@ func NewFlags() *Flags {
AudioFormat: defaults.OutputFormat,
AudioProvider: defaults.Provider,
ImageAPI: "nanobanana",
- VideoEnabled: true,
DeckName: "Bulgarian Vocabulary",
OpenAIModel: "gpt-4o-mini-tts",
OpenAISpeed: 0.9,
@@ -114,22 +104,6 @@ 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.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().BoolVar(&flags.StoryUltraRealistic, "ultra-realistic", false, "Force photorealistic rendering for all pages (overrides the default random 50/50 pick)")
- cmd.Flags().StringVar(&flags.StorySlug, "story-slug", "",
- "Force the output directory slug for --story (e.g. \"ai-jungle-quest\"). "+
- "Use this to repair a partial run: existing pages are skipped, missing ones are generated.")
- 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.NarrateEnabled, "narrate", false,
- "Generate a cinematic MP3 narration of the story after --story completes (default false). "+
- "Requires GOOGLE_API_KEY. Use --narrator-voice to pick a specific voice.")
- cmd.Flags().BoolVar(&flags.VideoEnabled, "video", flags.VideoEnabled,
- "Prompt to generate Veo videos after comic generation (default true; use --video=false to skip)")
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.RetryFailedAssets, "retry-failed-assets", false, "Scan existing cards and regenerate missing or failed audio/image assets, stopping on the first error")
diff --git a/internal/cli/flags_test.go b/internal/cli/flags_test.go
index 5c56770..4c20543 100644
--- a/internal/cli/flags_test.go
+++ b/internal/cli/flags_test.go
@@ -21,8 +21,6 @@ func TestNewFlags(t *testing.T) {
{"AudioProvider", flags.AudioProvider, audio.DefaultProviderConfig().Provider},
{"ImageAPI", flags.ImageAPI, "nanobanana"},
{"ImageAPISpecified", flags.ImageAPISpecified, false},
- {"NanoBananaModelSpecified", flags.NanoBananaModelSpecified, false},
- {"NanoBananaTextModelSpecified", flags.NanoBananaTextModelSpecified, false},
{"DeckName", flags.DeckName, "Bulgarian Vocabulary"},
{"OpenAIModel", flags.OpenAIModel, "gpt-4o-mini-tts"},
{"OpenAISpeed", flags.OpenAISpeed, 0.9},
@@ -33,7 +31,9 @@ func TestNewFlags(t *testing.T) {
{"GeminiTTSModel", flags.GeminiTTSModel, audio.DefaultProviderConfig().GeminiTTSModel},
{"GeminiVoice", flags.GeminiVoice, ""},
{"NanoBananaModel", flags.NanoBananaModel, "gemini-3.1-flash-image-preview"},
+ {"NanoBananaModelSpecified", flags.NanoBananaModelSpecified, false},
{"NanoBananaTextModel", flags.NanoBananaTextModel, "gemini-2.5-flash"},
+ {"NanoBananaTextModelSpecified", flags.NanoBananaTextModelSpecified, false},
}
for _, tt := range tests {
diff --git a/internal/cli/video_runner.go b/internal/cli/video_runner.go
index 021a6bf..9e888fb 100644
--- a/internal/cli/video_runner.go
+++ b/internal/cli/video_runner.go
@@ -10,8 +10,7 @@ import (
// directly.
//
// apiKey is the Google/Gemini API key passed by the caller.
-// selectedPaths contains the absolute (or relative) paths of the gallery PNGs
-// to animate — typically returned by video.PromptForGalleryVideos.
+// selectedPaths contains the absolute (or relative) paths of the PNGs to animate.
//
// Each page prints a "Generating…" line before the API call and a "Video saved:"
// line with the output path on success. The MP4 is written next to its source
diff --git a/internal/httpctx/httpctx.go b/internal/httpctx/httpctx.go
index 2a65f50..ffbdc65 100644
--- a/internal/httpctx/httpctx.go
+++ b/internal/httpctx/httpctx.go
@@ -34,10 +34,6 @@ const (
// ListModelsTimeout bounds model-listing CLI calls.
ListModelsTimeout = 3 * time.Minute
- // StoryPageImageTimeout bounds a single comic page image pipeline (search +
- // download) when no parent deadline exists.
- StoryPageImageTimeout = 25 * time.Minute
-
// VeoCLIPerVideoTimeout bounds one gallery-to-MP4 Veo run (start + poll +
// download) when the CLI passes Background.
VeoCLIPerVideoTimeout = 25 * time.Minute
diff --git a/internal/story/artist.go b/internal/story/artist.go
deleted file mode 100644
index 0cc4f17..0000000
--- a/internal/story/artist.go
+++ /dev/null
@@ -1,849 +0,0 @@
-package story
-
-import (
- "context"
- "fmt"
- "math/rand/v2"
- "os"
- "path/filepath"
- "strings"
- "time"
-
- "google.golang.org/genai"
-
- "codeberg.org/snonux/totalrecall/internal/batch"
- "codeberg.org/snonux/totalrecall/internal/httpctx"
- "codeberg.org/snonux/totalrecall/internal/image"
-)
-
-const (
- // storyPageCount is the number of story pages (excluding cover/back/gallery).
- // Each page uses a 2×2 grid of 4 panels in landscape (16:9) format.
- // cover + 5 story pages + 3 gallery pages + back cover = 10 total.
- storyPageCount = 5
-
- // galleryPageCount is the number of text-free close-up character art pages
- // inserted between the story pages and the back cover. Each is a full-bleed
- // single illustration of the hero/heroine in a distinct dramatic pose.
- // cover + 5 story pages + 5 gallery pages + back cover = 12 total.
- galleryPageCount = 5
-
- // pageMaxRetries is the number of times a story page generation is retried
- // before being skipped. Gemini image generation occasionally returns no data
- // due to transient safety filter hits, API hiccups, or rate limiting; a retry
- // after a pause usually succeeds. 5 attempts with progressive backoff gives
- // the rate-limiter enough time to recover without burning the whole quota.
- pageMaxRetries = 5
-
- // pageRetryBase is multiplied by the attempt number to produce a progressive
- // backoff: 15 s → 30 s → 45 s → 60 s. The growing pause lets the tool
- // recover from rate-limit windows automatically instead of failing silently.
- pageRetryBase = 15 * time.Second
-
- // comicPageAspectRatio: 16:9 is the closest supported widescreen ratio for
- // the ThinkPad X1 Gen 9 (2560×1600 / 16:10), filling the display with minimal
- // letterboxing. The API supports 16:9 but not 16:10.
- comicPageAspectRatio = "16:9"
-
- // comicPromptMaxChars caps each page's story excerpt in the NanoBanana prompt.
- comicPromptMaxChars = 900
-
- // helperModel matches the story generator's proven model (gemini-2.5-flash).
- // Both the bible and blurb use the same SystemInstruction + user-content pattern
- // that the story generator uses successfully.
- helperModel = "gemini-2.5-flash"
-
- // helperTimeout gives Gemini up to 90 s per helper call; thinking tokens
- // within gemini-2.5-flash need more time than a plain text model.
- helperTimeout = 90 * time.Second
-
- // helperMaxTokens must be large enough to cover internal thinking tokens
- // (gemini-2.5-flash) plus the visible output. 8192 matches the story generator.
- helperMaxTokens = int32(8192)
-
- // helperRetryPause waits before retrying when the model returns an empty
- // response — typically caused by free-tier RPM exhaustion between rapid calls.
- helperRetryPause = 15 * time.Second
-
- // renderingRequirement is injected into every image prompt when --ultra-realistic
- // is active. Kept strong and explicit because image models often drift toward
- // comic/illustration when prompts also say "comic book", "masthead", or "panels".
- // Omitted when Artist.ultraRealistic is false (--no-ultra-realistic flag).
- renderingRequirement = "ULTRA-REALISTIC RENDERING (mandatory for this entire image):\n" +
- " • The output must look like a REAL PHOTOGRAPH or a high-budget live-action film still — " +
- "shot on a real set or location with real actors, costumes, and props.\n" +
- " • Skin, hair, fabric, metal, and environments must show real-world texture, lens blur, " +
- "and natural light — NOT ink, NOT cel shading, NOT painterly brushwork.\n" +
- " • FORBIDDEN overall styles: cartoon, anime, manga, comic-book line art, halftone dots, " +
- "Ben-Day, visible outlines, storybook illustration, watercolor/oil-paint look, or any " +
- "obviously drawn or stylized artwork.\n" +
- " • Speech bubbles, masthead lettering, and UI-like overlays (where the layout requires them) " +
- "may look like graphic design ON TOP of the photo — the underlying scene must stay photographic.\n" +
- " • Gallery pages (no bubbles): the whole frame must be 100%% photographic — no exception.\n"
-
- // renderingRequirementEnd is appended at the very end of each prompt so the
- // model's last tokens reinforce photorealism (helps when earlier text is long).
- renderingRequirementEnd = "\nFINAL LOCK — PHOTOREALISM: Entire image = camera-captured realism. " +
- "If anything looks illustrated rather than photographed, the output is wrong. " +
- "Do not drift toward comic art between panels or on gallery pages.\n"
-)
-
-// realisticStyles is the style pool used when ultra-realistic mode is active.
-// These descriptions avoid "comic strip" / "illustration" language so the image
-// model produces photographic output rather than comic-book artwork.
-var realisticStyles = []string{
- "ultra-realistic DSLR photography, cinematic 35mm lens, natural lighting, hyper-detailed textures",
- "cinematic still photography, golden-hour lighting, shallow depth of field, photojournalism quality",
- "hyper-realistic photography, studio-quality lighting, sharp focus, true-to-life colours and textures",
-}
-
-// comicStyles is the pool used when standard comic style is active.
-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",
-}
-
-// characterBiblePrompt instructs Gemini to produce a strict visual reference
-// prepended verbatim to every panel, cover, and back-cover prompt.
-// bibleSystemInstruction is the SystemInstruction role for the character bible call.
-// Matching the story generator's proven SystemInstruction + user-content split ensures
-// gemini-2.5-flash allocates its thinking budget correctly instead of returning empty.
-const bibleSystemInstruction = `You are a comic-book art director producing a CHARACTER CONSISTENCY GUIDE in English for an illustrator.
-
-For every named HUMAN character provide: name, apparent age category (young child, teenager,
-young adult, middle-aged, elderly), hair (colour + style), eye colour, skin tone, build,
-and the EXACT clothing they wear — specify garment, colour, pattern, and fit.
-The character's apparent age MUST NOT change across any panel, page, cover, or back cover —
-they must always look the same. Clothing must NOT change between panels unless the story
-explicitly describes a change; if no change is described, list the same outfit for all appearances.
-
-For every named ANIMAL character provide: name, species, exact breed, fur/feather/scale colour
-and pattern, eye colour, size, any distinctive markings, and typical body posture.
-The animal must look IDENTICAL on every page — same breed, same markings, same eye colour.
-Do NOT substitute a generic animal; if the story says Persian cat, every panel must show a
-Persian cat with the exact described colouring.
-
-Also describe: the setting (location, time of day, weather, key props) and overall
-lighting / colour mood.
-
-Be extremely specific — this guide will be copy-pasted into every panel prompt to lock visual
-consistency. Maximum 300 words. No headers, just dense descriptive prose.`
-
-// blurbSystemInstruction is the SystemInstruction role for the back-cover blurb call.
-const blurbSystemInstruction = `You are a comic-book editor writing back-cover marketing copy.
-Rules: write exactly 2–3 sentences in English; exciting and enticing; do NOT spoil the ending;
-use present-tense second-person (e.g. "Join Eli as she discovers…").
-Output only the blurb text — no quotes, no labels, no extra commentary.`
-
-// ArtistConfig holds settings for comic-book image generation via NanoBanana.
-type ArtistConfig struct {
- APIKey string // Google API key (NanoBanana image + Gemini bible generation)
- Model string // NanoBanana image model
- 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
- 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,
- TextModel: config.TextModel,
- }
- }
-
- return &Artist{
- nbClient: image.NewNanoBananaClient(nbConfig),
- apiKey: apiKey,
- outputDir: dir,
- style: style,
- ultraRealistic: ultraRealistic,
- }
-}
-
-// DrawComicPages generates 5 images total:
-// - <titleSlug>_cover.png — full-bleed cover
-// - <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.
-// DrawComicPages generates all 12 pages of the comic (cover + 5 story + 5 gallery + back).
-// panelScript is a [page][panel] slice of explicit visual descriptions produced by Gemini;
-// when non-nil it drives each panel directly instead of raw story text excerpts,
-// ensuring the illustrations follow the narrative chronologically and coherently.
-func (a *Artist) DrawComicPages(storyText, prebuiltBible, titleSlug string, entries []batch.WordEntry, panelScript [][]string) ([]string, error) {
- style := a.style
- if style == "" {
- // Ultra-realistic mode uses photography-only language so the image model
- // produces photographic output. The comicStyles pool contains "comic strip"
- // which dominates the model's style interpretation even when the
- // renderingRequirement const is present — hence a separate pool is needed.
- if a.ultraRealistic {
- style = realisticStyles[rand.IntN(len(realisticStyles))]
- } else {
- 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)
-
- var paths []string
- // recentRefs holds image bytes from recently generated pages for iterative
- // chaining: each new page receives the cover + the previous page as visual
- // reference so the model can match character appearance directly from pixels
- // rather than relying on text descriptions alone.
- var recentRefs [][]byte
-
- // 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.loadOrGenerate(titleSlug+"_cover", func() (string, []byte) {
- return 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
- }
-
- // 2. Story pages — each receives cover + previous page as refs.
- // When a panelScript is available, panels are driven by explicit visual descriptions
- // so the illustrations follow the story chronologically. Raw text excerpts are used
- // as fallback when the script is absent or incomplete for a given page.
- sections := splitIntoSections(storyText, storyPageCount)
- for i, section := range sections {
- pageNum := i + 1
- var pagePanels []string
- if i < len(panelScript) {
- pagePanels = panelScript[i]
- }
- prompt := buildStoryPagePrompt(section, pageNum, storyPageCount, style, bible, entries, a.renderReq(), pagePanels)
- fileName := fmt.Sprintf("%s_page_%d", titleSlug, pageNum)
- p, pageBytes := a.loadOrGenerate(fileName, func() (string, []byte) {
- return a.generateStoryPage(prompt, fileName, pageNum, recentRefs)
- })
- if p != "" {
- paths = append(paths, p)
- recentRefs = appendRef(recentRefs, pageBytes)
- }
- }
-
- // 3. Gallery pages — text-free close-up character art pages, one per pose.
- // Each is a full-bleed single illustration; no panels, no text, no speech bubbles.
- // They act as alternative covers and use the accumulated refs for consistency.
- for i := range galleryPageCount {
- galleryNum := i + 1
- prompt := buildGalleryPagePrompt(style, bible, galleryNum, a.renderReq())
- fileName := fmt.Sprintf("%s_gallery_%d", titleSlug, galleryNum)
- gp, galleryBytes := a.loadOrGenerate(fileName, func() (string, []byte) {
- return a.generatePageWithRetry(prompt, fileName, recentRefs,
- fmt.Sprintf("gallery page %d/%d", galleryNum, galleryPageCount))
- })
- if gp != "" {
- paths = append(paths, gp)
- recentRefs = appendRef(recentRefs, galleryBytes)
- }
- }
-
- // 4. Back cover — receives the same rolling refs as the last gallery page.
- // Retried up to pageMaxRetries times; failure is non-fatal.
- p, _ = a.loadOrGenerate(titleSlug+"_back", func() (string, []byte) {
- return a.generatePageWithRetry(buildBackCoverPrompt(storyText, style, bible, blurb, a.renderReq()), titleSlug+"_back", recentRefs, "back cover")
- })
- if p != "" {
- paths = append(paths, p)
- }
-
- return paths, nil
-}
-
-// generateStoryPage attempts to generate a single story page up to pageMaxRetries
-// times using progressive backoff (pageRetryBase × attempt). Growing pauses let
-// rate-limit windows clear automatically. Non-fatal on exhaustion — the caller
-// continues so the PDF is never aborted by a single transient failure.
-func (a *Artist) generateStoryPage(prompt, fileName string, pageNum int, refs [][]byte) (string, []byte) {
- fmt.Printf(" Generating story page %d/%d...\n", pageNum, storyPageCount)
- return a.retryPage(pageMaxRetries, func(attempt int) (string, []byte, error) {
- return a.generateSinglePage(prompt, fileName, refs)
- }, fmt.Sprintf("story page %d", pageNum))
-}
-
-// generatePageWithRetry attempts to generate a single comic page (cover, gallery,
-// or back cover) up to pageMaxRetries times with progressive backoff. Non-fatal.
-func (a *Artist) generatePageWithRetry(prompt, fileName string, refs [][]byte, label string) (string, []byte) {
- fmt.Printf(" Generating %s...\n", label)
- return a.retryPage(pageMaxRetries, func(attempt int) (string, []byte, error) {
- return a.generateSinglePage(prompt, fileName, refs)
- }, label)
-}
-
-// retryPage is the shared retry loop used by generateStoryPage and
-// generatePageWithRetry. Each failed attempt waits pageRetryBase × attempt
-// before the next try, giving rate-limit windows time to clear:
-//
-// attempt 1 fails → wait 15 s
-// attempt 2 fails → wait 30 s
-// attempt 3 fails → wait 45 s
-// attempt 4 fails → wait 60 s
-// attempt 5 fails → log warning, return ("", nil)
-func (a *Artist) retryPage(maxAttempts int, generateFn func(attempt int) (string, []byte, error), label string) (string, []byte) {
- for attempt := 1; attempt <= maxAttempts; attempt++ {
- p, imgBytes, err := generateFn(attempt)
- if err == nil {
- return p, imgBytes
- }
- if attempt < maxAttempts {
- pause := pageRetryBase * time.Duration(attempt)
- fmt.Printf(" Warning: %s attempt %d/%d failed (%v), retrying in %s...\n",
- label, attempt, maxAttempts, err, pause)
- time.Sleep(pause)
- } else {
- fmt.Printf(" Warning: %s failed after %d attempts: %v\n",
- label, maxAttempts, err)
- }
- }
- return "", nil
-}
-
-// loadOrGenerate returns the saved pa