From d773e26b19dcea2f4a1917ebb6edaa4f5721693f Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 20 Apr 2026 19:07:56 +0300 Subject: Remove story feature for task 05 --- README.md | 106 ----- cmd/totalrecall/main.go | 23 +- cmd/totalrecall/story.go | 40 -- internal/cli/flags.go | 54 +-- internal/cli/flags_test.go | 4 +- internal/cli/video_runner.go | 3 +- internal/httpctx/httpctx.go | 4 - internal/story/artist.go | 849 ---------------------------------- internal/story/gallery_copy_test.go | 32 -- internal/story/generator.go | 420 ----------------- internal/story/narrator.go | 525 --------------------- internal/story/pdf.go | 42 -- internal/story/runner.go | 353 -------------- internal/video/gallery_prompt.go | 247 ---------- internal/video/gallery_prompt_test.go | 313 ------------- internal/video/generate_selected.go | 3 +- internal/video/story_run.go | 30 -- internal/video/veo.go | 4 +- 18 files changed, 23 insertions(+), 3029 deletions(-) delete mode 100644 cmd/totalrecall/story.go delete mode 100644 internal/story/artist.go delete mode 100644 internal/story/gallery_copy_test.go delete mode 100644 internal/story/generator.go delete mode 100644 internal/story/narrator.go delete mode 100644 internal/story/pdf.go delete mode 100644 internal/story/runner.go delete mode 100644 internal/video/gallery_prompt.go delete mode 100644 internal/video/gallery_prompt_test.go delete mode 100644 internal/video/story_run.go 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//` with files named `_*.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: `_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 `_tts_todo.txt` if narration fails - - **Vocabulary learning file** (`_comic_vocabulary.txt`) — word list with translations + full story text - - **Theme file** (`_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//`: - - `_story.txt` — ~250-word Bulgarian story using every word naturally - - `_cover.png` — traditional comic book front cover with Bulgarian title - - `_page_1.png` … `_page_5.png` — five 2×2-panel story pages (16:9) - - `_gallery_1.png` … `_gallery_5.png` — five close-up character gallery pages - - `_back.png` — back cover with blurb - - `.pdf` — all pages assembled into a single PDF - - `_narration.mp3` — cinematic Gemini TTS narration (only when `--narrate` is passed) - - `_tts_todo.txt` — written if `--narrate` was given but narration failed - - `_comic_vocabulary.txt` — vocabulary words + full story text for learning - - `_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//` 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//_gallery_1.mp4 - comics//_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 : 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: -// - _cover.png — full-bleed cover -// - _page_1.png … _3 — 4-panel (2×2 grid) landscape story pages -// - _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 path and image bytes for fileName. -// If the PNG already exists on disk it is loaded and returned without an API -// call — skipping regeneration of pages that were produced in a previous run. -// If the file is missing, generateFn is called to produce it. This lets a -// re-run fill in only the pages that failed previously without wasting quota. -func (a *Artist) loadOrGenerate(fileName string, generateFn func() (string, []byte)) (string, []byte) { - path := filepath.Join(a.outputDir, fileName+".png") - if _, err := os.Stat(path); err == nil { - // Page exists — load bytes for the reference chain and skip the API call. - b, readErr := os.ReadFile(path) - if readErr != nil { - fmt.Printf(" Warning: could not read existing %s for chaining: %v\n", fileName+".png", readErr) - return path, nil - } - fmt.Printf(" Skipping %s (already exists)\n", fileName+".png") - return path, b - } - return generateFn() -} - -// appendRef adds imgBytes to refs and keeps at most 2 entries (cover anchor + -// the immediately preceding page). Larger windows inflate the multimodal -// payload significantly without proportional consistency gains. -func appendRef(refs [][]byte, imgBytes []byte) [][]byte { - if len(imgBytes) == 0 { - return refs - } - refs = append(refs, imgBytes) - if len(refs) > 2 { - // Keep the first entry (cover anchor) and the latest page only. - refs = [][]byte{refs[0], refs[len(refs)-1]} - } - return refs -} - -// resolveHelperTexts returns the character bible and back-cover blurb. -// The bible comes from prebuiltBible (produced by GenerateFull in the same -// Gemini call as the story — no extra API call, no rate-limiting). The blurb -// is still generated with a separate call since it is not part of story generation. -func (a *Artist) resolveHelperTexts(storyText, prebuiltBible string) (bible, blurb string) { - bible = prebuiltBible - if bible != "" { - fmt.Printf(" Character bible ready (%d chars)\n", len(bible)) - } else { - fmt.Println(" Warning: no character bible — characters may vary between pages") - } - - if a.apiKey == "" { - return bible, "" - } - - client, err := httpctx.NewGenAIClient(context.Background(), &genai.ClientConfig{APIKey: a.apiKey}) - if err != nil { - fmt.Printf(" Warning: Gemini client failed for blurb (%v)\n", err) - return bible, "" - } - - blurb = a.callGeminiHelper(client, blurbSystemInstruction, storyText, "back-cover blurb") - if blurb != "" { - fmt.Printf(" Back-cover blurb ready (%d chars)\n", len(blurb)) - } - return bible, blurb -} - -// callGeminiHelper sends one text prompt to helperModel and returns the trimmed response. -// Retries once after helperRetryPause on empty response. -func (a *Artist) callGeminiHelper(client *genai.Client, systemInstruction, userPrompt, label string) string { - for attempt := 1; attempt <= 2; attempt++ { - ctx, cancel := context.WithTimeout(context.Background(), helperTimeout) - resp, err := client.Models.GenerateContent(ctx, helperModel, - []*genai.Content{genai.NewContentFromText(userPrompt, genai.RoleUser)}, - &genai.GenerateContentConfig{ - SystemInstruction: &genai.Content{ - Parts: []*genai.Part{{Text: systemInstruction}}, - }, - MaxOutputTokens: helperMaxTokens, - }, - ) - cancel() - - if err != nil { - fmt.Printf(" Warning: %s attempt %d failed: %v\n", label, attempt, err) - } else if text := strings.TrimSpace(resp.Text()); text != "" { - return text - } else { - fmt.Printf(" Warning: %s attempt %d returned empty response\n", label, attempt) - } - - if attempt < 2 { - fmt.Printf(" Retrying %s in %s...\n", label, helperRetryPause) - time.Sleep(helperRetryPause) - } - } - return "" -} - -// generateSinglePage downloads and saves one image for the given prompt. -// refs are optional previously generated page images passed as multimodal -// context to the image model for iterative chaining consistency. -// Returns the saved file path and raw PNG bytes (for use as ref in next page). -func (a *Artist) generateSinglePage(prompt, fileNamePattern string, refs [][]byte) (string, []byte, error) { - opts := image.DefaultSearchOptions("vocabulary story") - opts.CustomPrompt = prompt - opts.AspectRatio = comicPageAspectRatio - opts.ReferenceImages = refs - - downloader := image.NewDownloader(a.nbClient, &image.DownloadOptions{ - OutputDir: a.outputDir, - OverwriteExisting: true, - CreateDir: true, - FileNamePattern: fileNamePattern, - MaxSizeBytes: 20 * 1024 * 1024, - }) - - pageCtx, pageCancel := context.WithTimeout(context.Background(), httpctx.StoryPageImageTimeout) - defer pageCancel() - - _, savedPath, err := downloader.DownloadBestMatchWithOptions(pageCtx, opts) - if err != nil { - return "", nil, err - } - - // Read back the saved PNG so callers can pass it as a reference image to - // subsequent pages. Non-fatal if the read fails — we just skip the reference. - imgBytes, readErr := os.ReadFile(savedPath) - if readErr != nil { - fmt.Printf(" Warning: could not read back %s for chaining: %v\n", savedPath, readErr) - imgBytes = nil - } - - 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 "" -} - -// appendUltraRealisticEnd adds a final photorealism reminder when renderReq is -// non-empty (ultra-realistic mode), so long prompts still end on a strong constraint. -func appendUltraRealisticEnd(renderReq string) string { - if renderReq == "" { - return "" - } - return renderingRequirementEnd -} - -// buildCoverPrompt constructs the front-cover image prompt. -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 { - teaser = teaser[:300] - if idx := strings.LastIndex(teaser, " "); idx > 0 { - teaser = teaser[:idx] - } - teaser += "…" - } - - bibleBlock := bibleSection(bible, "cover") - mainArtVerb := "illustration" - if renderReq != "" { - mainArtVerb = "photographed cinematic scene" - } - coverBleed := "single full-bleed illustration" - if renderReq != "" { - coverBleed = "single full-bleed image (photoreal — like a physical comic book cover photo shoot)" - } - return fmt.Sprintf( - // Bulgarian language rule placed first so the model processes it before any other instruction. - "ЗАДЪЛЖИТЕЛНО / MANDATORY LANGUAGE RULE: This is a BULGARIAN comic book. "+ - "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"+ - renderReq+ - "TRADITIONAL COMIC BOOK FRONT COVER — %s, landscape 16:9 format.\n"+ - "NO panel grid. NO speech bubbles.\n"+ - "MANDATORY MASTHEAD — the most important visual element on this cover:\n"+ - " • Invent a DRAMATIC, STORY-SPECIFIC comic book title that fits the characters and "+ - "theme of the story teaser below (e.g. for a space story: 'ГАЛАКТИЧЕСКИ ГЕРОИ', for "+ - "a mystery: 'ТАЙНАТА НА ГОРАТА'). The title must be in HUGE, dominant lettering "+ - "across the very top of the cover — bold comic-book masthead font, thick outlines, "+ - "bright contrasting colours (yellow, red, or white on dark), taking up the top 20%% "+ - "of the image. This title MUST be legible and unmissable.\n"+ - " • Directly below the main title, add a smaller subtitle banner: "+ - "'BULGARIAN VOCABULARY ADVENTURE' in a contrasting accent colour.\n"+ - " • Add a bold comic-book LOGO BUG (small circular or star-shaped badge) "+ - "in the top-left corner — e.g. a planet, rocket, magnifying glass, sword — "+ - "matching the story theme. The logo should feel like a real publisher imprint.\n"+ - "Remaining layout rules:\n"+ - " • MAIN ART: below the masthead, a dramatic %s of EXACTLY the named characters "+ - "from the story (as described in the reference above) — same faces, same ages, same "+ - "clothing, same animals. Do NOT invent new characters or use generic stand-ins.\n"+ - " • COVER LINES: 2–3 short Bulgarian teaser phrases in bold display type "+ - "(e.g. 'НЕВЕРОЯТНО ПРИКЛЮЧЕНИЕ!' or 'СРЕЩА С НЕПОЗНАТОТО!')\n"+ - " • BOTTOM STRIP: price box bottom-left, issue number bottom-right — "+ - "classic Silver-Age / Bronze-Age comic production design.\n"+ - "IMPORTANT: only the characters named in the reference may appear on this cover. "+ - "Same age, same face, same clothing as in the interior pages. "+ - "LANGUAGE REMINDER: all cover text in Bulgarian Cyrillic — see rule at top. "+ - "Story teaser:\n\n%s"+ - "%s", - style, bibleBlock, coverBleed, mainArtVerb, teaser, appendUltraRealisticEnd(renderReq), - ) -} - -// buildStoryPagePrompt constructs a landscape comic page prompt. -// Layout uses a 2×2 grid of 4 panels optimised for the 16:9 aspect ratio. -// entries are injected as a vocabulary block so the image model features and -// labels each word visually inside the panels — making each page a learning tool. -// The Bulgarian language requirement is placed at the very top so it is processed -// before all other instructions. -// buildStoryPagePrompt constructs the image prompt for one 4-panel story page. -// When pagePanels contains explicit visual descriptions (from the Gemini panel -// script), those drive each panel directly for narrative coherence. Otherwise -// the raw story excerpt is used as a fallback. -func buildStoryPagePrompt(section string, pageNum, totalPages int, style, bible string, entries []batch.WordEntry, renderReq string, pagePanels []string) string { - bibleBlock := bibleSection(bible, fmt.Sprintf("story page %d of %d", pageNum, totalPages)) - vocabBlock := buildVocabBlock(entries) - panelLayout := buildPanelLayout(section, pagePanels) - - storyPanelRealism := "" - if renderReq != "" { - storyPanelRealism = "PANEL REALISM: Each of the 4 panels must depict a PHOTOGRAPHED scene (real actors, real lighting). " + - "Speech bubbles, thought bubbles, and vocabulary labels are flat graphic overlays only — " + - "the world behind them must not look drawn or cartoon-like.\n" - } - - return fmt.Sprintf( - // Lead with the hard language constraint so it is processed first. - "ЗАДЪЛЖИТЕЛНО / MANDATORY LANGUAGE RULE: This is a BULGARIAN comic book. "+ - "Every word of text inside speech bubbles, thought bubbles, caption boxes, "+ - "and panel labels MUST be written in Bulgarian Cyrillic script "+ - "(например: Здравей! Какво правиш? Побързай!). "+ - "English text anywhere in the panels is STRICTLY FORBIDDEN — use ONLY Bulgarian.\n\n"+ - "%s"+ // vocabulary block — before art style so it is never truncated - "Art style: %s.%s\n"+ - "Comic book story page %d of %d.\n"+ - "%s"+ // panel layout (script-driven or excerpt-driven) - "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"+ - "MANDATORY SPEECH BUBBLES — this is a comic book; characters MUST speak:\n"+ - " • At least 3 of the 4 panels MUST contain a speech bubble or thought bubble.\n"+ - " • If the panel description includes quoted dialogue, render it EXACTLY inside a speech bubble.\n"+ - " • Speech bubbles have a white background, black outline, and a tail pointing to the speaker.\n"+ - " • Thought bubbles use a cloud shape with small circles leading to the thinker.\n"+ - " • ALL bubble text is in Bulgarian Cyrillic — never Roman letters.\n"+ - "VARIETY MANDATE — every panel MUST differ from the others in at least 3 of these dimensions: "+ - "camera angle (close-up, medium shot, wide shot, bird's-eye, low angle), "+ - "character pose or action, location or background detail, lighting or time-of-day, "+ - "and foreground objects. Repeating the same angle or composition across panels is FORBIDDEN.\n"+ - renderReq+ - storyPanelRealism+ - "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 as defined.\n"+ - " • Animal characters: identical breed, fur colour/pattern, markings, and eye colour — "+ - "NEVER substitute a different animal or a generic version of the species.\n"+ - " • Clothing changes only if this page's description explicitly describes a change.\n"+ - " • LANGUAGE: all speech, thought, and caption text — Bulgarian Cyrillic ONLY.\n"+ - "%s", - vocabBlock, style, bibleBlock, pageNum, totalPages, panelLayout, appendUltraRealisticEnd(renderReq), - ) -} - -// buildPanelLayout returns the MANDATORY PANEL LAYOUT block. -// When pagePanels are provided (from the Gemini panel script) each panel gets -// an explicit visual instruction; otherwise the raw excerpt is used so every -// panel can interpret it freely. -func buildPanelLayout(section string, pagePanels []string) string { - labels := [4]string{"TOP-LEFT", "TOP-RIGHT", "BOTTOM-LEFT", "BOTTOM-RIGHT"} - - // Script-driven path: all 4 panel descriptions are non-empty. - if len(pagePanels) == 4 && pagePanels[0] != "" && pagePanels[1] != "" && pagePanels[2] != "" && pagePanels[3] != "" { - var sb strings.Builder - sb.WriteString("MANDATORY PANEL LAYOUT — divide the image into exactly 4 panels in a 2×2 grid.\n") - sb.WriteString("Draw each panel EXACTLY as described below — these are the precise scenes to illustrate:\n") - for i, label := range labels { - sb.WriteString(fmt.Sprintf(" • %s panel: %s\n", label, pagePanels[i])) - } - return sb.String() - } - - // Fallback: excerpt-driven path when the panel script is absent or incomplete. - excerpt := strings.TrimSpace(section) - if len(excerpt) > comicPromptMaxChars { - excerpt = excerpt[:comicPromptMaxChars] - if idx := strings.LastIndex(excerpt, " "); idx > 0 { - excerpt = excerpt[:idx] - } - excerpt += "…" - } - return "MANDATORY PANEL LAYOUT — divide the image into exactly 4 panels in a 2×2 grid:\n" + - " • TOP-LEFT panel: scene 1 from the excerpt\n" + - " • TOP-RIGHT panel: scene 2 from the excerpt\n" + - " • BOTTOM-LEFT panel: scene 3 from the excerpt\n" + - " • BOTTOM-RIGHT panel: scene 4 from the excerpt\n" + - "Story excerpt (ALL panels must illustrate THIS excerpt only):\n\n" + excerpt + "\n" -} - -// buildVocabBlock formats the vocabulary entries as a mandatory visual instruction -// block. Each word must appear as a clearly labelled object or element in at least -// one panel — making the comic page a vocabulary learning tool as well as a story page. -func buildVocabBlock(entries []batch.WordEntry) string { - if len(entries) == 0 { - return "" - } - var sb strings.Builder - sb.WriteString("VOCABULARY WORDS — each word below MUST appear as a clearly visible, labelled\n") - sb.WriteString("object or element in at least one panel. Show the object in the scene and add a\n") - sb.WriteString("small Bulgarian label directly on it (bold text, contrasting colour, easy to read):\n") - for _, e := range entries { - if e.Translation != "" { - sb.WriteString(fmt.Sprintf(" • %s (%s)\n", e.Bulgarian, e.Translation)) - } else { - sb.WriteString(fmt.Sprintf(" • %s\n", e.Bulgarian)) - } - } - sb.WriteString("\n") - return sb.String() -} - -// buildBackCoverPrompt constructs the back-cover image prompt. -// blurb is an English marketing summary generated by Gemini; when non-empty it is -// embedded verbatim in the blurb-box instruction so the image model renders it. -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 { - ending = ending[len(ending)-200:] - if idx := strings.Index(ending, " "); idx > 0 { - ending = ending[idx+1:] - } - } - - // Build the blurb box instruction: use the generated blurb if available, - // otherwise ask the model to leave a styled empty box. - blurbBoxInstruction := "a rectangular text box (white or cream background, thin black border) " + - "near the bottom — styled like a classic back-cover synopsis box, box shape required." - if blurb != "" { - blurbBoxInstruction = fmt.Sprintf( - "a rectangular text box (white or cream background, thin black border) "+ - "near the bottom displaying this blurb text in italic type:\n"+ - " \"%s\"", blurb) - } - - bibleBlock := bibleSection(bible, "back cover") - backBleed := "single full-bleed illustration" - if renderReq != "" { - backBleed = "single full-bleed image (photoreal — like a physical back-cover photo shoot)" - } - return fmt.Sprintf( - // Bulgarian language rule placed first for maximum model compliance. - "ЗАДЪЛЖИТЕЛНО / MANDATORY LANGUAGE RULE: This is a BULGARIAN comic book. "+ - "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"+ - renderReq+ - "TRADITIONAL COMIC BOOK BACK COVER — %s, landscape 16:9 format.\n"+ - "NO panel grid. NO speech bubbles.\n"+ - "Layout rules (must follow exactly):\n"+ - " • MAIN ART: a calm, warm, resolved scene filling the upper 60%% of the cover — "+ - "EXACTLY the named characters from the story (as described in the reference above) "+ - "in a peaceful or triumphant ending moment, with the full story setting behind them. "+ - "The scene must look PHOTOGRAPHED (live-action), not drawn. "+ - "Do NOT invent new characters or use generic stand-ins.\n"+ - " • BLURB BOX: %s\n"+ - " • BOTTOM STRIP: barcode box bottom-left (black-and-white barcode graphic), "+ - "series title 'BULGARIAN VOCABULARY ADVENTURE' bottom-right — "+ - "classic comic book back-cover production design.\n"+ - "IMPORTANT: only the characters named in the reference may appear on this back cover. "+ - "Same age, same face, same clothing, same animals as in the interior pages. "+ - "LANGUAGE REMINDER: all text in Bulgarian Cyrillic — see rule at top. "+ - "Story ending hint:\n\n%s"+ - "%s", - style, bibleBlock, backBleed, blurbBoxInstruction, ending, appendUltraRealisticEnd(renderReq), - ) -} - -// galleryPoses are the close-up compositions cycled across the 3 gallery pages. -// Each is a distinct dramatic framing so the pages feel like variant cover art. -var galleryPoses = []string{ - "extreme close-up portrait: face and shoulders filling the entire frame, dramatic three-quarter lighting, intense gaze directly at the viewer, fine detail on eyes and expression", - "dynamic action pose: full body, low-angle shot looking up at the main character against the sky or setting backdrop, confident stance, hair and clothing caught in motion", - "atmospheric mid-shot: waist-up, the main character silhouetted or lit by the ambient environment (bioluminescence, sunset, neon glow), looking off into the distance with a sense of wonder or resolve", - "profile close-up: side view of face and upper body, soft rim lighting tracing the jawline and hair, contemplative expression, rich background bokeh", - "power stance full-body: the main character seen from the front at eye level, arms relaxed but ready, environment filling the frame behind them, golden-hour or dramatic storm light", -} - -// 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, renderReq string) string { - pose := galleryPoses[(galleryNum-1)%len(galleryPoses)] - bibleBlock := bibleSection(bible, fmt.Sprintf("gallery page %d", galleryNum)) - galleryArt := "FULL-BLEED SINGLE ILLUSTRATION" - if renderReq != "" { - galleryArt = "FULL-BLEED SINGLE PHOTOGRAPH — must look 100%% like a real camera shot (no illustration style)" - } - return fmt.Sprintf( - "Art style: %s.%s\n"+ - renderReq+ - "%s — landscape 16:9 format, ONE image only, NO grid, NO panels.\n"+ - "DO NOT split the image into multiple panels or sections. The ENTIRE canvas is ONE single scene.\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 character gallery page. "+ - "If ultra-realistic mode: pure cinematic photography only — not painted or comic art.\n\n"+ - "Composition: %s\n\n"+ - "The subject MUST be EXACTLY the main character(s) described in the reference above — "+ - "same faces, same genders, same ages, same clothing. Include the companion animal if naturally present. "+ - "Do NOT invent new characters or change any character's gender. Do NOT add any text overlays.\n"+ - "Background: the story's setting rendered with full cinematic atmosphere and colour mood."+ - "%s", - style, bibleBlock, galleryArt, pose, appendUltraRealisticEnd(renderReq), - ) -} - -// bibleSection formats the character bible as a labelled block for the prompt. -// Returns empty string when bible is empty. -func bibleSection(bible, context string) string { - if bible == "" { - return "" - } - return fmt.Sprintf( - "\nCHARACTER & SETTING REFERENCE (%s — follow exactly, do NOT change clothing):\n%s\n", - context, bible, - ) -} - -func pickStyle() string { - if rand.Float64() < 0.9 { - return comicStyles[0] - } - 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 { - return distributeParagraphs(paragraphs, n) - } - return splitByChars(text, n) -} - -func splitParagraphs(text string) []string { - var out []string - for _, p := range strings.Split(text, "\n\n") { - if p = strings.TrimSpace(p); p != "" { - out = append(out, p) - } - } - return out -} - -func distributeParagraphs(paragraphs []string, n int) []string { - sections := make([]string, n) - size, rem, idx := len(paragraphs)/n, len(paragraphs)%n, 0 - for i := range n { - count := size - if i < rem { - count++ - } - sections[i] = strings.Join(paragraphs[idx:idx+count], "\n\n") - idx += count - } - return sections -} - -func splitByChars(text string, n int) []string { - size := len(text) / n - sections := make([]string, n) - for i := range n { - start := i * size - end := start + size - if i == n-1 { - end = len(text) - } - sections[i] = strings.TrimSpace(text[start:end]) - } - return sections -} - -func orDefault(s, def string) string { - if s != "" { - return s - } - return def -} diff --git a/internal/story/gallery_copy_test.go b/internal/story/gallery_copy_test.go deleted file mode 100644 index 7c6f4c1..0000000 --- a/internal/story/gallery_copy_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package story - -import ( - "os" - "path/filepath" - "testing" -) - -func TestCopyGalleryPNGsToComicsGallery(t *testing.T) { - root := t.TempDir() - comicDir := filepath.Join(root, "comics", "my-slug") - if err := os.MkdirAll(comicDir, 0755); err != nil { - t.Fatal(err) - } - g1 := filepath.Join(comicDir, "my-slug_gallery_1.png") - if err := os.WriteFile(g1, []byte("png1"), 0644); err != nil { - t.Fatal(err) - } - _ = os.WriteFile(filepath.Join(comicDir, "my-slug_cover.png"), []byte("x"), 0644) - - if err := copyGalleryPNGsToComicsGallery(root, comicDir); err != nil { - t.Fatal(err) - } - dst := filepath.Join(root, "comics", "gallery", "my-slug_gallery_1.png") - b, err := os.ReadFile(dst) - if err != nil { - t.Fatal(err) - } - if string(b) != "png1" { - t.Fatalf("got %q", b) - } -} diff --git a/internal/story/generator.go b/internal/story/generator.go deleted file mode 100644 index bac5d82..0000000 --- a/internal/story/generator.go +++ /dev/null @@ -1,420 +0,0 @@ -package story - -import ( - "context" - "fmt" - "math/rand/v2" - "strings" - "time" - - "google.golang.org/genai" - - "codeberg.org/snonux/totalrecall/internal/batch" - "codeberg.org/snonux/totalrecall/internal/httpctx" -) - -const ( - storyGeminiModel = "gemini-2.5-flash" - storyTimeout = 120 * time.Second - // 8192 tokens for story-only generation (thinking + ~650-word visible story). - storyMaxTokens = int32(8192) - // 16384 total for the combined story+bible call, with thinking capped at 8192 - // (see storyFullThinkingBudget). This guarantees ~8192 tokens for the visible - // output (story ~650 words + bible ~280 words ≈ 1300 tokens — well within budget). - storyFullMaxTokens = int32(16384) - // storyFullThinkingBudget caps the internal chain-of-thought so the model - // cannot consume all MaxOutputTokens with thinking and produce no visible text. - // Without this cap, gemini-2.5-flash exhausts all tokens on reasoning for the - // complex combined prompt, making resp.Text() return an empty string. - storyFullThinkingBudget = int32(8192) - storySystemPrompt = "You are a creative Bulgarian language teacher. Write engaging stories that naturally incorporate vocabulary words to help students learn." - - // storyBibleSeparator is the exact line the model must output between the - // story and the character bible. Parsing splits the response on this marker. - storyBibleSeparator = "---CHARACTER GUIDE---" - - // storyTitleSeparator is the exact line the model outputs after the bible to - // deliver a short English comic title. parseGenerateResult extracts it for - // use as the output directory name and file prefix. - storyTitleSeparator = "---COMIC TITLE---" - - // storyPanelSeparator marks the start of the 20-line panel visual script. - // The script lists one sentence per panel (P1-A … P5-D) so the artist can - // draw each panel from an explicit description rather than guessing from - // raw prose — this is the primary mechanism for narrative coherence. - storyPanelSeparator = "---PANEL SCRIPT---" - - // storyPageCount duplicated here so generator.go can reference it without - // importing artist.go (both are in package story; used in buildStoryPromptFull). - storyPagesInScript = 5 - storyPanelsPerPage = 4 -) - -// storyGenres is the pool of genres picked randomly each run to keep stories -// varied — not always fairy tales. Realistic/slice-of-life is weighted at 40% -// (picked when index 0 or 1 is chosen) and the rest appear equally. -var storyGenres = []string{ - "a warm realistic slice-of-life story", - "a heartfelt family drama", - "an exciting science-fiction adventure", - "a thrilling action-adventure story", - "a mystery with a surprising twist", - "a funny comedy with silly misunderstandings", - "a fantasy quest in a magical world", - "a spooky but kid-friendly horror story", - "a space exploration adventure", - "a superhero origin story", -} - -// pickStoryGenre returns a random genre from the pool. -// Realistic genres (indices 0–1) appear 40% of the time; the rest 60%. -func pickStoryGenre() string { - if rand.Float64() < 0.4 { - return storyGenres[rand.IntN(2)] - } - return storyGenres[2+rand.IntN(len(storyGenres)-2)] -} - -// resolveGenre returns theme if non-empty, otherwise picks a random genre. -// This lets the caller override the genre