summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/cli/flags.go54
-rw-r--r--internal/cli/flags_test.go4
-rw-r--r--internal/cli/video_runner.go3
-rw-r--r--internal/httpctx/httpctx.go4
-rw-r--r--internal/story/artist.go849
-rw-r--r--internal/story/gallery_copy_test.go32
-rw-r--r--internal/story/generator.go420
-rw-r--r--internal/story/narrator.go525
-rw-r--r--internal/story/pdf.go42
-rw-r--r--internal/story/runner.go353
-rw-r--r--internal/video/gallery_prompt.go247
-rw-r--r--internal/video/gallery_prompt_test.go313
-rw-r--r--internal/video/generate_selected.go3
-rw-r--r--internal/video/story_run.go30
-rw-r--r--internal/video/veo.go4
15 files changed, 19 insertions, 2864 deletions
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 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