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 | |
| 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
| -rw-r--r-- | cmd/totalrecall/main.go | 115 | ||||
| -rw-r--r-- | cmd/totalrecall/processor_config.go | 57 | ||||
| -rw-r--r-- | cmd/totalrecall/story.go | 40 | ||||
| -rw-r--r-- | internal/cli/video_runner.go | 35 | ||||
| -rw-r--r-- | internal/video/gallery_prompt.go (renamed from internal/cli/prompts.go) | 12 | ||||
| -rw-r--r-- | internal/video/gallery_prompt_test.go (renamed from internal/cli/prompts_test.go) | 2 | ||||
| -rw-r--r-- | internal/video/generate_selected.go | 45 | ||||
| -rw-r--r-- | internal/video/story_run.go | 30 | ||||
| -rw-r--r-- | internal/video/veo.go | 6 | ||||
| -rw-r--r-- | internal/video/veo_test.go | 4 |
10 files changed, 195 insertions, 151 deletions
diff --git a/cmd/totalrecall/main.go b/cmd/totalrecall/main.go index a0444b2..ad03303 100644 --- a/cmd/totalrecall/main.go +++ b/cmd/totalrecall/main.go @@ -4,10 +4,8 @@ import ( "fmt" "os" "path/filepath" - "strings" "github.com/spf13/cobra" - "github.com/spf13/viper" "codeberg.org/snonux/totalrecall/internal/archive" "codeberg.org/snonux/totalrecall/internal/cli" @@ -15,51 +13,9 @@ import ( "codeberg.org/snonux/totalrecall/internal/gui" "codeberg.org/snonux/totalrecall/internal/models" "codeberg.org/snonux/totalrecall/internal/processor" - "codeberg.org/snonux/totalrecall/internal/story" + "codeberg.org/snonux/totalrecall/internal/video" ) -// newProcessorConfig reads all Viper-sourced settings in a single pass and -// returns a fully-resolved processor.Config. Centralising all Viper access -// here means the processor package is free of any Viper dependency, which -// improves testability and removes tight coupling to the global config singleton. -func newProcessorConfig() *processor.Config { - return &processor.Config{ - // Translation & phonetic - TranslationProvider: strings.TrimSpace(viper.GetString("translation.provider")), - PhoneticProvider: strings.TrimSpace(viper.GetString("phonetic.provider")), - TranslationGeminiModel: viper.GetString("translation.gemini_model"), - - // Audio - AudioProvider: strings.ToLower(strings.TrimSpace(viper.GetString("audio.provider"))), - AudioFormat: strings.ToLower(strings.TrimSpace(viper.GetString("audio.format"))), - AudioFormatSet: viper.IsSet("audio.format"), - GeminiTTSModel: strings.TrimSpace(viper.GetString("audio.gemini_tts_model")), - GeminiVoice: strings.TrimSpace(viper.GetString("audio.gemini_voice")), - OpenAIVoice: strings.TrimSpace(viper.GetString("audio.openai_voice")), - OpenAIModel: viper.GetString("audio.openai_model"), - OpenAIModelSet: viper.IsSet("audio.openai_model"), - OpenAISpeed: viper.GetFloat64("audio.openai_speed"), - OpenAISpeedSet: viper.IsSet("audio.openai_speed"), - OpenAIInstruction: viper.GetString("audio.openai_instruction"), - OpenAIInstructionSet: viper.IsSet("audio.openai_instruction"), - - // Image - ImageProvider: strings.ToLower(strings.TrimSpace(viper.GetString("image.provider"))), - ImageOpenAIModel: viper.GetString("image.openai_model"), - ImageOpenAIModelSet: viper.IsSet("image.openai_model"), - ImageOpenAISize: viper.GetString("image.openai_size"), - ImageOpenAISizeSet: viper.IsSet("image.openai_size"), - ImageOpenAIQuality: viper.GetString("image.openai_quality"), - ImageOpenAIQualitySet: viper.IsSet("image.openai_quality"), - ImageOpenAIStyle: viper.GetString("image.openai_style"), - ImageOpenAIStyleSet: viper.IsSet("image.openai_style"), - ImageNanoBananaModel: strings.TrimSpace(viper.GetString("image.nanobanana_model")), - ImageNanoBananaModelSet: viper.IsSet("image.nanobanana_model"), - ImageNanoBananaTextModel: strings.TrimSpace(viper.GetString("image.nanobanana_text_model")), - ImageNanoBananaTextModelSet: viper.IsSet("image.nanobanana_text_model"), - } -} - func main() { // Create flags instance flags := cli.NewFlags() @@ -105,23 +61,11 @@ func runCommand(cmd *cobra.Command, args []string, flags *cli.Flags) error { // This is deliberately placed before processor creation because it does not // need the full processor pipeline (no Anki cards, no per-word audio). if flags.StoryFile != "" { - runner := story.NewRunner(&story.RunnerConfig{ - APIKey: cli.GetGoogleAPIKey(), - TextModel: flags.NanoBananaTextModel, - ImageModel: flags.NanoBananaModel, - ImageTextModel: flags.NanoBananaTextModel, - OutputDir: ".", - Style: flags.StoryStyle, - Theme: flags.StoryTheme, - UltraRealistic: storyUltraRealistic(flags.StoryNoUltraRealistic, flags.StoryUltraRealistic), - NarratorVoice: flags.NarratorVoice, - NarrateEnabled: flags.NarrateEnabled, - Slug: flags.StorySlug, - }) + runner := newStoryRunner(flags) if err := runner.Run(flags.StoryFile); err != nil { return err } - return runStoryVideos(flags) + return video.RunStoryVideos(flags.VideoEnabled, ".", cli.GetGoogleAPIKey()) } // Auto-adjust image size for DALL-E 3 @@ -133,7 +77,7 @@ func runCommand(cmd *cobra.Command, args []string, flags *cli.Flags) error { // Resolve all Viper config values once here so the processor never touches // the global Viper singleton directly (Dependency Inversion Principle). - proc := processor.NewProcessor(flags, newProcessorConfig()) + proc := newProcessor(flags) // Handle batch processing if flags.BatchFile != "" { @@ -166,8 +110,8 @@ func runCommand(cmd *cobra.Command, args []string, flags *cli.Flags) error { return nil } -// runGUIMode launches the GUI application. It lives in cmd/main.go so that -// gui.New() is called from the composition root rather than from the +// runGUIMode launches the GUI application from the cmd/totalrecall package so +// that gui.New() is called from the composition root rather than from the // processor package, reducing the processor→gui import coupling. func runGUIMode(proc *processor.Processor, flags *cli.Flags) error { guiConfig := proc.GUIConfig() @@ -190,50 +134,3 @@ func runGUIMode(proc *processor.Processor, flags *cli.Flags) error { return nil } - -// runStoryVideos is called after the story runner completes. When the -// --video flag is true (default), it prompts the user to select gallery pages -// for Veo video generation and then generates the selected videos. -// Passing --video=false skips the prompt entirely. -// -// Video generation failures are intentionally non-fatal: the comic, PDF, and -// narration are already on disk, so a Veo API error should not invalidate -// those outputs. Errors are printed as warnings and the function returns nil. -func runStoryVideos(flags *cli.Flags) error { - if !flags.VideoEnabled { - return nil - } - - // The story runner writes gallery PNGs into ./comics/<slug>/, so we search - // from "." recursively to find them regardless of the exact slug. - // PromptForGalleryVideos returns the actual file paths (not just page numbers) - // so GenerateSelectedVideos can locate them without a second directory search. - selectedPaths, err := cli.PromptForGalleryVideos(".") - if err != nil { - fmt.Fprintf(os.Stderr, "Warning: video prompt failed: %v\n", err) - return nil - } - - if err := cli.GenerateSelectedVideos(cli.GetGoogleAPIKey(), selectedPaths); err != nil { - fmt.Fprintf(os.Stderr, "Warning: video generation failed: %v\n", err) - } - - return nil -} - -// storyUltraRealistic converts the --ultra-realistic / --no-ultra-realistic -// bool flags into a *bool for RunnerConfig. -// - --ultra-realistic → pointer to true (force photorealistic panels) -// - --no-ultra-realistic → pointer to false (force standard comic style) -// - neither flag set → nil (runner picks randomly 50/50 each run) -func storyUltraRealistic(noUltraRealistic, ultraRealistic bool) *bool { - if ultraRealistic { - v := true - return &v - } - if noUltraRealistic { - v := false - return &v - } - return nil // nil → random pick in NewRunner -} diff --git a/cmd/totalrecall/processor_config.go b/cmd/totalrecall/processor_config.go new file mode 100644 index 0000000..4875fa3 --- /dev/null +++ b/cmd/totalrecall/processor_config.go @@ -0,0 +1,57 @@ +package main + +import ( + "strings" + + "github.com/spf13/viper" + + "codeberg.org/snonux/totalrecall/internal/cli" + "codeberg.org/snonux/totalrecall/internal/processor" +) + +// newProcessorConfig reads all Viper-sourced settings in a single pass and +// returns a fully-resolved processor.Config. Centralising all Viper access +// here means the processor package is free of any Viper dependency, which +// improves testability and removes tight coupling to the global config singleton. +func newProcessorConfig() *processor.Config { + return &processor.Config{ + // Translation & phonetic + TranslationProvider: strings.TrimSpace(viper.GetString("translation.provider")), + PhoneticProvider: strings.TrimSpace(viper.GetString("phonetic.provider")), + TranslationGeminiModel: viper.GetString("translation.gemini_model"), + + // Audio + AudioProvider: strings.ToLower(strings.TrimSpace(viper.GetString("audio.provider"))), + AudioFormat: strings.ToLower(strings.TrimSpace(viper.GetString("audio.format"))), + AudioFormatSet: viper.IsSet("audio.format"), + GeminiTTSModel: strings.TrimSpace(viper.GetString("audio.gemini_tts_model")), + GeminiVoice: strings.TrimSpace(viper.GetString("audio.gemini_voice")), + OpenAIVoice: strings.TrimSpace(viper.GetString("audio.openai_voice")), + OpenAIModel: viper.GetString("audio.openai_model"), + OpenAIModelSet: viper.IsSet("audio.openai_model"), + OpenAISpeed: viper.GetFloat64("audio.openai_speed"), + OpenAISpeedSet: viper.IsSet("audio.openai_speed"), + OpenAIInstruction: viper.GetString("audio.openai_instruction"), + OpenAIInstructionSet: viper.IsSet("audio.openai_instruction"), + + // Image + ImageProvider: strings.ToLower(strings.TrimSpace(viper.GetString("image.provider"))), + ImageOpenAIModel: viper.GetString("image.openai_model"), + ImageOpenAIModelSet: viper.IsSet("image.openai_model"), + ImageOpenAISize: viper.GetString("image.openai_size"), + ImageOpenAISizeSet: viper.IsSet("image.openai_size"), + ImageOpenAIQuality: viper.GetString("image.openai_quality"), + ImageOpenAIQualitySet: viper.IsSet("image.openai_quality"), + ImageOpenAIStyle: viper.GetString("image.openai_style"), + ImageOpenAIStyleSet: viper.IsSet("image.openai_style"), + ImageNanoBananaModel: strings.TrimSpace(viper.GetString("image.nanobanana_model")), + ImageNanoBananaModelSet: viper.IsSet("image.nanobanana_model"), + ImageNanoBananaTextModel: strings.TrimSpace(viper.GetString("image.nanobanana_text_model")), + ImageNanoBananaTextModelSet: viper.IsSet("image.nanobanana_text_model"), + } +} + +// newProcessor builds a processor from CLI flags and the Viper-backed config. +func newProcessor(flags *cli.Flags) *processor.Processor { + return processor.NewProcessor(flags, newProcessorConfig()) +} diff --git a/cmd/totalrecall/story.go b/cmd/totalrecall/story.go new file mode 100644 index 0000000..e0bfba3 --- /dev/null +++ b/cmd/totalrecall/story.go @@ -0,0 +1,40 @@ +package main + +import ( + "codeberg.org/snonux/totalrecall/internal/cli" + "codeberg.org/snonux/totalrecall/internal/story" +) + +// newStoryRunner wires a story.Runner from CLI flags and API keys. +func newStoryRunner(flags *cli.Flags) *story.Runner { + return story.NewRunner(&story.RunnerConfig{ + APIKey: cli.GetGoogleAPIKey(), + TextModel: flags.NanoBananaTextModel, + ImageModel: flags.NanoBananaModel, + ImageTextModel: flags.NanoBananaTextModel, + OutputDir: ".", + Style: flags.StoryStyle, + Theme: flags.StoryTheme, + UltraRealistic: storyUltraRealistic(flags.StoryNoUltraRealistic, flags.StoryUltraRealistic), + NarratorVoice: flags.NarratorVoice, + NarrateEnabled: flags.NarrateEnabled, + Slug: flags.StorySlug, + }) +} + +// storyUltraRealistic converts the --ultra-realistic / --no-ultra-realistic +// bool flags into a *bool for RunnerConfig. +// - --ultra-realistic → pointer to true (force photorealistic panels) +// - --no-ultra-realistic → pointer to false (force standard comic style) +// - neither flag set → nil (runner picks randomly 50/50 each run) +func storyUltraRealistic(noUltraRealistic, ultraRealistic bool) *bool { + if ultraRealistic { + v := true + return &v + } + if noUltraRealistic { + v := false + return &v + } + return nil // nil → random pick in NewRunner +} 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) } diff --git a/internal/cli/prompts.go b/internal/video/gallery_prompt.go index d7190c9..013f9b2 100644 --- a/internal/cli/prompts.go +++ b/internal/video/gallery_prompt.go @@ -1,4 +1,4 @@ -package cli +package video import ( "bufio" @@ -95,7 +95,7 @@ func findGalleryPages(outputDir string) ([]int, []string, error) { return nil }) if err != nil { - return nil, nil, fmt.Errorf("cli: walking gallery files in %s: %w", outputDir, err) + return nil, nil, fmt.Errorf("video: walking gallery files in %s: %w", outputDir, err) } sort.Strings(matches) @@ -153,7 +153,7 @@ func askYesNo(prompt string) (bool, error) { reader := bufio.NewReader(os.Stdin) line, err := reader.ReadString('\n') if err != nil { - return false, fmt.Errorf("cli: reading user input: %w", err) + return false, fmt.Errorf("video: reading user input: %w", err) } answer := strings.TrimSpace(strings.ToLower(line)) return answer == "y", nil @@ -172,7 +172,7 @@ func askPageSelection(availablePages []int) ([]int, error) { reader := bufio.NewReader(os.Stdin) line, err := reader.ReadString('\n') if err != nil { - return nil, fmt.Errorf("cli: reading page selection: %w", err) + return nil, fmt.Errorf("video: reading page selection: %w", err) } input := strings.TrimSpace(line) @@ -229,10 +229,10 @@ func parseSelection(input string, max int) ([]int, error) { } n, err := strconv.Atoi(tok) if err != nil { - return nil, fmt.Errorf("cli: invalid page number %q: %w", tok, err) + return nil, fmt.Errorf("video: invalid page number %q: %w", tok, err) } if n <= 0 { - return nil, fmt.Errorf("cli: page numbers must be positive, got %d", n) + return nil, fmt.Errorf("video: page numbers must be positive, got %d", n) } seen[n] = struct{}{} } diff --git a/internal/cli/prompts_test.go b/internal/video/gallery_prompt_test.go index 7eb6344..869d708 100644 --- a/internal/cli/prompts_test.go +++ b/internal/video/gallery_prompt_test.go @@ -1,4 +1,4 @@ -package cli +package video import ( "os" diff --git a/internal/video/generate_selected.go b/internal/video/generate_selected.go new file mode 100644 index 0000000..4407aaa --- /dev/null +++ b/internal/video/generate_selected.go @@ -0,0 +1,45 @@ +package video + +import ( + "context" + "fmt" +) + +// GenerateSelectedVideos 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). +// +// 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. +// +// 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 := NewVeoGenerator(apiKey) + if err != nil { + return fmt.Errorf("video: 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("video: generating video for %s: %w", imgPath, err) + } + + fmt.Printf("Video saved: %s\n", mp4Path) + } + + return nil +} diff --git a/internal/video/story_run.go b/internal/video/story_run.go new file mode 100644 index 0000000..e3c1951 --- /dev/null +++ b/internal/video/story_run.go @@ -0,0 +1,30 @@ +package video + +import ( + "fmt" + "os" +) + +// RunStoryVideos runs the post-story gallery prompt and Veo generation when +// videoEnabled is true. When false, it returns immediately (e.g. --video=false). +// +// Video generation failures are intentionally non-fatal: the comic, PDF, and +// narration are already on disk, so a Veo API error should not invalidate those +// outputs. Errors are printed as warnings and the function returns nil. +func RunStoryVideos(videoEnabled bool, outputDir string, apiKey string) error { + if !videoEnabled { + return nil + } + + selectedPaths, err := PromptForGalleryVideos(outputDir) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: video prompt failed: %v\n", err) + return nil + } + + if err := GenerateSelectedVideos(apiKey, selectedPaths); err != nil { + fmt.Fprintf(os.Stderr, "Warning: video generation failed: %v\n", err) + } + + return nil +} diff --git a/internal/video/veo.go b/internal/video/veo.go index 05fd0f3..34d0871 100644 --- a/internal/video/veo.go +++ b/internal/video/veo.go @@ -1,6 +1,6 @@ -// Package video provides video generation capabilities using Google's Veo model. -// It reads existing gallery images (comic-style flashcard panels) and animates -// them into short MP4 clips via the Veo API's long-running operation pattern. +// Package video provides Veo-based MP4 generation from comic gallery PNGs, +// interactive gallery page selection after --story runs, and the combined +// post-story flow (RunStoryVideos). package video import ( diff --git a/internal/video/veo_test.go b/internal/video/veo_test.go index 7facd43..ce653e3 100644 --- a/internal/video/veo_test.go +++ b/internal/video/veo_test.go @@ -37,7 +37,7 @@ func TestNewVeoGenerator_WhitespaceKey(t *testing.T) { // TestNewVeoGenerator_ClientInitFailure verifies that a genai client // initialisation error propagates as a wrapped error. func TestNewVeoGenerator_ClientInitFailure(t *testing.T) { - t.Parallel() + // Do not use t.Parallel: this test replaces the package-global newGenaiClient hook. // Temporarily replace the genai client constructor with one that always fails. orig := newGenaiClient @@ -251,7 +251,7 @@ func TestLoadGalleryImage_MultipleMatchesUsesFirst(t *testing.T) { // TestNewVeoGenerator_WithMockClient verifies that NewVeoGenerator succeeds // when the genai client factory does not return an error. func TestNewVeoGenerator_WithMockClient(t *testing.T) { - t.Parallel() + // Do not use t.Parallel: this test replaces the package-global newGenaiClient hook. orig := newGenaiClient newGenaiClient = func(_ context.Context, _ *genai.ClientConfig) (*genai.Client, error) { |
