summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-21 22:34:55 +0300
committerPaul Buetow <paul@buetow.org>2026-04-21 22:34:55 +0300
commitd558ed55df855e3ce01b19e7f1b1fe44dce8e64a (patch)
tree8285bce505da6384a9d430af6aef9834b90d96d4
parentd773e26b19dcea2f4a1917ebb6edaa4f5721693f (diff)
Audit stripped comicgen leftovers
-rw-r--r--assets/comic-bd-compacted-words.txt10
-rw-r--r--internal/cli/video_runner.go21
-rw-r--r--internal/cli/video_runner_test.go44
-rw-r--r--internal/gui/audio_paths.go6
-rw-r--r--internal/gui/dialogs.go158
-rw-r--r--internal/gui/generator.go27
-rw-r--r--internal/gui/orchestrator.go7
-rw-r--r--internal/gui/parallel_runner.go1
-rw-r--r--internal/gui/persistence.go6
-rw-r--r--internal/httpctx/httpctx.go6
-rw-r--r--internal/httpctx/httpctx_test.go4
-rw-r--r--internal/video/generate_selected.go44
-rw-r--r--internal/video/veo.go325
-rw-r--r--internal/video/veo_test.go273
14 files changed, 3 insertions, 929 deletions
diff --git a/assets/comic-bd-compacted-words.txt b/assets/comic-bd-compacted-words.txt
deleted file mode 100644
index 0729586..0000000
--- a/assets/comic-bd-compacted-words.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-безпокоя се
-възраждане
-козина
-мерки
-мюзикъл
-печатам
-поздрави
-президент
-раиран
-стенен часовник
diff --git a/internal/cli/video_runner.go b/internal/cli/video_runner.go
deleted file mode 100644
index 9e888fb..0000000
--- a/internal/cli/video_runner.go
+++ /dev/null
@@ -1,21 +0,0 @@
-package cli
-
-import (
- "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 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 PNGs to animate.
-//
-// 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 {
- return video.GenerateSelectedVideos(apiKey, selectedPaths)
-}
diff --git a/internal/cli/video_runner_test.go b/internal/cli/video_runner_test.go
deleted file mode 100644
index 1fd201d..0000000
--- a/internal/cli/video_runner_test.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package cli
-
-import (
- "testing"
-)
-
-// TestGenerateSelectedVideos_EmptyPaths verifies that GenerateSelectedVideos
-// returns nil immediately when no paths are provided, without attempting any
-// API calls.
-func TestGenerateSelectedVideos_EmptyPaths(t *testing.T) {
- err := GenerateSelectedVideos("any-api-key", []string{})
- if err != nil {
- t.Fatalf("expected nil for empty paths, got: %v", err)
- }
-}
-
-// TestGenerateSelectedVideos_NilPaths verifies that GenerateSelectedVideos
-// handles a nil slice the same way as an empty slice.
-func TestGenerateSelectedVideos_NilPaths(t *testing.T) {
- err := GenerateSelectedVideos("any-api-key", nil)
- if err != nil {
- t.Fatalf("expected nil for nil paths, got: %v", err)
- }
-}
-
-// TestGenerateSelectedVideos_EmptyAPIKey verifies that GenerateSelectedVideos
-// returns an error when a non-empty path list is provided but the API key is
-// empty. The error originates from video.NewVeoGenerator, so we just check
-// that some error is returned without making any real API calls.
-func TestGenerateSelectedVideos_EmptyAPIKey(t *testing.T) {
- err := GenerateSelectedVideos("", []string{"/some/word_gallery_1.png"})
- if err == nil {
- t.Fatal("expected error for empty API key with non-empty paths, got nil")
- }
-}
-
-// TestGenerateSelectedVideos_WhitespaceAPIKey verifies that a whitespace-only
-// API key is treated equivalently to an empty key when paths are supplied.
-func TestGenerateSelectedVideos_WhitespaceAPIKey(t *testing.T) {
- err := GenerateSelectedVideos(" ", []string{"/some/word_gallery_1.png"})
- if err == nil {
- t.Fatal("expected error for whitespace API key with non-empty paths, got nil")
- }
-}
diff --git a/internal/gui/audio_paths.go b/internal/gui/audio_paths.go
index c9d3ec2..47371d4 100644
--- a/internal/gui/audio_paths.go
+++ b/internal/gui/audio_paths.go
@@ -20,12 +20,6 @@ func (a *Application) resolveBgBgAudioFiles(wordDir string) (string, string) {
return resolveBgBgAudioFilesInDir(wordDir)
}
-// hasAnyAudioFile returns true if the card directory contains any audio file.
-// Delegates to the package-level helper used by CardService.
-func (a *Application) hasAnyAudioFile(wordDir string) bool {
- return hasAnyAudioFileInDir(wordDir)
-}
-
// resolveAudioFileFromMetadata reads audio_metadata.txt from wordDir and returns
// the path stored under key. Returns empty string when the key is absent or the
// file does not exist on disk.
diff --git a/internal/gui/dialogs.go b/internal/gui/dialogs.go
deleted file mode 100644
index 072557e..0000000
--- a/internal/gui/dialogs.go
+++ /dev/null
@@ -1,158 +0,0 @@
-package gui
-
-import (
- "fmt"
- "path/filepath"
-
- "fyne.io/fyne/v2"
- "fyne.io/fyne/v2/container"
- "fyne.io/fyne/v2/dialog"
- "fyne.io/fyne/v2/widget"
-
- "codeberg.org/snonux/totalrecall/internal/cli"
-)
-
-// showGalleryVideoDialog presents a multi-checkbox list of gallery PNG paths
-// so the user can choose which pages to animate into MP4 clips using Veo.
-// All items are selected by default. On confirmation the selected paths are
-// passed to cli.GenerateSelectedVideos which runs in a background goroutine
-// while a progress dialog keeps the user informed.
-//
-// galleryPNGs contains the absolute paths of candidate PNG files (typically
-// story page images). outputDir is informational — videos are always written
-// next to their source PNGs by the generator.
-// apiKey is the Google/Gemini API key required by the Veo model.
-// The function must be called on the Fyne main goroutine.
-func (a *Application) showGalleryVideoDialog(galleryPNGs []string, outputDir, apiKey string) {
- if len(galleryPNGs) == 0 {
- dialog.ShowInformation("No Gallery Images",
- "No gallery PNG images were found to generate videos from.", a.window)
- return
- }
-
- // Build checkbox list — all ticked by default.
- selected := buildSelectionMap(galleryPNGs)
- checkboxes := buildCheckboxList(galleryPNGs, selected)
-
- content := buildGalleryDialogContent(outputDir, checkboxes)
-
- // Show a custom confirm dialog. On "Generate" the user-selected subset is
- // collected and passed to the video generator in a goroutine.
- customDialog := dialog.NewCustomConfirm(
- "Generate Videos",
- "Generate",
- "Cancel",
- content,
- func(confirmed bool) {
- if !confirmed {
- return
- }
- paths := collectSelectedPaths(galleryPNGs, selected)
- a.runVideoGeneration(paths, apiKey)
- },
- a.window,
- )
-
- customDialog.Resize(fyne.NewSize(520, 400))
- customDialog.Show()
-}
-
-// buildSelectionMap creates a map of path -> *bool with all entries set to true
-// so every gallery image is selected by default.
-func buildSelectionMap(galleryPNGs []string) map[string]*bool {
- selected := make(map[string]*bool, len(galleryPNGs))
- for _, p := range galleryPNGs {
- v := true
- selected[p] = &v
- }
- return selected
-}
-
-// buildCheckboxList creates one labelled checkbox per gallery PNG. Each checkbox
-// mutates the corresponding *bool in selected so the confirm callback can read
-// the final state without iterating widgets again.
-func buildCheckboxList(galleryPNGs []string, selected map[string]*bool) []fyne.CanvasObject {
- checkboxes := make([]fyne.CanvasObject, 0, len(galleryPNGs))
- for _, p := range galleryPNGs {
- p := p // capture loop variable
- label := filepath.Base(p)
- check := widget.NewCheck(label, func(checked bool) {
- *selected[p] = checked
- })
- check.SetChecked(true)
- checkboxes = append(checkboxes, check)
- }
- return checkboxes
-}
-
-// buildGalleryDialogContent assembles the scrollable VBox shown inside the
-// gallery video dialog. outputDir is displayed as a hint so the user knows
-// where the output will land.
-func buildGalleryDialogContent(outputDir string, checkboxes []fyne.CanvasObject) fyne.CanvasObject {
- hint := widget.NewLabel(fmt.Sprintf("Output directory: %s\nSelect which pages to animate:", outputDir))
- hint.Wrapping = fyne.TextWrapWord
-
- checkList := container.NewVBox(checkboxes...)
- scroll := container.NewVScroll(checkList)
- scroll.SetMinSize(fyne.NewSize(480, 260))
-
- return container.NewVBox(hint, widget.NewSeparator(), scroll)
-}
-
-// collectSelectedPaths returns the subset of galleryPNGs for which the user
-// left the checkbox ticked.
-func collectSelectedPaths(galleryPNGs []string, selected map[string]*bool) []string {
- paths := make([]string, 0, len(galleryPNGs))
- for _, p := range galleryPNGs {
- if v, ok := selected[p]; ok && *v {
- paths = append(paths, p)
- }
- }
- return paths
-}
-
-// runVideoGeneration shows an infinite-progress dialog and calls
-// cli.GenerateSelectedVideos in a background goroutine. The progress dialog is
-// dismissed and the result is shown to the user once generation completes.
-// Must be called on the Fyne main goroutine.
-func (a *Application) runVideoGeneration(paths []string, apiKey string) {
- if len(paths) == 0 {
- dialog.ShowInformation("Nothing Selected",
- "No images were selected for video generation.", a.window)
- return
- }
-
- // Infinite progress bar to indicate background work.
- progressDialog := dialog.NewCustom(
- "Generating Videos",
- "Please wait…",
- buildProgressContent(len(paths)),
- a.window,
- )
- progressDialog.Show()
-
- // Run generation in a background goroutine; update the UI when done.
- go func() {
- err := cli.GenerateSelectedVideos(apiKey, paths)
- fyne.Do(func() {
- progressDialog.Hide()
- if err != nil {
- dialog.ShowError(fmt.Errorf("video generation failed: %w", err), a.window)
- return
- }
- dialog.ShowInformation("Videos Generated",
- fmt.Sprintf("Successfully generated %d video(s).\nVideos are saved next to their source images.", len(paths)),
- a.window,
- )
- })
- }()
-}
-
-// buildProgressContent returns the widget shown inside the progress dialog:
-// a spinning activity indicator together with a short explanatory label.
-func buildProgressContent(count int) fyne.CanvasObject {
- bar := widget.NewProgressBarInfinite()
- label := widget.NewLabel(fmt.Sprintf("Generating %d video(s) with Veo — this may take several minutes…", count))
- label.Wrapping = fyne.TextWrapWord
- return container.NewVBox(label, bar)
-}
diff --git a/internal/gui/generator.go b/internal/gui/generator.go
index 153b365..f4a6c21 100644
--- a/internal/gui/generator.go
+++ b/internal/gui/generator.go
@@ -4,8 +4,6 @@ import (
"context"
"math/rand"
"time"
-
- "codeberg.org/snonux/totalrecall/internal/audio"
)
// randomVoice picks a random voice from the provided list.
@@ -27,16 +25,6 @@ func randomOpenAISpeed() float64 {
// directly (setting newAudioProvider / audioConfig / config) continue to work
// without modification, while production code uses the pre-built orchestrator.
-// audioProviderName returns the lowercase TTS provider name.
-func (a *Application) audioProviderName() string {
- return a.getOrchestrator().audioProviderName()
-}
-
-// audioOutputFormat resolves the effective audio output format.
-func (a *Application) audioOutputFormat() string {
- return a.getOrchestrator().audioOutputFormat()
-}
-
// translateWord translates a Bulgarian word to English.
func (a *Application) translateWord(word string) (string, error) {
return a.getOrchestrator().TranslateWord(word)
@@ -91,18 +79,3 @@ func (a *Application) generateImagesWithPrompt(ctx context.Context, word, custom
return o.generateImagesWithPromptAndNotify(ctx, word, customPrompt, translation, cardDir, promptUI)
}
-
-// getPhoneticInfo fetches phonetic information for a Bulgarian word.
-func (a *Application) getPhoneticInfo(word string) (string, error) {
- return a.getOrchestrator().GetPhoneticInfo(word)
-}
-
-// saveAudioAttribution saves attribution metadata for a generated audio file.
-func (a *Application) saveAudioAttribution(word, audioFile, voice string, speed float64) error {
- return a.getOrchestrator().saveAudioAttribution(word, audioFile, voice, speed)
-}
-
-// saveAudioMetadata writes the sidecar metadata file for a generated audio file.
-func (a *Application) saveAudioMetadata(cardDir string, audioCfg audio.Config, voice string, speed float64, cardType, audioFile, audioFileBack string) error {
- return a.getOrchestrator().saveAudioMetadata(cardDir, audioCfg, voice, speed, cardType, audioFile, audioFileBack)
-}
diff --git a/internal/gui/orchestrator.go b/internal/gui/orchestrator.go
index ef4a167..0278128 100644
--- a/internal/gui/orchestrator.go
+++ b/internal/gui/orchestrator.go
@@ -88,13 +88,6 @@ func (o *GenerationOrchestrator) TranslateEnglishToBulgarian(word string) (strin
// --- Audio provider helpers ---
-// audioProviderName returns the lowercase provider name from config, defaulting
-// to the shared audio default when none is set. Delegates to AudioConfigResolver
-// so Application and tests keep a stable method on GenerationOrchestrator.
-func (o *GenerationOrchestrator) audioProviderName() string {
- return o.audioResolver.ProviderName()
-}
-
// audioOutputFormat resolves the effective output format (e.g. "mp3" or "wav").
func (o *GenerationOrchestrator) audioOutputFormat() string {
return o.audioResolver.OutputFormat()
diff --git a/internal/gui/parallel_runner.go b/internal/gui/parallel_runner.go
index 2567304..e19eef1 100644
--- a/internal/gui/parallel_runner.go
+++ b/internal/gui/parallel_runner.go
@@ -31,7 +31,6 @@ type imageGenResult struct {
// phoneticGenResult is an internal channel payload for phonetic goroutines.
type phoneticGenResult struct {
info string
- err error
}
// ParallelRunner coordinates parallel audio, image, and phonetics work for
diff --git a/internal/gui/persistence.go b/internal/gui/persistence.go
index e97a379..dc05b36 100644
--- a/internal/gui/persistence.go
+++ b/internal/gui/persistence.go
@@ -4,12 +4,6 @@ import (
"fmt"
)
-// ensureWordDirectoryAndMetadata creates a new card directory and writes word
-// metadata. Delegates to CardService.
-func (a *Application) ensureWordDirectoryAndMetadata(word string) (string, error) {
- return a.getCardService().EnsureWordDirectoryAndMetadata(word)
-}
-
// ensureCardDirectory ensures a card directory exists for the given word.
// Delegates to CardService.
func (a *Application) ensureCardDirectory(word string) (string, error) {
diff --git a/internal/httpctx/httpctx.go b/internal/httpctx/httpctx.go
index ffbdc65..075f948 100644
--- a/internal/httpctx/httpctx.go
+++ b/internal/httpctx/httpctx.go
@@ -20,7 +20,7 @@ const (
OpenAIHTTPTimeout = 15 * time.Minute
// GenAIHTTPTimeout bounds each Google GenAI SDK HTTP request (Gemini text,
- // image, TTS, Veo polling, file download).
+ // image and TTS generation).
GenAIHTTPTimeout = 30 * time.Minute
// ImageDownloadTimeout limits fetches of remote image URLs (e.g. DALL-E
@@ -34,10 +34,6 @@ const (
// ListModelsTimeout bounds model-listing CLI calls.
ListModelsTimeout = 3 * time.Minute
- // VeoCLIPerVideoTimeout bounds one gallery-to-MP4 Veo run (start + poll +
- // download) when the CLI passes Background.
- VeoCLIPerVideoTimeout = 25 * time.Minute
-
// SingleWordProcessTimeout caps ProcessWordWithTranslation when the CLI
// uses an unbounded context (batch processing already applies per-word
// timeouts elsewhere).
diff --git a/internal/httpctx/httpctx_test.go b/internal/httpctx/httpctx_test.go
index 6cd6521..d82dc99 100644
--- a/internal/httpctx/httpctx_test.go
+++ b/internal/httpctx/httpctx_test.go
@@ -43,10 +43,10 @@ func TestWithTimeoutUnlessSet_NoDeadline(t *testing.T) {
}
}
-func TestWithTimeoutUnlessSet_NilUsesBackground(t *testing.T) {
+func TestWithTimeoutUnlessSet_TODOContext(t *testing.T) {
t.Parallel()
- ctx, cancel := WithTimeoutUnlessSet(nil, 50*time.Millisecond)
+ ctx, cancel := WithTimeoutUnlessSet(context.TODO(), 50*time.Millisecond)
defer cancel()
if err := ctx.Err(); err != nil {
diff --git a/internal/video/generate_selected.go b/internal/video/generate_selected.go
deleted file mode 100644
index b29f578..0000000
--- a/internal/video/generate_selected.go
+++ /dev/null
@@ -1,44 +0,0 @@
-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 PNGs to animate.
-//
-// 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/veo.go b/internal/video/veo.go
deleted file mode 100644
index 5c62ec0..0000000
--- a/internal/video/veo.go
+++ /dev/null
@@ -1,325 +0,0 @@
-// Package video provides Veo-based MP4 generation from selected PNGs.
-package video
-
-import (
- "context"
- "fmt"
- "log"
- "os"
- "path/filepath"
- "strings"
- "time"
-
- "google.golang.org/genai"
-
- "codeberg.org/snonux/totalrecall/internal/httpctx"
-)
-
-const (
- // DefaultVeoModel is the Veo model used for video generation.
- // veo-2.0-generate-001 is the current stable Gemini-API-accessible model.
- DefaultVeoModel = "veo-2.0-generate-001"
-
- // videoDurationSeconds is the clip length requested from Veo.
- // 8 seconds is the minimum duration supported by the Veo API and produces
- // clips long enough to convey the flashcard content without excess.
- videoDurationSeconds = int32(8)
-
- // videoAspectRatio is the target aspect ratio for generated clips.
- // 16:9 matches the landscape orientation of the comic-style gallery panels.
- videoAspectRatio = "16:9"
-
- // pollInterval is the time to wait between operation status checks.
- // Veo generation typically takes 1–3 minutes; 15 s keeps polling overhead low.
- pollInterval = 15 * time.Second
-
- // maxPollAttempts caps the number of polling iterations so that a hung or
- // stalled Veo operation does not block the process indefinitely.
- // At 15 s per attempt, 40 attempts ≈ 10 minutes — well above the observed
- // worst-case generation time of ~3 minutes.
- maxPollAttempts = 40
-)
-
-// VeoGenerator wraps the Google GenAI client for Veo video generation.
-type VeoGenerator struct {
- client *genai.Client
- model string
-}
-
-// newGenaiClient is the constructor used in production and can be replaced in
-// unit tests to inject a mock transport.
-var newGenaiClient = httpctx.NewGenAIClient
-
-// NewVeoGenerator creates a new VeoGenerator backed by the Gemini API.
-// It returns an error if the API key is empty or the SDK client cannot be
-// initialised (e.g. due to network or credential issues).
-func NewVeoGenerator(apiKey string) (*VeoGenerator, error) {
- apiKey = strings.TrimSpace(apiKey)
- if apiKey == "" {
- return nil, fmt.Errorf("veo: API key is required")
- }
-
- client, err := newGenaiClient(context.Background(), &genai.ClientConfig{
- APIKey: apiKey,
- Backend: genai.BackendGeminiAPI,
- })
- if err != nil {
- return nil, fmt.Errorf("veo: failed to create genai client: %w", err)
- }
-
- return &VeoGenerator{
- client: client,
- model: DefaultVeoModel,
- }, nil
-}
-
-// GenerateVideoFromGallery reads the gallery PNG for pageNum, calls the Veo API,
-// polls until the operation completes, then writes the resulting MP4 to outputDir.
-// It returns the absolute path of the saved MP4 file, or an error.
-//
-// galleryPath is the directory containing files named
-// "<slug>_gallery_<N>.png" (e.g. /stories/ябълка/ябълка_gallery_1.png).
-// outputDir is where the output MP4 will be written.
-// pageNum selects which gallery page to animate (1-based).
-func (g *VeoGenerator) GenerateVideoFromGallery(ctx context.Context, galleryPath string, outputDir string, pageNum int) (string, error) {
- ctx, cancel := httpctx.WithTimeoutUnlessSet(ctx, httpctx.VeoCLIPerVideoTimeout)
- defer cancel()
-
- imgPath, imgBytes, err := loadGalleryImage(galleryPath, pageNum)
- if err != nil {
- return "", err
- }
-
- prompt := buildVeoPrompt()
-
- log.Printf("veo: generating video from %s (page %d)", imgPath, pageNum)
-
- mp4Path, err := g.generateAndSave(ctx, imgBytes, prompt, outputDir, imgPath, pageNum)
- if err != nil {
- return "", err
- }
-
- return mp4Path, nil
-}
-
-// GenerateVideoFromPath reads the gallery PNG at the given absolute (or
-// relative) imgPath, calls the Veo API, and writes the resulting MP4 to the
-// same directory that contains imgPath. It returns the absolute path of the
-// saved MP4 or an error.
-//
-// This variant is preferred over GenerateVideoFromGallery when the caller
-// already knows the exact image path (e.g. from a recursive directory walk),
-// because it avoids a second glob search and always writes the video next to
-// its source image.
-func (g *VeoGenerator) GenerateVideoFromPath(ctx context.Context, imgPath string) (string, error) {
- ctx, cancel := httpctx.WithTimeoutUnlessSet(ctx, httpctx.VeoCLIPerVideoTimeout)
- defer cancel()
-
- imgBytes, err := os.ReadFile(imgPath)
- if err != nil {
- return "", fmt.Errorf("veo: reading gallery image %s: %w", imgPath, err)
- }
-
- // Derive the page number from the file name for saveMP4 naming purposes.
- pageNum := pageNumFromPath(imgPath)
-
- prompt := buildVeoPrompt()
-
- log.Printf("veo: generating video from %s", imgPath)
-
- // Write the MP4 next to the source image so gallery + video stay together.
- outputDir := filepath.Dir(imgPath)
-
- return g.generateAndSave(ctx, imgBytes, prompt, outputDir, imgPath, pageNum)
-}
-
-// pageNumFromPath extracts the gallery page number from a file name of the
-// form "<slug>_gallery_<N>.png". Returns 0 when the name does not match.
-func pageNumFromPath(imgPath string) int {
- base := filepath.Base(imgPath)
- name := strings.TrimSuffix(base, ".png")
- const marker = "_gallery_"
- idx := strings.LastIndex(name, marker)
- if idx < 0 {
- return 0
- }
- numStr := name[idx+len(marker):]
- var n int
- if _, err := fmt.Sscanf(numStr, "%d", &n); err != nil || n <= 0 {
- return 0
- }
- return n
-}
-
-// loadGalleryImage finds the gallery PNG for the given page number and returns
-// its path and raw bytes. It searches galleryPath for any file whose name
-// matches the pattern "*_gallery_<N>.png".
-func loadGalleryImage(galleryPath string, pageNum int) (string, []byte, error) {
- pattern := filepath.Join(galleryPath, fmt.Sprintf("*_gallery_%d.png", pageNum))
- matches, err := filepath.Glob(pattern)
- if err != nil {
- return "", nil, fmt.Errorf("veo: glob for gallery image: %w", err)
- }
- if len(matches) == 0 {
- return "", nil, fmt.Errorf("veo: no gallery image found for page %d in %s", pageNum, galleryPath)
- }
-
- imgPath := matches[0]
- imgBytes, err := os.ReadFile(imgPath)
- if err != nil {
- return "", nil, fmt.Errorf("veo: reading gallery image %s: %w", imgPath, err)
- }
-
- return imgPath, imgBytes, nil
-}
-
-// buildVeoPrompt returns the text prompt sent alongside the gallery image.
-// The prompt asks Veo to animate the comic panel while preserving the style
-// and characters so that the result fits naturally into a flashcard context.
-func buildVeoPrompt() string {
- return "Animate this comic-style flashcard illustration as a short, loopable 8-second clip. " +
- "Preserve the hand-drawn comic art style exactly — bold outlines, flat colours, speech bubbles. " +
- "Add subtle motion: characters breathe or gesture gently, the Bulgarian word label pulses softly, " +
- "and background elements drift slowly. Keep the mood educational and friendly. " +
- "No scene cuts, no camera moves — a single steady wide shot throughout. " +
- "Do not change the characters, layout, or colour palette."
-}
-
-// generateAndSave calls the Veo API, polls the long-running operation, downloads
-// the resulting video bytes, and writes them to an MP4 file in outputDir.
-func (g *VeoGenerator) generateAndSave(ctx context.Context, imgBytes []byte, prompt, outputDir, srcPath string, pageNum int) (string, error) {
- op, err := g.startOperation(ctx, imgBytes, prompt)
- if err != nil {
- return "", err
- }
-
- op, err = g.pollUntilDone(ctx, op)
- if err != nil {
- return "", err
- }
-
- videoBytes, err := g.downloadVideo(ctx, op)
- if err != nil {
- return "", err
- }
-
- return saveMP4(videoBytes, outputDir, srcPath, pageNum)
-}
-
-// startOperation submits the image + prompt to the Veo API and returns the
-// initial operation descriptor (which will have Done == false).
-func (g *VeoGenerator) startOperation(ctx context.Context, imgBytes []byte, prompt string) (*genai.GenerateVideosOperation, error) {
- dur := videoDurationSeconds
- cfg := &genai.GenerateVideosConfig{
- AspectRatio: videoAspectRatio,
- DurationSeconds: &dur,
- NumberOfVideos: 1,
- }
-
- source := &genai.GenerateVideosSource{
- Prompt: prompt,
- Image: &genai.Image{
- ImageBytes: imgBytes,
- MIMEType: "image/png",
- },
- }
-
- op, err := g.client.Models.GenerateVideosFromSource(ctx, g.model, source, cfg)
- if err != nil {
- return nil, fmt.Errorf("veo: failed to start video generation: %w", err)
- }
-
- log.Printf("veo: operation started (done=%v)", op.Done)
- return op, nil
-}
-
-// pollUntilDone repeatedly calls GetVideosOperation until the operation reports
-// completion, the context is cancelled, or maxPollAttempts is reached.
-// It sleeps pollInterval between checks to avoid hammering the API.
-func (g *VeoGenerator) pollUntilDone(ctx context.Context, op *genai.GenerateVideosOperation) (*genai.GenerateVideosOperation, error) {
- for attempt := 0; !op.Done; attempt++ {
- if attempt >= maxPollAttempts {
- return nil, fmt.Errorf("veo: operation did not complete after %d attempts (%s each)", maxPollAttempts, pollInterval)
- }
-
- log.Printf("veo: operation in progress, waiting %s (attempt %d/%d)...", pollInterval, attempt+1, maxPollAttempts)
-
- select {
- case <-ctx.Done():
- return nil, fmt.Errorf("veo: context cancelled while polling: %w", ctx.Err())
- case <-time.After(pollInterval):
- }
-
- var err error
- op, err = g.client.Operations.GetVideosOperation(ctx, op, nil)
- if err != nil {
- return nil, fmt.Errorf("veo: polling operation failed: %w", err)
- }
- }
-
- log.Printf("veo: operation completed")
- return op, nil
-}
-
-// downloadVideo extracts the video from a completed operation, downloading bytes
-// via the Files API when the response contains only a URI reference.
-func (g *VeoGenerator) downloadVideo(ctx context.Context, op *genai.GenerateVideosOperation) ([]byte, error) {
- // Surface any API-level error (e.g. content policy or geographic restriction)
- // before checking for videos, so the caller gets a meaningful message.
- if len(op.Error) > 0 {
- msg, _ := op.Error["message"].(string)
- if msg == "" {
- msg = fmt.Sprintf("%v", op.Error)
- }
- return nil, fmt.Errorf("veo: %s", msg)
- }
- if op.Response == nil || len(op.Response.GeneratedVideos) == 0 {
- return nil, fmt.Errorf("veo: operation completed but no videos in response")
- }
-
- gv := op.Response.GeneratedVideos[0]
- if gv == nil || gv.Video == nil {
- return nil, fmt.Errorf("veo: generated video entry is empty")
- }
-
- // When the Gemini API returns a URI, download bytes via the Files API.
- if gv.Video.URI != "" {
- log.Printf("veo: downloading video from URI %s", gv.Video.URI)
- data, err := g.client.Files.Download(ctx, genai.NewDownloadURIFromGeneratedVideo(gv), nil)
- if err != nil {
- return nil, fmt.Errorf("veo: downloading video: %w", err)
- }
- return data, nil
- }
-
- // Inline bytes path (used in some Vertex AI configurations).
- if len(gv.Video.VideoBytes) > 0 {
- return gv.Video.VideoBytes, nil
- }
-
- return nil, fmt.Errorf("veo: no video bytes or URI available in response")
-}
-
-// saveMP4 writes videoBytes to a file in outputDir, deriving the file name from
-// the source gallery image path and the page number.
-// It returns the absolute path of the written file.
-func saveMP4(videoBytes []byte, outputDir, srcPath string, pageNum int) (string, error) {
- if err := os.MkdirAll(outputDir, 0o755); err != nil {
- return "", fmt.Errorf("veo: creating output dir %s: %w", outputDir, err)
- }
-
- // Derive base name from the source image, e.g. "ябълка_gallery_1.png" → "ябълка_gallery_1.mp4"
- base := strings.TrimSuffix(filepath.Base(srcPath), ".png")
- if base == "" || base == srcPath {
- // Fallback when the source name is unexpected.
- base = fmt.Sprintf("gallery_%d", pageNum)
- }
-
- outPath := filepath.Join(outputDir, base+".mp4")
- if err := os.WriteFile(outPath, videoBytes, 0o644); err != nil {
- return "", fmt.Errorf("veo: writing mp4 to %s: %w", outPath, err)
- }
-
- log.Printf("veo: saved MP4 to %s (%d bytes)", outPath, len(videoBytes))
- return outPath, nil
-}
diff --git a/internal/video/veo_test.go b/internal/video/veo_test.go
deleted file mode 100644
index ce653e3..0000000
--- a/internal/video/veo_test.go
+++ /dev/null
@@ -1,273 +0,0 @@
-// Package video_test provides unit tests for the Veo video generator.
-// All tests are mock-based — no real API calls are made.
-package video
-
-import (
- "context"
- "errors"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "google.golang.org/genai"
-)
-
-// TestNewVeoGenerator_EmptyKey verifies that an empty API key is rejected.
-func TestNewVeoGenerator_EmptyKey(t *testing.T) {
- t.Parallel()
-
- _, err := NewVeoGenerator("")
- if err == nil {
- t.Fatal("expected error for empty API key, got nil")
- }
-}
-
-// TestNewVeoGenerator_WhitespaceKey verifies that a whitespace-only API key is
-// treated the same as an empty key.
-func TestNewVeoGenerator_WhitespaceKey(t *testing.T) {
- t.Parallel()
-
- _, err := NewVeoGenerator(" ")
- if err == nil {
- t.Fatal("expected error for whitespace API key, got nil")
- }
-}
-
-// TestNewVeoGenerator_ClientInitFailure verifies that a genai client
-// initialisation error propagates as a wrapped error.
-func TestNewVeoGenerator_ClientInitFailure(t *testing.T) {
- // 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
- newGenaiClient = func(_ context.Context, _ *genai.ClientConfig) (*genai.Client, error) {
- return nil, errors.New("injected init error")
- }
- t.Cleanup(func() { newGenaiClient = orig })
-
- _, err := NewVeoGenerator("test-api-key")
- if err == nil {
- t.Fatal("expected error from client init failure, got nil")
- }
- if !strings.Contains(err.Error(), "injected init error") {
- t.Fatalf("unexpected error text: %v", err)
- }
-}
-
-// TestLoadGalleryImage_Missing verifies that loadGalleryImage returns an error
-// when no matching file exists in the given directory.
-func TestLoadGalleryImage_Missing(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- _, _, err := loadGalleryImage(dir, 1)
- if err == nil {
- t.Fatal("expected error for missing gallery image, got nil")
- }
-}
-
-// TestLoadGalleryImage_Found verifies that loadGalleryImage returns the correct
-// path and bytes when the expected file exists.
-func TestLoadGalleryImage_Found(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- imgFile := filepath.Join(dir, "ябълка_gallery_2.png")
- wantBytes := []byte("fake-png-data")
- if err := os.WriteFile(imgFile, wantBytes, 0o644); err != nil {
- t.Fatalf("setup: write test image: %v", err)
- }
-
- gotPath, gotBytes, err := loadGalleryImage(dir, 2)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if gotPath != imgFile {
- t.Errorf("path: got %q, want %q", gotPath, imgFile)
- }
- if string(gotBytes) != string(wantBytes) {
- t.Errorf("bytes: got %q, want %q", gotBytes, wantBytes)
- }
-}
-
-// TestBuildVeoPrompt verifies that the prompt is non-empty and contains the
-// key terms that shape Veo's output style.
-func TestBuildVeoPrompt(t *testing.T) {
- t.Parallel()
-
- prompt := buildVeoPrompt()
- if prompt == "" {
- t.Fatal("buildVeoPrompt returned empty string")
- }