package convert import ( "context" "os" "os/exec" "path/filepath" "strings" "testing" "convert/internal/media" ) func TestBuildAtempoChain(t *testing.T) { if got := BuildAtempoChain(1.0); len(got) != 0 { t.Fatalf("expected empty chain for 1.0, got %v", got) } got4 := BuildAtempoChain(4.0) if len(got4) != 2 || got4[0] != "atempo=2" || got4[1] != "atempo=2" { t.Fatalf("unexpected 4x chain: %v", got4) } gotQuarter := BuildAtempoChain(0.25) if len(gotQuarter) != 2 || gotQuarter[0] != "atempo=0.5" || gotQuarter[1] != "atempo=0.5" { t.Fatalf("unexpected 0.25x chain: %v", gotQuarter) } } func TestBuildCommand_MultiAudioMergeAndTrim(t *testing.T) { info := &media.VideoInfo{ Path: `C:\videos\clip.mp4`, Duration: 60, Width: 1920, Height: 1080, AudioStreams: []media.AudioStream{ {Index: 1, AudioOrder: 0, Codec: "aac"}, {Index: 2, AudioOrder: 1, Codec: "aac"}, }, } req := Request{ InputPath: `C:\videos\clip.mp4`, TrimStart: 10, TrimEnd: 30, Encoder: "libx264", HwDecode: "nvdec", Preset: "slow", CRF: 20, Tune: "film", FPS: 60, Speed: 2.0, AudioMode: "merge", Crop: CropRect{Enabled: true, X: 10, Y: 10, W: 1281, H: 721}, FastStart: true, Overwrite: true, } built, err := BuildCommand(req, info) if err != nil { t.Fatalf("unexpected error: %v", err) } if built.ExpectedDuration != 10 { t.Fatalf("expected 10s output duration, got %v", built.ExpectedDuration) } joined := strings.Join(built.Args, " ") for _, must := range []string{ "-hwaccel nvdec", "-ss 10.000", "-to 30.000", "crop=1280:720:10:10", "setpts=1/2*PTS", "[0:a:0][0:a:1]amix=inputs=2:normalize=0,atempo=2[a]", "-c:v libx264", "-crf 20", "-preset slow", "-tune film", "-r 60", "-movflags +faststart", } { if !strings.Contains(joined, must) { t.Fatalf("expected %q in args: %s", must, joined) } } } func TestBuildCommand_ValidationErrors(t *testing.T) { info := &media.VideoInfo{Path: "a.mp4", Duration: 30} if _, err := BuildCommand(Request{InputPath: "a.mp4", TrimStart: 20, TrimEnd: 10, Speed: 1}, info); err == nil { t.Fatalf("expected error when trimEnd <= trimStart") } if _, err := BuildCommand(Request{InputPath: "a.mp4", OutputPath: "a.mp4", Speed: 1}, info); err == nil { t.Fatalf("expected error when output == input") } if _, err := BuildCommand(Request{InputPath: "a.mp4", Speed: 12}, info); err == nil { t.Fatalf("expected error for speed > 8.0") } } func TestProgressParser(t *testing.T) { p := newProgressParser(10.0) lines := []string{ "fps=120.5", "total_size=500000", "out_time_us=5000000", "speed=2.0x", "progress=continue", } var got Progress var ok bool for _, l := range lines { got, ok = p.feed(l) } if !ok { t.Fatalf("expected progress completion on progress=continue") } if got.Percent != 50.0 { t.Fatalf("expected 50%%, got %v", got.Percent) } if got.ETASec != 2.5 { t.Fatalf("expected ETA 2.5s, got %v", got.ETASec) } } func TestIntegration_RealFFmpegConvert(t *testing.T) { bins := media.FindBinaries("", "") if bins.FFmpeg == "" || bins.FFprobe == "" { t.Skip("ffmpeg/ffprobe not installed on host") } tmpDir := t.TempDir() src := filepath.Join(tmpDir, "source.mp4") dst := filepath.Join(tmpDir, "out.mp4") // Generate 4s synthetic test video with 2 audio tracks gen := exec.Command(bins.FFmpeg, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=duration=4:size=640x360:rate=30", "-f", "lavfi", "-i", "sine=frequency=440:duration=4", "-f", "lavfi", "-i", "sine=frequency=880:duration=4", "-map", "0:v", "-map", "1:a", "-map", "2:a", "-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac", "-y", src, ) if out, err := gen.CombinedOutput(); err != nil { t.Fatalf("failed generating test video: %v (%s)", err, string(out)) } info, err := media.Probe(bins.FFprobe, src) if err != nil { t.Fatalf("probe failed: %v", err) } if len(info.AudioStreams) != 2 { t.Fatalf("expected 2 audio streams, got %d", len(info.AudioStreams)) } built, err := BuildCommand(Request{ InputPath: src, OutputPath: dst, TrimStart: 1.0, TrimEnd: 3.0, Encoder: "libx264", Preset: "ultrafast", CRF: 24, FPS: 30, Speed: 2.0, AudioMode: "merge", Crop: CropRect{Enabled: true, X: 10, Y: 10, W: 320, H: 180}, FastStart: true, Overwrite: true, }, info) if err != nil { t.Fatalf("BuildCommand failed: %v", err) } var lastProg Progress runner := NewRunner() if err := runner.Run(context.Background(), bins.FFmpeg, built, func(p Progress) { lastProg = p }); err != nil { t.Fatalf("runner failed: %v", err) } if !lastProg.Done || lastProg.Percent != 100 { t.Fatalf("expected Done=true and 100%%, got %+v", lastProg) } if _, err := os.Stat(dst); err != nil { t.Fatalf("output missing: %v", err) } outInfo, err := media.Probe(bins.FFprobe, dst) if err != nil { t.Fatalf("probe output failed: %v", err) } if outInfo.Width != 320 || outInfo.Height != 180 { t.Fatalf("expected cropped 320x180, got %dx%d", outInfo.Width, outInfo.Height) } if len(outInfo.AudioStreams) != 1 { t.Fatalf("expected 1 merged audio stream, got %d", len(outInfo.AudioStreams)) } if outInfo.Duration < 0.7 || outInfo.Duration > 1.4 { t.Fatalf("expected ~1.0s duration after 2s clip @ 2x speed, got %v", outInfo.Duration) } } func TestBuildCommand_EmptySelectedTracksMutes(t *testing.T) { info := &media.VideoInfo{ Path: `C:\videos\clip.mp4`, Duration: 10, Width: 1280, Height: 720, AudioStreams: []media.AudioStream{ {Index: 1, AudioOrder: 0, Codec: "aac"}, {Index: 2, AudioOrder: 1, Codec: "aac"}, }, } built, err := BuildCommand(Request{ InputPath: `C:\videos\clip.mp4`, Encoder: "libx264", Preset: "ultrafast", CRF: 23, Speed: 1.0, AudioMode: "merge", SelectedTracks: []int{}, }, info) if err != nil { t.Fatalf("unexpected error: %v", err) } joined := strings.Join(built.Args, " ") if !strings.Contains(joined, " -an ") { t.Fatalf("expected -an when SelectedTracks is explicit empty slice, got: %s", joined) } } func TestRunner_NoClobberPreservesExistingOutput(t *testing.T) { bins := media.FindBinaries("", "") if bins.FFmpeg == "" { t.Skip("ffmpeg not installed on host") } tmpDir := t.TempDir() src := filepath.Join(tmpDir, "source.mp4") dst := filepath.Join(tmpDir, "existing.mp4") gen := exec.Command(bins.FFmpeg, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=duration=1:size=320x180:rate=30", "-c:v", "libx264", "-preset", "ultrafast", "-y", src, ) if out, err := gen.CombinedOutput(); err != nil { t.Fatalf("failed generating test video: %v (%s)", err, string(out)) } sentinel := []byte("keep-me-intact") if err := os.WriteFile(dst, sentinel, 0o644); err != nil { t.Fatalf("write sentinel failed: %v", err) } info := &media.VideoInfo{Path: src, Duration: 1, Width: 320, Height: 180} built, err := BuildCommand(Request{ InputPath: src, OutputPath: dst, Encoder: "libx264", Preset: "ultrafast", CRF: 24, Speed: 1.0, AudioMode: "mute", Overwrite: false, }, info) if err != nil { t.Fatalf("BuildCommand failed: %v", err) } runner := NewRunner() if err := runner.Run(context.Background(), bins.FFmpeg, built, nil); err == nil { t.Fatalf("expected error when Overwrite=false and destination exists") } got, err := os.ReadFile(dst) if err != nil || string(got) != string(sentinel) { t.Fatalf("existing destination file was modified or deleted! err=%v got=%q", err, string(got)) } }