summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-08 09:53:17 +0300
committerPaul Buetow <paul@buetow.org>2026-04-08 09:53:17 +0300
commitcd4265b8f87811db758c89bab5e62daa6c39337a (patch)
tree94c0a129b4b6ee9132baaa81eb3fbc96b083cbd4 /internal
parent7e7d34bfc0aec2aa8dff4cefa720e4a9b42d95d8 (diff)
feat(httpctx): add timeouts for OpenAI, Gemini, and HTTP downloads
Introduce internal/httpctx with non-zero http.Client timeouts for go-openai and google.golang.org/genai, shared image download client, and WithTimeoutUnlessSet for operation-level deadlines when callers use Background. Wire NewOpenAIClient/NewGenAIClient everywhere clients are constructed. Apply Search timeouts for DALL-E and Nano Banana, provider audio timeouts, model-list timeouts, Veo operation timeouts, story page download context, and single-word CLI processing cap. Made-with: Cursor
Diffstat (limited to 'internal')
-rw-r--r--internal/audio/gemini_provider.go7
-rw-r--r--internal/audio/openai_provider.go7
-rw-r--r--internal/cli/video_runner.go1
-rw-r--r--internal/httpctx/httpctx.go96
-rw-r--r--internal/httpctx/httpctx_test.go55
-rw-r--r--internal/image/nanobanana.go7
-rw-r--r--internal/image/openai.go13
-rw-r--r--internal/models/lister.go15
-rw-r--r--internal/phonetic/fetcher.go5
-rw-r--r--internal/processor/processor.go5
-rw-r--r--internal/story/artist.go8
-rw-r--r--internal/story/generator.go3
-rw-r--r--internal/story/narrator.go5
-rw-r--r--internal/translation/translator.go5
-rw-r--r--internal/video/veo.go10
15 files changed, 219 insertions, 23 deletions
diff --git a/internal/audio/gemini_provider.go b/internal/audio/gemini_provider.go
index 5100c5e..b573a02 100644
--- a/internal/audio/gemini_provider.go
+++ b/internal/audio/gemini_provider.go
@@ -12,6 +12,8 @@ import (
"strings"
"google.golang.org/genai"
+
+ "codeberg.org/snonux/totalrecall/internal/httpctx"
)
const (
@@ -44,7 +46,7 @@ func NewGeminiProvider(config GeminiAudioConfig, outputFormat string) (Provider,
return nil, errors.New("google API key is required")
}
- client, err := genai.NewClient(context.Background(), &genai.ClientConfig{
+ client, err := httpctx.NewGenAIClient(context.Background(), &genai.ClientConfig{
APIKey: normalized.APIKey,
Backend: genai.BackendGeminiAPI,
})
@@ -60,6 +62,9 @@ func NewGeminiProvider(config GeminiAudioConfig, outputFormat string) (Provider,
// GenerateAudio generates audio using Gemini TTS and writes it to the output file.
func (p *GeminiProvider) GenerateAudio(ctx context.Context, text string, outputFile string) error {
+ ctx, cancel := httpctx.WithTimeoutUnlessSet(ctx, httpctx.GenAIHTTPTimeout)
+ defer cancel()
+
if err := ValidateBulgarianText(text); err != nil {
return err
}
diff --git a/internal/audio/openai_provider.go b/internal/audio/openai_provider.go
index e6212f8..5b59a3e 100644
--- a/internal/audio/openai_provider.go
+++ b/internal/audio/openai_provider.go
@@ -10,6 +10,8 @@ import (
"strings"
"github.com/sashabaranov/go-openai"
+
+ "codeberg.org/snonux/totalrecall/internal/httpctx"
)
// Compile-time check that OpenAIProvider implements the Provider interface.
@@ -31,7 +33,7 @@ func NewOpenAIProvider(config OpenAIAudioConfig, outputFormat string) (Provider,
}
return &OpenAIProvider{
- client: openai.NewClient(config.Key),
+ client: httpctx.NewOpenAIClient(config.Key),
config: config,
outputFormat: outputFormat,
}, nil
@@ -39,6 +41,9 @@ func NewOpenAIProvider(config OpenAIAudioConfig, outputFormat string) (Provider,
// GenerateAudio generates audio using OpenAI TTS
func (p *OpenAIProvider) GenerateAudio(ctx context.Context, text string, outputFile string) error {
+ ctx, cancel := httpctx.WithTimeoutUnlessSet(ctx, httpctx.OpenAIHTTPTimeout)
+ defer cancel()
+
// Validate Bulgarian text
if err := ValidateBulgarianText(text); err != nil {
return err
diff --git a/internal/cli/video_runner.go b/internal/cli/video_runner.go
index 96cd7cd..aab23ae 100644
--- a/internal/cli/video_runner.go
+++ b/internal/cli/video_runner.go
@@ -34,6 +34,7 @@ func GenerateSelectedVideos(apiKey string, selectedPaths []string) error {
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("cli: generating video for %s: %w", imgPath, err)
diff --git a/internal/httpctx/httpctx.go b/internal/httpctx/httpctx.go
new file mode 100644
index 0000000..2a65f50
--- /dev/null
+++ b/internal/httpctx/httpctx.go
@@ -0,0 +1,96 @@
+// Package httpctx provides HTTP client defaults and context helpers for
+// outbound API calls. go-openai uses http.Client{} with zero timeout by
+// default; google.golang.org/genai can also run without an explicit client
+// deadline. This package sets consistent per-request HTTP timeouts and
+// optional operation-level context deadlines when callers pass Background.
+package httpctx
+
+import (
+ "context"
+ "net/http"
+ "time"
+
+ "github.com/sashabaranov/go-openai"
+ "google.golang.org/genai"
+)
+
+const (
+ // OpenAIHTTPTimeout bounds each go-openai HTTP round-trip (TTS, chat,
+ // images). Without this, the default client has no timeout.
+ OpenAIHTTPTimeout = 15 * time.Minute
+
+ // GenAIHTTPTimeout bounds each Google GenAI SDK HTTP request (Gemini text,
+ // image, TTS, Veo polling, file download).
+ GenAIHTTPTimeout = 30 * time.Minute
+
+ // ImageDownloadTimeout limits fetches of remote image URLs (e.g. DALL-E
+ // temporary URLs, HTTP image links).
+ ImageDownloadTimeout = 60 * time.Second
+
+ // OperationTimeoutDefault caps a full high-level operation (e.g. one image
+ // Search including scene + generation) when the caller did not set a deadline.
+ OperationTimeoutDefault = 15 * time.Minute
+
+ // ListModelsTimeout bounds model-listing CLI calls.
+ ListModelsTimeout = 3 * time.Minute
+
+ // StoryPageImageTimeout bounds a single comic page image pipeline (search +
+ // download) when no parent deadline exists.
+ StoryPageImageTimeout = 25 * 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).
+ SingleWordProcessTimeout = 10 * time.Minute
+)
+
+// OpenAIHTTPClient returns an http.Client for go-openai DefaultConfig.
+func OpenAIHTTPClient() *http.Client {
+ return &http.Client{Timeout: OpenAIHTTPTimeout}
+}
+
+// GenAIHTTPClient returns an http.Client for google.golang.org/genai.
+func GenAIHTTPClient() *http.Client {
+ return &http.Client{Timeout: GenAIHTTPTimeout}
+}
+
+// ImageDownloadHTTPClient returns a client for generic image URL downloads.
+func ImageDownloadHTTPClient() *http.Client {
+ return &http.Client{Timeout: ImageDownloadTimeout}
+}
+
+// NewOpenAIClient creates a go-openai client whose HTTP transport has a deadline.
+func NewOpenAIClient(token string) *openai.Client {
+ cfg := openai.DefaultConfig(token)
+ cfg.HTTPClient = OpenAIHTTPClient()
+ return openai.NewClientWithConfig(cfg)
+}
+
+// NewGenAIClient wraps genai.NewClient, setting HTTPClient when the config does
+// not supply one so outbound requests never rely on an unbounded default.
+func NewGenAIClient(ctx context.Context, cfg *genai.ClientConfig) (*genai.Client, error) {
+ if cfg == nil {
+ cfg = &genai.ClientConfig{}
+ }
+ merged := *cfg
+ if merged.HTTPClient == nil {
+ merged.HTTPClient = GenAIHTTPClient()
+ }
+ return genai.NewClient(ctx, &merged)
+}
+
+// WithTimeoutUnlessSet returns a child context with timeout d when ctx has no
+// deadline. If ctx already has a deadline, it returns ctx and a no-op cancel.
+func WithTimeoutUnlessSet(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ if _, ok := ctx.Deadline(); ok {
+ return ctx, func() {}
+ }
+ return context.WithTimeout(ctx, d)
+}
diff --git a/internal/httpctx/httpctx_test.go b/internal/httpctx/httpctx_test.go
new file mode 100644
index 0000000..6cd6521
--- /dev/null
+++ b/internal/httpctx/httpctx_test.go
@@ -0,0 +1,55 @@
+package httpctx
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+func TestWithTimeoutUnlessSet_AlreadyHasDeadline(t *testing.T) {
+ t.Parallel()
+
+ parent, cancel := context.WithTimeout(context.Background(), time.Hour)
+ defer cancel()
+
+ ctx, childCancel := WithTimeoutUnlessSet(parent, time.Nanosecond)
+ defer childCancel()
+
+ if ctx != parent {
+ t.Fatal("expected same context when parent already has deadline")
+ }
+
+ deadline, ok := ctx.Deadline()
+ if !ok {
+ t.Fatal("expected deadline on context")
+ }
+ if time.Until(deadline) < 30*time.Minute {
+ t.Fatalf("expected parent ~1h deadline preserved, got %v", time.Until(deadline))
+ }
+}
+
+func TestWithTimeoutUnlessSet_NoDeadline(t *testing.T) {
+ t.Parallel()
+
+ ctx, cancel := WithTimeoutUnlessSet(context.Background(), 50*time.Millisecond)
+ defer cancel()
+
+ deadline, ok := ctx.Deadline()
+ if !ok {
+ t.Fatal("expected deadline")
+ }
+ if time.Until(deadline) > time.Second {
+ t.Fatalf("deadline too far: %v", deadline)
+ }
+}
+
+func TestWithTimeoutUnlessSet_NilUsesBackground(t *testing.T) {
+ t.Parallel()
+
+ ctx, cancel := WithTimeoutUnlessSet(nil, 50*time.Millisecond)
+ defer cancel()
+
+ if err := ctx.Err(); err != nil {
+ t.Fatalf("context should not be done: %v", err)
+ }
+}
diff --git a/internal/image/nanobanana.go b/internal/image/nanobanana.go
index ac55f3b..34a83ba 100644
--- a/internal/image/nanobanana.go
+++ b/internal/image/nanobanana.go
@@ -16,6 +16,8 @@ import (
"time"
"google.golang.org/genai"
+
+ "codeberg.org/snonux/totalrecall/internal/httpctx"
)
const (
@@ -52,7 +54,7 @@ type NanoBananaClient struct {
// (ImageSearcher + AttributionProvider).
var _ ImageClient = (*NanoBananaClient)(nil)
-var newNanoBananaClient = genai.NewClient
+var newNanoBananaClient = httpctx.NewGenAIClient
var nanoBananaGenerateText = func(ctx context.Context, c *NanoBananaClient, model, systemPrompt, userPrompt string, temperature float32, maxOutputTokens int32) (string, error) {
return c.generateText(ctx, model, systemPrompt, userPrompt, temperature, maxOutputTokens)
}
@@ -84,6 +86,9 @@ func NewNanoBananaClient(config *NanoBananaConfig) *NanoBananaClient {
// Search generates an educational image for the Bulgarian word using Nano Banana.
func (c *NanoBananaClient) Search(ctx context.Context, opts *SearchOptions) ([]SearchResult, error) {
+ ctx, cancel := httpctx.WithTimeoutUnlessSet(ctx, httpctx.OperationTimeoutDefault)
+ defer cancel()
+
if err := c.ensureReady(); err != nil {
return nil, err
}
diff --git a/internal/image/openai.go b/internal/image/openai.go
index c277406..9fb8148 100644
--- a/internal/image/openai.go
+++ b/internal/image/openai.go
@@ -11,16 +11,16 @@ import (
"time"
"github.com/sashabaranov/go-openai"
+
+ "codeberg.org/snonux/totalrecall/internal/httpctx"
)
// Compile-time check that OpenAIClient implements the full ImageClient interface
// (ImageSearcher + AttributionProvider).
var _ ImageClient = (*OpenAIClient)(nil)
-// imageHTTPClient is a shared HTTP client with a generous timeout for image
-// downloads. http.DefaultClient has no timeout, which can block goroutines
-// indefinitely on slow or unresponsive servers.
-var imageHTTPClient = &http.Client{Timeout: 60 * time.Second}
+// imageHTTPClient is a shared HTTP client with a timeout for image downloads.
+var imageHTTPClient = httpctx.ImageDownloadHTTPClient()
// OpenAIClient implements ImageSearcher for OpenAI DALL-E image generation
type OpenAIClient struct {
@@ -52,7 +52,7 @@ func NewOpenAIClient(config *OpenAIConfig) *OpenAIClient {
return &OpenAIClient{}
}
- client := openai.NewClient(config.APIKey)
+ client := httpctx.NewOpenAIClient(config.APIKey)
// Set defaults
if config.Model == "" {
@@ -82,6 +82,9 @@ func NewOpenAIClient(config *OpenAIConfig) *OpenAIClient {
// Search generates an image for the Bulgarian word using DALL-E
func (c *OpenAIClient) Search(ctx context.Context, opts *SearchOptions) ([]SearchResult, error) {
+ ctx, cancel := httpctx.WithTimeoutUnlessSet(ctx, httpctx.OperationTimeoutDefault)
+ defer cancel()
+
if c.client == nil {
return nil, &SearchError{
Provider: "openai",
diff --git a/internal/models/lister.go b/internal/models/lister.go
index ca3d3a2..2f05988 100644
--- a/internal/models/lister.go
+++ b/internal/models/lister.go
@@ -10,6 +10,8 @@ import (
"github.com/sashabaranov/go-openai"
"google.golang.org/genai"
+
+ "codeberg.org/snonux/totalrecall/internal/httpctx"
)
type openAIModelLister interface {
@@ -43,11 +45,11 @@ func NewLister(openAIKey, geminiKey string, out io.Writer) *Lister {
}
if lister.openAIKey != "" {
- lister.openAIClient = openai.NewClient(lister.openAIKey)
+ lister.openAIClient = httpctx.NewOpenAIClient(lister.openAIKey)
}
if lister.geminiKey != "" {
- client, err := genai.NewClient(context.Background(), &genai.ClientConfig{
+ client, err := httpctx.NewGenAIClient(context.Background(), &genai.ClientConfig{
APIKey: lister.geminiKey,
})
if err != nil {
@@ -97,7 +99,10 @@ func (l *Lister) printOpenAIModels() error {
return fmt.Errorf("OpenAI client not initialized")
}
- models, err := l.openAIClient.ListModels(context.Background())
+ ctx, cancel := context.WithTimeout(context.Background(), httpctx.ListModelsTimeout)
+ defer cancel()
+
+ models, err := l.openAIClient.ListModels(ctx)
if err != nil {
return fmt.Errorf("failed to list OpenAI models: %w", err)
}
@@ -193,7 +198,9 @@ func (l *Lister) printGeminiModels() error {
return fmt.Errorf("gemini client not initialized")
}
- ctx := context.Background()
+ ctx, cancel := context.WithTimeout(context.Background(), httpctx.ListModelsTimeout)
+ defer cancel()
+
config := &genai.ListModelsConfig{
QueryBase: genai.Ptr(true),
}
diff --git a/internal/phonetic/fetcher.go b/internal/phonetic/fetcher.go
index aa821b5..3e02052 100644
--- a/internal/phonetic/fetcher.go
+++ b/internal/phonetic/fetcher.go
@@ -14,6 +14,7 @@ import (
"google.golang.org/genai"
appconfig "codeberg.org/snonux/totalrecall/internal/config"
+ "codeberg.org/snonux/totalrecall/internal/httpctx"
)
const (
@@ -58,7 +59,7 @@ type Fetcher struct {
geminiInitErr error
}
-var newGeminiClient = genai.NewClient
+var newGeminiClient = httpctx.NewGenAIClient
var fetchOpenAIPhonetic = func(ctx context.Context, client *openai.Client, word string) (string, error) {
req := openai.ChatCompletionRequest{
@@ -122,7 +123,7 @@ func NewFetcher(config *Config) *Fetcher {
switch fetcher.provider {
case ProviderOpenAI:
if fetcher.openAIKey != "" {
- fetcher.openAIClient = openai.NewClient(fetcher.openAIKey)
+ fetcher.openAIClient = httpctx.NewOpenAIClient(fetcher.openAIKey)
}
case ProviderGemini:
if fetcher.googleAPIKey != "" {
diff --git a/internal/processor/processor.go b/internal/processor/processor.go
index 67dc897..433ef28 100644
--- a/internal/processor/processor.go
+++ b/internal/processor/processor.go
@@ -15,6 +15,7 @@ import (
"codeberg.org/snonux/totalrecall/internal/batch"
"codeberg.org/snonux/totalrecall/internal/cli"
"codeberg.org/snonux/totalrecall/internal/gui"
+ "codeberg.org/snonux/totalrecall/internal/httpctx"
"codeberg.org/snonux/totalrecall/internal/image"
"codeberg.org/snonux/totalrecall/internal/phonetic"
"codeberg.org/snonux/totalrecall/internal/store"
@@ -239,7 +240,9 @@ func (p *Processor) ProcessSingleWord(word string) error {
// ProcessWordWithTranslation processes a word with an optional provided English
// translation, using the default en-bg card type.
func (p *Processor) ProcessWordWithTranslation(word, providedTranslation string) error {
- return p.ProcessWordWithTranslationAndType(context.Background(), word, providedTranslation, internal.CardTypeEnBg)
+ ctx, cancel := context.WithTimeout(context.Background(), httpctx.SingleWordProcessTimeout)
+ defer cancel()
+ return p.ProcessWordWithTranslationAndType(ctx, word, providedTranslation, internal.CardTypeEnBg)
}
// ProcessWordWithTranslationAndType processes a word with optional provided
diff --git a/internal/story/artist.go b/internal/story/artist.go
index 4ae0a6f..8e55d33 100644
--- a/internal/story/artist.go
+++ b/internal/story/artist.go
@@ -12,6 +12,7 @@ import (
"google.golang.org/genai"
"codeberg.org/snonux/totalrecall/internal/batch"
+ "codeberg.org/snonux/totalrecall/internal/httpctx"
"codeberg.org/snonux/totalrecall/internal/image"
)
@@ -384,7 +385,7 @@ func (a *Artist) resolveHelperTexts(storyText, prebuiltBible string) (bible, blu
return bible, ""
}
- client, err := genai.NewClient(context.Background(), &genai.ClientConfig{APIKey: a.apiKey})
+ client, err := httpctx.NewGenAIClient(context.Background(), &genai.ClientConfig{APIKey: a.apiKey})
if err != nil {
fmt.Printf(" Warning: Gemini client failed for blurb (%v)\n", err)
return bible, ""
@@ -447,7 +448,10 @@ func (a *Artist) generateSinglePage(prompt, fileNamePattern string, refs [][]byt
MaxSizeBytes: 20 * 1024 * 1024,
})
- _, savedPath, err := downloader.DownloadBestMatchWithOptions(context.Background(), opts)
+ pageCtx, pageCancel := context.WithTimeout(context.Background(), httpctx.StoryPageImageTimeout)
+ defer pageCancel()
+
+ _, savedPath, err := downloader.DownloadBestMatchWithOptions(pageCtx, opts)
if err != nil {
return "", nil, err
}
diff --git a/internal/story/generator.go b/internal/story/generator.go
index 5c4c671..bac5d82 100644
--- a/internal/story/generator.go
+++ b/internal/story/generator.go
@@ -10,6 +10,7 @@ import (
"google.golang.org/genai"
"codeberg.org/snonux/totalrecall/internal/batch"
+ "codeberg.org/snonux/totalrecall/internal/httpctx"
)
const (
@@ -150,7 +151,7 @@ func NewGenerator(config *Config) *Generator {
g.textModel = config.TextModel
}
- client, err := genai.NewClient(context.Background(), &genai.ClientConfig{
+ client, err := httpctx.NewGenAIClient(context.Background(), &genai.ClientConfig{
APIKey: config.APIKey,
})
if err != nil {
diff --git a/internal/story/narrator.go b/internal/story/narrator.go
index be3c21c..b8c7341 100644
--- a/internal/story/narrator.go
+++ b/internal/story/narrator.go
@@ -13,6 +13,7 @@ import (
"google.golang.org/genai"
"codeberg.org/snonux/totalrecall/internal/audio"
+ "codeberg.org/snonux/totalrecall/internal/httpctx"
)
const (
@@ -235,7 +236,7 @@ func (n *Narrator) buildIntro(storyText string) string {
if n.apiKey == "" {
return ""
}
- client, err := genai.NewClient(context.Background(), &genai.ClientConfig{APIKey: n.apiKey})
+ client, err := httpctx.NewGenAIClient(context.Background(), &genai.ClientConfig{APIKey: n.apiKey})
if err != nil {
fmt.Printf(" Warning: intro text generation failed: %v\n", err)
return ""
@@ -310,7 +311,7 @@ func (n *Narrator) buildConclusion(storyText string) string {
return ""
}
- client, err := genai.NewClient(context.Background(), &genai.ClientConfig{APIKey: n.apiKey})
+ client, err := httpctx.NewGenAIClient(context.Background(), &genai.ClientConfig{APIKey: n.apiKey})
if err != nil {
fmt.Printf(" Warning: conclusion text generation failed: %v\n", err)
return ""
diff --git a/internal/translation/translator.go b/internal/translation/translator.go
index 179e137..84ca326 100644
--- a/internal/translation/translator.go
+++ b/internal/translation/translator.go
@@ -12,6 +12,7 @@ import (
"google.golang.org/genai"
appconfig "codeberg.org/snonux/totalrecall/internal/config"
+ "codeberg.org/snonux/totalrecall/internal/httpctx"
)
const (
@@ -59,7 +60,7 @@ type Translator struct {
geminiModel string
}
-var newGeminiClient = genai.NewClient
+var newGeminiClient = httpctx.NewGenAIClient
// NewTranslator creates a new translator instance from the provided config.
func NewTranslator(config *Config) *Translator {
@@ -77,7 +78,7 @@ func NewTranslator(config *Config) *Translator {
}
if normalized.OpenAIKey != "" {
- translator.openAIClient = openai.NewClient(normalized.OpenAIKey)
+ translator.openAIClient = httpctx.NewOpenAIClient(normalized.OpenAIKey)
}
if normalized.GoogleAPIKey != "" {
diff --git a/internal/video/veo.go b/internal/video/veo.go
index 018c06a..05fd0f3 100644
--- a/internal/video/veo.go
+++ b/internal/video/veo.go
@@ -13,6 +13,8 @@ import (
"time"
"google.golang.org/genai"
+
+ "codeberg.org/snonux/totalrecall/internal/httpctx"
)
const (
@@ -48,7 +50,7 @@ type VeoGenerator struct {
// newGenaiClient is the constructor used in production and can be replaced in
// unit tests to inject a mock transport.
-var newGenaiClient = genai.NewClient
+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
@@ -82,6 +84,9 @@ func NewVeoGenerator(apiKey string) (*VeoGenerator, error) {
// 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
@@ -109,6 +114,9 @@ func (g *VeoGenerator) GenerateVideoFromGallery(ctx context.Context, galleryPath
// 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)