diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-08 09:57:25 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-08 09:57:25 +0300 |
| commit | ef1eefce9a1515a17490d6624ef88f2ef41330e1 (patch) | |
| tree | 42c9b979b04f4b2ca3ec9f778e57233171738f76 /internal/cli | |
| parent | f486e9c677c72a409df8696104661847354286df (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.go | 247 | ||||
| -rw-r--r-- | internal/cli/prompts_test.go | 313 | ||||
| -rw-r--r-- | internal/cli/video_runner.go | 35 |
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) } |
