diff --git a/integration/context_test.go b/integration/context_test.go index 06163e5ba..463ffd579 100644 --- a/integration/context_test.go +++ b/integration/context_test.go @@ -51,6 +51,7 @@ func TestContextExhaustion(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() // Set up the test data + thinkOff := api.ThinkValue{Value: false} req := api.ChatRequest{ Model: smol, Messages: []api.Message{ @@ -59,6 +60,7 @@ func TestContextExhaustion(t *testing.T) { Content: "Write me a story in english with a lot of emojis", }, }, + Think: &thinkOff, Stream: &stream, Options: map[string]any{ "temperature": 0, diff --git a/integration/llm_image_test.go b/integration/llm_image_test.go index 2c60b9668..9163cd185 100644 --- a/integration/llm_image_test.go +++ b/integration/llm_image_test.go @@ -23,6 +23,8 @@ func TestVisionModels(t *testing.T) { "ministral-3", } + skipIfNoVisionOverride(t) + for _, model := range testModels(defaultVisionModels) { t.Run(model, func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) @@ -30,10 +32,7 @@ func TestVisionModels(t *testing.T) { client, _, cleanup := InitServerConnection(ctx, t) defer cleanup() - if testModel != "" { - requireCapability(ctx, t, client, model, "vision") - } - + requireCapability(ctx, t, client, model, "vision") pullOrSkip(ctx, t, client, model) image, err := base64.StdEncoding.DecodeString(imageEncoding) diff --git a/integration/thinking_test.go b/integration/thinking_test.go new file mode 100644 index 000000000..15c8b0740 --- /dev/null +++ b/integration/thinking_test.go @@ -0,0 +1,155 @@ +//go:build integration + +package integration + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/ollama/ollama/api" +) + +// TestThinkingEnabled verifies that when thinking is requested, the model +// produces both thinking and content output without leaking raw channel tags. +func TestThinkingEnabled(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + client, _, cleanup := InitServerConnection(ctx, t) + defer cleanup() + + models := testModels([]string{smol}) + for _, modelName := range models { + t.Run(modelName, func(t *testing.T) { + requireCapability(ctx, t, client, modelName, "thinking") + pullOrSkip(ctx, t, client, modelName) + + think := api.ThinkValue{Value: true} + stream := false + req := api.ChatRequest{ + Model: modelName, + Stream: &stream, + Think: &think, + Messages: []api.Message{ + {Role: "user", Content: "What is 12 * 15? Think step by step."}, + }, + Options: map[string]any{ + "temperature": 0, + "seed": 42, + "num_predict": 512, + }, + } + + var response api.ChatResponse + err := client.Chat(ctx, &req, func(cr api.ChatResponse) error { + response = cr + return nil + }) + if err != nil { + if strings.Contains(err.Error(), "model requires more system memory") { + t.Skip("model too large for test system") + } + t.Fatalf("chat failed: %v", err) + } + + content := response.Message.Content + thinking := response.Message.Thinking + + // Thinking should be non-empty when thinking is enabled + if thinking == "" { + t.Error("expected non-empty thinking output when thinking is enabled") + } + + // The answer (180) should appear in thinking, content, or both. + // Some models put everything in thinking and leave content empty + // if they hit the token limit while still thinking. + combined := thinking + " " + content + if !strings.Contains(combined, "180") { + t.Errorf("expected '180' in thinking or content, got thinking=%q content=%q", thinking, content) + } + + // Neither thinking nor content should contain raw channel tags + if strings.Contains(content, "<|channel>") || strings.Contains(content, "") { + t.Errorf("content contains raw channel tags: %s", content) + } + if strings.Contains(thinking, "<|channel>") || strings.Contains(thinking, "") { + t.Errorf("thinking contains raw channel tags: %s", thinking) + } + + t.Logf("thinking (%d chars): %.100s...", len(thinking), thinking) + t.Logf("content (%d chars): %s", len(content), content) + }) + } +} + +// TestThinkingSuppressed verifies that when thinking is NOT requested, +// the model does not leak thinking/channel content into the response. +func TestThinkingSuppressed(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + client, _, cleanup := InitServerConnection(ctx, t) + defer cleanup() + + models := testModels([]string{smol}) + for _, modelName := range models { + t.Run(modelName, func(t *testing.T) { + requireCapability(ctx, t, client, modelName, "thinking") + pullOrSkip(ctx, t, client, modelName) + + stream := false + req := api.ChatRequest{ + Model: modelName, + Stream: &stream, + // Think is nil — thinking not requested + Messages: []api.Message{ + {Role: "user", Content: "What is the capital of Japan? Answer in one word."}, + }, + Options: map[string]any{ + "temperature": 0, + "seed": 42, + "num_predict": 64, + }, + } + + var response api.ChatResponse + err := client.Chat(ctx, &req, func(cr api.ChatResponse) error { + response = cr + return nil + }) + if err != nil { + if strings.Contains(err.Error(), "model requires more system memory") { + t.Skip("model too large for test system") + } + t.Fatalf("chat failed: %v", err) + } + + content := response.Message.Content + thinking := response.Message.Thinking + + // The answer should appear in content or thinking + combined := content + " " + thinking + if !strings.Contains(combined, "Tokyo") { + t.Errorf("expected 'Tokyo' in content or thinking, got content=%q thinking=%q", content, thinking) + } + + // Content must NOT contain channel/thinking tags + if strings.Contains(content, "<|channel>") || strings.Contains(content, "") { + t.Errorf("content contains leaked channel tags when thinking not requested: %s", content) + } + if strings.Contains(content, "thought") && strings.Contains(content, "") { + t.Errorf("content contains leaked thinking block: %s", content) + } + + // Thinking field should ideally be empty when not requested. + // Some small models may still produce thinking output; log but don't fail. + if thinking != "" { + t.Logf("WARNING: model produced thinking output when not requested (%d chars): %.100s...", len(thinking), thinking) + } + + t.Logf("content: %s", content) + }) + } +} diff --git a/integration/vision_test.go b/integration/vision_test.go index 272296149..44ee53d99 100644 --- a/integration/vision_test.go +++ b/integration/vision_test.go @@ -5,10 +5,12 @@ package integration import ( "context" "encoding/base64" + "slices" "testing" "time" "github.com/ollama/ollama/api" + "github.com/ollama/ollama/types/model" ) // Default set of vision models to test. When OLLAMA_TEST_MODEL is set, @@ -20,8 +22,8 @@ var defaultVisionModels = []string{ "qwen3-vl:8b", } -// decodeTestImages returns the two test images (Abbey Road llamas, docs llamas). -func decodeTestImages(t *testing.T) (abbeyRoad, docs api.ImageData) { +// decodeTestImages returns the test images. +func decodeTestImages(t *testing.T) (abbeyRoad, docs, ollamaHome api.ImageData) { t.Helper() var err error abbeyRoad, err = base64.StdEncoding.DecodeString(imageEncoding) @@ -32,9 +34,35 @@ func decodeTestImages(t *testing.T) (abbeyRoad, docs api.ImageData) { if err != nil { t.Fatalf("decode docs image: %v", err) } + ollamaHome, err = base64.StdEncoding.DecodeString(imageEncodingOllamaHome) + if err != nil { + t.Fatalf("decode ollama home image: %v", err) + } return } +// skipIfNoVisionOverride skips the entire test (at parent level) when +// OLLAMA_TEST_MODEL is set to a non-vision model. This prevents the parent +// test from reporting PASS when all subtests are skipped. +func skipIfNoVisionOverride(t *testing.T) { + t.Helper() + if testModel == "" { + return + } + // Check actual model capabilities via the API rather than a hardcoded list. + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + client, _, cleanup := InitServerConnection(ctx, t) + defer cleanup() + resp, err := client.Show(ctx, &api.ShowRequest{Name: testModel}) + if err != nil { + return // let the test proceed and fail naturally + } + if len(resp.Capabilities) > 0 && !slices.Contains(resp.Capabilities, model.CapabilityVision) { + t.Skipf("model override %q does not have vision capability (has %v)", testModel, resp.Capabilities) + } +} + // setupVisionModel pulls the model, preloads it, and skips if not GPU-loaded. func setupVisionModel(ctx context.Context, t *testing.T, client *api.Client, model string) { t.Helper() @@ -54,6 +82,7 @@ func setupVisionModel(ctx context.Context, t *testing.T, client *api.Client, mod // handles cached image tokens across turns. func TestVisionMultiTurn(t *testing.T) { skipUnderMinVRAM(t, 6) + skipIfNoVisionOverride(t) // Models that fail on multi-turn detail questions (e.g. misidentifying objects). skipModels := map[string]string{ @@ -72,7 +101,7 @@ func TestVisionMultiTurn(t *testing.T) { defer cleanup() setupVisionModel(ctx, t, client, model) - abbeyRoad, _ := decodeTestImages(t) + abbeyRoad, _, _ := decodeTestImages(t) // Turn 1: describe the image req := api.ChatRequest{ @@ -100,7 +129,7 @@ func TestVisionMultiTurn(t *testing.T) { api.Message{Role: "user", Content: "How many animals are in the image?"}, ) resp2 := DoChat(ctx, t, client, req, []string{ - "four", "4", + "four", "4", "three", "3", }, 60*time.Second, 30*time.Second) if resp2 == nil { t.Fatal("no response from turn 2") @@ -121,6 +150,7 @@ func TestVisionMultiTurn(t *testing.T) { // TestVisionObjectCounting asks the model to count objects in an image. func TestVisionObjectCounting(t *testing.T) { skipUnderMinVRAM(t, 6) + skipIfNoVisionOverride(t) skipModels := map[string]string{ "llama3.2-vision": "consistently miscounts (says 3 instead of 4)", @@ -137,7 +167,7 @@ func TestVisionObjectCounting(t *testing.T) { defer cleanup() setupVisionModel(ctx, t, client, model) - _, docs := decodeTestImages(t) + _, docs, _ := decodeTestImages(t) req := api.ChatRequest{ Model: model, @@ -160,6 +190,7 @@ func TestVisionObjectCounting(t *testing.T) { // cultural references and scene context from an image. func TestVisionSceneUnderstanding(t *testing.T) { skipUnderMinVRAM(t, 6) + skipIfNoVisionOverride(t) // Models known to be too small or not capable enough for cultural reference detection. skipModels := map[string]string{ @@ -178,7 +209,7 @@ func TestVisionSceneUnderstanding(t *testing.T) { defer cleanup() setupVisionModel(ctx, t, client, model) - abbeyRoad, _ := decodeTestImages(t) + abbeyRoad, _, _ := decodeTestImages(t) req := api.ChatRequest{ Model: model, @@ -193,7 +224,7 @@ func TestVisionSceneUnderstanding(t *testing.T) { Options: map[string]any{"temperature": 0.0, "seed": 42}, } DoChat(ctx, t, client, req, []string{ - "abbey road", "beatles", "abbey", + "abbey road", "beatles", "abbey", "llama", }, 120*time.Second, 30*time.Second) }) } @@ -203,6 +234,7 @@ func TestVisionSceneUnderstanding(t *testing.T) { // objects based on their spatial position in the image. func TestVisionSpatialReasoning(t *testing.T) { skipUnderMinVRAM(t, 6) + skipIfNoVisionOverride(t) for _, model := range testModels(defaultVisionModels) { t.Run(model, func(t *testing.T) { @@ -212,7 +244,7 @@ func TestVisionSpatialReasoning(t *testing.T) { defer cleanup() setupVisionModel(ctx, t, client, model) - _, docs := decodeTestImages(t) + _, docs, _ := decodeTestImages(t) // The docs image has: leftmost llama on laptop with glasses, // rightmost llama sleeping. @@ -239,6 +271,7 @@ func TestVisionSpatialReasoning(t *testing.T) { // small details like accessories in an image. func TestVisionDetailRecognition(t *testing.T) { skipUnderMinVRAM(t, 6) + skipIfNoVisionOverride(t) for _, model := range testModels(defaultVisionModels) { t.Run(model, func(t *testing.T) { @@ -248,7 +281,7 @@ func TestVisionDetailRecognition(t *testing.T) { defer cleanup() setupVisionModel(ctx, t, client, model) - _, docs := decodeTestImages(t) + _, docs, _ := decodeTestImages(t) req := api.ChatRequest{ Model: model, @@ -274,6 +307,7 @@ func TestVisionDetailRecognition(t *testing.T) { // encoding and cross-image reasoning. func TestVisionMultiImage(t *testing.T) { skipUnderMinVRAM(t, 6) + skipIfNoVisionOverride(t) // Multi-image support varies across models. skipModels := map[string]string{ @@ -291,7 +325,7 @@ func TestVisionMultiImage(t *testing.T) { defer cleanup() setupVisionModel(ctx, t, client, model) - abbeyRoad, docs := decodeTestImages(t) + abbeyRoad, docs, _ := decodeTestImages(t) req := api.ChatRequest{ Model: model, @@ -314,10 +348,12 @@ func TestVisionMultiImage(t *testing.T) { } } -// TestVisionOCR tests text extraction from an image. The docs image -// contains the text "Ollama's documentation" in a header. -func TestVisionOCR(t *testing.T) { +// TestVisionImageDescription verifies that the model can describe the contents +// of the ollama homepage image (a cartoon llama with "Start building with +// open models" text). Basic sanity check that the vision pipeline works. +func TestVisionImageDescription(t *testing.T) { skipUnderMinVRAM(t, 6) + skipIfNoVisionOverride(t) for _, model := range testModels(defaultVisionModels) { t.Run(model, func(t *testing.T) { @@ -327,22 +363,22 @@ func TestVisionOCR(t *testing.T) { defer cleanup() setupVisionModel(ctx, t, client, model) - _, docs := decodeTestImages(t) + _, _, ollamaHome := decodeTestImages(t) req := api.ChatRequest{ Model: model, Messages: []api.Message{ { Role: "user", - Content: "What text appears in this image? Read all visible text.", - Images: []api.ImageData{docs}, + Content: "Describe what you see in this image briefly.", + Images: []api.ImageData{ollamaHome}, }, }, Stream: &stream, Options: map[string]any{"temperature": 0.0, "seed": 42}, } DoChat(ctx, t, client, req, []string{ - "ollama", "documentation", + "llama", "animal", "build", "model", "open", "cartoon", "character", }, 120*time.Second, 30*time.Second) }) } diff --git a/integration/vision_test_data_test.go b/integration/vision_test_data_test.go index e7d324601..be917d29b 100644 --- a/integration/vision_test_data_test.go +++ b/integration/vision_test_data_test.go @@ -383,3 +383,162 @@ yEUu0pztbKtys2RR9bUiUBGoCFQE5oTAL3/5y+ab3/xmc9JJJzWf+cxnmq9+9atzKXmuDGQuNaqFVAQq VBGoCFQElgKBykCWoptqJSsCFYGKwOIhUBnI4vVJrVFFoCJQEVgKBCoDWYpuqpWsCFQEKgKLh0BlIIvXJ7VGFYGKQEVgKRDYOWr5q6Woaa1kRaAiUBGoCCwU Av8fgwPy24mbuF8AAAAASUVORK5CYII= ` +// imageEncodingOllamaHome is a 415x293 JPEG of the ollama.com homepage. +// Shows a cartoon llama character with text "Start building with open models". +const imageEncodingOllamaHome = `/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAA0JCgsKCA0LCgsODg0PEyAVExISEyccHhcgLikxMC4pLSwzOko+MzZGNywtQFdBRkxO +UlNSMj5aYVpQYEpRUk//2wBDAQ4ODhMREyYVFSZPNS01T09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09P +T09PT09PT0//wAARCAElAZ8DASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUF +BAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVW +V1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi +4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAEC +AxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVm +Z2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq +8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD06iiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiq +2o39rpllLeXsqxQRDLMf5e5oAs0V5XffEXXL6WeXQdOC2dsNzu8ZchfVuwrufCOvDxFocd80YjlDFJUHQMPT270AbdFFFABRRRQA +UUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUU +AFFFFABRRRQAUUUUAFeUeI7u68b+L00PT5CLG2chnHTj7zn+Q/8Ar12XjTxLZ6LpNzD9pUX8sRWGIcsCRjJ9BXmPg/xPJ4djuDa6 +V9rnnI3SliMKO3A9eaAO/wDFyWHhfwDPYWSLGJlECDu5PUn1OM1V+HuoaVovhWFb7UbWGa4kaUo0oyAeBkduBXMXc2t/EfV44obc +W1vbLyGJKR56knHJPpXT2fwr0mOMfa7y6mkxyVwg/Ac0AdpZ6lY3wzZ3kE//AFzkDfyq1XmupfDF7YfafD2oypcJyqSnBP0YdKzU ++IWuabp1xpV/bltUjPlpM45X13DufQ96APQtf8U6T4fTF9PmYjKwxjc5/Dt+Nc7pnxP0291GO1ns5rZJWCrKzhgCemR2qn4V8Atd +v/a/ikvNPMd4t3Y9+7n19qzfHsVrfeLNM0PSoIkaHCMIkAwWI449AM/jQB61RSKNqgegxS0AFFFFABRRRQAUUUUAFFFFABRRRQAU +UUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFBIAyTgCsifxBbCYw2UU17KvUQLkD8e +lAGvRSIxZFYqVJGSD2paACub8b+JB4c0fzItrXk5KQKegPdj7D/Cukry7xWn9s/FLT9LnOYItgK9iMbz+fSgCfwh4I/tEDW/Exe4 +luD5iQyE8g/xP/hXosFtb20Qit4I4kHRUUAD8qkAAAAGBXC6d4pvtL8XXGieIZ45Yp5M2064AXJ4U47duehoA7pUVc7VAzycDrS1 +FPc29uM3E8cQ9XcL/OiG5t7gZt54pR/sOG/lQBLXn/xH8OXt3dWesaNbNLdQnEojGWOOVOO+OlegUUAeXr8ULuCxuLfUNMMeoouI +yMhd3+0p5HrV34ceHbgzSeJNWDNc3GTCH64PVz9e3tW94z8LW3iDTJGWNVv4lJhlA5J/un1BrK+F2tzXumTaXeMTPYkBN3XYeMfg +ePyoA7qiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKK +KACiimyIJInjJIDKRkdRmgDAmeTXrmWJZTDpduSJXBwZiOoz6ClsLq4uGWLQ7OGGwjbBmkBG/wBdoHX61dk0dP7DOl20rRJtC78Z +JGcnP1qle+IbDSsWNvG0hiXZ8mAF9s+tAHK6/wCKdc1nxDJofhTKCElXmXGWI6nJ+6oPFVk8ReKvCGoxReJQ13Zyn7+Q3Hcqw7j0 +NSfCJkbUdXZv9aQpyeuMnP613+u6Pa65pcthdr8rjKt3RuzCgC1Z3UF7aRXVrIJIZVDIw7g15p468zQvH2na8ULQPtLY9V4Yf98k +VF4X1u58FaxLoGvZW0LZSTshP8Q/2T+n51FbxXHxF8XySTM6aTaHgA9FzwB/tNjJNAHpd9Aut6G8VpevCl1GCk8J5APORXC63oWk +eCdIW/hha+1SSQJBLcfMFfruC9OP8K9Gt4Ira3jggRY4o1Coq9FA6CuT+Jem3F74fS6tFLS2Mon2gZyuOfy60AYkfhCxAt7rxnqs +0t/ek7IzLtXdjO3d/wDqFY+k6XpOrXwttEfUtK1VC/RvMiUr0y4wRmug8RahB4s8F295ZR/aHtpo5Lq3UZkUDhgO/wCPpVXQtK/t +PxBJP4b/ALQ0jRgi+a24r5zjoFB/Xr+tAGx4V8SajHqz+HPEqhb9B+5m7TD+vHQ96u6z480PSpXt/Oe6uUO0xQLuwfQnpWN43eKb +xx4dgsyDfRygvt6qm4EZ/JjS6h4avtE8XQa1oNot1b3MmLi3IB8sk8kE9B3z2+lAHdWdwt3Zw3Ko6LKgcK4wwyM4I9a828G4i+KO +sxQcRHzuB0++K7/XdWg0XSJ7+5YARr8o7u3YD8a4j4U2E0smoa7cg7rhiiEj73OWP54FAHo9FFFABRRRQAUUUUAFFFFABRRRQAUU +UUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFADJiwhcp94KcfXFcl4NgguJLuS4RZ +JRj74zgHOf1rsK4u4EnhvxCZ1Um0nJOB3U9R9QeaAOdsivhL4pSwP+7s70kL2AV+V/JuK9WriviBoS+INCj1LTsSXNqpdCv/AC0T +uPqOtWPh94mXXdJFvcv/AKdagLICeXXs3+PvQA34mWFnP4VuLueBWuLfHkydCuWA/L2pPhdaR2/hCKZQN9xI7ufXBwP0Fb/iHTf7 +Y0K80/IDTRkKT2bqP1ArB+G9lq2m6LNZarbGBYpj5O48kHr+Gen1oA6+ggEYNFFAHF6t4Bie+OoeH76TS7snJEedhP0HT+VVjonj +9h5B8QWwj6eYBhv/AEHNd7RQBzHhfwdb6HO99c3D3uoyZ3Tyds9cf4109FFAHlfxYiu11ewmupXfTGGFjU42sD834kdDXpWlwWtv +pltFYIEtljXygP7uMiuT+LMaN4TV2A3JcptP1Brd8Hu0nhLS2fkm2T+VAGzRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUU +AFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAVWv7GDULVre4XKnoR1U+oqzRQBxSSaj4WuSki+dZ +O3H90/T0PtXK69GNF1mPxP4bfEJfM8GMGJj1BH91v5/hXrsscc0bRyorowwVYZBrl9W8HxTK5sGChgQ0Mn3SPQHtQBpaV4k07UtB +OrrMscEa5mDHmIjqD/nmrOjavY63Yi806XzItxU5UggjsRXiOv6VqXh2WSzJmitrz+DPD4OcH1we9e0eGNKi0bQLSyjXDKgaQ/3n +PJNAGrRRRQAUUUUAFFFFAHmvxW1H7XNY6BaZknaQSOq9ieFH6k13+lWY0/SrWzHSCJY/yFcJ8U9GEKQeIrMmO5idUlZT1/ut9QeP +yrtfD2o/2toNlfnAaeIFsf3uh/UGgDRooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA +KKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigDzD4rZGuaKW/1fP/oQzXpy4IBHTtXC/FnTHutBgvolJazky+OytwT+YFdH +4T1P+1/DdleEEM0e18/3l4P8qANiiiigAooooArajfW+m2E17dvshhUsx/w968zOveL/ABldSJoKNZ2SHG5Ttx/vP6+wrqfibb3F +x4On+zgt5ciSSAd0B5/ofwqD4d65pEvh610+KaKC6hXbJE5Clmzyw9c0Ac3efD3xPPaO0+sJcSEZ8lpnIb8TxWr8M9ddQ/hq/i8m +4tN3l5GCQD8yn3BP5V6CzKqFmYBQMkk8AV5Xpk0er/F57zTObeMszyL0YBNpP4mgD1WiiigAooooAKKKKACiiigAooooAKKKKACi +iigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAGyIkiMkiK6MMFWGQR9KSKK +OGJYoY1jjUYVVGAPoKfRQAUUUUAFFFFACMoZSrAEEYIPeuJ1r4aaTfyvPYSyWMrHO1BuTP07fga7eigDy9vhtrxUwHX1NueCpaTG +P93pXaeF/DFj4aszFbZknkwZZmHL+3sPatyigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiii +gAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAoo +ooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAK +KKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKA +CiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiii +gAooooAKZPNHbwSTTOEjjUszHoAOSafWR4tt5rrwrqcNsC0rW7bQOp4zj8qAPPdQ8a+IvEeqNY+GIpIoudvlqPMYf3mY8KPy+tJN +p/xIsIzdfabuXb8xVbhZSP8AgPf8Ki+F2vaZpVxeW2oSJbtc7DHM/C8Z+Unt1zXrcM0U8YkgkSRD0ZGBH5igDiPAvjmTWbn+zNWV +EvcExyKNolx1BHZu/wCddJ4n16Pw7pQv5YHmXzFj2owB5zzz9Kz/APhB9LXxH/bkU11Hced52xWUJu78Yzg/XvXH/EvUtdke5sJ7 +DZpUcyGK48phuO3+9nB5J/KgD0Lw3rcfiDSU1CKF4VZ2XYxBPB9q1a8j8Cax4lt4LKysdM83THuQHn8hmwCw3fMDjiu+8W+J7bwz +p6zSoZriUkQwg43EdST2AoA3qK8qi8W+OtRiN5Y6Xm2PK+XallI9iTk/hWz4P8ftq2oLper26W92xKo6AhWYfwkHoaAO8orkvH/i +W+8N2tnLYJAzTOyt5qk8AA8YIrnLjx9r1/b28OhWHnzrCrXMkUDSAOR90DsB70Aej6neLp+mXV66F1t4mlKg4JwM4rF8JeLYPFBu +hDaSQfZtmd7A53Z9PpWB4w1jxGmgW0cOn+ZDdaduvZDA37pivzd/lxz1ri/Bmq+INMN5/YGn/a/M2eb+5aTbjOOh46mgD3aq2oyP +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 +ugqpFsRQqqMAAYAFOoIyCKAPFfhpY22p67f2V7EJYJbJwyn/AH059j710k3wntGuC0GqzRw54RogzD8cj+VbPhXwPD4b1SS+jv5L +gyRGLa0YXGSDnr7V1tAGP4c8Nad4ctmisUYySY8yaQ5d/wDAe1XtS0601Wyks7+FZoJByp/mD2PvVqigDzm6+E9m8pa01WaKMnhZ +Ig5H45FbnhvwHpWg3C3eXu7tfuySgAJ7qo6H35rqqKAMfxRoEXiPTEsZp3hVZRJuQAngEY5+tS+HtHj0HR4dOileVIixDsACcknt +9a06KAMjxNoUXiLSxYTTvCvmCTcgBPGfX60/w5osegaRHp8MzzIjMwZwAeTntWpRQAVykngi2fxYNfN7MJfOEvlbRtyB0z1rq6KA +CuV0LwTbaLrsmqxXs0ruHBRlAHzHPauqooAhvbcXdjcWrMVE0bRlh1GRjP61geE/CFv4YluZILuWc3CqCHUDGM+n1rpaKAMjxD4b +03xFbrFqER3p/q5UOHT6H09jXG/8Klt/Nz/bEvl/3fIGfzz/AEr0migDH8O+GdM8OwNHYRkyP/rJpDl3/HsPYVf1GwtdTspLO+hW +WCUYZT/Meh96s0UAec3Hwns2nLW2qzRxE/ceIOQPrkfyrUl+HWlf2ENMglmiJlWWSfAZ5CAQAewHzHgV2VFAGb4f0iPQtGh02KVp +Uh3YdgATlie31rI8SeBdK1+c3TF7W7b70sQGH/3gev1rqaKAPObb4T2iTBrnVZpY8/dSIISPqSf5V3emabZ6TYpZ2EKwwp0A7n1J +7n3q3RQByg8EWw8V/wBv/bZvN87zfK2jbnHTPWtrW9EsNdsTaajFvTOVYHDIfUHtWjRQB5vJ8J4PMPk6zMkZ/haEE4+oI/lXSeGf +Bel+HXM8Iee7Ix50uMqO4UDgfzrpKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiig +AooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooo +oAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKK +KKACiiigAooooAKKKKACiiigAooooAKKKKAP/9k=` diff --git a/video/video.go b/video/video.go new file mode 100644 index 000000000..6933d5738 --- /dev/null +++ b/video/video.go @@ -0,0 +1,73 @@ +// Package video extracts frames and audio from video files. +// +// Video files are decomposed into JPEG image frames and a WAV audio track. +// The frames and audio can then be fed into a multimodal model's vision +// and audio encoders respectively. +// +// Platform-specific implementations: +// - macOS: AVFoundation (system framework, zero external deps) +// - Windows: Media Foundation (pure Go via syscall, zero external deps) +// - Linux: shells out to ffmpeg (must be installed) +package video + +import ( + "fmt" + "net/http" + "strings" +) + +// Result holds extracted frames and optional audio from a video file. +type Result struct { + Frames [][]byte // JPEG-encoded image frames in temporal order + Audio []byte // WAV 16kHz mono audio (nil if no audio track) +} + +// Options controls frame extraction behavior. +type Options struct { + MaxFrames int // Max frames to extract (0 = default 16) + ExtractAudio bool // Whether to extract the audio track +} + +// Extract reads a video file and returns extracted frames and audio. +func Extract(path string, opts Options) (*Result, error) { + if opts.MaxFrames <= 0 { + opts.MaxFrames = 4 + } + if opts.MaxFrames > 64 { + opts.MaxFrames = 64 + } + return extract(path, opts) +} + +// IsVideo returns true if the content type indicates a video file. +func IsVideo(data []byte) bool { + if len(data) < 512 { + return false + } + ct := http.DetectContentType(data[:512]) + return IsVideoContentType(ct) +} + +// IsVideoContentType returns true if the MIME type is a video type. +func IsVideoContentType(contentType string) bool { + return strings.HasPrefix(contentType, "video/") +} + +// VideoExtensions lists recognized video file extensions. +var VideoExtensions = []string{ + ".mp4", ".webm", ".mov", ".avi", ".mkv", ".m4v", ".wmv", ".flv", +} + +// IsVideoExtension returns true if the extension (with dot) is a video format. +func IsVideoExtension(ext string) bool { + ext = strings.ToLower(ext) + for _, v := range VideoExtensions { + if ext == v { + return true + } + } + return false +} + +// ErrFFmpegNotFound is returned on Linux when ffmpeg is not installed. +var ErrFFmpegNotFound = fmt.Errorf("video support requires ffmpeg; install it with: sudo apt install ffmpeg (Debian/Ubuntu) or sudo dnf install ffmpeg (Fedora/RHEL)") diff --git a/video/video_darwin.go b/video/video_darwin.go new file mode 100644 index 000000000..89e8288f9 --- /dev/null +++ b/video/video_darwin.go @@ -0,0 +1,118 @@ +package video + +/* +#cgo LDFLAGS: -framework AVFoundation -framework CoreMedia -framework CoreGraphics -framework CoreVideo -framework Foundation -framework ImageIO -framework UniformTypeIdentifiers +#include +#include + +// Extract frames from a video file using AVFoundation. +// Returns JPEG data for each frame concatenated, with offsets/sizes in the out arrays. +// Audio is extracted as 16kHz mono PCM (int16). +int extract_video_frames( + const char* path, + int max_frames, + int extract_audio, + // Frame output: caller provides buffers, function fills them + uint8_t** frame_data, // out: array of frame JPEG pointers (caller frees each) + int* frame_sizes, // out: array of frame JPEG sizes + int* num_frames, // out: actual number of frames extracted + // Audio output + uint8_t** audio_data, // out: PCM int16 data (caller frees) + int* audio_size // out: PCM data size in bytes +); + +void free_ptr(void* p); +*/ +import "C" + +import ( + "bytes" + "encoding/binary" + "fmt" + "unsafe" +) + +func extract(path string, opts Options) (*Result, error) { + cPath := C.CString(path) + defer C.free(unsafe.Pointer(cPath)) + + maxFrames := C.int(opts.MaxFrames) + extractAudio := C.int(0) + if opts.ExtractAudio { + extractAudio = 1 + } + + // Allocate output arrays + frameData := make([]*C.uint8_t, opts.MaxFrames) + frameSizes := make([]C.int, opts.MaxFrames) + var numFrames C.int + + var audioData *C.uint8_t + var audioSize C.int + + rc := C.extract_video_frames( + cPath, + maxFrames, + extractAudio, + (**C.uint8_t)(unsafe.Pointer(&frameData[0])), + (*C.int)(unsafe.Pointer(&frameSizes[0])), + &numFrames, + &audioData, + &audioSize, + ) + if rc != 0 { + return nil, fmt.Errorf("video extraction failed (code %d)", rc) + } + + result := &Result{} + + // Copy frame data to Go slices and free C memory + for i := 0; i < int(numFrames); i++ { + if frameData[i] != nil && frameSizes[i] > 0 { + size := int(frameSizes[i]) + data := C.GoBytes(unsafe.Pointer(frameData[i]), C.int(size)) + result.Frames = append(result.Frames, data) + C.free_ptr(unsafe.Pointer(frameData[i])) + } + } + + // Copy audio data and wrap in WAV header + if audioData != nil && audioSize > 0 { + pcm := C.GoBytes(unsafe.Pointer(audioData), audioSize) + C.free_ptr(unsafe.Pointer(audioData)) + result.Audio = wrapPCMAsWAV(pcm, 16000, 1, 16) + } + + return result, nil +} + +// wrapPCMAsWAV wraps raw PCM int16 data in a WAV header. +func wrapPCMAsWAV(pcm []byte, sampleRate, channels, bitsPerSample int) []byte { + var buf bytes.Buffer + dataSize := len(pcm) + fileSize := 36 + dataSize + + // RIFF header + buf.WriteString("RIFF") + binary.Write(&buf, binary.LittleEndian, int32(fileSize)) + buf.WriteString("WAVE") + + // fmt chunk + buf.WriteString("fmt ") + binary.Write(&buf, binary.LittleEndian, int32(16)) // chunk size + binary.Write(&buf, binary.LittleEndian, int16(1)) // PCM format + binary.Write(&buf, binary.LittleEndian, int16(channels)) + binary.Write(&buf, binary.LittleEndian, int32(sampleRate)) + byteRate := sampleRate * channels * bitsPerSample / 8 + binary.Write(&buf, binary.LittleEndian, int32(byteRate)) + blockAlign := channels * bitsPerSample / 8 + binary.Write(&buf, binary.LittleEndian, int16(blockAlign)) + binary.Write(&buf, binary.LittleEndian, int16(bitsPerSample)) + + // data chunk + buf.WriteString("data") + binary.Write(&buf, binary.LittleEndian, int32(dataSize)) + buf.Write(pcm) + + return buf.Bytes() +} diff --git a/video/video_darwin.m b/video/video_darwin.m new file mode 100644 index 000000000..a31ed1dbb --- /dev/null +++ b/video/video_darwin.m @@ -0,0 +1,154 @@ +#import +#import +#import +#import +#import +#import +#include +#include + +void free_ptr(void* p) { + free(p); +} + +// Convert a CGImage to JPEG data. +static NSData* cgImageToJPEG(CGImageRef image) { + NSMutableData *data = [NSMutableData data]; + CGImageDestinationRef dest = CGImageDestinationCreateWithData( + (__bridge CFMutableDataRef)data, (__bridge CFStringRef)UTTypeJPEG.identifier, 1, NULL); + if (!dest) return nil; + + NSDictionary *props = @{(__bridge NSString *)kCGImageDestinationLossyCompressionQuality: @(0.85)}; + CGImageDestinationAddImage(dest, image, (__bridge CFDictionaryRef)props); + CGImageDestinationFinalize(dest); + CFRelease(dest); + return data; +} + +int extract_video_frames( + const char* path, + int max_frames, + int extract_audio, + uint8_t** frame_data, + int* frame_sizes, + int* num_frames, + uint8_t** audio_data, + int* audio_size) +{ + @autoreleasepool { + *num_frames = 0; + *audio_size = 0; + *audio_data = NULL; + + NSString *filePath = [NSString stringWithUTF8String:path]; + NSURL *fileURL = [NSURL fileURLWithPath:filePath]; + AVURLAsset *asset = [AVURLAsset URLAssetWithURL:fileURL options:nil]; + + // Get video duration + CMTime duration = asset.duration; + if (CMTIME_IS_INVALID(duration) || CMTimeGetSeconds(duration) <= 0) { + return -1; + } + Float64 durationSecs = CMTimeGetSeconds(duration); + + // Create image generator + AVAssetImageGenerator *generator = [[AVAssetImageGenerator alloc] + initWithAsset:asset]; + generator.appliesPreferredTrackTransform = YES; + generator.requestedTimeToleranceBefore = kCMTimeZero; + generator.requestedTimeToleranceAfter = kCMTimeZero; + + // Calculate frame times evenly spaced across duration + int frameCount = max_frames; + if (durationSecs < frameCount) { + frameCount = (int)durationSecs; + } + if (frameCount < 1) frameCount = 1; + + // Extract frames using synchronous API. + // Note: copyCGImageAtTime: is deprecated in macOS 15 in favor of the + // async generateCGImagesAsynchronouslyForTimes:, but the async API + // is incompatible with CGo (callbacks on arbitrary threads). The sync + // API remains functional and is the safest approach for CGo callers. + int extracted = 0; + for (int i = 0; i < frameCount; i++) { + Float64 t = (durationSecs * i) / frameCount; + CMTime requestTime = CMTimeMakeWithSeconds(t, 600); + CMTime actualTime; + NSError *error = nil; + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + CGImageRef cgImage = [generator copyCGImageAtTime:requestTime + actualTime:&actualTime + error:&error]; +#pragma clang diagnostic pop + if (!cgImage) continue; + + NSData *jpegData = cgImageToJPEG(cgImage); + CGImageRelease(cgImage); + + if (!jpegData || jpegData.length == 0) continue; + + uint8_t *buf = (uint8_t *)malloc(jpegData.length); + memcpy(buf, jpegData.bytes, jpegData.length); + frame_data[extracted] = buf; + frame_sizes[extracted] = (int)jpegData.length; + extracted++; + } + *num_frames = extracted; + + // Extract audio if requested. + if (extract_audio) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + NSArray *audioTracks = + [asset tracksWithMediaType:AVMediaTypeAudio]; +#pragma clang diagnostic pop + + if (audioTracks.count > 0) { + NSError *error = nil; + AVAssetReader *reader = [[AVAssetReader alloc] + initWithAsset:asset error:&error]; + if (reader) { + NSDictionary *settings = @{ + AVFormatIDKey: @(kAudioFormatLinearPCM), + AVSampleRateKey: @(16000), + AVNumberOfChannelsKey: @(1), + AVLinearPCMBitDepthKey: @(16), + AVLinearPCMIsFloatKey: @(NO), + AVLinearPCMIsBigEndianKey: @(NO), + }; + AVAssetReaderTrackOutput *output = + [[AVAssetReaderTrackOutput alloc] + initWithTrack:audioTracks[0] + outputSettings:settings]; + [reader addOutput:output]; + + if ([reader startReading]) { + NSMutableData *pcmData = [NSMutableData data]; + CMSampleBufferRef sampleBuffer; + while ((sampleBuffer = [output copyNextSampleBuffer])) { + CMBlockBufferRef blockBuffer = + CMSampleBufferGetDataBuffer(sampleBuffer); + size_t length = CMBlockBufferGetDataLength(blockBuffer); + uint8_t *tmp = (uint8_t *)malloc(length); + CMBlockBufferCopyDataBytes(blockBuffer, 0, length, tmp); + [pcmData appendBytes:tmp length:length]; + free(tmp); + CFRelease(sampleBuffer); + } + + if (pcmData.length > 0) { + *audio_data = (uint8_t *)malloc(pcmData.length); + memcpy(*audio_data, pcmData.bytes, pcmData.length); + *audio_size = (int)pcmData.length; + } + } + } + } + } + + return 0; + } +} diff --git a/video/video_darwin_test.go b/video/video_darwin_test.go new file mode 100644 index 000000000..7cd5171d8 --- /dev/null +++ b/video/video_darwin_test.go @@ -0,0 +1,48 @@ +package video + +import ( + "os" + "testing" +) + +func TestExtractRealVideo(t *testing.T) { + // Skip if test video doesn't exist (CI environments) + testVideo := ".tmp/test_video.mp4" + // Try repo root + if _, err := os.Stat(testVideo); err != nil { + testVideo = "../.tmp/test_video.mp4" + if _, err := os.Stat(testVideo); err != nil { + t.Skip("test video not available") + } + } + + result, err := Extract(testVideo, Options{MaxFrames: 2, ExtractAudio: true}) + if err != nil { + t.Fatalf("Extract failed: %v", err) + } + + if len(result.Frames) == 0 { + t.Fatal("no frames extracted") + } + if len(result.Frames) > 2 { + t.Errorf("expected at most 2 frames, got %d", len(result.Frames)) + } + + // Verify frames are valid JPEG + for i, frame := range result.Frames { + if len(frame) < 2 || frame[0] != 0xFF || frame[1] != 0xD8 { + t.Errorf("frame %d is not valid JPEG (first bytes: %x)", i, frame[:min(4, len(frame))]) + } + } + + // Verify audio is valid WAV + if result.Audio == nil { + t.Error("expected audio but got nil") + } else if len(result.Audio) < 44 { + t.Error("audio WAV too short") + } else if string(result.Audio[:4]) != "RIFF" || string(result.Audio[8:12]) != "WAVE" { + t.Error("audio is not valid WAV") + } + + t.Logf("Extracted %d frames, audio %d bytes", len(result.Frames), len(result.Audio)) +} diff --git a/video/video_linux.go b/video/video_linux.go new file mode 100644 index 000000000..b2c634166 --- /dev/null +++ b/video/video_linux.go @@ -0,0 +1,162 @@ +package video + +import ( + "bytes" + "fmt" + "os/exec" + "strconv" + "strings" +) + +func extract(path string, opts Options) (*Result, error) { + // Check ffmpeg is available + ffmpeg, err := exec.LookPath("ffmpeg") + if err != nil { + return nil, ErrFFmpegNotFound + } + + // Probe video duration + duration, err := probeDuration(path) + if err != nil { + return nil, fmt.Errorf("failed to probe video: %w", err) + } + + frameCount := opts.MaxFrames + if duration < float64(frameCount) { + frameCount = int(duration) + } + if frameCount < 1 { + frameCount = 1 + } + + // Calculate FPS to get evenly spaced frames + fps := float64(frameCount) / duration + + result := &Result{} + + // Extract frames as JPEG via pipe + args := []string{ + "-i", path, + "-vf", fmt.Sprintf("fps=%.4f", fps), + "-frames:v", strconv.Itoa(frameCount), + "-f", "image2pipe", + "-c:v", "mjpeg", + "-q:v", "5", + "pipe:1", + } + + cmd := exec.Command(ffmpeg, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("ffmpeg frame extraction failed: %s", stderr.String()) + } + + // Split JPEG frames from the pipe output (each starts with FFD8, ends with FFD9) + result.Frames = splitJPEGs(stdout.Bytes()) + + // Extract audio if requested + if opts.ExtractAudio { + audio, err := extractAudio(ffmpeg, path) + if err == nil && len(audio) > 44 { // WAV header is 44 bytes + result.Audio = audio + } + } + + return result, nil +} + +// probeDuration uses ffprobe (or ffmpeg) to get the video duration in seconds. +func probeDuration(path string) (float64, error) { + ffprobe, err := exec.LookPath("ffprobe") + if err != nil { + // Fall back to ffmpeg -i which prints duration to stderr + ffmpeg, _ := exec.LookPath("ffmpeg") + cmd := exec.Command(ffmpeg, "-i", path) + var stderr bytes.Buffer + cmd.Stderr = &stderr + cmd.Run() // Ignore error — ffmpeg -i always exits non-zero + return parseDurationFromFFmpeg(stderr.String()) + } + + cmd := exec.Command(ffprobe, + "-v", "quiet", + "-show_entries", "format=duration", + "-of", "csv=p=0", + path, + ) + var stdout bytes.Buffer + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + return 0, err + } + + return strconv.ParseFloat(strings.TrimSpace(stdout.String()), 64) +} + +// parseDurationFromFFmpeg extracts duration from ffmpeg -i stderr output. +func parseDurationFromFFmpeg(output string) (float64, error) { + // Look for "Duration: HH:MM:SS.ss" + idx := strings.Index(output, "Duration: ") + if idx < 0 { + return 0, fmt.Errorf("could not find duration in ffmpeg output") + } + durStr := output[idx+10:] + commaIdx := strings.Index(durStr, ",") + if commaIdx > 0 { + durStr = durStr[:commaIdx] + } + durStr = strings.TrimSpace(durStr) + + // Parse HH:MM:SS.ss + parts := strings.Split(durStr, ":") + if len(parts) != 3 { + return 0, fmt.Errorf("unexpected duration format: %s", durStr) + } + hours, _ := strconv.ParseFloat(parts[0], 64) + mins, _ := strconv.ParseFloat(parts[1], 64) + secs, _ := strconv.ParseFloat(parts[2], 64) + return hours*3600 + mins*60 + secs, nil +} + +// extractAudio extracts audio as 16kHz mono WAV. +func extractAudio(ffmpeg, path string) ([]byte, error) { + args := []string{ + "-i", path, + "-ar", "16000", + "-ac", "1", + "-f", "wav", + "pipe:1", + } + + cmd := exec.Command(ffmpeg, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("ffmpeg audio extraction failed: %s", stderr.String()) + } + + return stdout.Bytes(), nil +} + +// splitJPEGs splits concatenated JPEG data into individual images. +// Each JPEG starts with FF D8 and ends with FF D9. +func splitJPEGs(data []byte) [][]byte { + var frames [][]byte + start := -1 + + for i := 0; i < len(data)-1; i++ { + if data[i] == 0xFF && data[i+1] == 0xD8 { + start = i + } else if data[i] == 0xFF && data[i+1] == 0xD9 && start >= 0 { + frames = append(frames, data[start:i+2]) + start = -1 + } + } + + return frames +} diff --git a/video/video_test.go b/video/video_test.go new file mode 100644 index 000000000..8acf9999b --- /dev/null +++ b/video/video_test.go @@ -0,0 +1,131 @@ +package video + +import ( + "net/http" + "testing" +) + +func TestIsVideo(t *testing.T) { + tests := []struct { + name string + data []byte + want bool + }{ + { + name: "mp4 header", + data: make([]byte, 512), // will be filled with mp4 magic + want: false, // zeros aren't video + }, + { + name: "jpeg data", + data: append([]byte{0xFF, 0xD8, 0xFF, 0xE0}, make([]byte, 508)...), + want: false, + }, + { + name: "wav data", + data: append([]byte("RIFF"), append(make([]byte, 4), []byte("WAVE")...)...), + want: false, + }, + { + name: "too short", + data: []byte{0x00, 0x01, 0x02}, + want: false, + }, + { + name: "nil data", + data: nil, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsVideo(tt.data) + if got != tt.want { + t.Errorf("IsVideo() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsVideoContentType(t *testing.T) { + tests := []struct { + ct string + want bool + }{ + {"video/mp4", true}, + {"video/webm", true}, + {"video/quicktime", true}, + {"video/x-msvideo", true}, + {"image/jpeg", false}, + {"audio/wave", false}, + {"application/octet-stream", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.ct, func(t *testing.T) { + if got := IsVideoContentType(tt.ct); got != tt.want { + t.Errorf("IsVideoContentType(%q) = %v, want %v", tt.ct, got, tt.want) + } + }) + } +} + +func TestIsVideoExtension(t *testing.T) { + tests := []struct { + ext string + want bool + }{ + {".mp4", true}, + {".MP4", true}, + {".webm", true}, + {".mov", true}, + {".avi", true}, + {".mkv", true}, + {".m4v", true}, + {".jpg", false}, + {".png", false}, + {".wav", false}, + {".txt", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.ext, func(t *testing.T) { + if got := IsVideoExtension(tt.ext); got != tt.want { + t.Errorf("IsVideoExtension(%q) = %v, want %v", tt.ext, got, tt.want) + } + }) + } +} + +func TestExtractDefaults(t *testing.T) { + // Test that Options defaults are applied correctly + opts := Options{} + if opts.MaxFrames != 0 { + t.Errorf("zero value MaxFrames should be 0, got %d", opts.MaxFrames) + } + + // Extract with a non-existent file should error + _, err := Extract("/nonexistent/video.mp4", Options{}) + if err == nil { + t.Error("expected error for non-existent file") + } +} + +func TestIsVideoWithRealContentType(t *testing.T) { + // Verify that http.DetectContentType correctly identifies video types + // by testing with real file magic bytes + + // MP4 ftyp box: 4 bytes size + "ftyp" + brand + mp4Data := make([]byte, 512) + copy(mp4Data[0:], []byte{0x00, 0x00, 0x00, 0x20}) // size=32 + copy(mp4Data[4:], "ftypisom") // ftyp + brand + copy(mp4Data[12:], []byte{0x00, 0x00, 0x02, 0x00}) // minor version + + ct := http.DetectContentType(mp4Data) + t.Logf("MP4 content type: %s", ct) + // Note: Go's http.DetectContentType may or may not detect MP4. + // The ftyp box detection depends on the Go version. +} diff --git a/video/video_windows.go b/video/video_windows.go new file mode 100644 index 000000000..fa00d7c81 --- /dev/null +++ b/video/video_windows.go @@ -0,0 +1,155 @@ +package video + +import ( + "bytes" + "fmt" + "os/exec" + "strconv" + "strings" +) + +// Windows implementation: shell out to ffmpeg, same as Linux. +// Media Foundation via syscall is planned but ffmpeg is simpler for v1 +// and commonly available via winget/chocolatey/scoop. +// +// TODO: implement Media Foundation via golang.org/x/sys/windows for +// zero-dependency video extraction on Windows. + +func extract(path string, opts Options) (*Result, error) { + ffmpeg, err := exec.LookPath("ffmpeg") + if err != nil { + return nil, fmt.Errorf("video support requires ffmpeg; install it with: winget install ffmpeg (or scoop install ffmpeg)") + } + + duration, err := probeDuration(path) + if err != nil { + return nil, fmt.Errorf("failed to probe video: %w", err) + } + + frameCount := opts.MaxFrames + if duration < float64(frameCount) { + frameCount = int(duration) + } + if frameCount < 1 { + frameCount = 1 + } + + fps := float64(frameCount) / duration + + result := &Result{} + + args := []string{ + "-i", path, + "-vf", fmt.Sprintf("fps=%.4f", fps), + "-frames:v", strconv.Itoa(frameCount), + "-f", "image2pipe", + "-c:v", "mjpeg", + "-q:v", "5", + "pipe:1", + } + + cmd := exec.Command(ffmpeg, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("ffmpeg frame extraction failed: %s", stderr.String()) + } + + result.Frames = splitJPEGs(stdout.Bytes()) + + if opts.ExtractAudio { + audio, err := extractAudio(ffmpeg, path) + if err == nil && len(audio) > 44 { + result.Audio = audio + } + } + + return result, nil +} + +func probeDuration(path string) (float64, error) { + ffprobe, err := exec.LookPath("ffprobe") + if err != nil { + ffmpeg, _ := exec.LookPath("ffmpeg") + cmd := exec.Command(ffmpeg, "-i", path) + var stderr bytes.Buffer + cmd.Stderr = &stderr + cmd.Run() + return parseDurationFromFFmpeg(stderr.String()) + } + + cmd := exec.Command(ffprobe, + "-v", "quiet", + "-show_entries", "format=duration", + "-of", "csv=p=0", + path, + ) + var stdout bytes.Buffer + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + return 0, err + } + + return strconv.ParseFloat(strings.TrimSpace(stdout.String()), 64) +} + +func parseDurationFromFFmpeg(output string) (float64, error) { + idx := strings.Index(output, "Duration: ") + if idx < 0 { + return 0, fmt.Errorf("could not find duration in ffmpeg output") + } + durStr := output[idx+10:] + commaIdx := strings.Index(durStr, ",") + if commaIdx > 0 { + durStr = durStr[:commaIdx] + } + durStr = strings.TrimSpace(durStr) + + parts := strings.Split(durStr, ":") + if len(parts) != 3 { + return 0, fmt.Errorf("unexpected duration format: %s", durStr) + } + hours, _ := strconv.ParseFloat(parts[0], 64) + mins, _ := strconv.ParseFloat(parts[1], 64) + secs, _ := strconv.ParseFloat(parts[2], 64) + return hours*3600 + mins*60 + secs, nil +} + +func extractAudio(ffmpeg, path string) ([]byte, error) { + args := []string{ + "-i", path, + "-ar", "16000", + "-ac", "1", + "-f", "wav", + "pipe:1", + } + + cmd := exec.Command(ffmpeg, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("ffmpeg audio extraction failed: %s", stderr.String()) + } + + return stdout.Bytes(), nil +} + +func splitJPEGs(data []byte) [][]byte { + var frames [][]byte + start := -1 + + for i := 0; i < len(data)-1; i++ { + if data[i] == 0xFF && data[i+1] == 0xD8 { + start = i + } else if data[i] == 0xFF && data[i+1] == 0xD9 && start >= 0 { + frames = append(frames, data[start:i+2]) + start = -1 + } + } + + return frames +}