FFmpeg: Add tests, refactor package, and split into sub-packages #4604

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-01-08 18:27:33 +01:00
parent d169392639
commit bbb30e6a33
26 changed files with 532 additions and 356 deletions

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"github.com/photoprism/photoprism/internal/ffmpeg" "github.com/photoprism/photoprism/internal/ffmpeg"
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
"github.com/photoprism/photoprism/internal/thumb" "github.com/photoprism/photoprism/internal/thumb"
) )
@@ -18,12 +19,12 @@ func (c *Config) FFmpegEnabled() bool {
} }
// FFmpegEncoder returns the FFmpeg AVC encoder name. // FFmpegEncoder returns the FFmpeg AVC encoder name.
func (c *Config) FFmpegEncoder() ffmpeg.AvcEncoder { func (c *Config) FFmpegEncoder() encode.Encoder {
if c.options.FFmpegEncoder == "" || c.options.FFmpegEncoder == ffmpeg.SoftwareEncoder.String() { if c.options.FFmpegEncoder == "" || c.options.FFmpegEncoder == encode.SoftwareAvc.String() {
return ffmpeg.SoftwareEncoder return encode.SoftwareAvc
} }
return ffmpeg.FindEncoder(c.options.FFmpegEncoder) return encode.FindEncoder(c.options.FFmpegEncoder)
} }
// FFmpegSize returns the maximum ffmpeg video encoding size in pixels (720-7680). // FFmpegSize returns the maximum ffmpeg video encoding size in pixels (720-7680).
@@ -73,9 +74,9 @@ func (c *Config) FFmpegMapAudio() string {
} }
// FFmpegOptions returns the FFmpeg transcoding options. // FFmpegOptions returns the FFmpeg transcoding options.
func (c *Config) FFmpegOptions(encoder ffmpeg.AvcEncoder, bitrate string) (ffmpeg.Options, error) { func (c *Config) FFmpegOptions(encoder encode.Encoder, bitrate string) (encode.Options, error) {
// Transcode all other formats with FFmpeg. // Transcode all other formats with FFmpeg.
opt := ffmpeg.Options{ opt := encode.Options{
Bin: c.FFmpegBin(), Bin: c.FFmpegBin(),
Encoder: encoder, Encoder: encoder,
Size: c.FFmpegSize(), Size: c.FFmpegSize(),

View File

@@ -4,6 +4,7 @@ import (
"testing" "testing"
"github.com/photoprism/photoprism/internal/ffmpeg" "github.com/photoprism/photoprism/internal/ffmpeg"
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
"github.com/photoprism/photoprism/internal/thumb" "github.com/photoprism/photoprism/internal/thumb"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -11,15 +12,15 @@ import (
func TestConfig_FFmpegEncoder(t *testing.T) { func TestConfig_FFmpegEncoder(t *testing.T) {
c := NewConfig(CliTestContext()) c := NewConfig(CliTestContext())
assert.Equal(t, ffmpeg.SoftwareEncoder, c.FFmpegEncoder()) assert.Equal(t, encode.SoftwareAvc, c.FFmpegEncoder())
c.options.FFmpegEncoder = "nvidia" c.options.FFmpegEncoder = "nvidia"
assert.Equal(t, ffmpeg.NvidiaEncoder, c.FFmpegEncoder()) assert.Equal(t, encode.NvidiaAvc, c.FFmpegEncoder())
c.options.FFmpegEncoder = "intel" c.options.FFmpegEncoder = "intel"
assert.Equal(t, ffmpeg.IntelEncoder, c.FFmpegEncoder()) assert.Equal(t, encode.IntelAvc, c.FFmpegEncoder())
c.options.FFmpegEncoder = "xxx" c.options.FFmpegEncoder = "xxx"
assert.Equal(t, ffmpeg.SoftwareEncoder, c.FFmpegEncoder()) assert.Equal(t, encode.SoftwareAvc, c.FFmpegEncoder())
c.options.FFmpegEncoder = "" c.options.FFmpegEncoder = ""
assert.Equal(t, ffmpeg.SoftwareEncoder, c.FFmpegEncoder()) assert.Equal(t, encode.SoftwareAvc, c.FFmpegEncoder())
} }
func TestConfig_FFmpegEnabled(t *testing.T) { func TestConfig_FFmpegEnabled(t *testing.T) {
@@ -104,10 +105,10 @@ func TestConfig_FFmpegMapAudio(t *testing.T) {
func TestConfig_FFmpegOptions(t *testing.T) { func TestConfig_FFmpegOptions(t *testing.T) {
c := NewConfig(CliTestContext()) c := NewConfig(CliTestContext())
bitrate := "25M" bitrate := "25M"
opt, err := c.FFmpegOptions(ffmpeg.SoftwareEncoder, bitrate) opt, err := c.FFmpegOptions(encode.SoftwareAvc, bitrate)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, c.FFmpegBin(), opt.Bin) assert.Equal(t, c.FFmpegBin(), opt.Bin)
assert.Equal(t, ffmpeg.SoftwareEncoder, opt.Encoder) assert.Equal(t, encode.SoftwareAvc, opt.Encoder)
assert.Equal(t, bitrate, opt.Bitrate) assert.Equal(t, bitrate, opt.Bitrate)
assert.Equal(t, ffmpeg.MapVideoDefault, opt.MapVideo) assert.Equal(t, ffmpeg.MapVideoDefault, opt.MapVideo)
assert.Equal(t, ffmpeg.MapAudioDefault, opt.MapAudio) assert.Equal(t, ffmpeg.MapAudioDefault, opt.MapAudio)

View File

@@ -0,0 +1,30 @@
package apple
import (
"os/exec"
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
)
// AvcConvertCmd returns the command for hardware-accelerated transcoding of video files to MPEG-4 AVC.
func AvcConvertCmd(srcName, destName string, opt encode.Options) *exec.Cmd {
// ffmpeg -hide_banner -h encoder=h264_videotoolbox
return exec.Command(
opt.Bin,
"-y",
"-strict", "-2",
"-i", srcName,
"-c:v", opt.Encoder.String(),
"-map", opt.MapVideo,
"-map", opt.MapAudio,
"-c:a", "aac",
"-vf", opt.VideoFilter(encode.FormatYUV420P),
"-profile", "high",
"-level", "51",
"-r", "30",
"-b:v", opt.Bitrate,
"-f", "mp4",
"-movflags", "+faststart",
destName,
)
}

View File

@@ -4,187 +4,72 @@ import (
"fmt" "fmt"
"os/exec" "os/exec"
"github.com/photoprism/photoprism/internal/ffmpeg/apple"
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
"github.com/photoprism/photoprism/internal/ffmpeg/intel"
"github.com/photoprism/photoprism/internal/ffmpeg/nvidia"
"github.com/photoprism/photoprism/internal/ffmpeg/v4l"
"github.com/photoprism/photoprism/internal/ffmpeg/vaapi"
"github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/fs"
) )
// AvcConvertCommand returns the command for converting video files to MPEG-4 AVC. // AvcConvertCmd returns the command for converting video files to MPEG-4 AVC.
func AvcConvertCommand(fileName, avcName string, opt Options) (result *exec.Cmd, useMutex bool, err error) { func AvcConvertCmd(srcName, destName string, opt encode.Options) (cmd *exec.Cmd, useMutex bool, err error) {
if fileName == "" { if srcName == "" {
return nil, false, fmt.Errorf("empty input filename") return nil, false, fmt.Errorf("empty source filename")
} else if avcName == "" { } else if destName == "" {
return nil, false, fmt.Errorf("empty output filename") return nil, false, fmt.Errorf("empty destination filename")
} }
// Don't transcode more than one video at the same time. // Don't transcode more than one video at the same time.
useMutex = true useMutex = true
// Get configured ffmpeg command name. // Use default ffmpeg command name.
ffmpeg := opt.Bin if opt.Bin == "" {
opt.Bin = DefaultBin
// Use default ffmpeg command name?
if ffmpeg == "" {
ffmpeg = DefaultBin
} }
// Don't use hardware transcoding for animated images. // Don't use hardware transcoding for animated images.
if fs.TypeAnimated[fs.FileType(fileName)] != "" { if fs.TypeAnimated[fs.FileType(srcName)] != "" {
result = exec.Command( cmd = exec.Command(
ffmpeg, opt.Bin,
"-y", "-y",
"-strict", "-2", "-strict", "-2",
"-i", fileName, "-i", srcName,
"-pix_fmt", FormatYUV420P.String(), "-pix_fmt", encode.FormatYUV420P.String(),
"-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", "-vf", "scale='trunc(iw/2)*2:trunc(ih/2)*2'",
"-f", "mp4", "-f", "mp4",
"-movflags", "+faststart", // puts headers at the beginning for faster streaming "-movflags", "+faststart", // puts headers at the beginning for faster streaming
avcName, destName,
) )
return result, useMutex, nil return cmd, useMutex, nil
} }
// Display encoder info. // Display encoder info.
if opt.Encoder != SoftwareEncoder { if opt.Encoder != encode.SoftwareAvc {
log.Infof("convert: ffmpeg encoder %s selected", opt.Encoder.String()) log.Infof("convert: ffmpeg encoder %s selected", opt.Encoder.String())
} }
switch opt.Encoder { switch opt.Encoder {
case IntelEncoder: case encode.IntelAvc:
// ffmpeg -hide_banner -h encoder=h264_qsv cmd = intel.AvcConvertCmd(srcName, destName, opt)
result = exec.Command(
ffmpeg,
"-y",
"-strict", "-2",
"-hwaccel", "qsv",
"-hwaccel_output_format", "qsv",
"-qsv_device", "/dev/dri/renderD128",
"-i", fileName,
"-c:a", "aac",
"-vf", opt.VideoFilter(FormatQSV),
"-c:v", opt.Encoder.String(),
"-map", opt.MapVideo,
"-map", opt.MapAudio,
"-r", "30",
"-b:v", opt.Bitrate,
"-bitrate", opt.Bitrate,
"-f", "mp4",
"-movflags", "+faststart", // puts headers at the beginning for faster streaming
avcName,
)
case AppleEncoder: case encode.AppleAvc:
// ffmpeg -hide_banner -h encoder=h264_videotoolbox cmd = apple.AvcConvertCmd(srcName, destName, opt)
result = exec.Command(
ffmpeg,
"-y",
"-strict", "-2",
"-i", fileName,
"-c:v", opt.Encoder.String(),
"-map", opt.MapVideo,
"-map", opt.MapAudio,
"-c:a", "aac",
"-vf", opt.VideoFilter(FormatYUV420P),
"-profile", "high",
"-level", "51",
"-r", "30",
"-b:v", opt.Bitrate,
"-f", "mp4",
"-movflags", "+faststart",
avcName,
)
case VAAPIEncoder: case encode.VaapiAvc:
result = exec.Command( cmd = vaapi.AvcConvertCmd(srcName, destName, opt)
ffmpeg,
"-y",
"-strict", "-2",
"-hwaccel", "vaapi",
"-i", fileName,
"-c:a", "aac",
"-vf", opt.VideoFilter(FormatNV12),
"-c:v", opt.Encoder.String(),
"-map", opt.MapVideo,
"-map", opt.MapAudio,
"-r", "30",
"-b:v", opt.Bitrate,
"-f", "mp4",
"-movflags", "+faststart", // puts headers at the beginning for faster streaming
avcName,
)
case NvidiaEncoder: case encode.NvidiaAvc:
// ffmpeg -hide_banner -h encoder=h264_nvenc cmd = nvidia.AvcConvertCmd(srcName, destName, opt)
result = exec.Command(
ffmpeg,
"-y",
"-strict", "-2",
"-hwaccel", "auto",
"-i", fileName,
"-pix_fmt", FormatYUV420P.String(),
"-c:v", opt.Encoder.String(),
"-map", opt.MapVideo,
"-map", opt.MapAudio,
"-c:a", "aac",
"-preset", "15",
"-pixel_format", "yuv420p",
"-gpu", "any",
"-vf", opt.VideoFilter(FormatYUV420P),
"-rc:v", "constqp",
"-cq", "0",
"-tune", "2",
"-r", "30",
"-b:v", opt.Bitrate,
"-profile:v", "1",
"-level:v", "auto",
"-coder:v", "1",
"-f", "mp4",
"-movflags", "+faststart", // puts headers at the beginning for faster streaming
avcName,
)
case Video4LinuxEncoder: case encode.V4LAvc:
// ffmpeg -hide_banner -h encoder=h264_v4l2m2m cmd = v4l.AvcConvertCmd(srcName, destName, opt)
result = exec.Command(
ffmpeg,
"-y",
"-strict", "-2",
"-i", fileName,
"-c:v", opt.Encoder.String(),
"-map", opt.MapVideo,
"-map", opt.MapAudio,
"-c:a", "aac",
"-vf", opt.VideoFilter(FormatYUV420P),
"-num_output_buffers", "72",
"-num_capture_buffers", "64",
"-max_muxing_queue_size", "1024",
"-crf", "23",
"-r", "30",
"-b:v", opt.Bitrate,
"-f", "mp4",
"-movflags", "+faststart", // puts headers at the beginning for faster streaming
avcName,
)
default: default:
result = exec.Command( cmd = encode.AvcConvertCmd(srcName, destName, opt)
ffmpeg,
"-y",
"-strict", "-2",
"-i", fileName,
"-c:v", opt.Encoder.String(),
"-map", opt.MapVideo,
"-map", opt.MapAudio,
"-c:a", "aac",
"-vf", opt.VideoFilter(FormatYUV420P),
"-max_muxing_queue_size", "1024",
"-crf", "23",
"-r", "30",
"-b:v", opt.Bitrate,
"-f", "mp4",
"-movflags", "+faststart", // puts headers at the beginning for faster streaming
avcName,
)
} }
return result, useMutex, nil return cmd, useMutex, nil
} }

View File

@@ -1,40 +1,62 @@
package ffmpeg package ffmpeg
import ( import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
"github.com/photoprism/photoprism/pkg/fs"
) )
func TestAvcConvertCommand(t *testing.T) { func TestAvcConvertCmd(t *testing.T) {
t.Run("empty filename", func(t *testing.T) { runCmd := func(t *testing.T, encoder encode.Encoder, srcName, destName string, cmd *exec.Cmd) {
opt := Options{ var out bytes.Buffer
Bin: "", var stderr bytes.Buffer
Encoder: "intel", cmd.Stdout = &out
Size: 1500, cmd.Stderr = &stderr
Bitrate: "50M", cmd.Env = append(cmd.Env, []string{
MapVideo: MapVideoDefault, fmt.Sprintf("HOME=%s", fs.Abs("./testdata")),
MapAudio: MapAudioDefault, }...)
}
_, _, err := AvcConvertCommand("", "", opt)
assert.Equal(t, err.Error(), "empty input filename") // Transcode source media file to AVC.
}) start := time.Now()
t.Run("avc name empty", func(t *testing.T) { if err := cmd.Run(); err != nil {
opt := Options{ if stderr.String() != "" {
Bin: "", err = errors.New(stderr.String())
Encoder: "intel", }
Size: 1500,
Bitrate: "50M",
MapVideo: MapVideoDefault,
MapAudio: MapAudioDefault,
}
_, _, err := AvcConvertCommand("VID123.mov", "", opt)
assert.Equal(t, err.Error(), "empty output filename") // Remove broken video file.
}) if !fs.FileExists(destName) {
t.Run("animated file", func(t *testing.T) { // Do nothing.
opt := Options{ } else if removeErr := os.Remove(destName); removeErr != nil {
t.Logf("%s: failed to remove %s after error (%s)", encoder, srcName, removeErr)
}
// Log ffmpeg output for debugging.
if err.Error() != "" {
t.Error(err)
t.Fatalf("%s: failed to transcode %s [%s]", encoder, srcName, time.Since(start))
}
}
// Log filename and transcoding time.
t.Logf("%s: created %s [%s]", encoder, destName, time.Since(start))
if removeErr := os.Remove(destName); removeErr != nil {
t.Fatalf("%s: failed to remove %s after successful test (%s)", encoder, srcName, removeErr)
}
}
t.Run("NoSource", func(t *testing.T) {
opt := encode.Options{
Bin: "", Bin: "",
Encoder: "intel", Encoder: "intel",
Size: 1500, Size: 1500,
@@ -42,33 +64,101 @@ func TestAvcConvertCommand(t *testing.T) {
MapVideo: MapVideoDefault, MapVideo: MapVideoDefault,
MapAudio: MapAudioDefault, MapAudio: MapAudioDefault,
} }
r, _, err := AvcConvertCommand("VID123.gif", "VID123.gif.avc", opt) _, _, err := AvcConvertCmd("", "", opt)
assert.Equal(t, "empty source filename", err.Error())
})
t.Run("NoDestination", func(t *testing.T) {
opt := encode.Options{
Bin: "",
Encoder: "intel",
Size: 1500,
Bitrate: "50M",
MapVideo: MapVideoDefault,
MapAudio: MapAudioDefault,
}
_, _, err := AvcConvertCmd("VID123.mov", "", opt)
assert.Equal(t, "empty destination filename", err.Error())
})
t.Run("Animation", func(t *testing.T) {
opt := encode.Options{
Bin: "",
Encoder: "intel",
Size: 1500,
Bitrate: "50M",
MapVideo: MapVideoDefault,
MapAudio: MapAudioDefault,
}
r, _, err := AvcConvertCmd("VID123.gif", "VID123.gif.avc", opt)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
assert.Contains(t, r.String(), "bin/ffmpeg -y -strict -2 -i VID123.gif -pix_fmt yuv420p -vf scale=trunc(iw/2)*2:trunc(ih/2)*2 -f mp4 -movflags +faststart VID123.gif.avc") assert.Contains(t, r.String(), "bin/ffmpeg -y -strict -2 -i VID123.gif -pix_fmt yuv420p -vf scale='trunc(iw/2)*2:trunc(ih/2)*2' -f mp4 -movflags +faststart VID123.gif.avc")
}) })
t.Run("libx264", func(t *testing.T) { t.Run("VP9", func(t *testing.T) {
opt := Options{ encoder := encode.SoftwareAvc
Bin: "",
Encoder: "libx264", opt := encode.Options{
Bin: "/usr/bin/ffmpeg",
Encoder: encoder,
Size: 1500, Size: 1500,
Bitrate: "50M", Bitrate: "50M",
MapVideo: MapVideoDefault, MapVideo: MapVideoDefault,
MapAudio: MapAudioDefault, MapAudio: MapAudioDefault,
} }
r, _, err := AvcConvertCommand("VID123.mov", "VID123.mov.avc", opt)
srcName := fs.Abs("./testdata/25fps.vp9")
destName := fs.Abs("./testdata/25fps.avc")
cmd, _, err := AvcConvertCmd(srcName, destName, opt)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
assert.Contains(t, r.String(), "bin/ffmpeg -y -strict -2 -i VID123.mov -c:v libx264 -map 0:v:0 -map 0:a:0? -c:a aac -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=yuv420p -max_muxing_queue_size 1024 -crf 23 -r 30 -b:v 50M -f mp4 -movflags +faststart VID123.mov.avc") cmdStr := cmd.String()
cmdStr = strings.Replace(cmdStr, srcName, "SRC", 1)
cmdStr = strings.Replace(cmdStr, destName, "DEST", 1)
assert.Equal(t, "/usr/bin/ffmpeg -y -strict -2 -i SRC -c:v libx264 -map 0:v:0 -map 0:a:0? -c:a aac -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=yuv420p -max_muxing_queue_size 1024 -crf 23 -r 30 -b:v 50M -f mp4 -movflags +faststart DEST", cmdStr)
// Performs software transcoding.
runCmd(t, encoder, srcName, destName, cmd)
}) })
t.Run("h264_qsv", func(t *testing.T) { t.Run("Vaapi", func(t *testing.T) {
opt := Options{ encoder := encode.VaapiAvc
opt := encode.Options{
Bin: "/usr/bin/ffmpeg",
Encoder: encoder,
Size: 1500,
Bitrate: "50M",
MapVideo: MapVideoDefault,
MapAudio: MapAudioDefault,
}
srcName := fs.Abs("./testdata/25fps.vp9")
destName := fs.Abs("./testdata/25fps.vaapi.avc")
cmd, _, err := AvcConvertCmd(srcName, destName, opt)
if err != nil {
t.Fatal(err)
}
cmdStr := cmd.String()
cmdStr = strings.Replace(cmdStr, srcName, "SRC", 1)
cmdStr = strings.Replace(cmdStr, destName, "DEST", 1)
assert.Equal(t, "/usr/bin/ffmpeg -y -strict -2 -hwaccel vaapi -i SRC -c:a aac -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=nv12,hwupload -c:v h264_vaapi -map 0:v:0 -map 0:a:0? -r 30 -b:v 50M -f mp4 -movflags +faststart DEST", cmdStr)
// Requires Video4Linux transcoding device.
// runCmd(t, encoder, srcName, destName, cmd)
})
t.Run("QSV", func(t *testing.T) {
opt := encode.Options{
Bin: "", Bin: "",
Encoder: "h264_qsv", Encoder: "h264_qsv",
Size: 1500, Size: 1500,
@@ -76,7 +166,7 @@ func TestAvcConvertCommand(t *testing.T) {
MapVideo: MapVideoDefault, MapVideo: MapVideoDefault,
MapAudio: MapAudioDefault, MapAudio: MapAudioDefault,
} }
r, _, err := AvcConvertCommand("VID123.mov", "VID123.mov.avc", opt) r, _, err := AvcConvertCmd("VID123.mov", "VID123.mov.avc", opt)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -84,8 +174,8 @@ func TestAvcConvertCommand(t *testing.T) {
assert.Contains(t, r.String(), "/bin/ffmpeg -y -strict -2 -hwaccel qsv -hwaccel_output_format qsv -qsv_device /dev/dri/renderD128 -i VID123.mov -c:a aac -vf scale_qsv=w='if(gte(iw,ih), min(1500, iw), -1)':h='if(gte(iw,ih), -1, min(1500, ih))':format=nv12 -c:v h264_qsv -map 0:v:0 -map 0:a:0? -r 30 -b:v 50M -bitrate 50M -f mp4 -movflags +faststart VID123.mov.avc") assert.Contains(t, r.String(), "/bin/ffmpeg -y -strict -2 -hwaccel qsv -hwaccel_output_format qsv -qsv_device /dev/dri/renderD128 -i VID123.mov -c:a aac -vf scale_qsv=w='if(gte(iw,ih), min(1500, iw), -1)':h='if(gte(iw,ih), -1, min(1500, ih))':format=nv12 -c:v h264_qsv -map 0:v:0 -map 0:a:0? -r 30 -b:v 50M -bitrate 50M -f mp4 -movflags +faststart VID123.mov.avc")
}) })
t.Run("h264_videotoolbox", func(t *testing.T) { t.Run("Apple", func(t *testing.T) {
opt := Options{ opt := encode.Options{
Bin: "", Bin: "",
Encoder: "h264_videotoolbox", Encoder: "h264_videotoolbox",
Size: 1500, Size: 1500,
@@ -93,7 +183,7 @@ func TestAvcConvertCommand(t *testing.T) {
MapVideo: MapVideoDefault, MapVideo: MapVideoDefault,
MapAudio: MapAudioDefault, MapAudio: MapAudioDefault,
} }
r, _, err := AvcConvertCommand("VID123.mov", "VID123.mov.avc", opt) r, _, err := AvcConvertCmd("VID123.mov", "VID123.mov.avc", opt)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -101,25 +191,8 @@ func TestAvcConvertCommand(t *testing.T) {
assert.Contains(t, r.String(), "bin/ffmpeg -y -strict -2 -i VID123.mov -c:v h264_videotoolbox -map 0:v:0 -map 0:a:0? -c:a aac -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=yuv420p -profile high -level 51 -r 30 -b:v 50M -f mp4 -movflags +faststart VID123.mov.avc") assert.Contains(t, r.String(), "bin/ffmpeg -y -strict -2 -i VID123.mov -c:v h264_videotoolbox -map 0:v:0 -map 0:a:0? -c:a aac -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=yuv420p -profile high -level 51 -r 30 -b:v 50M -f mp4 -movflags +faststart VID123.mov.avc")
}) })
t.Run("h264_vaapi", func(t *testing.T) { t.Run("Nvidia", func(t *testing.T) {
opt := Options{ opt := encode.Options{
Bin: "",
Encoder: "h264_vaapi",
Size: 1500,
Bitrate: "50M",
MapVideo: MapVideoDefault,
MapAudio: MapAudioDefault,
}
r, _, err := AvcConvertCommand("VID123.mov", "VID123.mov.avc", opt)
if err != nil {
t.Fatal(err)
}
assert.Contains(t, r.String(), "bin/ffmpeg -y -strict -2 -hwaccel vaapi -i VID123.mov -c:a aac -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=nv12,hwupload -c:v h264_vaapi -map 0:v:0 -map 0:a:0? -r 30 -b:v 50M -f mp4 -movflags +faststart VID123.mov.avc")
})
t.Run("h264_nvenc", func(t *testing.T) {
opt := Options{
Bin: "", Bin: "",
Encoder: "h264_nvenc", Encoder: "h264_nvenc",
Size: 1500, Size: 1500,
@@ -127,7 +200,7 @@ func TestAvcConvertCommand(t *testing.T) {
MapVideo: MapVideoDefault, MapVideo: MapVideoDefault,
MapAudio: MapAudioDefault, MapAudio: MapAudioDefault,
} }
r, _, err := AvcConvertCommand("VID123.mov", "VID123.mov.avc", opt) r, _, err := AvcConvertCmd("VID123.mov", "VID123.mov.avc", opt)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -135,8 +208,8 @@ func TestAvcConvertCommand(t *testing.T) {
assert.Contains(t, r.String(), "bin/ffmpeg -y -strict -2 -hwaccel auto -i VID123.mov -pix_fmt yuv420p -c:v h264_nvenc -map 0:v:0 -map 0:a:0? -c:a aac -preset 15 -pixel_format yuv420p -gpu any -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=yuv420p -rc:v constqp -cq 0 -tune 2 -r 30 -b:v 50M -profile:v 1 -level:v auto -coder:v 1 -f mp4 -movflags +faststart VID123.mov.avc") assert.Contains(t, r.String(), "bin/ffmpeg -y -strict -2 -hwaccel auto -i VID123.mov -pix_fmt yuv420p -c:v h264_nvenc -map 0:v:0 -map 0:a:0? -c:a aac -preset 15 -pixel_format yuv420p -gpu any -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=yuv420p -rc:v constqp -cq 0 -tune 2 -r 30 -b:v 50M -profile:v 1 -level:v auto -coder:v 1 -f mp4 -movflags +faststart VID123.mov.avc")
}) })
t.Run("h264_v4l2m2m", func(t *testing.T) { t.Run("Video4Linux", func(t *testing.T) {
opt := Options{ opt := encode.Options{
Bin: "", Bin: "",
Encoder: "h264_v4l2m2m", Encoder: "h264_v4l2m2m",
Size: 1500, Size: 1500,
@@ -144,7 +217,7 @@ func TestAvcConvertCommand(t *testing.T) {
MapVideo: MapVideoDefault, MapVideo: MapVideoDefault,
MapAudio: MapAudioDefault, MapAudio: MapAudioDefault,
} }
r, _, err := AvcConvertCommand("VID123.mov", "VID123.mov.avc", opt) r, _, err := AvcConvertCmd("VID123.mov", "VID123.mov.avc", opt)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)

View File

@@ -0,0 +1,25 @@
package encode
import "os/exec"
// AvcConvertCmd returns the command for software transcoding of video files to MPEG-4 AVC.
func AvcConvertCmd(srcName, destName string, opt Options) *exec.Cmd {
return exec.Command(
opt.Bin,
"-y",
"-strict", "-2",
"-i", srcName,
"-c:v", opt.Encoder.String(),
"-map", opt.MapVideo,
"-map", opt.MapAudio,
"-c:a", "aac",
"-vf", opt.VideoFilter(FormatYUV420P),
"-max_muxing_queue_size", "1024",
"-crf", "23",
"-r", "30",
"-b:v", opt.Bitrate,
"-f", "mp4",
"-movflags", "+faststart", // puts headers at the beginning for faster streaming
destName,
)
}

View File

@@ -0,0 +1,31 @@
/*
Package encode provides FFmpeg video encoder related types and functions.
Copyright (c) 2018 - 2024 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://www.photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package encode
import (
"github.com/photoprism/photoprism/internal/event"
)
var log = event.Log

View File

@@ -0,0 +1,63 @@
package encode
import "github.com/photoprism/photoprism/pkg/clean"
// Encoder represents a supported FFmpeg AVC encoder name.
type Encoder string
// String returns the FFmpeg AVC encoder name as string.
func (name Encoder) String() string {
return string(name)
}
// Supported FFmpeg AVC encoders.
const (
SoftwareAvc Encoder = "libx264" // SoftwareAvc see https://trac.ffmpeg.org/wiki/HWAccelIntro.
IntelAvc Encoder = "h264_qsv" // IntelAvc is the Intel Quick Sync H.264 encoder.
AppleAvc Encoder = "h264_videotoolbox" // AppleAvc is the Apple Video Toolbox H.264 encoder.
VaapiAvc Encoder = "h264_vaapi" // VaapiAvc is the Video Acceleration API H.264 encoder.
NvidiaAvc Encoder = "h264_nvenc" // NvidiaAvc is the NVIDIA H.264 encoder.
V4LAvc Encoder = "h264_v4l2m2m" // V4LAvc is the Video4Linux H.264 encoder.
)
// AvcEncoders is the list of supported H.264 encoders with aliases.
var AvcEncoders = map[string]Encoder{
"": SoftwareAvc,
"default": SoftwareAvc,
"software": SoftwareAvc,
string(SoftwareAvc): SoftwareAvc,
"intel": IntelAvc,
"qsv": IntelAvc,
string(IntelAvc): IntelAvc,
"apple": AppleAvc,
"osx": AppleAvc,
"mac": AppleAvc,
"macos": AppleAvc,
"darwin": AppleAvc,
string(AppleAvc): AppleAvc,
"vaapi": VaapiAvc,
"libva": VaapiAvc,
string(VaapiAvc): VaapiAvc,
"nvidia": NvidiaAvc,
"nvenc": NvidiaAvc,
"cuda": NvidiaAvc,
string(NvidiaAvc): NvidiaAvc,
"v4l2": V4LAvc,
"v4l": V4LAvc,
"video4linux": V4LAvc,
"rp4": V4LAvc,
"raspberry": V4LAvc,
"raspberrypi": V4LAvc,
string(V4LAvc): V4LAvc,
}
// FindEncoder finds an FFmpeg encoder by name.
func FindEncoder(s string) Encoder {
if encoder, ok := AvcEncoders[s]; ok {
return encoder
} else {
log.Warnf("ffmpeg: unsupported encoder %s", clean.Log(s))
}
return SoftwareAvc
}

View File

@@ -1,4 +1,4 @@
package ffmpeg package encode
import ( import (
"testing" "testing"

View File

@@ -1,11 +1,11 @@
package ffmpeg package encode
import "fmt" import "fmt"
// Options represents transcoding options. // Options represents transcoding options.
type Options struct { type Options struct {
Bin string Bin string
Encoder AvcEncoder Encoder Encoder
Size int Size int
Bitrate string Bitrate string
MapVideo string MapVideo string

View File

@@ -1,4 +1,4 @@
package ffmpeg package encode
import ( import (
"testing" "testing"

View File

@@ -1,4 +1,4 @@
package ffmpeg package encode
// PixelFormat represents a standard pixel format. // PixelFormat represents a standard pixel format.
type PixelFormat string type PixelFormat string

View File

@@ -1,63 +0,0 @@
package ffmpeg
import "github.com/photoprism/photoprism/pkg/clean"
// AvcEncoder represents a supported FFmpeg AVC encoder name.
type AvcEncoder string
// String returns the FFmpeg AVC encoder name as string.
func (name AvcEncoder) String() string {
return string(name)
}
// Supported FFmpeg AVC encoders.
const (
SoftwareEncoder AvcEncoder = "libx264" // SoftwareEncoder see https://trac.ffmpeg.org/wiki/HWAccelIntro.
IntelEncoder AvcEncoder = "h264_qsv" // IntelEncoder is the Intel Quick Sync H.264 encoder.
AppleEncoder AvcEncoder = "h264_videotoolbox" // AppleEncoder is the Apple Video Toolbox H.264 encoder.
VAAPIEncoder AvcEncoder = "h264_vaapi" // VAAPIEncoder is the Video Acceleration API H.264 encoder.
NvidiaEncoder AvcEncoder = "h264_nvenc" // NvidiaEncoder is the NVIDIA H.264 encoder.
Video4LinuxEncoder AvcEncoder = "h264_v4l2m2m" // Video4LinuxEncoder is the Video4Linux H.264 encoder.
)
// AvcEncoders is the list of supported H.264 encoders with aliases.
var AvcEncoders = map[string]AvcEncoder{
"": SoftwareEncoder,
"default": SoftwareEncoder,
"software": SoftwareEncoder,
string(SoftwareEncoder): SoftwareEncoder,
"intel": IntelEncoder,
"qsv": IntelEncoder,
string(IntelEncoder): IntelEncoder,
"apple": AppleEncoder,
"osx": AppleEncoder,
"mac": AppleEncoder,
"macos": AppleEncoder,
"darwin": AppleEncoder,
string(AppleEncoder): AppleEncoder,
"vaapi": VAAPIEncoder,
"libva": VAAPIEncoder,
string(VAAPIEncoder): VAAPIEncoder,
"nvidia": NvidiaEncoder,
"nvenc": NvidiaEncoder,
"cuda": NvidiaEncoder,
string(NvidiaEncoder): NvidiaEncoder,
"v4l2": Video4LinuxEncoder,
"v4l": Video4LinuxEncoder,
"video4linux": Video4LinuxEncoder,
"rp4": Video4LinuxEncoder,
"raspberry": Video4LinuxEncoder,
"raspberrypi": Video4LinuxEncoder,
string(Video4LinuxEncoder): Video4LinuxEncoder,
}
// FindEncoder finds an FFmpeg encoder by name.
func FindEncoder(s string) AvcEncoder {
if encoder, ok := AvcEncoders[s]; ok {
return encoder
} else {
log.Warnf("ffmpeg: unsupported encoder %s", clean.Log(s))
}
return SoftwareEncoder
}

View File

@@ -0,0 +1,32 @@
package intel
import (
"os/exec"
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
)
// AvcConvertCmd returns the command for hardware-accelerated transcoding of video files to MPEG-4 AVC.
func AvcConvertCmd(srcName, destName string, opt encode.Options) *exec.Cmd {
// ffmpeg -hide_banner -h encoder=h264_qsv
return exec.Command(
opt.Bin,
"-y",
"-strict", "-2",
"-hwaccel", "qsv",
"-hwaccel_output_format", "qsv",
"-qsv_device", "/dev/dri/renderD128",
"-i", srcName,
"-c:a", "aac",
"-vf", opt.VideoFilter(encode.FormatQSV),
"-c:v", opt.Encoder.String(),
"-map", opt.MapVideo,
"-map", opt.MapAudio,
"-r", "30",
"-b:v", opt.Bitrate,
"-bitrate", opt.Bitrate,
"-f", "mp4",
"-movflags", "+faststart", // puts headers at the beginning for faster streaming
destName,
)
}

View File

@@ -0,0 +1,39 @@
package nvidia
import (
"os/exec"
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
)
// AvcConvertCmd returns the command for hardware-accelerated transcoding of video files to MPEG-4 AVC.
func AvcConvertCmd(srcName, destName string, opt encode.Options) *exec.Cmd {
// ffmpeg -hide_banner -h encoder=h264_nvenc
return exec.Command(
opt.Bin,
"-y",
"-strict", "-2",
"-hwaccel", "auto",
"-i", srcName,
"-pix_fmt", encode.FormatYUV420P.String(),
"-c:v", opt.Encoder.String(),
"-map", opt.MapVideo,
"-map", opt.MapAudio,
"-c:a", "aac",
"-preset", "15",
"-pixel_format", "yuv420p",
"-gpu", "any",
"-vf", opt.VideoFilter(encode.FormatYUV420P),
"-rc:v", "constqp",
"-cq", "0",
"-tune", "2",
"-r", "30",
"-b:v", opt.Bitrate,
"-profile:v", "1",
"-level:v", "auto",
"-coder:v", "1",
"-f", "mp4",
"-movflags", "+faststart", // puts headers at the beginning for faster streaming
destName,
)
}

BIN
internal/ffmpeg/testdata/25fps.vp9 vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,32 @@
package v4l
import (
"os/exec"
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
)
// AvcConvertCmd returns the command for hardware-accelerated transcoding of video files to MPEG-4 AVC.
func AvcConvertCmd(srcName, destName string, opt encode.Options) *exec.Cmd {
// ffmpeg -hide_banner -h encoder=h264_v4l2m2m
return exec.Command(
opt.Bin,
"-y",
"-strict", "-2",
"-i", srcName,
"-c:v", opt.Encoder.String(),
"-map", opt.MapVideo,
"-map", opt.MapAudio,
"-c:a", "aac",
"-vf", opt.VideoFilter(encode.FormatYUV420P),
"-num_output_buffers", "72",
"-num_capture_buffers", "64",
"-max_muxing_queue_size", "1024",
"-crf", "23",
"-r", "30",
"-b:v", opt.Bitrate,
"-f", "mp4",
"-movflags", "+faststart", // puts headers at the beginning for faster streaming
destName,
)
}

View File

@@ -0,0 +1,28 @@
package vaapi
import (
"os/exec"
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
)
// AvcConvertCmd returns the command for hardware-accelerated transcoding of video files to MPEG-4 AVC.
func AvcConvertCmd(srcName, destName string, opt encode.Options) *exec.Cmd {
return exec.Command(
opt.Bin,
"-y",
"-strict", "-2",
"-hwaccel", "vaapi",
"-i", srcName,
"-c:a", "aac",
"-vf", opt.VideoFilter(encode.FormatNV12),
"-c:v", opt.Encoder.String(),
"-map", opt.MapVideo,
"-map", opt.MapAudio,
"-r", "30",
"-b:v", opt.Bitrate,
"-f", "mp4",
"-movflags", "+faststart", // puts headers at the beginning for faster streaming
destName,
)
}

View File

@@ -6,15 +6,15 @@ import (
"github.com/photoprism/photoprism/pkg/media" "github.com/photoprism/photoprism/pkg/media"
) )
// ConvertCommand represents a command to be executed for converting a MediaFile. // ConvertCmd represents a command to be executed for converting a MediaFile.
// including any options to be used for this. // including any options to be used for this.
type ConvertCommand struct { type ConvertCmd struct {
Cmd *exec.Cmd Cmd *exec.Cmd
Orientation media.Orientation Orientation media.Orientation
} }
// String returns the conversion command as string e.g. for logging. // String returns the conversion command as string e.g. for logging.
func (c *ConvertCommand) String() string { func (c *ConvertCmd) String() string {
if c.Cmd == nil { if c.Cmd == nil {
return "" return ""
} }
@@ -23,33 +23,32 @@ func (c *ConvertCommand) String() string {
} }
// WithOrientation sets the media Orientation after successful conversion. // WithOrientation sets the media Orientation after successful conversion.
func (c *ConvertCommand) WithOrientation(o media.Orientation) *ConvertCommand { func (c *ConvertCmd) WithOrientation(o media.Orientation) *ConvertCmd {
c.Orientation = media.ParseOrientation(o, c.Orientation) c.Orientation = media.ParseOrientation(o, c.Orientation)
return c return c
} }
// ResetOrientation resets the media Orientation after successful conversion. // ResetOrientation resets the media Orientation after successful conversion.
func (c *ConvertCommand) ResetOrientation() *ConvertCommand { func (c *ConvertCmd) ResetOrientation() *ConvertCmd {
return c.WithOrientation(media.ResetOrientation) return c.WithOrientation(media.ResetOrientation)
} }
// NewConvertCommand returns a new file converter command with default options. // NewConvertCmd returns a new file converter command with default options.
func NewConvertCommand(cmd *exec.Cmd) *ConvertCommand { func NewConvertCmd(cmd *exec.Cmd) *ConvertCmd {
if cmd == nil { if cmd == nil {
return nil return nil
} }
return &ConvertCommand{ return &ConvertCmd{
Cmd: cmd, // File conversion command. Cmd: cmd, // File conversion command.
Orientation: media.KeepOrientation, // Keep the orientation by default. Orientation: media.KeepOrientation, // Keep the orientation by default.
} }
} }
// ConvertCommands represents a list of possible ConvertCommand commands for converting a MediaFile, // ConvertCmds represents a list of possible ConvertCommand commands for converting a MediaFile, sorted by priority.
// sorted by priority. type ConvertCmds []*ConvertCmd
type ConvertCommands []*ConvertCommand
// NewConvertCommands returns a new, empty list of ConvertCommand commands. // NewConvertCmds returns a new, empty list of ConvertCommand commands.
func NewConvertCommands() ConvertCommands { func NewConvertCmds() ConvertCmds {
return make(ConvertCommands, 0, 8) return make(ConvertCmds, 0, 8)
} }

View File

@@ -9,12 +9,12 @@ import (
"github.com/photoprism/photoprism/pkg/media" "github.com/photoprism/photoprism/pkg/media"
) )
func TestNewConvertCommand(t *testing.T) { func TestNewConvertCmd(t *testing.T) {
t.Run("Nil", func(t *testing.T) { t.Run("Nil", func(t *testing.T) {
assert.Nil(t, NewConvertCommand(nil)) assert.Nil(t, NewConvertCmd(nil))
}) })
t.Run("Default", func(t *testing.T) { t.Run("Default", func(t *testing.T) {
result := NewConvertCommand( result := NewConvertCmd(
exec.Command("/usr/bin/sips", "-Z", "123", "-s", "format", "jpeg", "--out", "file.jpeg", "file.heic"), exec.Command("/usr/bin/sips", "-Z", "123", "-s", "format", "jpeg", "--out", "file.jpeg", "file.heic"),
) )
assert.NotNil(t, result) assert.NotNil(t, result)
@@ -23,7 +23,7 @@ func TestNewConvertCommand(t *testing.T) {
assert.Equal(t, media.KeepOrientation, result.Orientation) assert.Equal(t, media.KeepOrientation, result.Orientation)
}) })
t.Run("WithOrientation", func(t *testing.T) { t.Run("WithOrientation", func(t *testing.T) {
result := NewConvertCommand( result := NewConvertCmd(
exec.Command("/usr/bin/sips", "-Z", "123", "-s", "format", "jpeg", "--out", "file.jpeg", "file.heic"), exec.Command("/usr/bin/sips", "-Z", "123", "-s", "format", "jpeg", "--out", "file.jpeg", "file.heic"),
) )
result.WithOrientation(media.ResetOrientation) result.WithOrientation(media.ResetOrientation)
@@ -34,9 +34,9 @@ func TestNewConvertCommand(t *testing.T) {
}) })
} }
func TestNewConvertCommands(t *testing.T) { func TestNewConvertCmds(t *testing.T) {
t.Run("Success", func(t *testing.T) { t.Run("Success", func(t *testing.T) {
result := NewConvertCommands() result := NewConvertCmds()
assert.NotNil(t, result) assert.NotNil(t, result)
}) })
} }

View File

@@ -108,16 +108,16 @@ func (w *Convert) ToImage(f *MediaFile, force bool) (result *MediaFile, err erro
} }
// Run external commands for other formats. // Run external commands for other formats.
var cmds ConvertCommands var cmds ConvertCmds
var useMutex bool var useMutex bool
var expectedMime string var expectedMime string
switch fs.LowerExt(imageName) { switch fs.LowerExt(imageName) {
case fs.ExtPNG: case fs.ExtPNG:
cmds, useMutex, err = w.PngConvertCommands(f, imageName) cmds, useMutex, err = w.PngConvertCmds(f, imageName)
expectedMime = fs.MimeTypePNG expectedMime = fs.MimeTypePNG
case fs.ExtJPEG: case fs.ExtJPEG:
cmds, useMutex, err = w.JpegConvertCommands(f, imageName, xmpName) cmds, useMutex, err = w.JpegConvertCmds(f, imageName, xmpName)
expectedMime = fs.MimeTypeJPEG expectedMime = fs.MimeTypeJPEG
default: default:
return nil, fmt.Errorf("convert: unspported target format %s (%s)", fs.LowerExt(imageName), clean.Log(f.RootRelName())) return nil, fmt.Errorf("convert: unspported target format %s (%s)", fs.LowerExt(imageName), clean.Log(f.RootRelName()))

View File

@@ -9,9 +9,9 @@ import (
"github.com/photoprism/photoprism/internal/ffmpeg" "github.com/photoprism/photoprism/internal/ffmpeg"
) )
// JpegConvertCommands returns the supported commands for converting a MediaFile to JPEG, sorted by priority. // JpegConvertCmds returns the supported commands for converting a MediaFile to JPEG, sorted by priority.
func (w *Convert) JpegConvertCommands(f *MediaFile, jpegName string, xmpName string) (result ConvertCommands, useMutex bool, err error) { func (w *Convert) JpegConvertCmds(f *MediaFile, jpegName string, xmpName string) (result ConvertCmds, useMutex bool, err error) {
result = NewConvertCommands() result = NewConvertCmds()
if f == nil { if f == nil {
return result, useMutex, fmt.Errorf("file is nil - you may have found a bug") return result, useMutex, fmt.Errorf("file is nil - you may have found a bug")
@@ -23,7 +23,7 @@ func (w *Convert) JpegConvertCommands(f *MediaFile, jpegName string, xmpName str
// Apple Scriptable image processing system: https://ss64.com/osx/sips.html // Apple Scriptable image processing system: https://ss64.com/osx/sips.html
if (f.IsRaw() || f.IsHEIF()) && w.conf.SipsEnabled() && w.sipsExclude.Allow(fileExt) { if (f.IsRaw() || f.IsHEIF()) && w.conf.SipsEnabled() && w.sipsExclude.Allow(fileExt) {
result = append(result, NewConvertCommand( result = append(result, NewConvertCmd(
exec.Command(w.conf.SipsBin(), "-Z", maxSize, "-s", "format", "jpeg", "--out", jpegName, f.FileName())), exec.Command(w.conf.SipsBin(), "-Z", maxSize, "-s", "format", "jpeg", "--out", jpegName, f.FileName())),
) )
} }
@@ -34,7 +34,7 @@ func (w *Convert) JpegConvertCommands(f *MediaFile, jpegName string, xmpName str
// TODO: Adjust command flags for correct colors with HDR10-encoded HEVC videos, // TODO: Adjust command flags for correct colors with HDR10-encoded HEVC videos,
// see https://github.com/photoprism/photoprism/issues/4488 // see https://github.com/photoprism/photoprism/issues/4488
result = append(result, NewConvertCommand( result = append(result, NewConvertCmd(
exec.Command(w.conf.FFmpegBin(), "-y", "-strict", "-2", "-ss", timeOffset, "-i", f.FileName(), "-vframes", "1", exec.Command(w.conf.FFmpegBin(), "-y", "-strict", "-2", "-ss", timeOffset, "-i", f.FileName(), "-vframes", "1",
// Unfortunately, this filter renders thumbnails of non-HDR videos too dark: // Unfortunately, this filter renders thumbnails of non-HDR videos too dark:
// "-vf", "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=gamma:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p", // "-vf", "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=gamma:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p",
@@ -44,7 +44,7 @@ func (w *Convert) JpegConvertCommands(f *MediaFile, jpegName string, xmpName str
// Use heif-convert for HEIC/HEIF and AVIF image files. // Use heif-convert for HEIC/HEIF and AVIF image files.
if (f.IsHEIC() || f.IsAVIF()) && w.conf.HeifConvertEnabled() { if (f.IsHEIC() || f.IsAVIF()) && w.conf.HeifConvertEnabled() {
result = append(result, NewConvertCommand( result = append(result, NewConvertCmd(
exec.Command(w.conf.HeifConvertBin(), "-q", w.conf.JpegQuality().String(), f.FileName(), jpegName)). exec.Command(w.conf.HeifConvertBin(), "-q", w.conf.JpegQuality().String(), f.FileName(), jpegName)).
WithOrientation(w.conf.HeifConvertOrientation()), WithOrientation(w.conf.HeifConvertOrientation()),
) )
@@ -82,7 +82,7 @@ func (w *Convert) JpegConvertCommands(f *MediaFile, jpegName string, xmpName str
args = append(args, "--cachedir", dir) args = append(args, "--cachedir", dir)
} }
result = append(result, NewConvertCommand( result = append(result, NewConvertCmd(
exec.Command(w.conf.DarktableBin(), args...)), exec.Command(w.conf.DarktableBin(), args...)),
) )
} }
@@ -93,7 +93,7 @@ func (w *Convert) JpegConvertCommands(f *MediaFile, jpegName string, xmpName str
args := []string{"-o", jpegName, "-p", profile, "-s", "-d", jpegQuality, "-js3", "-b8", "-c", f.FileName()} args := []string{"-o", jpegName, "-p", profile, "-s", "-d", jpegQuality, "-js3", "-b8", "-c", f.FileName()}
result = append(result, NewConvertCommand( result = append(result, NewConvertCmd(
exec.Command(w.conf.RawTherapeeBin(), args...)), exec.Command(w.conf.RawTherapeeBin(), args...)),
) )
} }
@@ -102,14 +102,14 @@ func (w *Convert) JpegConvertCommands(f *MediaFile, jpegName string, xmpName str
// Extract preview image from DNG files. // Extract preview image from DNG files.
if f.IsDNG() && w.conf.ExifToolEnabled() { if f.IsDNG() && w.conf.ExifToolEnabled() {
// Example: exiftool -b -PreviewImage -w IMG_4691.DNG.jpg IMG_4691.DNG // Example: exiftool -b -PreviewImage -w IMG_4691.DNG.jpg IMG_4691.DNG
result = append(result, NewConvertCommand( result = append(result, NewConvertCmd(
exec.Command(w.conf.ExifToolBin(), "-q", "-q", "-b", "-PreviewImage", f.FileName())), exec.Command(w.conf.ExifToolBin(), "-q", "-q", "-b", "-PreviewImage", f.FileName())),
) )
} }
// Decode JPEG XL image if support is enabled. // Decode JPEG XL image if support is enabled.
if f.IsJpegXL() && w.conf.JpegXLEnabled() { if f.IsJpegXL() && w.conf.JpegXLEnabled() {
result = append(result, NewConvertCommand( result = append(result, NewConvertCmd(
exec.Command(w.conf.JpegXLDecoderBin(), f.FileName(), jpegName)), exec.Command(w.conf.JpegXLDecoderBin(), f.FileName(), jpegName)),
) )
} }
@@ -120,7 +120,7 @@ func (w *Convert) JpegConvertCommands(f *MediaFile, jpegName string, xmpName str
quality := fmt.Sprintf("%d", w.conf.JpegQuality()) quality := fmt.Sprintf("%d", w.conf.JpegQuality())
resize := fmt.Sprintf("%dx%d>", w.conf.JpegSize(), w.conf.JpegSize()) resize := fmt.Sprintf("%dx%d>", w.conf.JpegSize(), w.conf.JpegSize())
args := []string{f.FileName(), "-flatten", "-resize", resize, "-quality", quality, jpegName} args := []string{f.FileName(), "-flatten", "-resize", resize, "-quality", quality, jpegName}
result = append(result, NewConvertCommand( result = append(result, NewConvertCmd(
exec.Command(w.conf.ImageMagickBin(), args...)), exec.Command(w.conf.ImageMagickBin(), args...)),
) )
} }

View File

@@ -8,9 +8,9 @@ import (
"github.com/photoprism/photoprism/internal/ffmpeg" "github.com/photoprism/photoprism/internal/ffmpeg"
) )
// PngConvertCommands returns commands for converting a media file to PNG, if possible. // PngConvertCmds returns commands for converting a media file to PNG, if possible.
func (w *Convert) PngConvertCommands(f *MediaFile, pngName string) (result ConvertCommands, useMutex bool, err error) { func (w *Convert) PngConvertCmds(f *MediaFile, pngName string) (result ConvertCmds, useMutex bool, err error) {
result = NewConvertCommands() result = NewConvertCmds()
if f == nil { if f == nil {
return result, useMutex, fmt.Errorf("file is nil - you may have found a bug") return result, useMutex, fmt.Errorf("file is nil - you may have found a bug")
@@ -22,7 +22,7 @@ func (w *Convert) PngConvertCommands(f *MediaFile, pngName string) (result Conve
// Apple Scriptable image processing system: https://ss64.com/osx/sips.html // Apple Scriptable image processing system: https://ss64.com/osx/sips.html
if (f.IsRaw() || f.IsHEIF()) && w.conf.SipsEnabled() && w.sipsExclude.Allow(fileExt) { if (f.IsRaw() || f.IsHEIF()) && w.conf.SipsEnabled() && w.sipsExclude.Allow(fileExt) {
result = append(result, NewConvertCommand( result = append(result, NewConvertCmd(
exec.Command(w.conf.SipsBin(), "-Z", maxSize, "-s", "format", "png", "--out", pngName, f.FileName())), exec.Command(w.conf.SipsBin(), "-Z", maxSize, "-s", "format", "png", "--out", pngName, f.FileName())),
) )
} }
@@ -30,14 +30,14 @@ func (w *Convert) PngConvertCommands(f *MediaFile, pngName string) (result Conve
// Extract a video still image that can be used as preview. // Extract a video still image that can be used as preview.
if f.IsAnimated() && !f.IsWebP() && w.conf.FFmpegEnabled() { if f.IsAnimated() && !f.IsWebP() && w.conf.FFmpegEnabled() {
// Use "ffmpeg" to extract a PNG still image from the video. // Use "ffmpeg" to extract a PNG still image from the video.
result = append(result, NewConvertCommand( result = append(result, NewConvertCmd(
exec.Command(w.conf.FFmpegBin(), "-y", "-strict", "-2", "-ss", ffmpeg.PreviewTimeOffset(f.Duration()), "-i", f.FileName(), "-vframes", "1", pngName)), exec.Command(w.conf.FFmpegBin(), "-y", "-strict", "-2", "-ss", ffmpeg.PreviewTimeOffset(f.Duration()), "-i", f.FileName(), "-vframes", "1", pngName)),
) )
} }
// Use heif-convert for HEIC/HEIF and AVIF image files. // Use heif-convert for HEIC/HEIF and AVIF image files.
if (f.IsHEIC() || f.IsAVIF()) && w.conf.HeifConvertEnabled() { if (f.IsHEIC() || f.IsAVIF()) && w.conf.HeifConvertEnabled() {
result = append(result, NewConvertCommand( result = append(result, NewConvertCmd(
exec.Command(w.conf.HeifConvertBin(), f.FileName(), pngName)). exec.Command(w.conf.HeifConvertBin(), f.FileName(), pngName)).
WithOrientation(w.conf.HeifConvertOrientation()), WithOrientation(w.conf.HeifConvertOrientation()),
) )
@@ -45,7 +45,7 @@ func (w *Convert) PngConvertCommands(f *MediaFile, pngName string) (result Conve
// Decode JPEG XL image if support is enabled. // Decode JPEG XL image if support is enabled.
if f.IsJpegXL() && w.conf.JpegXLEnabled() { if f.IsJpegXL() && w.conf.JpegXLEnabled() {
result = append(result, NewConvertCommand( result = append(result, NewConvertCmd(
exec.Command(w.conf.JpegXLDecoderBin(), f.FileName(), pngName)), exec.Command(w.conf.JpegXLDecoderBin(), f.FileName(), pngName)),
) )
} }
@@ -54,14 +54,14 @@ func (w *Convert) PngConvertCommands(f *MediaFile, pngName string) (result Conve
// otherwise try to convert the media file with ImageMagick. // otherwise try to convert the media file with ImageMagick.
if w.conf.RsvgConvertEnabled() && f.IsSVG() { if w.conf.RsvgConvertEnabled() && f.IsSVG() {
args := []string{"-a", "-f", "png", "-o", pngName, f.FileName()} args := []string{"-a", "-f", "png", "-o", pngName, f.FileName()}
result = append(result, NewConvertCommand( result = append(result, NewConvertCmd(
exec.Command(w.conf.RsvgConvertBin(), args...)), exec.Command(w.conf.RsvgConvertBin(), args...)),
) )
} else if w.conf.ImageMagickEnabled() && w.imageMagickExclude.Allow(fileExt) && } else if w.conf.ImageMagickEnabled() && w.imageMagickExclude.Allow(fileExt) &&
(f.IsImage() && !f.IsJpegXL() && !f.IsRaw() && !f.IsHEIF() || f.IsVector() && w.conf.VectorEnabled()) { (f.IsImage() && !f.IsJpegXL() && !f.IsRaw() && !f.IsHEIF() || f.IsVector() && w.conf.VectorEnabled()) {
resize := fmt.Sprintf("%dx%d>", w.conf.PngSize(), w.conf.PngSize()) resize := fmt.Sprintf("%dx%d>", w.conf.PngSize(), w.conf.PngSize())
args := []string{f.FileName(), "-flatten", "-resize", resize, pngName} args := []string{f.FileName(), "-flatten", "-resize", resize, pngName}
result = append(result, NewConvertCommand( result = append(result, NewConvertCmd(
exec.Command(w.conf.ImageMagickBin(), args...)), exec.Command(w.conf.ImageMagickBin(), args...)),
) )
} }

View File

@@ -192,7 +192,7 @@ func TestConvert_ToImage(t *testing.T) {
}) })
} }
func TestConvert_PngConvertCommands(t *testing.T) { func TestConvert_PngConvertCmds(t *testing.T) {
cnf := config.TestConfig() cnf := config.TestConfig()
convert := NewConvert(cnf) convert := NewConvert(cnf)
@@ -208,7 +208,7 @@ func TestConvert_PngConvertCommands(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
cmds, useMutex, err := convert.PngConvertCommands(mediaFile, pngFile) cmds, useMutex, err := convert.PngConvertCmds(mediaFile, pngFile)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)

View File

@@ -12,13 +12,13 @@ import (
"github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/ffmpeg" "github.com/photoprism/photoprism/internal/ffmpeg"
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/fs"
) )
// ToAvc converts a single video file to MPEG-4 AVC. // ToAvc converts a single video file to MPEG-4 AVC.
func (w *Convert) ToAvc(f *MediaFile, encoder ffmpeg.AvcEncoder, noMutex, force bool) (file *MediaFile, err error) { func (w *Convert) ToAvc(f *MediaFile, encoder encode.Encoder, noMutex, force bool) (file *MediaFile, err error) {
// Abort if the source media file is nil. // Abort if the source media file is nil.
if f == nil { if f == nil {
return nil, fmt.Errorf("convert: file is nil - you may have found a bug") return nil, fmt.Errorf("convert: file is nil - you may have found a bug")
@@ -66,7 +66,7 @@ func (w *Convert) ToAvc(f *MediaFile, encoder ffmpeg.AvcEncoder, noMutex, force
avcName, _ = fs.FileName(f.FileName(), w.conf.SidecarPath(), w.conf.OriginalsPath(), fs.ExtAVC) avcName, _ = fs.FileName(f.FileName(), w.conf.SidecarPath(), w.conf.OriginalsPath(), fs.ExtAVC)
} }
cmd, useMutex, err := w.AvcConvertCommand(f, avcName, encoder) cmd, useMutex, err := w.AvcConvertCmd(f, avcName, encoder)
// Return if an error occurred. // Return if an error occurred.
if err != nil { if err != nil {
@@ -138,8 +138,8 @@ func (w *Convert) ToAvc(f *MediaFile, encoder ffmpeg.AvcEncoder, noMutex, force
} }
// Try again using software encoder. // Try again using software encoder.
if encoder != ffmpeg.SoftwareEncoder { if encoder != encode.SoftwareAvc {
return w.ToAvc(f, ffmpeg.SoftwareEncoder, true, false) return w.ToAvc(f, encode.SoftwareAvc, true, false)
} else { } else {
return nil, err return nil, err
} }
@@ -152,8 +152,8 @@ func (w *Convert) ToAvc(f *MediaFile, encoder ffmpeg.AvcEncoder, noMutex, force
return NewMediaFile(avcName) return NewMediaFile(avcName)
} }
// AvcConvertCommand returns the command for converting video files to MPEG-4 AVC. // AvcConvertCmd returns the command for converting video files to MPEG-4 AVC.
func (w *Convert) AvcConvertCommand(f *MediaFile, avcName string, encoder ffmpeg.AvcEncoder) (result *exec.Cmd, useMutex bool, err error) { func (w *Convert) AvcConvertCmd(f *MediaFile, avcName string, encoder encode.Encoder) (result *exec.Cmd, useMutex bool, err error) {
fileExt := f.Extension() fileExt := f.Extension()
fileName := f.FileName() fileName := f.FileName()
@@ -170,11 +170,11 @@ func (w *Convert) AvcConvertCommand(f *MediaFile, avcName string, encoder ffmpeg
} }
// Use FFmpeg to transcode all other media files to AVC. // Use FFmpeg to transcode all other media files to AVC.
var opt ffmpeg.Options var opt encode.Options
if opt, err = w.conf.FFmpegOptions(encoder, w.AvcBitrate(f)); err != nil { if opt, err = w.conf.FFmpegOptions(encoder, w.AvcBitrate(f)); err != nil {
return nil, false, fmt.Errorf("convert: failed to transcode %s (%s)", clean.Log(f.BaseName()), err) return nil, false, fmt.Errorf("convert: failed to transcode %s (%s)", clean.Log(f.BaseName()), err)
} else { } else {
return ffmpeg.AvcConvertCommand(fileName, avcName, opt) return ffmpeg.AvcConvertCmd(fileName, avcName, opt)
} }
} }

View File

@@ -8,7 +8,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/ffmpeg" "github.com/photoprism/photoprism/internal/ffmpeg/encode"
"github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/fs"
) )
@@ -30,7 +30,7 @@ func TestConvert_ToAvc(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
avcFile, err := convert.ToAvc(mf, ffmpeg.SoftwareEncoder, false, false) avcFile, err := convert.ToAvc(mf, encode.SoftwareAvc, false, false)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -137,7 +137,7 @@ func TestConvert_AvcBitrate(t *testing.T) {
}) })
} }
func TestConvert_AvcConvertCommand(t *testing.T) { func TestConvert_AvcConvertCmd(t *testing.T) {
conf := config.TestConfig() conf := config.TestConfig()
convert := NewConvert(conf) convert := NewConvert(conf)
@@ -149,7 +149,7 @@ func TestConvert_AvcConvertCommand(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
r, _, err := convert.AvcConvertCommand(mf, "avc1", ffmpeg.SoftwareEncoder) r, _, err := convert.AvcConvertCmd(mf, "avc1", encode.SoftwareAvc)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -166,7 +166,7 @@ func TestConvert_AvcConvertCommand(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
r, useMutex, err := convert.AvcConvertCommand(mf, "avc1", ffmpeg.SoftwareEncoder) r, useMutex, err := convert.AvcConvertCmd(mf, "avc1", encode.SoftwareAvc)
assert.False(t, useMutex) assert.False(t, useMutex)
assert.Error(t, err) assert.Error(t, err)
@@ -181,7 +181,7 @@ func TestConvert_AvcConvertCommand(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
r, useMutex, err := convert.AvcConvertCommand(mf, avcName, ffmpeg.SoftwareEncoder) r, useMutex, err := convert.AvcConvertCmd(mf, avcName, encode.SoftwareAvc)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)