279 lines
7.5 KiB
Go
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))
|
|
}
|
|
}
|