From d30fba0ac226da67a78349d322f4ad44b6f4f1a7 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 6 Apr 2026 11:19:16 +0300 Subject: feat: add Veo video generator package for comic gallery animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces internal/video/veo.go with NewVeoGenerator and GenerateVideoFromGallery. Reads gallery PNG pages, submits them to the Veo API (veo-2.0-generate-001) as image-to-video with an 8-second 16:9 clip request, polls the long-running operation every 15 s, then saves the resulting MP4 to the output directory. Unit tests in veo_test.go cover key error paths and helper functions using mocks — no real API calls required. Co-Authored-By: Claude Sonnet 4.6 --- internal/video/veo.go | 250 +++++++++++++++++++++++++++++++++++++++++++++ internal/video/veo_test.go | 158 ++++++++++++++++++++++++++++ 2 files changed, 408 insertions(+) create mode 100644 internal/video/veo.go create mode 100644 internal/video/veo_test.go diff --git a/internal/video/veo.go b/internal/video/veo.go new file mode 100644 index 0000000..d76866b --- /dev/null +++ b/internal/video/veo.go @@ -0,0 +1,250 @@ +// 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 + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "time" + + "google.golang.org/genai" +) + +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. + videoDurationSeconds = int32(8) + + // videoAspectRatio is the target aspect ratio for generated clips. + 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 +) + +// 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 = genai.NewClient + +// 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) { + 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 +} + +// 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 or the context is cancelled. It sleeps pollInterval between checks. +func (g *VeoGenerator) pollUntilDone(ctx context.Context, op *genai.GenerateVideosOperation) (*genai.GenerateVideosOperation, error) { + for !op.Done { + log.Printf("veo: operation in progress, waiting %s...", pollInterval) + + 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) { + 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 new file mode 100644 index 0000000..5f7abd9 --- /dev/null +++ b/internal/video/veo_test.go @@ -0,0 +1,158 @@ +// 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) { + t.Parallel() + + // 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") + } +} -- cgit v1.2.3