summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-06 11:19:16 +0300
committerPaul Buetow <paul@buetow.org>2026-04-06 11:19:16 +0300
commitd30fba0ac226da67a78349d322f4ad44b6f4f1a7 (patch)
treebb31250b54456e14aeb68990f881485b3d1266c3
parentaa9444a75c22e381e94ef4988d42e44af5c51016 (diff)
feat: add Veo video generator package for comic gallery animation
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 <noreply@anthropic.com>
-rw-r--r--internal/video/veo.go250
-rw-r--r--internal/video/veo_test.go158
2 files changed, 408 insertions, 0 deletions
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
+// "<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) {
+ 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_<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 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")
+ }
+}