summaryrefslogtreecommitdiff
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
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
-rw-r--r--cmd/totalrecall/main.go115
-rw-r--r--cmd/totalrecall/processor_config.go57
-rw-r--r--cmd/totalrecall/story.go40
-rw-r--r--internal/cli/video_runner.go35
-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.go45
-rw-r--r--internal/video/story_run.go30
-rw-r--r--internal/video/veo.go6
-rw-r--r--internal/video/veo_test.go4
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) {