diff options
| -rw-r--r-- | cmd/totalrecall/main.go | 1 | ||||
| -rw-r--r-- | internal/cli/command.go | 3 | ||||
| -rw-r--r-- | internal/cli/flags.go | 3 | ||||
| -rw-r--r-- | internal/story/artist.go | 39 | ||||
| -rw-r--r-- | internal/story/runner.go | 12 | ||||
| -rw-r--r-- | internal/version.go | 2 |
6 files changed, 51 insertions, 9 deletions
diff --git a/cmd/totalrecall/main.go b/cmd/totalrecall/main.go index f3a84f9..a3523ce 100644 --- a/cmd/totalrecall/main.go +++ b/cmd/totalrecall/main.go @@ -71,6 +71,7 @@ func runCommand(cmd *cobra.Command, args []string, flags *cli.Flags) error { Theme: flags.StoryTheme, UltraRealistic: storyUltraRealistic(flags.StoryNoUltraRealistic), NarratorVoice: flags.NarratorVoice, + Slug: flags.StorySlug, }) return runner.Run(flags.StoryFile) } diff --git a/internal/cli/command.go b/internal/cli/command.go index a1168b5..090b2d1 100644 --- a/internal/cli/command.go +++ b/internal/cli/command.go @@ -69,6 +69,9 @@ func setupFlags(cmd *cobra.Command, flags *Flags) { cmd.Flags().StringVar(&flags.StoryStyle, "story-style", "", "Art style for comic pages (default: random). E.g. \"ultra realistic comic strip with photographic detail and dramatic lighting\"") cmd.Flags().StringVar(&flags.StoryTheme, "story-theme", "", "Genre/theme for the story (default: random). E.g. \"a thrilling space adventure with aliens and spaceships\"") cmd.Flags().BoolVar(&flags.StoryNoUltraRealistic, "no-ultra-realistic", false, "Disable photorealistic rendering requirement; produces standard comic-book style output") + cmd.Flags().StringVar(&flags.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") diff --git a/internal/cli/flags.go b/internal/cli/flags.go index 104f721..9c83138 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -23,7 +23,8 @@ type Flags struct { 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 + StoryNoUltraRealistic bool // --no-ultra-realistic: disable photorealistic rendering requirement + 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) SkipAudio bool SkipImages bool diff --git a/internal/story/artist.go b/internal/story/artist.go index 885717a..4f9ff4c 100644 --- a/internal/story/artist.go +++ b/internal/story/artist.go @@ -5,6 +5,7 @@ import ( "fmt" "math/rand/v2" "os" + "path/filepath" "strings" "time" @@ -217,7 +218,9 @@ func (a *Artist) DrawComicPages(storyText, prebuiltBible, titleSlug string, entr // 1. Cover — generated without refs (it is the visual baseline). // Retried up to pageMaxRetries times; failure is non-fatal but the cover // is omitted from the PDF and no anchor reference is established. - p, coverBytes := a.generatePageWithRetry(buildCoverPrompt(storyText, style, bible, a.renderReq()), titleSlug+"_cover", nil, "cover page") + 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 @@ -232,7 +235,9 @@ func (a *Artist) DrawComicPages(storyText, prebuiltBible, titleSlug string, entr pageNum := i + 1 prompt := buildStoryPagePrompt(section, pageNum, storyPageCount, style, bible, entries, a.renderReq()) fileName := fmt.Sprintf("%s_page_%d", titleSlug, pageNum) - p, pageBytes := a.generateStoryPage(prompt, fileName, pageNum, recentRefs) + 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) @@ -246,8 +251,10 @@ func (a *Artist) DrawComicPages(storyText, prebuiltBible, titleSlug string, entr galleryNum := i + 1 prompt := buildGalleryPagePrompt(style, bible, galleryNum, a.renderReq()) fileName := fmt.Sprintf("%s_gallery_%d", titleSlug, galleryNum) - gp, galleryBytes := a.generatePageWithRetry(prompt, fileName, recentRefs, - fmt.Sprintf("gallery page %d/%d", galleryNum, galleryPageCount)) + 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) @@ -256,7 +263,9 @@ func (a *Artist) DrawComicPages(storyText, prebuiltBible, titleSlug string, entr // 4. Back cover — receives the same rolling refs as the last gallery page. // Retried up to pageMaxRetries times; failure is non-fatal. - p, _ = a.generatePageWithRetry(buildBackCoverPrompt(storyText, style, bible, blurb, a.renderReq()), titleSlug+"_back", recentRefs, "back cover") + 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) } @@ -309,6 +318,26 @@ func (a *Artist) generatePageWithRetry(prompt, fileName string, refs [][]byte, l 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. diff --git a/internal/story/runner.go b/internal/story/runner.go index 3299d59..c738ad0 100644 --- a/internal/story/runner.go +++ b/internal/story/runner.go @@ -47,6 +47,11 @@ type RunnerConfig struct { // NarratorVoice picks a specific Gemini cinematic voice for narration. // Empty → random pick from the curated cinematic pool each run. NarratorVoice string + // Slug overrides the auto-generated title slug used as the output directory + // name and file prefix. When non-empty the runner skips slug derivation and + // uses this value directly. Combine with loadOrGenerate's skip-existing logic + // to repair a partial comic run without regenerating already-present pages. + Slug string } // Runner orchestrates the full pipeline: text → image → narration. @@ -137,10 +142,13 @@ func (r *Runner) Run(batchFile string) error { fmt.Printf(" Character bible ready from story generation (%d chars)\n", len(result.Bible)) } - // Derive a slug from the generated title and create the comics subfolder. + // Derive a slug from the generated title, or use the forced slug from config. // All output files (images, PDF, story text, narration) go into comics/<slug>/. slug := slugify(result.Title) - if result.Title != "" { + if r.config != nil && r.config.Slug != "" { + slug = r.config.Slug + fmt.Printf(" Comic title: %q (slug forced: %s)\n", result.Title, slug) + } else if result.Title != "" { fmt.Printf(" Comic title: %q (slug: %s)\n", result.Title, slug) } comicsDir := filepath.Join(dir, "comics", slug) diff --git a/internal/version.go b/internal/version.go index 9f7173a..edfaf53 100644 --- a/internal/version.go +++ b/internal/version.go @@ -1,3 +1,3 @@ package internal -const Version = "0.17.0" +const Version = "0.18.0" |
