summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-06 11:28:48 +0300
committerPaul Buetow <paul@buetow.org>2026-04-06 11:28:48 +0300
commitcd45f841932052435a28f669405b23f313d9cf62 (patch)
tree81a01f7d28176da3de847243ec3c78087301fea3 /internal
parentef1f3d03ac3f117fecd999fc4fa96f557bfa8fe4 (diff)
fix: correct video integration after comic generation
PromptForGalleryVideos now returns []string paths (from recursive walk) instead of []int page numbers, so GenerateSelectedVideos always has the exact path to each gallery PNG regardless of which comics/<slug>/ subdirectory it lives in. Previously, GenerateSelectedVideos passed "." as galleryPath and called loadGalleryImage with a non-recursive filepath.Glob that could not find PNGs in subdirectories, causing video generation to always fail with "no gallery image found". Additional changes: - Add VeoGenerator.GenerateVideoFromPath that accepts a full image path and writes the MP4 next to the source PNG - Make runStoryVideos non-fatal: video errors print a warning and return nil so comic/PDF/narration outputs are never invalidated by Veo errors - Add filterPathsByPages helper and tests for the new behaviour - Add TestFindGalleryPages_Recursive to cover the comics/<slug>/ layout Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal')
-rw-r--r--internal/cli/prompts.go35
-rw-r--r--internal/cli/prompts_test.go76
-rw-r--r--internal/cli/video_runner.go22
-rw-r--r--internal/video/veo.go46
4 files changed, 165 insertions, 14 deletions
diff --git a/internal/cli/prompts.go b/internal/cli/prompts.go
index b43bb5d..d7190c9 100644
--- a/internal/cli/prompts.go
+++ b/internal/cli/prompts.go
@@ -14,11 +14,16 @@ import (
// PromptForGalleryVideos lists all *_gallery_*.png files found under outputDir
// (searching recursively), shows them to the user, and asks whether they want
// to generate videos. If the user agrees, it asks which pages to generate
-// (e.g. "1,3,5" or "all") and returns the parsed list of page numbers.
+// (e.g. "1,3,5" or "all") and returns the paths of the selected PNGs.
+//
+// Returning paths (rather than page numbers) lets the caller pass them
+// directly to GenerateSelectedVideos without a second directory lookup,
+// which would fail because gallery images live in a per-comic subdirectory
+// (comics/<slug>/) rather than in the top-level output directory.
//
// Returns an empty slice when the user declines or enters nothing.
// Returns an error only on unexpected I/O or parse failures.
-func PromptForGalleryVideos(outputDir string) ([]int, error) {
+func PromptForGalleryVideos(outputDir string) ([]string, error) {
pages, pngPaths, err := findGalleryPages(outputDir)
if err != nil {
return nil, err
@@ -39,7 +44,31 @@ func PromptForGalleryVideos(outputDir string) ([]int, error) {
return nil, nil
}
- return askPageSelection(pages)
+ selectedPages, err := askPageSelection(pages)
+ if err != nil {
+ return nil, err
+ }
+
+ return filterPathsByPages(pngPaths, selectedPages), nil
+}
+
+// filterPathsByPages returns only those paths whose embedded page number
+// appears in the selectedPages slice. The result preserves the order from
+// pngPaths (which is already sorted alphabetically by findGalleryPages).
+func filterPathsByPages(pngPaths []string, selectedPages []int) []string {
+ pageSet := make(map[int]struct{}, len(selectedPages))
+ for _, p := range selectedPages {
+ pageSet[p] = struct{}{}
+ }
+
+ result := make([]string, 0, len(selectedPages))
+ for _, path := range pngPaths {
+ n := extractPageNumber(filepath.Base(path))
+ if _, ok := pageSet[n]; ok {
+ result = append(result, path)
+ }
+ }
+ return result
}
// findGalleryPages walks outputDir recursively looking for *_gallery_*.png
diff --git a/internal/cli/prompts_test.go b/internal/cli/prompts_test.go
index f50880d..053f0b4 100644
--- a/internal/cli/prompts_test.go
+++ b/internal/cli/prompts_test.go
@@ -190,3 +190,79 @@ func TestFindGalleryPages_WithFiles(t *testing.T) {
t.Errorf("expected 3 paths, got %d", len(paths))
}
}
+
+// TestFindGalleryPages_Recursive verifies that gallery PNGs placed in
+// subdirectories (as the story runner writes them into comics/<slug>/) are
+// found by the recursive walk.
+func TestFindGalleryPages_Recursive(t *testing.T) {
+ root := t.TempDir()
+
+ // Simulate comics/<slug>/ layout.
+ subDir := filepath.Join(root, "comics", "my_story")
+ if err := os.MkdirAll(subDir, 0o755); err != nil {
+ t.Fatalf("creating subdir: %v", err)
+ }
+
+ for _, name := range []string{
+ "my_story_gallery_1.png",
+ "my_story_gallery_2.png",
+ } {
+ if err := os.WriteFile(filepath.Join(subDir, name), []byte(""), 0644); err != nil {
+ t.Fatalf("creating test file: %v", err)
+ }
+ }
+
+ pages, paths, err := findGalleryPages(root)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ wantPages := []int{1, 2}
+ if !reflect.DeepEqual(pages, wantPages) {
+ t.Errorf("pages = %v, want %v", pages, wantPages)
+ }
+ if len(paths) != 2 {
+ t.Errorf("expected 2 paths, got %d: %v", len(paths), paths)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// filterPathsByPages
+// ---------------------------------------------------------------------------
+
+func TestFilterPathsByPages(t *testing.T) {
+ paths := []string{
+ "/comics/slug/slug_gallery_1.png",
+ "/comics/slug/slug_gallery_2.png",
+ "/comics/slug/slug_gallery_3.png",
+ }
+
+ got := filterPathsByPages(paths, []int{1, 3})
+ want := []string{
+ "/comics/slug/slug_gallery_1.png",
+ "/comics/slug/slug_gallery_3.png",
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("filterPathsByPages = %v, want %v", got, want)
+ }
+}
+
+func TestFilterPathsByPages_All(t *testing.T) {
+ paths := []string{
+ "/comics/slug/slug_gallery_1.png",
+ "/comics/slug/slug_gallery_2.png",
+ }
+
+ got := filterPathsByPages(paths, []int{1, 2})
+ if !reflect.DeepEqual(got, paths) {
+ t.Errorf("filterPathsByPages all = %v, want %v", got, paths)
+ }
+}
+
+func TestFilterPathsByPages_Empty(t *testing.T) {
+ paths := []string{"/comics/slug/slug_gallery_1.png"}
+ got := filterPathsByPages(paths, []int{})
+ if len(got) != 0 {
+ t.Errorf("expected empty result, got %v", got)
+ }
+}
diff --git a/internal/cli/video_runner.go b/internal/cli/video_runner.go
index 2679a26..96cd7cd 100644
--- a/internal/cli/video_runner.go
+++ b/internal/cli/video_runner.go
@@ -12,15 +12,15 @@ import (
// (Veo generation is slow and API quotas make parallelism impractical).
//
// apiKey is the Google/Gemini API key passed by the caller.
-// selected is the list of gallery page numbers to process (from PromptForGalleryVideos).
-// outputDir is both the directory that contains the gallery PNGs and the
-// destination for the resulting MP4 files (written next to the PNGs).
+// selectedPaths contains the absolute (or relative) paths of the gallery PNGs
+// to animate — typically returned by PromptForGalleryVideos.
//
// Each page prints a "Generating…" line before the API call and a "Video saved:"
-// line with the output path on success. The function stops and returns on the
-// first error so callers can log it without silently skipping pages.
-func GenerateSelectedVideos(apiKey string, selected []int, outputDir string) error {
- if len(selected) == 0 {
+// line with the output path on success. The MP4 is written next to its source
+// PNG so that gallery images and their videos stay in the same directory.
+// The function stops and returns on the first error so the caller can log it.
+func GenerateSelectedVideos(apiKey string, selectedPaths []string) error {
+ if len(selectedPaths) == 0 {
return nil
}
@@ -31,12 +31,12 @@ func GenerateSelectedVideos(apiKey string, selected []int, outputDir string) err
ctx := context.Background()
- for _, pageNum := range selected {
- fmt.Printf("Generating video for gallery page %d...\n", pageNum)
+ for _, imgPath := range selectedPaths {
+ fmt.Printf("Generating video for: %s\n", imgPath)
- mp4Path, err := gen.GenerateVideoFromGallery(ctx, outputDir, outputDir, pageNum)
+ mp4Path, err := gen.GenerateVideoFromPath(ctx, imgPath)
if err != nil {
- return fmt.Errorf("cli: generating video for page %d: %w", pageNum, err)
+ return fmt.Errorf("cli: generating video for %s: %w", imgPath, err)
}
fmt.Printf("Video saved: %s\n", mp4Path)
diff --git a/internal/video/veo.go b/internal/video/veo.go
index d76866b..ba23c74 100644
--- a/internal/video/veo.go
+++ b/internal/video/veo.go
@@ -90,6 +90,52 @@ func (g *VeoGenerator) GenerateVideoFromGallery(ctx context.Context, galleryPath
return mp4Path, nil
}
+// GenerateVideoFromPath reads the gallery PNG at the given absolute (or
+// relative) imgPath, calls the Veo API, and writes the resulting MP4 to the
+// same directory that contains imgPath. It returns the absolute path of the
+// saved MP4 or an error.
+//
+// This variant is preferred over GenerateVideoFromGallery when the caller
+// already knows the exact image path (e.g. from a recursive directory walk),
+// because it avoids a second glob search and always writes the video next to
+// its source image.
+func (g *VeoGenerator) GenerateVideoFromPath(ctx context.Context, imgPath string) (string, error) {
+ imgBytes, err := os.ReadFile(imgPath)
+ if err != nil {
+ return "", fmt.Errorf("veo: reading gallery image %s: %w", imgPath, err)
+ }
+
+ // Derive the page number from the file name for saveMP4 naming purposes.
+ pageNum := pageNumFromPath(imgPath)
+
+ prompt := buildVeoPrompt()
+
+ log.Printf("veo: generating video from %s", imgPath)
+
+ // Write the MP4 next to the source image so gallery + video stay together.
+ outputDir := filepath.Dir(imgPath)
+
+ return g.generateAndSave(ctx, imgBytes, prompt, outputDir, imgPath, pageNum)
+}
+
+// pageNumFromPath extracts the gallery page number from a file name of the
+// form "<slug>_gallery_<N>.png". Returns 0 when the name does not match.
+func pageNumFromPath(imgPath string) int {
+ base := filepath.Base(imgPath)
+ name := strings.TrimSuffix(base, ".png")
+ const marker = "_gallery_"
+ idx := strings.LastIndex(name, marker)
+ if idx < 0 {
+ return 0
+ }
+ numStr := name[idx+len(marker):]
+ var n int
+ if _, err := fmt.Sscanf(numStr, "%d", &n); err != nil || n <= 0 {
+ return 0
+ }
+ return n
+}
+
// loadGalleryImage finds the gallery PNG for the given page number and returns
// its path and raw bytes. It searches galleryPath for any file whose name
// matches the pattern "*_gallery_<N>.png".