summaryrefslogtreecommitdiff
path: root/internal/cli
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-08 09:57:25 +0300
committerPaul Buetow <paul@buetow.org>2026-04-08 09:57:25 +0300
commitef1eefce9a1515a17490d6624ef88f2ef41330e1 (patch)
tree42c9b979b04f4b2ca3ec9f778e57233171738f76 /internal/cli
parentf486e9c677c72a409df8696104661847354286df (diff)
refactor: slim cmd composition root, move story video flow to internal/video
- Add newProcessor() and newProcessorConfig in processor_config.go; newStoryRunner and storyUltraRealistic in story.go; main.go focuses on runCommand wiring. - Move gallery prompts, GenerateSelectedVideos, and RunStoryVideos into internal/video; cli.GenerateSelectedVideos delegates for GUI compatibility. - Remove t.Parallel from Veo tests that patch newGenaiClient (race with globals). Made-with: Cursor
Diffstat (limited to 'internal/cli')
-rw-r--r--internal/cli/prompts.go247
-rw-r--r--internal/cli/prompts_test.go313
-rw-r--r--internal/cli/video_runner.go35
3 files changed, 5 insertions, 590 deletions
diff --git a/internal/cli/prompts.go b/internal/cli/prompts.go
deleted file mode 100644
index d7190c9..0000000
--- a/internal/cli/prompts.go
+++ /dev/null
@@ -1,247 +0,0 @@
-package cli
-
-import (
- "bufio"
- "fmt"
- "io/fs"
- "os"
- "path/filepath"
- "sort"
- "strconv"
- "strings"
-)
-
-// 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 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) ([]string, error) {
- pages, pngPaths, err := findGalleryPages(outputDir)
- if err != nil {
- return nil, err
- }
-
- if len(pages) == 0 {
- fmt.Println("No gallery PNG files found — skipping video generation.")
- return nil, nil
- }
-
- printGalleryFiles(pngPaths)
-
- agreed, err := askYesNo("Generate videos for these gallery pages? [y/N]: ")
- if err != nil {
- return nil, err
- }
- if !agreed {
- return nil, nil
- }
-
- 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
-// files and returns the sorted list of unique page numbers and matching paths.
-// Walking recursively is necessary because the story runner places gallery
-// images in a per-comic subdirectory (comics/<slug>/) rather than directly
-// in the top-level output directory.
-func findGalleryPages(outputDir string) ([]int, []string, error) {
- var matches []string
-
- err := filepath.WalkDir(outputDir, func(path string, d fs.DirEntry, walkErr error) error {
- if walkErr != nil {
- // Skip unreadable directories rather than aborting the whole walk.
- return nil
- }
- if d.IsDir() {
- return nil
- }
- base := filepath.Base(path)
- // Match files that follow the *_gallery_N.png naming convention.
- if strings.Contains(base, "_gallery_") && strings.HasSuffix(base, ".png") {
- matches = append(matches, path)
- }
- return nil
- })
- if err != nil {
- return nil, nil, fmt.Errorf("cli: walking gallery files in %s: %w", outputDir, err)
- }
-
- sort.Strings(matches)
-
- pageSet := map[int]struct{}{}
- for _, m := range matches {
- n := extractPageNumber(filepath.Base(m))
- if n > 0 {
- pageSet[n] = struct{}{}
- }
- }
-
- pages := make([]int, 0, len(pageSet))
- for n := range pageSet {
- pages = append(pages, n)
- }
- sort.Ints(pages)
-
- return pages, matches, nil
-}
-
-// extractPageNumber parses the page number from a gallery file name of the
-// form "<slug>_gallery_<N>.png". Returns 0 when the name does not match.
-func extractPageNumber(base string) int {
- // Strip extension
- name := strings.TrimSuffix(base, ".png")
- // Find the last "_gallery_" segment and extract the trailing integer.
- const marker = "_gallery_"
- idx := strings.LastIndex(name, marker)
- if idx < 0 {
- return 0
- }
- numStr := name[idx+len(marker):]
- n, err := strconv.Atoi(numStr)
- if err != nil || n <= 0 {
- return 0
- }
- return n
-}
-
-// printGalleryFiles prints each gallery PNG path so the user can review what
-// will be animated before confirming.
-func printGalleryFiles(paths []string) {
- fmt.Println("Found gallery pages:")
- for _, p := range paths {
- fmt.Printf(" %s\n", p)
- }
-}
-
-// askYesNo prints prompt, reads one line from stdin, and returns true only
-// when the user types "y" or "Y". Any other input (including empty) returns
-// false, matching a safe-default "no" behaviour.
-func askYesNo(prompt string) (bool, error) {
- fmt.Print(prompt)
- reader := bufio.NewReader(os.Stdin)
- line, err := reader.ReadString('\n')
- if err != nil {
- return false, fmt.Errorf("cli: reading user input: %w", err)
- }
- answer := strings.TrimSpace(strings.ToLower(line))
- return answer == "y", nil
-}
-
-// askPageSelection prints a prompt asking the user which pages to include
-// and parses the reply into a slice of ints. "all" expands to every available
-// page number. An empty reply is treated as "all".
-func askPageSelection(availablePages []int) ([]int, error) {
- max := 0
- if len(availablePages) > 0 {
- max = availablePages[len(availablePages)-1]
- }
-
- fmt.Printf("Which pages? (e.g. 1,3,5 or all) [all]: ")
- reader := bufio.NewReader(os.Stdin)
- line, err := reader.ReadString('\n')
- if err != nil {
- return nil, fmt.Errorf("cli: reading page selection: %w", err)
- }
-
- input := strings.TrimSpace(line)
- if input == "" || strings.ToLower(input) == "all" {
- return availablePages, nil
- }
-
- selected, err := parseSelection(input, max)
- if err != nil {
- return nil, err
- }
-
- // Filter to only pages that actually exist.
- pageExists := make(map[int]bool, len(availablePages))
- for _, p := range availablePages {
- pageExists[p] = true
- }
-
- result := make([]int, 0, len(selected))
- for _, p := range selected {
- if pageExists[p] {
- result = append(result, p)
- } else {
- fmt.Printf(" Warning: page %d not found — skipping.\n", p)
- }
- }
-
- return result, nil
-}
-
-// parseSelection converts a comma-separated string of page numbers (e.g. "1,3,5")
-// or the keyword "all" into a sorted, deduplicated slice of ints.
-//
-// max is used only when input is "all"; individual page numbers may exceed max
-// without error (the caller is responsible for validating against real files).
-// Returns an error for non-numeric tokens or numbers <= 0.
-func parseSelection(input string, max int) ([]int, error) {
- input = strings.TrimSpace(input)
- if strings.ToLower(input) == "all" {
- pages := make([]int, max)
- for i := range pages {
- pages[i] = i + 1
- }
- return pages, nil
- }
-
- seen := map[int]struct{}{}
- tokens := strings.Split(input, ",")
-
- for _, tok := range tokens {
- tok = strings.TrimSpace(tok)
- if tok == "" {
- continue
- }
- n, err := strconv.Atoi(tok)
- if err != nil {
- return nil, fmt.Errorf("cli: invalid page number %q: %w", tok, err)
- }
- if n <= 0 {
- return nil, fmt.Errorf("cli: page numbers must be positive, got %d", n)
- }
- seen[n] = struct{}{}
- }
-
- result := make([]int, 0, len(seen))
- for n := range seen {
- result = append(result, n)
- }
- sort.Ints(result)
-
- return result, nil
-}
diff --git a/internal/cli/prompts_test.go b/internal/cli/prompts_test.go
deleted file mode 100644
index 7eb6344..0000000
--- a/internal/cli/prompts_test.go
+++ /dev/null
@@ -1,313 +0,0 @@
-package cli
-
-import (
- "os"
- "path/filepath"
- "reflect"
- "testing"
-)
-
-// ---------------------------------------------------------------------------
-// parseSelection
-// ---------------------------------------------------------------------------
-
-func TestParseSelection_All(t *testing.T) {
- got, err := parseSelection("all", 4)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- want := []int{1, 2, 3, 4}
- if !reflect.DeepEqual(got, want) {
- t.Errorf("parseSelection(\"all\", 4) = %v, want %v", got, want)
- }
-}
-
-func TestParseSelection_AllCaseInsensitive(t *testing.T) {
- got, err := parseSelection("ALL", 3)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- want := []int{1, 2, 3}
- if !reflect.DeepEqual(got, want) {
- t.Errorf("parseSelection(\"ALL\", 3) = %v, want %v", got, want)
- }
-}
-
-func TestParseSelection_CommaSeparated(t *testing.T) {
- got, err := parseSelection("1,3,5", 10)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- want := []int{1, 3, 5}
- if !reflect.DeepEqual(got, want) {
- t.Errorf("parseSelection(\"1,3,5\", 10) = %v, want %v", got, want)
- }
-}
-
-func TestParseSelection_SortsOutput(t *testing.T) {
- // Input order is reversed; output must be sorted.
- got, err := parseSelection("5,2,1", 10)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- want := []int{1, 2, 5}
- if !reflect.DeepEqual(got, want) {
- t.Errorf("parseSelection(\"5,2,1\", 10) = %v, want %v", got, want)
- }
-}
-
-func TestParseSelection_DuplicatesDeduped(t *testing.T) {
- got, err := parseSelection("2,2,3", 5)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- want := []int{2, 3}
- if !reflect.DeepEqual(got, want) {
- t.Errorf("parseSelection(\"2,2,3\", 5) = %v, want %v", got, want)
- }
-}
-
-func TestParseSelection_SinglePage(t *testing.T) {
- got, err := parseSelection("7", 10)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- want := []int{7}
- if !reflect.DeepEqual(got, want) {
- t.Errorf("parseSelection(\"7\", 10) = %v, want %v", got, want)
- }
-}
-
-func TestParseSelection_WhitespaceTrimmed(t *testing.T) {
- got, err := parseSelection(" 1 , 3 , 5 ", 10)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- want := []int{1, 3, 5}
- if !reflect.DeepEqual(got, want) {
- t.Errorf("parseSelection with spaces = %v, want %v", got, want)
- }
-}
-
-func TestParseSelection_InvalidToken(t *testing.T) {
- _, err := parseSelection("1,abc,3", 10)
- if err == nil {
- t.Fatal("expected error for non-numeric token, got nil")
- }
-}
-
-func TestParseSelection_ZeroPage(t *testing.T) {
- _, err := parseSelection("0,1", 5)
- if err == nil {
- t.Fatal("expected error for zero page number, got nil")
- }
-}
-
-func TestParseSelection_NegativePage(t *testing.T) {
- _, err := parseSelection("-1", 5)
- if err == nil {
- t.Fatal("expected error for negative page number, got nil")
- }
-}
-
-func TestParseSelection_EmptyTokensIgnored(t *testing.T) {
- // Trailing comma should not produce an error; the empty token is skipped.
- got, err := parseSelection("1,2,", 5)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- want := []int{1, 2}
- if !reflect.DeepEqual(got, want) {
- t.Errorf("parseSelection(\"1,2,\", 5) = %v, want %v", got, want)
- }
-}
-
-// ---------------------------------------------------------------------------
-// extractPageNumber
-// ---------------------------------------------------------------------------
-
-func TestExtractPageNumber(t *testing.T) {
- cases := []struct {
- input string
- want int
- }{
- {"story_gallery_1.png", 1},
- {"my_story_gallery_10.png", 10},
- {"no_match.png", 0},
- {"_gallery_.png", 0}, // missing number after marker
- {"gallery_0.png", 0}, // zero is invalid
- {"gallery_-1.png", 0}, // negative is invalid
- }
-
- for _, tc := range cases {
- got := extractPageNumber(tc.input)
- if got != tc.want {
- t.Errorf("extractPageNumber(%q) = %d, want %d", tc.input, got, tc.want)
- }
- }
-}
-
-// ---------------------------------------------------------------------------
-// findGalleryPages
-// ---------------------------------------------------------------------------
-
-func TestFindGalleryPages_NoFiles(t *testing.T) {
- dir := t.TempDir()
- pages, paths, err := findGalleryPages(dir)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if len(pages) != 0 || len(paths) != 0 {
- t.Errorf("expected empty results for empty dir, got pages=%v paths=%v", pages, paths)
- }
-}
-
-func TestFindGalleryPages_WithFiles(t *testing.T) {
- dir := t.TempDir()
-
- // Create dummy gallery PNGs.
- for _, name := range []string{
- "story_gallery_1.png",
- "story_gallery_3.png",
- "story_gallery_2.png",
- } {
- if err := os.WriteFile(filepath.Join(dir, name), []byte(""), 0644); err != nil {
- t.Fatalf("creating test file: %v", err)
- }
- }
-
- pages, paths, err := findGalleryPages(dir)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- wantPages := []int{1, 2, 3}
- if !reflect.DeepEqual(pages, wantPages) {
- t.Errorf("pages = %v, want %v", pages, wantPages)
- }
-
- if len(paths) != 3 {
- 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)
- }
-}
-
-// ---------------------------------------------------------------------------
-// parseSelection — additional edge cases
-// ---------------------------------------------------------------------------
-
-// TestParseSelection_AllWithZeroMax verifies that "all" with a zero max
-// returns an empty (but non-nil) slice, since there are no available pages.
-func TestParseSelection_AllWithZeroMax(t *testing.T) {
- got, err := parseSelection("all", 0)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if len(got) != 0 {
- t.Errorf("parseSelection(\"all\", 0): expected empty slice, got %v", got)
- }
-}
-
-// TestParseSelection_PageExceedsMax verifies that a page number larger than
-// max is accepted without error — the caller filters against real files.
-func TestParseSelection_PageExceedsMax(t *testing.T) {
- got, err := parseSelection("99", 5)
- if err != nil {
- t.Fatalf("unexpected error for page > max: %v", err)
- }
- want := []int{99}
- if len(got) != 1 || got[0] != want[0] {
- t.Errorf("parseSelection(\"99\", 5) = %v, want %v", got, want)
- }
-}
-
-// TestParseSelection_OnlyWhitespace verifies that a purely whitespace input
-// (after trimming) is treated as "all" via the caller, but parseSelection
-// itself should not receive it — nonetheless it should not panic.
-func TestParseSelection_EmptyAfterTrim(t *testing.T) {
- // Empty string after trimming: no tokens, so result is empty, no error.
- got, err := parseSelection(" ", 5)
- if err != nil {
- t.Fatalf("unexpected error for all-whitespace input: %v", err)
- }
- // "all" is the keyword path — plain whitespace is NOT "all", so the
- // comma-split path runs and produces an empty result (all tokens are "").
- if len(got) != 0 {
- t.Errorf("parseSelection(\" \", 5): expected empty slice, got %v", got)
- }
-}
diff --git a/internal/cli/video_runner.go b/internal/cli/video_runner.go
index aab23ae..021a6bf 100644
--- a/internal/cli/video_runner.go
+++ b/internal/cli/video_runner.go
@@ -1,47 +1,22 @@
package cli
import (
- "context"
- "fmt"
-
"codeberg.org/snonux/totalrecall/internal/video"
)
// GenerateSelectedVideos is the CLI runner that animates gallery PNG files
-// into MP4 clips using Google's Veo model. It processes pages sequentially
-// (Veo generation is slow and API quotas make parallelism impractical).
+// into MP4 clips using Google's Veo model. It delegates to the video package
+// so GUI and tests can keep using the cli entry point without importing video
+// 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 PromptForGalleryVideos.
+// to animate — typically returned by video.PromptForGalleryVideos.
//
// 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
// 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
- }
-
- gen, err := video.NewVeoGenerator(apiKey)
- if err != nil {
- return fmt.Errorf("cli: initialising Veo generator: %w", err)
- }
-
- ctx := context.Background()
-
- for _, imgPath := range selectedPaths {
- fmt.Printf("Generating video for: %s\n", imgPath)
-
- // GenerateVideoFromPath applies an operation-level deadline when ctx has none.
- mp4Path, err := gen.GenerateVideoFromPath(ctx, imgPath)
- if err != nil {
- return fmt.Errorf("cli: generating video for %s: %w", imgPath, err)
- }
-
- fmt.Printf("Video saved: %s\n", mp4Path)
- }
-
- return nil
+ return video.GenerateSelectedVideos(apiKey, selectedPaths)
}