Files
convert/internal/convert/command_test.go
T
2026-11-12 18:04:53 +03:00

279 lines
7.5 KiB
Go

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))
}
}