FFmpeg: Refactor extraction of JPEG and PNG images from videos #4604

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-01-09 10:24:53 +01:00
parent 8b7871fc02
commit e2195d535e
34 changed files with 370 additions and 229 deletions

View File

@@ -1,7 +1,7 @@
/*
Package config provides global options, command-line flags, and user settings.
Copyright (c) 2018 - 2024 PhotoPrism UG. All rights reserved.
Copyright (c) 2018 - 2025 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"):

View File

@@ -79,8 +79,8 @@ func (c *Config) FFmpegOptions(encoder encode.Encoder, bitrate string) (encode.O
opt := encode.Options{
Bin: c.FFmpegBin(),
Encoder: encoder,
Size: c.FFmpegSize(),
Bitrate: bitrate,
DestSize: c.FFmpegSize(),
DestBitrate: bitrate,
MapVideo: c.FFmpegMapVideo(),
MapAudio: c.FFmpegMapAudio(),
}

View File

@@ -109,7 +109,7 @@ func TestConfig_FFmpegOptions(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, c.FFmpegBin(), opt.Bin)
assert.Equal(t, encode.SoftwareAvc, opt.Encoder)
assert.Equal(t, bitrate, opt.Bitrate)
assert.Equal(t, bitrate, opt.DestBitrate)
assert.Equal(t, ffmpeg.MapVideoDefault, opt.MapVideo)
assert.Equal(t, ffmpeg.MapAudioDefault, opt.MapAudio)
assert.Equal(t, c.FFmpegMapVideo(), opt.MapVideo)

View File

@@ -1,7 +1,7 @@
/*
Package customize provides user settings to customize the app.
Copyright (c) 2018 - 2024 PhotoPrism UG. All rights reserved.
Copyright (c) 2018 - 2025 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"):

View File

@@ -1,7 +1,7 @@
/*
Package pwa provides data structures and tools for working with progressive web applications.
Copyright (c) 2018 - 2024 PhotoPrism UG. All rights reserved.
Copyright (c) 2018 - 2025 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"):

View File

@@ -1,7 +1,7 @@
/*
Package ttl provides cache expiration defaults and helper functions.
Copyright (c) 2018 - 2024 PhotoPrism UG. All rights reserved.
Copyright (c) 2018 - 2025 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"):

View File

@@ -6,8 +6,8 @@ import (
"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 {
// TranscodeToAvcCmd returns the FFmpeg command for hardware-accelerated transcoding to MPEG-4 AVC.
func TranscodeToAvcCmd(srcName, destName string, opt encode.Options) *exec.Cmd {
// ffmpeg -hide_banner -h encoder=h264_videotoolbox
return exec.Command(
opt.Bin,
@@ -22,7 +22,7 @@ func AvcConvertCmd(srcName, destName string, opt encode.Options) *exec.Cmd {
"-profile", "high",
"-level", "51",
"-r", "30",
"-b:v", opt.Bitrate,
"-b:v", opt.DestBitrate,
"-f", "mp4",
"-movflags", "+faststart",
destName,

View File

@@ -2,8 +2,8 @@ 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 {
// TranscodeToAvcCmd returns the default FFmpeg command for transcoding video files to MPEG-4 AVC.
func TranscodeToAvcCmd(srcName, destName string, opt Options) *exec.Cmd {
return exec.Command(
opt.Bin,
"-y",
@@ -17,7 +17,7 @@ func AvcConvertCmd(srcName, destName string, opt Options) *exec.Cmd {
"-max_muxing_queue_size", "1024",
"-crf", "23",
"-r", "30",
"-b:v", opt.Bitrate,
"-b:v", opt.DestBitrate,
"-f", "mp4",
"-movflags", "+faststart", // puts headers at the beginning for faster streaming
destName,

View File

@@ -1,7 +1,7 @@
/*
Package encode provides FFmpeg video encoder related types and functions.
Copyright (c) 2018 - 2024 PhotoPrism UG. All rights reserved.
Copyright (c) 2018 - 2025 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"):

View File

@@ -10,7 +10,7 @@ func (name Encoder) String() string {
return string(name)
}
// Supported FFmpeg AVC encoders.
// Currently supported FFmpeg output 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.

View File

@@ -1,12 +1,17 @@
package ffmpeg
package encode
import "time"
// PreviewTimeOffset returns an appropriate time offset depending on the duration for extracting a preview image.
// PreviewTimeOffset returns a time offset depending on the video duration for extracting a cover image,
// see https://trac.ffmpeg.org/wiki/Seeking and https://ffmpeg.org/ffmpeg-utils.html#time-duration-syntax.
func PreviewTimeOffset(d time.Duration) string {
// Default.
// Default time offset.
result := "00:00:00.001"
if d <= 0 {
return result
}
// If the video is long enough, don't use the first frames to avoid completely
// black or white thumbnails in case there is an effect or intro.
switch {

View File

@@ -0,0 +1,35 @@
package encode
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestPreviewTimeOffset(t *testing.T) {
t.Run("Second", func(t *testing.T) {
assert.Equal(t, "00:00:00.001", PreviewTimeOffset(time.Second))
})
t.Run("Minute", func(t *testing.T) {
assert.Equal(t, "00:00:03.000", PreviewTimeOffset(time.Minute))
})
t.Run("ThreeMinutes", func(t *testing.T) {
assert.Equal(t, "00:00:09.000", PreviewTimeOffset(3*time.Minute))
})
t.Run("FiveMinutes", func(t *testing.T) {
assert.Equal(t, "00:00:30.000", PreviewTimeOffset(5*time.Minute))
})
t.Run("FifteenMinutes", func(t *testing.T) {
assert.Equal(t, "00:01:00.000", PreviewTimeOffset(15*time.Minute))
})
t.Run("HalfHour", func(t *testing.T) {
assert.Equal(t, "00:01:00.000", PreviewTimeOffset(30*time.Minute))
})
t.Run("Hour", func(t *testing.T) {
assert.Equal(t, "00:01:00.000", PreviewTimeOffset(time.Hour))
})
t.Run("ThreeHours", func(t *testing.T) {
assert.Equal(t, "00:02:30.000", PreviewTimeOffset(3*time.Hour))
})
}

View File

@@ -1,25 +1,38 @@
package encode
import "fmt"
import (
"fmt"
"time"
)
// Options represents transcoding options.
// Options represents FFmpeg encoding options.
type Options struct {
Bin string
Encoder Encoder
Size int
Bitrate string
MapVideo string
MapAudio string
Bin string // FFmpeg binary filename, e.g. /usr/bin/ffmpeg.
Encoder Encoder // Supported FFmpeg output Encoder.
DestSize int // Maximum width and height of the output video file in pixels.
DestBitrate string // See https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate.
MapVideo string // See https://trac.ffmpeg.org/wiki/Map#Videostreamsonly.
MapAudio string // See https://trac.ffmpeg.org/wiki/Map#Audiostreamsonly.
TimeOffset string // See https://trac.ffmpeg.org/wiki/Seeking and https://ffmpeg.org/ffmpeg-utils.html#time-duration-syntax.
Duration time.Duration // See https://ffmpeg.org/ffmpeg.html#Main-options.
}
// NewPreviewImageOptions generates encoding options for extracting a video preview image.
func NewPreviewImageOptions(ffmpegBin string, videoDuration time.Duration) *Options {
return &Options{
Bin: ffmpegBin,
TimeOffset: PreviewTimeOffset(videoDuration),
}
}
// VideoFilter returns the FFmpeg video filter string based on the size limit in pixels and the pixel format.
func (o Options) VideoFilter(format PixelFormat) string {
func (o *Options) VideoFilter(format PixelFormat) string {
// scale specifies the FFmpeg downscale filter, see http://trac.ffmpeg.org/wiki/Scaling.
if format == "" {
return fmt.Sprintf("scale='if(gte(iw,ih), min(%d, iw), -2):if(gte(iw,ih), -2, min(%d, ih))'", o.Size, o.Size)
return fmt.Sprintf("scale='if(gte(iw,ih), min(%d, iw), -2):if(gte(iw,ih), -2, min(%d, ih))'", o.DestSize, o.DestSize)
} else if format == FormatQSV {
return fmt.Sprintf("scale_qsv=w='if(gte(iw,ih), min(%d, iw), -1)':h='if(gte(iw,ih), -1, min(%d, ih))':format=nv12", o.Size, o.Size)
return fmt.Sprintf("scale_qsv=w='if(gte(iw,ih), min(%d, iw), -1)':h='if(gte(iw,ih), -1, min(%d, ih))':format=nv12", o.DestSize, o.DestSize)
} else {
return fmt.Sprintf("scale='if(gte(iw,ih), min(%d, iw), -2):if(gte(iw,ih), -2, min(%d, ih))',format=%s", o.Size, o.Size, format)
return fmt.Sprintf("scale='if(gte(iw,ih), min(%d, iw), -2):if(gte(iw,ih), -2, min(%d, ih))',format=%s", o.DestSize, o.DestSize, format)
}
}

View File

@@ -7,23 +7,23 @@ import (
)
func TestOptions_VideoFilter(t *testing.T) {
Options := &Options{
opt := &Options{
Bin: "",
Encoder: "intel",
Size: 1500,
Bitrate: "50M",
DestSize: 1500,
DestBitrate: "50M",
MapVideo: "",
MapAudio: "",
}
t.Run("rgb32", func(t *testing.T) {
r := Options.VideoFilter("rgb32")
assert.Contains(t, r, "format=rgb32")
assert.Contains(t, r, "min(1500, iw)")
})
t.Run("empty format", func(t *testing.T) {
r := Options.VideoFilter("")
t.Run("Empty", func(t *testing.T) {
r := opt.VideoFilter("")
assert.NotContains(t, r, "format")
assert.Contains(t, r, "min(1500, iw)")
})
t.Run("Rgb32", func(t *testing.T) {
r := opt.VideoFilter("rgb32")
assert.Contains(t, r, "format=rgb32")
assert.Contains(t, r, "min(1500, iw)")
})
}

View File

@@ -0,0 +1,35 @@
package ffmpeg
import (
"os/exec"
"path/filepath"
"strings"
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
)
// ExtractImageCmd extracts a still image from the specified source video file.
func ExtractImageCmd(videoName, imageName string, opt *encode.Options) *exec.Cmd {
imageExt := strings.ToLower(filepath.Ext(imageName))
switch imageExt {
case ".png":
return ExtractPngImageCmd(videoName, imageName, opt)
default:
return ExtractJpegImageCmd(videoName, imageName, opt)
}
}
// ExtractJpegImageCmd extracts a JPEG still image from the specified source video file.
func ExtractJpegImageCmd(videoName, imageName string, opt *encode.Options) *exec.Cmd {
// TODO: Adjust command flags for correct colors with HDR10-encoded HEVC videos,
// see https://github.com/photoprism/photoprism/issues/4488.
// Unfortunately, this filter would render 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",
return exec.Command(opt.Bin, "-y", "-strict", "-2", "-ss", opt.TimeOffset, "-i", videoName, "-vframes", "1", imageName)
}
// ExtractPngImageCmd extracts a PNG still image from the specified source video file.
func ExtractPngImageCmd(videoName, imageName string, opt *encode.Options) *exec.Cmd {
return exec.Command(opt.Bin, "-y", "-strict", "-2", "-ss", opt.TimeOffset, "-i", videoName, "-vframes", "1", imageName)
}

View File

@@ -0,0 +1,63 @@
package ffmpeg
import (
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
"github.com/photoprism/photoprism/pkg/fs"
)
func TestExtractImageCmd(t *testing.T) {
opt := encode.NewPreviewImageOptions("/usr/bin/ffmpeg", time.Second*9)
srcName := fs.Abs("./testdata/25fps.vp9")
destName := fs.Abs("./testdata/25fps.jpg")
cmd := ExtractImageCmd(srcName, destName, opt)
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 -ss 00:00:03.000 -i SRC -vframes 1 DEST", cmdStr)
RunCommandTest(t, "jpg", srcName, destName, cmd, true)
}
func TestExtractJpegImageCmd(t *testing.T) {
opt := encode.NewPreviewImageOptions("/usr/bin/ffmpeg", time.Second*9)
srcName := fs.Abs("./testdata/25fps.vp9")
destName := fs.Abs("./testdata/25fps.jpeg")
cmd := ExtractJpegImageCmd(srcName, destName, opt)
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 -ss 00:00:03.000 -i SRC -vframes 1 DEST", cmdStr)
RunCommandTest(t, "jpeg", srcName, destName, cmd, true)
}
func TestExtractPngImageCmd(t *testing.T) {
opt := encode.NewPreviewImageOptions("/usr/bin/ffmpeg", time.Second*9)
srcName := fs.Abs("./testdata/25fps.vp9")
destName := fs.Abs("./testdata/25fps.png")
cmd := ExtractPngImageCmd(srcName, destName, opt)
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 -ss 00:00:03.000 -i SRC -vframes 1 DEST", cmdStr)
RunCommandTest(t, "png", srcName, destName, cmd, true)
}

View File

@@ -1,7 +1,7 @@
/*
Package ffmpeg provides FFmpeg video transcoding related types and functions.
Copyright (c) 2018 - 2024 PhotoPrism UG. All rights reserved.
Copyright (c) 2018 - 2025 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"):

View File

@@ -6,8 +6,8 @@ import (
"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 {
// TranscodeToAvcCmd returns the FFmpeg command for hardware-accelerated transcoding to MPEG-4 AVC.
func TranscodeToAvcCmd(srcName, destName string, opt encode.Options) *exec.Cmd {
// ffmpeg -hide_banner -h encoder=h264_qsv
return exec.Command(
opt.Bin,
@@ -23,8 +23,8 @@ func AvcConvertCmd(srcName, destName string, opt encode.Options) *exec.Cmd {
"-map", opt.MapVideo,
"-map", opt.MapAudio,
"-r", "30",
"-b:v", opt.Bitrate,
"-bitrate", opt.Bitrate,
"-b:v", opt.DestBitrate,
"-bitrate", opt.DestBitrate,
"-f", "mp4",
"-movflags", "+faststart", // puts headers at the beginning for faster streaming
destName,

View File

@@ -6,8 +6,8 @@ import (
"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 {
// TranscodeToAvcCmd returns the FFmpeg command for hardware-accelerated transcoding to MPEG-4 AVC.
func TranscodeToAvcCmd(srcName, destName string, opt encode.Options) *exec.Cmd {
// ffmpeg -hide_banner -h encoder=h264_nvenc
return exec.Command(
opt.Bin,
@@ -28,7 +28,7 @@ func AvcConvertCmd(srcName, destName string, opt encode.Options) *exec.Cmd {
"-cq", "0",
"-tune", "2",
"-r", "30",
"-b:v", opt.Bitrate,
"-b:v", opt.DestBitrate,
"-profile:v", "1",
"-level:v", "auto",
"-coder:v", "1",

View File

@@ -1,19 +0,0 @@
package ffmpeg
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestPreviewTimeOffset(t *testing.T) {
assert.Equal(t, "00:00:00.001", PreviewTimeOffset(time.Second))
assert.Equal(t, "00:00:03.000", PreviewTimeOffset(time.Minute))
assert.Equal(t, "00:00:09.000", PreviewTimeOffset(3*time.Minute))
assert.Equal(t, "00:00:30.000", PreviewTimeOffset(5*time.Minute))
assert.Equal(t, "00:01:00.000", PreviewTimeOffset(15*time.Minute))
assert.Equal(t, "00:01:00.000", PreviewTimeOffset(30*time.Minute))
assert.Equal(t, "00:01:00.000", PreviewTimeOffset(time.Hour))
assert.Equal(t, "00:02:30.000", PreviewTimeOffset(3*time.Hour))
}

58
internal/ffmpeg/test.go Normal file
View File

@@ -0,0 +1,58 @@
package ffmpeg
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"testing"
"time"
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
"github.com/photoprism/photoprism/pkg/fs"
)
func RunCommandTest(t *testing.T, encoder encode.Encoder, srcName, destName string, cmd *exec.Cmd, deleteAfterTest bool) {
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
cmd.Env = append(cmd.Env, []string{
fmt.Sprintf("HOME=%s", fs.Abs("./testdata")),
}...)
// Transcode source media file to AVC.
start := time.Now()
if err := cmd.Run(); err != nil {
if stderr.String() != "" {
err = errors.New(stderr.String())
}
// Remove broken video file.
if !deleteAfterTest || !fs.FileExists(destName) {
// Do nothing.
} 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))
// Return if destination file should not be deleted.
if !deleteAfterTest {
return
}
// Delete destination file after test.
if removeErr := os.Remove(destName); removeErr != nil {
t.Fatalf("%s: failed to remove %s after successful test (%s)", encoder, srcName, removeErr)
}
}

View File

@@ -13,8 +13,8 @@ import (
"github.com/photoprism/photoprism/pkg/fs"
)
// AvcConvertCmd returns the command for converting video files to MPEG-4 AVC.
func AvcConvertCmd(srcName, destName string, opt encode.Options) (cmd *exec.Cmd, useMutex bool, err error) {
// TranscodeCmd returns the FFmpeg command for transcoding existing video files to MPEG-4 AVC.
func TranscodeCmd(srcName, destName string, opt encode.Options) (cmd *exec.Cmd, useMutex bool, err error) {
if srcName == "" {
return nil, false, fmt.Errorf("empty source filename")
} else if destName == "" {
@@ -24,7 +24,7 @@ func AvcConvertCmd(srcName, destName string, opt encode.Options) (cmd *exec.Cmd,
// Don't transcode more than one video at the same time.
useMutex = true
// Use default ffmpeg command name.
// Use default FFmpeg command name.
if opt.Bin == "" {
opt.Bin = DefaultBin
}
@@ -53,22 +53,22 @@ func AvcConvertCmd(srcName, destName string, opt encode.Options) (cmd *exec.Cmd,
switch opt.Encoder {
case encode.IntelAvc:
cmd = intel.AvcConvertCmd(srcName, destName, opt)
cmd = intel.TranscodeToAvcCmd(srcName, destName, opt)
case encode.AppleAvc:
cmd = apple.AvcConvertCmd(srcName, destName, opt)
cmd = apple.TranscodeToAvcCmd(srcName, destName, opt)
case encode.VaapiAvc:
cmd = vaapi.AvcConvertCmd(srcName, destName, opt)
cmd = vaapi.TranscodeToAvcCmd(srcName, destName, opt)
case encode.NvidiaAvc:
cmd = nvidia.AvcConvertCmd(srcName, destName, opt)
cmd = nvidia.TranscodeToAvcCmd(srcName, destName, opt)
case encode.V4LAvc:
cmd = v4l.AvcConvertCmd(srcName, destName, opt)
cmd = v4l.TranscodeToAvcCmd(srcName, destName, opt)
default:
cmd = encode.AvcConvertCmd(srcName, destName, opt)
cmd = encode.TranscodeToAvcCmd(srcName, destName, opt)
}
return cmd, useMutex, nil

View File

@@ -1,14 +1,8 @@
package ffmpeg
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
@@ -16,55 +10,17 @@ import (
"github.com/photoprism/photoprism/pkg/fs"
)
func TestAvcConvertCmd(t *testing.T) {
runCmd := func(t *testing.T, encoder encode.Encoder, srcName, destName string, cmd *exec.Cmd) {
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
cmd.Env = append(cmd.Env, []string{
fmt.Sprintf("HOME=%s", fs.Abs("./testdata")),
}...)
// Transcode source media file to AVC.
start := time.Now()
if err := cmd.Run(); err != nil {
if stderr.String() != "" {
err = errors.New(stderr.String())
}
// Remove broken video file.
if !fs.FileExists(destName) {
// Do nothing.
} 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)
}
}
func TestTranscodeCmd(t *testing.T) {
t.Run("NoSource", func(t *testing.T) {
opt := encode.Options{
Bin: "",
Encoder: "intel",
Size: 1500,
Bitrate: "50M",
DestSize: 1500,
DestBitrate: "50M",
MapVideo: MapVideoDefault,
MapAudio: MapAudioDefault,
}
_, _, err := AvcConvertCmd("", "", opt)
_, _, err := TranscodeCmd("", "", opt)
assert.Equal(t, "empty source filename", err.Error())
})
@@ -72,12 +28,12 @@ func TestAvcConvertCmd(t *testing.T) {
opt := encode.Options{
Bin: "",
Encoder: "intel",
Size: 1500,
Bitrate: "50M",
DestSize: 1500,
DestBitrate: "50M",
MapVideo: MapVideoDefault,
MapAudio: MapAudioDefault,
}
_, _, err := AvcConvertCmd("VID123.mov", "", opt)
_, _, err := TranscodeCmd("VID123.mov", "", opt)
assert.Equal(t, "empty destination filename", err.Error())
})
@@ -85,12 +41,12 @@ func TestAvcConvertCmd(t *testing.T) {
opt := encode.Options{
Bin: "",
Encoder: "intel",
Size: 1500,
Bitrate: "50M",
DestSize: 1500,
DestBitrate: "50M",
MapVideo: MapVideoDefault,
MapAudio: MapAudioDefault,
}
r, _, err := AvcConvertCmd("VID123.gif", "VID123.gif.avc", opt)
r, _, err := TranscodeCmd("VID123.gif", "VID123.gif.avc", opt)
if err != nil {
t.Fatal(err)
@@ -98,14 +54,14 @@ func TestAvcConvertCmd(t *testing.T) {
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("VP9", func(t *testing.T) {
t.Run("VP9toAVC", func(t *testing.T) {
encoder := encode.SoftwareAvc
opt := encode.Options{
Bin: "/usr/bin/ffmpeg",
Encoder: encoder,
Size: 1500,
Bitrate: "50M",
DestSize: 1500,
DestBitrate: "50M",
MapVideo: MapVideoDefault,
MapAudio: MapAudioDefault,
}
@@ -113,7 +69,7 @@ func TestAvcConvertCmd(t *testing.T) {
srcName := fs.Abs("./testdata/25fps.vp9")
destName := fs.Abs("./testdata/25fps.avc")
cmd, _, err := AvcConvertCmd(srcName, destName, opt)
cmd, _, err := TranscodeCmd(srcName, destName, opt)
if err != nil {
t.Fatal(err)
@@ -125,16 +81,16 @@ func TestAvcConvertCmd(t *testing.T) {
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)
// Run generated command to test software transcoding.
RunCommandTest(t, encoder, srcName, destName, cmd, true)
})
t.Run("Vaapi", func(t *testing.T) {
encoder := encode.VaapiAvc
opt := encode.Options{
Bin: "/usr/bin/ffmpeg",
Encoder: encoder,
Size: 1500,
Bitrate: "50M",
DestSize: 1500,
DestBitrate: "50M",
MapVideo: MapVideoDefault,
MapAudio: MapAudioDefault,
}
@@ -142,7 +98,7 @@ func TestAvcConvertCmd(t *testing.T) {
srcName := fs.Abs("./testdata/25fps.vp9")
destName := fs.Abs("./testdata/25fps.vaapi.avc")
cmd, _, err := AvcConvertCmd(srcName, destName, opt)
cmd, _, err := TranscodeCmd(srcName, destName, opt)
if err != nil {
t.Fatal(err)
@@ -154,19 +110,19 @@ func TestAvcConvertCmd(t *testing.T) {
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)
// Running the generated command to test vaapi transcoding requires a compatible device.
// RunCommandTest(t, encoder, srcName, destName, cmd, true)
})
t.Run("QSV", func(t *testing.T) {
opt := encode.Options{
Bin: "",
Encoder: "h264_qsv",
Size: 1500,
Bitrate: "50M",
DestSize: 1500,
DestBitrate: "50M",
MapVideo: MapVideoDefault,
MapAudio: MapAudioDefault,
}
r, _, err := AvcConvertCmd("VID123.mov", "VID123.mov.avc", opt)
r, _, err := TranscodeCmd("VID123.mov", "VID123.mov.avc", opt)
if err != nil {
t.Fatal(err)
@@ -178,12 +134,12 @@ func TestAvcConvertCmd(t *testing.T) {
opt := encode.Options{
Bin: "",
Encoder: "h264_videotoolbox",
Size: 1500,
Bitrate: "50M",
DestSize: 1500,
DestBitrate: "50M",
MapVideo: MapVideoDefault,
MapAudio: MapAudioDefault,
}
r, _, err := AvcConvertCmd("VID123.mov", "VID123.mov.avc", opt)
r, _, err := TranscodeCmd("VID123.mov", "VID123.mov.avc", opt)
if err != nil {
t.Fatal(err)
@@ -195,12 +151,12 @@ func TestAvcConvertCmd(t *testing.T) {
opt := encode.Options{
Bin: "",
Encoder: "h264_nvenc",
Size: 1500,
Bitrate: "50M",
DestSize: 1500,
DestBitrate: "50M",
MapVideo: MapVideoDefault,
MapAudio: MapAudioDefault,
}
r, _, err := AvcConvertCmd("VID123.mov", "VID123.mov.avc", opt)
r, _, err := TranscodeCmd("VID123.mov", "VID123.mov.avc", opt)
if err != nil {
t.Fatal(err)
@@ -212,12 +168,12 @@ func TestAvcConvertCmd(t *testing.T) {
opt := encode.Options{
Bin: "",
Encoder: "h264_v4l2m2m",
Size: 1500,
Bitrate: "50M",
DestSize: 1500,
DestBitrate: "50M",
MapVideo: MapVideoDefault,
MapAudio: MapAudioDefault,
}
r, _, err := AvcConvertCmd("VID123.mov", "VID123.mov.avc", opt)
r, _, err := TranscodeCmd("VID123.mov", "VID123.mov.avc", opt)
if err != nil {
t.Fatal(err)

View File

@@ -6,8 +6,8 @@ import (
"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 {
// TranscodeToAvcCmd returns the FFmpeg command for hardware-accelerated transcoding to MPEG-4 AVC.
func TranscodeToAvcCmd(srcName, destName string, opt encode.Options) *exec.Cmd {
// ffmpeg -hide_banner -h encoder=h264_v4l2m2m
return exec.Command(
opt.Bin,
@@ -24,7 +24,7 @@ func AvcConvertCmd(srcName, destName string, opt encode.Options) *exec.Cmd {
"-max_muxing_queue_size", "1024",
"-crf", "23",
"-r", "30",
"-b:v", opt.Bitrate,
"-b:v", opt.DestBitrate,
"-f", "mp4",
"-movflags", "+faststart", // puts headers at the beginning for faster streaming
destName,

View File

@@ -6,8 +6,8 @@ import (
"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 {
// TranscodeToAvcCmd returns the FFmpeg command for hardware-accelerated transcoding to MPEG-4 AVC.
func TranscodeToAvcCmd(srcName, destName string, opt encode.Options) *exec.Cmd {
return exec.Command(
opt.Bin,
"-y",
@@ -20,7 +20,7 @@ func AvcConvertCmd(srcName, destName string, opt encode.Options) *exec.Cmd {
"-map", opt.MapVideo,
"-map", opt.MapAudio,
"-r", "30",
"-b:v", opt.Bitrate,
"-b:v", opt.DestBitrate,
"-f", "mp4",
"-movflags", "+faststart", // puts headers at the beginning for faster streaming
destName,

View File

@@ -1,7 +1,7 @@
/*
Package backup provides backup and restore functions for databases and albums.
Copyright (c) 2018 - 2024 PhotoPrism UG. All rights reserved.
Copyright (c) 2018 - 2025 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"):

View File

@@ -7,6 +7,7 @@ import (
"strconv"
"github.com/photoprism/photoprism/internal/ffmpeg"
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
)
// JpegConvertCmds returns the supported commands for converting a MediaFile to JPEG, sorted by priority.
@@ -30,15 +31,8 @@ func (w *Convert) JpegConvertCmds(f *MediaFile, jpegName string, xmpName string)
// Extract a video still image for use as a thumbnail (poster image).
if f.IsAnimated() && !f.IsWebP() && w.conf.FFmpegEnabled() {
timeOffset := ffmpeg.PreviewTimeOffset(f.Duration())
// TODO: Adjust command flags for correct colors with HDR10-encoded HEVC videos,
// see https://github.com/photoprism/photoprism/issues/4488
result = append(result, NewConvertCmd(
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:
// "-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",
jpegName)),
ffmpeg.ExtractJpegImageCmd(f.FileName(), jpegName, encode.NewPreviewImageOptions(w.conf.FFmpegBin(), f.Duration()))),
)
}

View File

@@ -6,6 +6,7 @@ import (
"strconv"
"github.com/photoprism/photoprism/internal/ffmpeg"
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
)
// PngConvertCmds returns commands for converting a media file to PNG, if possible.
@@ -31,7 +32,7 @@ func (w *Convert) PngConvertCmds(f *MediaFile, pngName string) (result ConvertCm
if f.IsAnimated() && !f.IsWebP() && w.conf.FFmpegEnabled() {
// Use "ffmpeg" to extract a PNG still image from the video.
result = append(result, NewConvertCmd(
exec.Command(w.conf.FFmpegBin(), "-y", "-strict", "-2", "-ss", ffmpeg.PreviewTimeOffset(f.Duration()), "-i", f.FileName(), "-vframes", "1", pngName)),
ffmpeg.ExtractPngImageCmd(f.FileName(), pngName, encode.NewPreviewImageOptions(w.conf.FFmpegBin(), f.Duration()))),
)
}

View File

@@ -66,7 +66,7 @@ func (w *Convert) ToAvc(f *MediaFile, encoder encode.Encoder, noMutex, force boo
avcName, _ = fs.FileName(f.FileName(), w.conf.SidecarPath(), w.conf.OriginalsPath(), fs.ExtAVC)
}
cmd, useMutex, err := w.AvcConvertCmd(f, avcName, encoder)
cmd, useMutex, err := w.TranscodeToAvcCmd(f, avcName, encoder)
// Return if an error occurred.
if err != nil {
@@ -152,8 +152,8 @@ func (w *Convert) ToAvc(f *MediaFile, encoder encode.Encoder, noMutex, force boo
return NewMediaFile(avcName)
}
// AvcConvertCmd returns the command for converting video files to MPEG-4 AVC.
func (w *Convert) AvcConvertCmd(f *MediaFile, avcName string, encoder encode.Encoder) (result *exec.Cmd, useMutex bool, err error) {
// TranscodeToAvcCmd returns the command for converting video files to MPEG-4 AVC.
func (w *Convert) TranscodeToAvcCmd(f *MediaFile, avcName string, encoder encode.Encoder) (result *exec.Cmd, useMutex bool, err error) {
fileExt := f.Extension()
fileName := f.FileName()
@@ -174,7 +174,7 @@ func (w *Convert) AvcConvertCmd(f *MediaFile, avcName string, encoder encode.Enc
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)
} else {
return ffmpeg.AvcConvertCmd(fileName, avcName, opt)
return ffmpeg.TranscodeCmd(fileName, avcName, opt)
}
}

View File

@@ -71,7 +71,7 @@ func TestConvert_AvcBitrate(t *testing.T) {
conf := config.TestConfig()
convert := NewConvert(conf)
t.Run("low", func(t *testing.T) {
t.Run("Low", func(t *testing.T) {
fileName := filepath.Join(conf.ExamplesPath(), "gopher-video.mp4")
assert.Truef(t, fs.FileExists(fileName), "input file does not exist: %s", fileName)
@@ -85,7 +85,7 @@ func TestConvert_AvcBitrate(t *testing.T) {
assert.Equal(t, "1M", convert.AvcBitrate(mf))
})
t.Run("medium", func(t *testing.T) {
t.Run("Medium", func(t *testing.T) {
fileName := filepath.Join(conf.ExamplesPath(), "gopher-video.mp4")
assert.Truef(t, fs.FileExists(fileName), "input file does not exist: %s", fileName)
@@ -102,7 +102,7 @@ func TestConvert_AvcBitrate(t *testing.T) {
assert.Equal(t, "16M", convert.AvcBitrate(mf))
})
t.Run("high", func(t *testing.T) {
t.Run("High", func(t *testing.T) {
fileName := filepath.Join(conf.ExamplesPath(), "gopher-video.mp4")
assert.Truef(t, fs.FileExists(fileName), "input file does not exist: %s", fileName)
@@ -119,7 +119,7 @@ func TestConvert_AvcBitrate(t *testing.T) {
assert.Equal(t, "25M", convert.AvcBitrate(mf))
})
t.Run("very_high", func(t *testing.T) {
t.Run("VeryHigh", func(t *testing.T) {
fileName := filepath.Join(conf.ExamplesPath(), "gopher-video.mp4")
assert.Truef(t, fs.FileExists(fileName), "input file does not exist: %s", fileName)
@@ -137,7 +137,7 @@ func TestConvert_AvcBitrate(t *testing.T) {
})
}
func TestConvert_AvcConvertCmd(t *testing.T) {
func TestConvert_TranscodeToAvcCmd(t *testing.T) {
conf := config.TestConfig()
convert := NewConvert(conf)
@@ -149,7 +149,7 @@ func TestConvert_AvcConvertCmd(t *testing.T) {
t.Fatal(err)
}
r, _, err := convert.AvcConvertCmd(mf, "avc1", encode.SoftwareAvc)
r, _, err := convert.TranscodeToAvcCmd(mf, "avc1", encode.SoftwareAvc)
if err != nil {
t.Fatal(err)
@@ -158,7 +158,7 @@ func TestConvert_AvcConvertCmd(t *testing.T) {
assert.Contains(t, r.Path, "ffmpeg")
assert.Contains(t, r.Args, "mp4")
})
t.Run("JPEG", func(t *testing.T) {
t.Run("Jpeg", func(t *testing.T) {
fileName := filepath.Join(conf.ExamplesPath(), "cat_black.jpg")
mf, err := NewMediaFile(fileName)
@@ -166,7 +166,7 @@ func TestConvert_AvcConvertCmd(t *testing.T) {
t.Fatal(err)
}
r, useMutex, err := convert.AvcConvertCmd(mf, "avc1", encode.SoftwareAvc)
r, useMutex, err := convert.TranscodeToAvcCmd(mf, "avc1", encode.SoftwareAvc)
assert.False(t, useMutex)
assert.Error(t, err)
@@ -181,7 +181,7 @@ func TestConvert_AvcConvertCmd(t *testing.T) {
t.Fatal(err)
}
r, useMutex, err := convert.AvcConvertCmd(mf, avcName, encode.SoftwareAvc)
r, useMutex, err := convert.TranscodeToAvcCmd(mf, avcName, encode.SoftwareAvc)
if err != nil {
t.Fatal(err)

View File

@@ -1,7 +1,7 @@
/*
Package get provides a registry for common services.
Copyright (c) 2018 - 2024 PhotoPrism UG. All rights reserved.
Copyright (c) 2018 - 2025 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"):

View File

@@ -1,7 +1,7 @@
/*
Package get provides a registry for common application services.
Copyright (c) 2018 - 2024 PhotoPrism UG. All rights reserved.
Copyright (c) 2018 - 2025 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"):

View File

@@ -1,7 +1,7 @@
/*
Package photoprism provides the core functionality of PhotoPrism®.
Copyright (c) 2018 - 2024 PhotoPrism UG. All rights reserved.
Copyright (c) 2018 - 2025 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"):