From ef1eefce9a1515a17490d6624ef88f2ef41330e1 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 8 Apr 2026 09:57:25 +0300 Subject: 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 --- cmd/totalrecall/main.go | 115 ++---------------------------------- cmd/totalrecall/processor_config.go | 57 ++++++++++++++++++ cmd/totalrecall/story.go | 40 +++++++++++++ 3 files changed, 103 insertions(+), 109 deletions(-) create mode 100644 cmd/totalrecall/processor_config.go create mode 100644 cmd/totalrecall/story.go (limited to 'cmd') 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//, 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 +} -- cgit v1.2.3