From d558ed55df855e3ce01b19e7f1b1fe44dce8e64a Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Tue, 21 Apr 2026 22:34:55 +0300 Subject: Audit stripped comicgen leftovers --- assets/comic-bd-compacted-words.txt | 10 -- internal/cli/video_runner.go | 21 --- internal/cli/video_runner_test.go | 44 ----- internal/gui/audio_paths.go | 6 - internal/gui/dialogs.go | 158 ------------------ internal/gui/generator.go | 27 --- internal/gui/orchestrator.go | 7 - internal/gui/parallel_runner.go | 1 - internal/gui/persistence.go | 6 - internal/httpctx/httpctx.go | 6 +- internal/httpctx/httpctx_test.go | 4 +- internal/video/generate_selected.go | 44 ----- internal/video/veo.go | 325 ------------------------------------ internal/video/veo_test.go | 273 ------------------------------ 14 files changed, 3 insertions(+), 929 deletions(-) delete mode 100644 assets/comic-bd-compacted-words.txt delete mode 100644 internal/cli/video_runner.go delete mode 100644 internal/cli/video_runner_test.go delete mode 100644 internal/gui/dialogs.go delete mode 100644 internal/video/generate_selected.go delete mode 100644 internal/video/veo.go delete mode 100644 internal/video/veo_test.go 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 -// "_gallery_.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 "_gallery_.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_.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") - } - - keywords := []string{"comic", "Bulgarian", "educational", "8-second"} - for _, kw := range keywords { - if !strings.Contains(prompt, kw) { - t.Errorf("expected prompt to contain %q", kw) - } - } -} - -// TestSaveMP4_WritesFile verifies that saveMP4 creates the expected MP4 file and -// returns its absolute path. -func TestSaveMP4_WritesFile(t *testing.T) { - t.Parallel() - - outDir := t.TempDir() - fakeVideo := []byte{0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70} // minimal ftyp box bytes - - // Simulate source path like the real gallery image would produce. - srcPath := "/stories/ябълка/ябълка_gallery_3.png" - - got, err := saveMP4(fakeVideo, outDir, srcPath, 3) - if err != nil { - t.Fatalf("saveMP4 failed: %v", err) - } - - if !strings.HasSuffix(got, ".mp4") { - t.Errorf("expected .mp4 suffix, got %q", got) - } - - data, err := os.ReadFile(got) - if err != nil { - t.Fatalf("reading saved MP4: %v", err) - } - if string(data) != string(fakeVideo) { - t.Errorf("file contents mismatch") - } -} - -// TestSaveMP4_CreatesOutputDir verifies that saveMP4 creates the output directory -// when it does not already exist. -func TestSaveMP4_CreatesOutputDir(t *testing.T) { - t.Parallel() - - base := t.TempDir() - outDir := filepath.Join(base, "nested", "output") - fakeVideo := []byte("video-data") - - _, err := saveMP4(fakeVideo, outDir, "word_gallery_1.png", 1) - if err != nil { - t.Fatalf("saveMP4 failed: %v", err) - } - - if _, statErr := os.Stat(outDir); os.IsNotExist(statErr) { - t.Error("expected output directory to be created") - } -} - -// TestSaveMP4_FallbackName verifies that saveMP4 uses a fallback name when the -// source path lacks a recognisable gallery file name (no .png suffix). -func TestSaveMP4_FallbackName(t *testing.T) { - t.Parallel() - - outDir := t.TempDir() - fakeVideo := []byte("video-data") - - // srcPath with no .png extension triggers the fallback naming path. - got, err := saveMP4(fakeVideo, outDir, "unusual_source", 5) - if err != nil { - t.Fatalf("saveMP4 failed: %v", err) - } - - if !strings.HasSuffix(got, ".mp4") { - t.Errorf("expected .mp4 suffix even for fallback name, got %q", got) - } -} - -// --------------------------------------------------------------------------- -// pageNumFromPath -// --------------------------------------------------------------------------- - -// TestPageNumFromPath verifies that pageNumFromPath correctly extracts the -// gallery page number from various file name patterns. -func TestPageNumFromPath(t *testing.T) { - t.Parallel() - - cases := []struct { - path string - want int - }{ - {"/stories/ябълка/ябълка_gallery_1.png", 1}, - {"/stories/word/word_gallery_10.png", 10}, - // Non-gallery path — should return 0. - {"/stories/word/word_cover.png", 0}, - // Missing trailing number — should return 0. - {"/stories/word/word_gallery_.png", 0}, - // Page number zero — should return 0 (non-positive). - {"/stories/word/word_gallery_0.png", 0}, - // Nested gallery name with multiple "_gallery_" tokens — last one wins. - {"/comics/slug/slug_gallery_3.png", 3}, - } - - for _, tc := range cases { - got := pageNumFromPath(tc.path) - if got != tc.want { - t.Errorf("pageNumFromPath(%q) = %d, want %d", tc.path, got, tc.want) - } - } -} - -// --------------------------------------------------------------------------- -// loadGalleryImage — additional edge cases -// --------------------------------------------------------------------------- - -// TestLoadGalleryImage_MultipleMatchesUsesFirst verifies that when several -// gallery files share the same page number, loadGalleryImage returns the -// lexicographically first match without error. -func TestLoadGalleryImage_MultipleMatchesUsesFirst(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - - // Two files for page 1 — alphabetical order determines which is returned. - files := []string{"aaa_gallery_1.png", "zzz_gallery_1.png"} - for _, name := range files { - if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil { - t.Fatalf("setup: %v", err) - } - } - - gotPath, gotBytes, err := loadGalleryImage(dir, 1) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // filepath.Glob returns results in sorted order, so aaa_gallery_1.png comes first. - expectedName := "aaa_gallery_1.png" - if filepath.Base(gotPath) != expectedName { - t.Errorf("expected first match %q, got %q", expectedName, filepath.Base(gotPath)) - } - if string(gotBytes) != expectedName { - t.Errorf("bytes mismatch: got %q, want %q", gotBytes, expectedName) - } -} - -// --------------------------------------------------------------------------- -// VeoGenerator — constructor with valid mock client -// --------------------------------------------------------------------------- - -// TestNewVeoGenerator_WithMockClient verifies that NewVeoGenerator succeeds -// when the genai client factory does not return an error. -func TestNewVeoGenerator_WithMockClient(t *testing.T) { - // Do not use t.Parallel: this test replaces the package-global newGenaiClient hook. - - orig := newGenaiClient - newGenaiClient = func(_ context.Context, _ *genai.ClientConfig) (*genai.Client, error) { - // Return a zero-value client pointer — sufficient for construction. - return &genai.Client{}, nil - } - t.Cleanup(func() { newGenaiClient = orig }) - - gen, err := NewVeoGenerator("valid-api-key") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if gen == nil { - t.Fatal("expected non-nil VeoGenerator") - } - if gen.model != DefaultVeoModel { - t.Errorf("model: got %q, want %q", gen.model, DefaultVeoModel) - } -} -- cgit v1.2.3