From ef1f3d03ac3f117fecd999fc4fa96f557bfa8fe4 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 6 Apr 2026 11:25:37 +0300 Subject: feat: add --video CLI flag to control Veo video prompt after story generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add VideoEnabled bool (default true) to Flags, register --video bool flag in command.go, export PromptForGalleryVideos/GenerateSelectedVideos, and wire them into main.go via runStoryVideos — skipped when --video=false. Also update findGalleryPages to walk subdirectories recursively so gallery PNGs inside comics// are discovered from the CWD search root. Co-Authored-By: Claude Sonnet 4.6 --- cmd/totalrecall/main.go | 24 +++++++++++++++++++++++- internal/cli/command.go | 2 ++ internal/cli/flags.go | 2 ++ internal/cli/prompts.go | 39 +++++++++++++++++++++++++++++---------- internal/cli/video_runner.go | 6 +++--- 5 files changed, 59 insertions(+), 14 deletions(-) diff --git a/cmd/totalrecall/main.go b/cmd/totalrecall/main.go index 9e64897..2c08497 100644 --- a/cmd/totalrecall/main.go +++ b/cmd/totalrecall/main.go @@ -117,7 +117,10 @@ func runCommand(cmd *cobra.Command, args []string, flags *cli.Flags) error { NarratorVoice: flags.NarratorVoice, Slug: flags.StorySlug, }) - return runner.Run(flags.StoryFile) + if err := runner.Run(flags.StoryFile); err != nil { + return err + } + return runStoryVideos(flags) } // Auto-adjust image size for DALL-E 3 @@ -187,6 +190,25 @@ 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. +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. + selected, err := cli.PromptForGalleryVideos(".") + if err != nil { + return fmt.Errorf("video prompt: %w", err) + } + + return cli.GenerateSelectedVideos(cli.GetGoogleAPIKey(), selected, ".") +} + // storyUltraRealistic converts the --no-ultra-realistic bool flag into a *bool // for RunnerConfig. When noUltraRealistic is true, returns a pointer to false // (forcing standard comic style). When false (flag not set), returns nil so diff --git a/internal/cli/command.go b/internal/cli/command.go index 090b2d1..c9dd889 100644 --- a/internal/cli/command.go +++ b/internal/cli/command.go @@ -75,6 +75,8 @@ func setupFlags(cmd *cobra.Command, flags *Flags) { cmd.Flags().StringVar(&flags.NarratorVoice, "narrator-voice", "", "Gemini voice for cinematic story narration (default: random from cinematic pool). "+ "Valid values: Charon, Fenrir, Enceladus, Algieba, Aoede, Schedar") + cmd.Flags().BoolVar(&flags.VideoEnabled, "video", flags.VideoEnabled, + "Prompt to generate Veo videos after comic generation (default true; use --video=false to skip)") cmd.Flags().BoolVar(&flags.SkipAudio, "skip-audio", false, "Skip audio generation") cmd.Flags().BoolVar(&flags.SkipImages, "skip-images", false, "Skip image download") cmd.Flags().BoolVar(&flags.GenerateAnki, "anki", false, "Generate Anki import file (APKG format by default, use --anki-csv for legacy CSV)") diff --git a/internal/cli/flags.go b/internal/cli/flags.go index df493a4..678b8ab 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -26,6 +26,7 @@ type Flags struct { StoryNoUltraRealistic bool // --no-ultra-realistic: disable photorealistic rendering requirement StorySlug string // --story-slug: force a specific output slug/directory (empty = auto from title) NarratorVoice string // --narrator-voice: Gemini voice for cinematic narration (empty = random) + VideoEnabled bool // --video: whether to prompt for Veo video generation after --story completes SkipAudio bool SkipImages bool GenerateAnki bool @@ -72,6 +73,7 @@ func NewFlags() *Flags { AudioFormat: defaults.OutputFormat, AudioProvider: defaults.Provider, ImageAPI: "nanobanana", + VideoEnabled: true, DeckName: "Bulgarian Vocabulary", OpenAIModel: "gpt-4o-mini-tts", OpenAISpeed: 0.9, diff --git a/internal/cli/prompts.go b/internal/cli/prompts.go index ff17767..b43bb5d 100644 --- a/internal/cli/prompts.go +++ b/internal/cli/prompts.go @@ -3,6 +3,7 @@ package cli import ( "bufio" "fmt" + "io/fs" "os" "path/filepath" "sort" @@ -10,14 +11,14 @@ import ( "strings" ) -// promptForGalleryVideos lists all *_gallery_*.png files found in outputDir, -// 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 parsed list of page numbers. +// 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 parsed list of page numbers. // // 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) ([]int, error) { +func PromptForGalleryVideos(outputDir string) ([]int, error) { pages, pngPaths, err := findGalleryPages(outputDir) if err != nil { return nil, err @@ -41,13 +42,31 @@ func promptForGalleryVideos(outputDir string) ([]int, error) { return askPageSelection(pages) } -// findGalleryPages globs for *_gallery_*.png files in outputDir and returns -// the sorted list of page numbers and matching file paths. +// 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//) rather than directly +// in the top-level output directory. func findGalleryPages(outputDir string) ([]int, []string, error) { - pattern := filepath.Join(outputDir, "*_gallery_*.png") - matches, err := filepath.Glob(pattern) + 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: glob gallery files in %s: %w", outputDir, err) + return nil, nil, fmt.Errorf("cli: walking gallery files in %s: %w", outputDir, err) } sort.Strings(matches) diff --git a/internal/cli/video_runner.go b/internal/cli/video_runner.go index 40a1fdd..2679a26 100644 --- a/internal/cli/video_runner.go +++ b/internal/cli/video_runner.go @@ -7,19 +7,19 @@ import ( "codeberg.org/snonux/totalrecall/internal/video" ) -// generateSelectedVideos is the CLI runner that animates gallery PNG files +// 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). // // apiKey is the Google/Gemini API key passed by the caller. -// selected is the list of gallery page numbers to process (from promptForGalleryVideos). +// selected is the list of gallery page numbers to process (from PromptForGalleryVideos). // outputDir is both the directory that contains the gallery PNGs and the // destination for the resulting MP4 files (written next to the PNGs). // // Each page prints a "Generating…" line before the API call and a "Video saved:" // line with the output path on success. The function stops and returns on the // first error so callers can log it without silently skipping pages. -func generateSelectedVideos(apiKey string, selected []int, outputDir string) error { +func GenerateSelectedVideos(apiKey string, selected []int, outputDir string) error { if len(selected) == 0 { return nil } -- cgit v1.2.3