diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-20 19:07:56 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-20 19:07:56 +0300 |
| commit | d773e26b19dcea2f4a1917ebb6edaa4f5721693f (patch) | |
| tree | cc38b381c0d367a84ad802c16bf7fcb0c67bc8ad | |
| parent | 7bb500b8cd567e45634fc01c97e8dcd5a1a9db22 (diff) | |
Remove story feature for task 05
| -rw-r--r-- | README.md | 106 | ||||
| -rw-r--r-- | cmd/totalrecall/main.go | 23 | ||||
| -rw-r--r-- | cmd/totalrecall/story.go | 40 | ||||
| -rw-r--r-- | internal/cli/flags.go | 54 | ||||
| -rw-r--r-- | internal/cli/flags_test.go | 4 | ||||
| -rw-r--r-- | internal/cli/video_runner.go | 3 | ||||
| -rw-r--r-- | internal/httpctx/httpctx.go | 4 | ||||
| -rw-r--r-- | internal/story/artist.go | 849 | ||||
| -rw-r--r-- | internal/story/gallery_copy_test.go | 32 | ||||
| -rw-r--r-- | internal/story/generator.go | 420 | ||||
| -rw-r--r-- | internal/story/narrator.go | 525 | ||||
| -rw-r--r-- | internal/story/pdf.go | 42 | ||||
| -rw-r--r-- | internal/story/runner.go | 353 | ||||
| -rw-r--r-- | internal/video/gallery_prompt.go | 247 | ||||
| -rw-r--r-- | internal/video/gallery_prompt_test.go | 313 | ||||
| -rw-r--r-- | internal/video/generate_selected.go | 3 | ||||
| -rw-r--r-- | internal/video/story_run.go | 30 | ||||
| -rw-r--r-- | internal/video/veo.go | 4 |
18 files changed, 23 insertions, 3029 deletions
@@ -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 |
