diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-06 11:28:48 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-06 11:28:48 +0300 |
| commit | cd45f841932052435a28f669405b23f313d9cf62 (patch) | |
| tree | 81a01f7d28176da3de847243ec3c78087301fea3 /internal | |
| parent | ef1f3d03ac3f117fecd999fc4fa96f557bfa8fe4 (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.go | 35 | ||||
| -rw-r--r-- | internal/cli/prompts_test.go | 76 | ||||
| -rw-r--r-- | internal/cli/video_runner.go | 22 | ||||
| -rw-r--r-- | internal/video/veo.go | 46 |
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". |
