summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-04 15:45:55 +0300
committerPaul Buetow <paul@buetow.org>2026-04-04 15:45:55 +0300
commit2d6cd1a4d388b22396827cbf151aa2990ca6cd5a (patch)
tree96076c7b50a2d5ae7adfaab4c4965802f5ec390c /internal
parent4e8c51db6e5f4563525ce923f058ab3dc7357cb5 (diff)
feat: skip existing pages on re-run, add --story-slug for targeted repairv0.18.0
- loadOrGenerate() in artist.go skips API calls for pages already on disk, loading bytes for the reference chain. Re-runs now only generate missing pages instead of regenerating the whole comic. - --story-slug flag forces the output directory slug so a repair run writes to the same directory as the original (e.g. --story-slug ai-jungle-quest). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal')
-rw-r--r--internal/cli/command.go3
-rw-r--r--internal/cli/flags.go3
-rw-r--r--internal/story/artist.go39
-rw-r--r--internal/story/runner.go12
-rw-r--r--internal/version.go2
5 files changed, 50 insertions, 9 deletions
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"