From 3cf1c699dfcc934b6b53ddce2aa98279db086d80 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Tue, 18 Jul 2023 15:15:04 +0200 Subject: [PATCH] Video: Refactor FFmpeg Transcoding Size Limit #3466 #3498 #3549 Signed-off-by: Michael Mayer --- frontend/package-lock.json | 120 ++++++++++++++++++++------ frontend/src/dialog/service/edit.vue | 2 +- frontend/tests/unit/config.js | 50 ++--------- internal/commands/show.go | 2 + internal/commands/show_thumb_sizes.go | 29 +++++++ internal/commands/show_video_sizes.go | 29 +++++++ internal/config/config.go | 2 +- internal/config/config_ffmpeg.go | 30 +++---- internal/config/config_ffmpeg_test.go | 28 ++++-- internal/config/flags.go | 16 ++-- internal/config/options.go | 2 +- internal/config/report.go | 2 +- internal/config/thumbnails.go | 2 +- internal/ffmpeg/config.go | 24 ++++-- internal/ffmpeg/convert.go | 24 ++---- internal/ffmpeg/format.go | 16 ++++ internal/thumb/fit.go | 10 +-- internal/thumb/report.go | 39 +++++++++ internal/thumb/report_test.go | 20 +++++ internal/thumb/size.go | 3 +- internal/thumb/sizes.go | 42 ++++++--- internal/thumb/video.go | 37 ++++++++ internal/thumb/video_test.go | 22 +++++ 23 files changed, 405 insertions(+), 146 deletions(-) create mode 100644 internal/commands/show_thumb_sizes.go create mode 100644 internal/commands/show_video_sizes.go create mode 100644 internal/ffmpeg/format.go create mode 100644 internal/thumb/report.go create mode 100644 internal/thumb/report_test.go create mode 100644 internal/thumb/video.go create mode 100644 internal/thumb/video_test.go diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5acb900bb..aca95707f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -3872,6 +3872,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.1.tgz", + "integrity": "sha512-09x0ZWFEjj4WD8PDbykUwo3t9arLn8NIzmmYEJFpYekOAQjpkGSyrQhNoRTcwwcFRu+ycWF78QZ63oWTqSjBcw==", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "get-intrinsic": "^1.2.1", + "is-array-buffer": "^3.0.2", + "is-shared-array-buffer": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -5619,9 +5638,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.461", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.461.tgz", - "integrity": "sha512-1JkvV2sgEGTDXjdsaQCeSwYYuhLRphRpc+g6EHTFELJXEiznLt3/0pZ9JuAOQ5p2rI3YxKTbivtvajirIfhrEQ==" + "version": "1.4.463", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.463.tgz", + "integrity": "sha512-fT3hvdUWLjDbaTGzyOjng/CQhQJSQP8ThO3XZAoaxHvHo2kUXiRQVMj9M235l8uDFiNPsPa6KHT1p3RaR6ugRw==" }, "node_modules/emoji-regex": { "version": "8.0.0", @@ -5717,11 +5736,12 @@ } }, "node_modules/es-abstract": { - "version": "1.21.3", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.21.3.tgz", - "integrity": "sha512-ZU4miiY1j3sGPFLJ34VJXEqhpmL+HGByCinGHv4HC+Fxl2fI2Z4yR6tl0mORnDr6PA8eihWo4LmSWDbvhALckg==", + "version": "1.22.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.1.tgz", + "integrity": "sha512-ioRRcXMO6OFyRpyzV3kE1IIBd4WG5/kltnzdxSCqoP8CMGs/Li+M1uF5o7lOkZVFjDs+NLesthnF66Pg/0q0Lw==", "dependencies": { "array-buffer-byte-length": "^1.0.0", + "arraybuffer.prototype.slice": "^1.0.1", "available-typed-arrays": "^1.0.5", "call-bind": "^1.0.2", "es-set-tostringtag": "^2.0.1", @@ -5748,10 +5768,13 @@ "object-keys": "^1.1.1", "object.assign": "^4.1.4", "regexp.prototype.flags": "^1.5.0", + "safe-array-concat": "^1.0.0", "safe-regex-test": "^1.0.0", "string.prototype.trim": "^1.2.7", "string.prototype.trimend": "^1.0.6", "string.prototype.trimstart": "^1.0.6", + "typed-array-buffer": "^1.0.0", + "typed-array-byte-length": "^1.0.0", "typed-array-byte-offset": "^1.0.0", "typed-array-length": "^1.0.4", "unbox-primitive": "^1.0.2", @@ -8454,15 +8477,11 @@ } }, "node_modules/is-typed-array": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.10.tgz", - "integrity": "sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", + "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" + "which-typed-array": "^1.1.11" }, "engines": { "node": ">= 0.4" @@ -8493,6 +8512,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" + }, "node_modules/isbinaryfile": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", @@ -12153,6 +12177,23 @@ "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" }, + "node_modules/safe-array-concat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.0.tgz", + "integrity": "sha512-9dVEFruWIsnie89yym+xWTAYASdpw3CJV7Li/6zBewGf9z2i1j31rP6jnY0pHEO4QZh6N0K11bFjWmdR8UGdPQ==", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.0", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -13113,9 +13154,9 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/terser": { - "version": "5.19.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.19.0.tgz", - "integrity": "sha512-JpcpGOQLOXm2jsomozdMDpd5f8ZHh1rR48OFgWUH3QsyZcfPgv2qDCYbcDEAYNd4OZRj2bWYKpwdll/udZCk/Q==", + "version": "5.19.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.19.1.tgz", + "integrity": "sha512-27hxBUVdV6GoNg1pKQ7Z5cbR6V9txPVyBA+FQw3BaZ1Wuzvztce5p156DaP0NVZNrMZZ+6iG9Syf7WgMNKDg2Q==", "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -13413,6 +13454,36 @@ "node": ">= 0.6" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", + "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", + "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", + "dependencies": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/typed-array-byte-offset": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", @@ -14077,9 +14148,9 @@ } }, "node_modules/webpack": { - "version": "5.88.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.88.1.tgz", - "integrity": "sha512-FROX3TxQnC/ox4N+3xQoWZzvGXSuscxR32rbzjpXgEzWudJFEJBpdlkkob2ylrv5yzzufD1zph1OoFsLtm6stQ==", + "version": "5.88.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.88.2.tgz", + "integrity": "sha512-JmcgNZ1iKj+aiR0OvTYtWQqJwq37Pf683dY9bVORwVbUrDhLhdn/PlO2sHsFHPkj7sHNQF3JwaAkp49V+Sq1tQ==", "dependencies": { "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.0", @@ -14431,16 +14502,15 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.10.tgz", - "integrity": "sha512-uxoA5vLUfRPdjCuJ1h5LlYdmTLbYfums398v3WLkM+i/Wltl2/XyZpQWKbN++ck5L64SR/grOHqtXCUKmlZPNA==", + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.11.tgz", + "integrity": "sha512-qe9UWWpkeG5yzZ0tNYxDmd7vo58HDBc39mZ0xWWpolAGADdFOzkfamWLDxkOWcvHQKVmdTyQdLD4NOfjLWTKew==", "dependencies": { "available-typed-arrays": "^1.0.5", "call-bind": "^1.0.2", "for-each": "^0.3.3", "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0", - "is-typed-array": "^1.1.10" + "has-tostringtag": "^1.0.0" }, "engines": { "node": ">= 0.4" diff --git a/frontend/src/dialog/service/edit.vue b/frontend/src/dialog/service/edit.vue index f76ba934c..ec5fba3c6 100644 --- a/frontend/src/dialog/service/edit.vue +++ b/frontend/src/dialog/service/edit.vue @@ -408,7 +408,7 @@ export default { for (let i = 0; i < thumbs.length; i++) { let t = thumbs[i]; - result.push({"text": t.w + 'x' + t.h, "value": t.size}); + result.push({"text": t.w + ' × ' + t.h, "value": t.size}); } return result; diff --git a/frontend/tests/unit/config.js b/frontend/tests/unit/config.js index bcb0bd334..c277e14ec 100644 --- a/frontend/tests/unit/config.js +++ b/frontend/tests/unit/config.js @@ -238,48 +238,14 @@ const clientConfig = { }, ], thumbs: [ - { - size: "fit_720", - use: "Mobile, TV", - w: 720, - h: 720, - }, - { - size: "fit_1280", - use: "Mobile, HD Ready TV", - w: 1280, - h: 1024, - }, - { - size: "fit_1920", - use: "Mobile, Full HD TV", - w: 1920, - h: 1200, - }, - { - size: "fit_2048", - use: "Tablets, Cinema 2K", - w: 2048, - h: 2048, - }, - { - size: "fit_2560", - use: "Quad HD, Retina Display", - w: 2560, - h: 1600, - }, - { - size: "fit_4096", - use: "Ultra HD, Retina 4K", - w: 4096, - h: 4096, - }, - { - size: "fit_7680", - use: "8K Ultra HD 2, Retina 6K", - w: 7680, - h: 4320, - }, + { size: "fit_720", usage: "SD TV, Mobile", w: 720, h: 720 }, + { size: "fit_1280", usage: "HD TV, SXGA", w: 1280, h: 1024 }, + { size: "fit_1920", usage: "Full HD", w: 1920, h: 1200 }, + { size: "fit_2048", usage: "DCI 2K, Tablets", w: 2048, h: 2048 }, + { size: "fit_2560", usage: "Quad HD, Notebooks", w: 2560, h: 1600 }, + { size: "fit_3840", usage: "4K Ultra HD", w: 3840, h: 2400 }, + { size: "fit_4096", usage: "DCI 4K, Retina 4K", w: 4096, h: 4096 }, + { size: "fit_7680", usage: "8K Ultra HD 2", w: 7680, h: 4320 }, ], status: "unregistered", mapKey: "D9ve6edlcVR2mEsNvCXa", diff --git a/internal/commands/show.go b/internal/commands/show.go index e9d768cac..053aef6ae 100644 --- a/internal/commands/show.go +++ b/internal/commands/show.go @@ -14,6 +14,8 @@ var ShowCommand = cli.Command{ ShowConfigYamlCommand, ShowSearchFiltersCommand, ShowFileFormatsCommand, + ShowThumbSizesCommand, + ShowVideoSizesCommand, ShowMetadataCommand, }, } diff --git a/internal/commands/show_thumb_sizes.go b/internal/commands/show_thumb_sizes.go new file mode 100644 index 000000000..936cadf70 --- /dev/null +++ b/internal/commands/show_thumb_sizes.go @@ -0,0 +1,29 @@ +package commands + +import ( + "fmt" + + "github.com/urfave/cli" + + "github.com/photoprism/photoprism/internal/thumb" + "github.com/photoprism/photoprism/pkg/report" +) + +// ShowThumbSizesCommand configures the command name, flags, and action. +var ShowThumbSizesCommand = cli.Command{ + Name: "thumb-sizes", + Usage: "Displays supported standard thumbnail sizes", + Flags: report.CliFlags, + Action: showThumbSizesAction, +} + +// showThumbSizesAction displays supported standard thumbnail sizes. +func showThumbSizesAction(ctx *cli.Context) error { + rows, cols := thumb.Report(thumb.Sizes.All(), false) + + result, err := report.RenderFormat(rows, cols, report.CliFormat(ctx)) + + fmt.Println(result) + + return err +} diff --git a/internal/commands/show_video_sizes.go b/internal/commands/show_video_sizes.go new file mode 100644 index 000000000..eaccfd21f --- /dev/null +++ b/internal/commands/show_video_sizes.go @@ -0,0 +1,29 @@ +package commands + +import ( + "fmt" + + "github.com/urfave/cli" + + "github.com/photoprism/photoprism/internal/thumb" + "github.com/photoprism/photoprism/pkg/report" +) + +// ShowVideoSizesCommand configures the command name, flags, and action. +var ShowVideoSizesCommand = cli.Command{ + Name: "video-sizes", + Usage: "Displays supported standard video sizes", + Flags: report.CliFlags, + Action: showVideoSizesAction, +} + +// showVideoSizesAction displays supported standard video sizes. +func showVideoSizesAction(ctx *cli.Context) error { + rows, cols := thumb.Report(thumb.VideoSizes, true) + + result, err := report.RenderFormat(rows, cols, report.CliFormat(ctx)) + + fmt.Println(result) + + return err +} diff --git a/internal/config/config.go b/internal/config/config.go index e42a86f9d..b83b14820 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -73,7 +73,7 @@ func init() { t := thumb.Sizes[name] if t.Public { - Thumbs = append(Thumbs, ThumbSize{Size: string(name), Use: t.Use, Width: t.Width, Height: t.Height}) + Thumbs = append(Thumbs, ThumbSize{Size: string(name), Usage: t.Usage, Width: t.Width, Height: t.Height}) } } } diff --git a/internal/config/config_ffmpeg.go b/internal/config/config_ffmpeg.go index f4cc54d81..23aa40dad 100644 --- a/internal/config/config_ffmpeg.go +++ b/internal/config/config_ffmpeg.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/photoprism/photoprism/internal/ffmpeg" + "github.com/photoprism/photoprism/internal/thumb" ) // FFmpegBin returns the ffmpeg executable file name. @@ -25,6 +26,11 @@ func (c *Config) FFmpegEncoder() ffmpeg.AvcEncoder { return ffmpeg.FindEncoder(c.options.FFmpegEncoder) } +// FFmpegSize returns the maximum ffmpeg video encoding size in pixels (720-7680). +func (c *Config) FFmpegSize() int { + return thumb.VideoSize(c.options.FFmpegSize).Width +} + // FFmpegBitrate returns the ffmpeg bitrate limit in MBit/s. func (c *Config) FFmpegBitrate() int { switch { @@ -37,18 +43,6 @@ func (c *Config) FFmpegBitrate() int { } } -// FFmpegResolution returns the ffmpeg resolution limit in pixel height. Goes from 144p to 8k. -func (c *Config) FFmpegResolution() int { - switch { - case c.options.FFmpegResolution <= 0: - return 4096 - case c.options.FFmpegResolution >= 8192: - return 8192 - default: - return c.options.FFmpegResolution - } -} - // FFmpegBitrateExceeded tests if the ffmpeg bitrate limit is exceeded. func (c *Config) FFmpegBitrateExceeded(mbit float64) bool { if mbit <= 0 { @@ -82,12 +76,12 @@ func (c *Config) FFmpegMapAudio() string { func (c *Config) FFmpegOptions(encoder ffmpeg.AvcEncoder, bitrate string) (ffmpeg.Options, error) { // Transcode all other formats with FFmpeg. opt := ffmpeg.Options{ - Bin: c.FFmpegBin(), - Encoder: encoder, - Bitrate: bitrate, - MapVideo: c.FFmpegMapVideo(), - MapAudio: c.FFmpegMapAudio(), - Resolution: fmt.Sprintf("%v", c.FFmpegResolution()), + Bin: c.FFmpegBin(), + Encoder: encoder, + Size: c.FFmpegSize(), + Bitrate: bitrate, + MapVideo: c.FFmpegMapVideo(), + MapAudio: c.FFmpegMapAudio(), } // Check diff --git a/internal/config/config_ffmpeg_test.go b/internal/config/config_ffmpeg_test.go index 3cfeb8b86..a2e09a254 100644 --- a/internal/config/config_ffmpeg_test.go +++ b/internal/config/config_ffmpeg_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/photoprism/photoprism/internal/ffmpeg" + "github.com/photoprism/photoprism/internal/thumb" "github.com/stretchr/testify/assert" ) @@ -43,15 +44,30 @@ func TestConfig_FFmpegBitrate(t *testing.T) { assert.Equal(t, 800, c.FFmpegBitrate()) } -func TestConfig_FFmpegResolution(t *testing.T) { +func TestConfig_FFmpegSize(t *testing.T) { c := NewConfig(CliTestContext()) - assert.Equal(t, 4096, c.FFmpegResolution()) + assert.Equal(t, 3840, c.FFmpegSize()) - c.options.FFmpegResolution = 1920 - assert.Equal(t, 1920, c.FFmpegResolution()) + c.options.FFmpegSize = 0 + assert.Equal(t, 3840, c.FFmpegSize()) - c.options.FFmpegResolution = 8640 - assert.Equal(t, 8192, c.FFmpegResolution()) + c.options.FFmpegSize = -1 + assert.Equal(t, 7680, c.FFmpegSize()) + + c.options.FFmpegSize = 10 + assert.Equal(t, 720, c.FFmpegSize()) + + c.options.FFmpegSize = 720 + assert.Equal(t, 720, c.FFmpegSize()) + + c.options.FFmpegSize = 1920 + assert.Equal(t, 1920, c.FFmpegSize()) + + c.options.FFmpegSize = 4000 + assert.Equal(t, 3840, c.FFmpegSize()) + + c.options.FFmpegSize = 8640 + assert.Equal(t, thumb.Sizes[thumb.Fit7680].Width, c.FFmpegSize()) } func TestConfig_FFmpegBitrateExceeded(t *testing.T) { diff --git a/internal/config/flags.go b/internal/config/flags.go index 2a187fa03..453895aa1 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -580,16 +580,16 @@ var Flags = CliFlags{ EnvVar: EnvVar("FFMPEG_ENCODER"), }}, { Flag: cli.IntFlag{ - Name: "ffmpeg-bitrate, vb", - Usage: "maximum FFmpeg encoding `BITRATE` (Mbit/s)", - Value: 50, - EnvVar: EnvVar("FFMPEG_BITRATE"), + Name: "ffmpeg-size, vs", + Usage: "maximum FFmpeg encoding size in `PIXELS` (720-7680)", + Value: thumb.Sizes[thumb.Fit3840].Width, + EnvVar: EnvVar("FFMPEG_SIZE"), }}, { Flag: cli.IntFlag{ - Name: "ffmpeg-resolution", - Usage: "maximum FFmpeg encoding `RESOLUTION` (height)", - Value: 4096, - EnvVar: EnvVar("FFMPEG_RESOLUTION"), + Name: "ffmpeg-bitrate, vb", + Usage: "maximum FFmpeg video `BITRATE` in Mbit/s", + Value: 50, + EnvVar: EnvVar("FFMPEG_BITRATE"), }}, { Flag: cli.StringFlag{ Name: "ffmpeg-map-video", diff --git a/internal/config/options.go b/internal/config/options.go index d67c67cab..18e3387d5 100644 --- a/internal/config/options.go +++ b/internal/config/options.go @@ -129,8 +129,8 @@ type Options struct { SipsBlacklist string `yaml:"SipsBlacklist" json:"-" flag:"sips-blacklist"` FFmpegBin string `yaml:"FFmpegBin" json:"-" flag:"ffmpeg-bin"` FFmpegEncoder string `yaml:"FFmpegEncoder" json:"FFmpegEncoder" flag:"ffmpeg-encoder"` + FFmpegSize int `yaml:"FFmpegSize" json:"FFmpegSize" flag:"ffmpeg-size"` FFmpegBitrate int `yaml:"FFmpegBitrate" json:"FFmpegBitrate" flag:"ffmpeg-bitrate"` - FFmpegResolution int `yaml:"FFmpegResolution" json:"FFmpegResolution" flag:"ffmpeg-resolution"` FFmpegMapVideo string `yaml:"FFmpegMapVideo" json:"FFmpegMapVideo" flag:"ffmpeg-map-video"` FFmpegMapAudio string `yaml:"FFmpegMapAudio" json:"FFmpegMapAudio" flag:"ffmpeg-map-audio"` ExifToolBin string `yaml:"ExifToolBin" json:"-" flag:"exiftool-bin"` diff --git a/internal/config/report.go b/internal/config/report.go index 661b3caf8..a9b8acb52 100644 --- a/internal/config/report.go +++ b/internal/config/report.go @@ -184,8 +184,8 @@ func (c *Config) Report() (rows [][]string, cols []string) { {"sips-blacklist", c.SipsBlacklist()}, {"ffmpeg-bin", c.FFmpegBin()}, {"ffmpeg-encoder", c.FFmpegEncoder().String()}, + {"ffmpeg-size", fmt.Sprintf("%d", c.FFmpegSize())}, {"ffmpeg-bitrate", fmt.Sprintf("%d", c.FFmpegBitrate())}, - {"ffmpeg-resolution", fmt.Sprintf("%d", c.FFmpegResolution())}, {"ffmpeg-map-video", c.FFmpegMapVideo()}, {"ffmpeg-map-audio", c.FFmpegMapAudio()}, {"exiftool-bin", c.ExifToolBin()}, diff --git a/internal/config/thumbnails.go b/internal/config/thumbnails.go index c0ddfe0ea..2a61c11db 100644 --- a/internal/config/thumbnails.go +++ b/internal/config/thumbnails.go @@ -3,7 +3,7 @@ package config // ThumbSize represents thumbnail info for use in client apps. type ThumbSize struct { Size string `json:"size"` - Use string `json:"use"` + Usage string `json:"usage"` Width int `json:"w"` Height int `json:"h"` } diff --git a/internal/ffmpeg/config.go b/internal/ffmpeg/config.go index a7c64dafc..cc828e1f5 100644 --- a/internal/ffmpeg/config.go +++ b/internal/ffmpeg/config.go @@ -1,11 +1,23 @@ package ffmpeg +import "fmt" + // Options represents transcoding options. type Options struct { - Bin string - Encoder AvcEncoder - Bitrate string - MapVideo string - MapAudio string - Resolution string + Bin string + Encoder AvcEncoder + Size int + Bitrate string + MapVideo string + MapAudio string +} + +// 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 { + // 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) + } 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) + } } diff --git a/internal/ffmpeg/convert.go b/internal/ffmpeg/convert.go index db038b501..01fd54da0 100644 --- a/internal/ffmpeg/convert.go +++ b/internal/ffmpeg/convert.go @@ -15,8 +15,6 @@ func AvcConvertCommand(fileName, avcName string, opt Options) (result *exec.Cmd, return nil, false, fmt.Errorf("empty output filename") } - scale := "scale='if(gte(iw,ih), min(" + opt.Resolution + ", iw), -2):if(gte(iw,ih), -2, min(" + opt.Resolution + ", ih))'" - // Don't transcode more than one video at the same time. useMutex = true @@ -26,7 +24,7 @@ func AvcConvertCommand(fileName, avcName string, opt Options) (result *exec.Cmd, opt.Bin, "-i", fileName, "-movflags", "faststart", - "-pix_fmt", "yuv420p", + "-pix_fmt", FormatYUV420P.String(), "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", "-f", "mp4", "-y", @@ -44,13 +42,12 @@ func AvcConvertCommand(fileName, avcName string, opt Options) (result *exec.Cmd, switch opt.Encoder { case IntelEncoder: // ffmpeg -hide_banner -h encoder=h264_qsv - format := "format=rgb32" result = exec.Command( opt.Bin, "-qsv_device", "/dev/dri/renderD128", "-i", fileName, "-c:a", "aac", - "-vf", scale+", "+format+"", + "-vf", opt.VideoFilter(FormatRGB32), "-c:v", opt.Encoder.String(), "-map", opt.MapVideo, "-map", opt.MapAudio, @@ -65,7 +62,6 @@ func AvcConvertCommand(fileName, avcName string, opt Options) (result *exec.Cmd, case AppleEncoder: // ffmpeg -hide_banner -h encoder=h264_videotoolbox - format := "format=yuv420p" result = exec.Command( opt.Bin, "-i", fileName, @@ -73,7 +69,7 @@ func AvcConvertCommand(fileName, avcName string, opt Options) (result *exec.Cmd, "-map", opt.MapVideo, "-map", opt.MapAudio, "-c:a", "aac", - "-vf", scale+", "+format+"", + "-vf", opt.VideoFilter(FormatYUV420P), "-profile", "high", "-level", "51", "-vsync", "vfr", @@ -85,13 +81,12 @@ func AvcConvertCommand(fileName, avcName string, opt Options) (result *exec.Cmd, ) case VAAPIEncoder: - format := "format=nv12,hwupload" result = exec.Command( opt.Bin, "-hwaccel", "vaapi", "-i", fileName, "-c:a", "aac", - "-vf", scale+", "+format+"", + "-vf", opt.VideoFilter(FormatNV12), "-c:v", opt.Encoder.String(), "-map", opt.MapVideo, "-map", opt.MapAudio, @@ -105,12 +100,11 @@ func AvcConvertCommand(fileName, avcName string, opt Options) (result *exec.Cmd, case NvidiaEncoder: // ffmpeg -hide_banner -h encoder=h264_nvenc - format := "format=yuv420p" result = exec.Command( opt.Bin, "-hwaccel", "auto", "-i", fileName, - "-pix_fmt", "yuv420p", + "-pix_fmt", FormatYUV420P.String(), "-c:v", opt.Encoder.String(), "-map", opt.MapVideo, "-map", opt.MapAudio, @@ -118,7 +112,7 @@ func AvcConvertCommand(fileName, avcName string, opt Options) (result *exec.Cmd, "-preset", "15", "-pixel_format", "yuv420p", "-gpu", "any", - "-vf", scale+", "+format+"", + "-vf", opt.VideoFilter(FormatYUV420P), "-rc:v", "constqp", "-cq", "0", "-tune", "2", @@ -134,7 +128,6 @@ func AvcConvertCommand(fileName, avcName string, opt Options) (result *exec.Cmd, case Video4LinuxEncoder: // ffmpeg -hide_banner -h encoder=h264_v4l2m2m - format := "format=yuv420p" result = exec.Command( opt.Bin, "-i", fileName, @@ -142,7 +135,7 @@ func AvcConvertCommand(fileName, avcName string, opt Options) (result *exec.Cmd, "-map", opt.MapVideo, "-map", opt.MapAudio, "-c:a", "aac", - "-vf", scale+", "+format+"", + "-vf", opt.VideoFilter(FormatYUV420P), "-num_output_buffers", "72", "-num_capture_buffers", "64", "-max_muxing_queue_size", "1024", @@ -156,7 +149,6 @@ func AvcConvertCommand(fileName, avcName string, opt Options) (result *exec.Cmd, ) default: - format := "format=yuv420p" result = exec.Command( opt.Bin, "-i", fileName, @@ -164,7 +156,7 @@ func AvcConvertCommand(fileName, avcName string, opt Options) (result *exec.Cmd, "-map", opt.MapVideo, "-map", opt.MapAudio, "-c:a", "aac", - "-vf", scale+", "+format+"", + "-vf", opt.VideoFilter(FormatYUV420P), "-max_muxing_queue_size", "1024", "-crf", "23", "-vsync", "vfr", diff --git a/internal/ffmpeg/format.go b/internal/ffmpeg/format.go new file mode 100644 index 000000000..420d1e7ba --- /dev/null +++ b/internal/ffmpeg/format.go @@ -0,0 +1,16 @@ +package ffmpeg + +// PixelFormat represents a standard pixel format. +type PixelFormat string + +// String returns the pixel format as string. +func (f PixelFormat) String() string { + return string(f) +} + +// Standard pixel formats. +const ( + FormatYUV420P PixelFormat = "yuv420p" + FormatRGB32 PixelFormat = "rgb32" + FormatNV12 PixelFormat = "nv12,hwupload" +) diff --git a/internal/thumb/fit.go b/internal/thumb/fit.go index 575931f22..69e92f049 100644 --- a/internal/thumb/fit.go +++ b/internal/thumb/fit.go @@ -2,9 +2,9 @@ package thumb import "image" -// Fitted contains only "fit" cropped thumbnail sizes from largest to smallest. +// FitSizes contains "fit" cropped thumbnail sizes from largest to smallest. // Best for the viewer as proportional resizing maintains the aspect ratio. -var Fitted = []Size{ +var FitSizes = SizeList{ Sizes[Fit7680], Sizes[Fit4096], Sizes[Fit3840], @@ -17,15 +17,15 @@ var Fitted = []Size{ // Fit returns the largest fitting thumbnail size. func Fit(w, h int) (size Size) { - j := len(Fitted) - 1 + j := len(FitSizes) - 1 for i := j; i >= 0; i-- { - if size = Fitted[i]; w <= size.Width && h <= size.Height { + if size = FitSizes[i]; w <= size.Width && h <= size.Height { return size } } - return Fitted[0] + return FitSizes[0] } // FitBounds returns the largest thumbnail size fitting the rectangle. diff --git a/internal/thumb/report.go b/internal/thumb/report.go new file mode 100644 index 000000000..94d2e7ea2 --- /dev/null +++ b/internal/thumb/report.go @@ -0,0 +1,39 @@ +package thumb + +import ( + "fmt" + "sort" + + "github.com/photoprism/photoprism/pkg/report" +) + +// Report returns a file format documentation table. +func Report(sizes SizeList, short bool) (rows [][]string, cols []string) { + if short { + cols = []string{"Size", "Usage"} + } else { + cols = []string{"Name", "Width", "Height", "Aspect Ratio", "Usage"} + } + + sorted := append(SizeList{}, sizes...) + + sort.Slice(sorted, func(i, j int) bool { + if sorted[i].Width == sorted[j].Width { + return sorted[i].Name < sorted[j].Name + } else { + return sorted[i].Width < sorted[j].Width + } + }) + + rows = make([][]string, 0, len(sorted)) + + for _, s := range sorted { + if short { + rows = append(rows, []string{fmt.Sprintf("%d", s.Width), s.Usage}) + } else { + rows = append(rows, []string{s.Name.String(), fmt.Sprintf("%d", s.Width), fmt.Sprintf("%d", s.Height), report.Bool(s.Fit, "Preserved", "1:1"), s.Usage}) + } + } + + return rows, cols +} diff --git a/internal/thumb/report_test.go b/internal/thumb/report_test.go new file mode 100644 index 000000000..f85182911 --- /dev/null +++ b/internal/thumb/report_test.go @@ -0,0 +1,20 @@ +package thumb + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestReport(t *testing.T) { + t.Run("Videos", func(t *testing.T) { + rows, cols := Report(VideoSizes, true) + assert.Equal(t, 2, len(cols)) + assert.Equal(t, len(VideoSizes), len(rows)) + }) + t.Run("Thumbs", func(t *testing.T) { + rows, cols := Report(Sizes.All(), false) + assert.Equal(t, 5, len(cols)) + assert.Equal(t, len(Sizes), len(rows)) + }) +} diff --git a/internal/thumb/size.go b/internal/thumb/size.go index 864e16a09..08393096e 100644 --- a/internal/thumb/size.go +++ b/internal/thumb/size.go @@ -4,10 +4,11 @@ import ( "image" ) +// Size represents a standard media resolution. type Size struct { Name Name `json:"name"` Source Name `json:"-"` - Use string `json:"use"` + Usage string `json:"usage"` Width int `json:"w"` Height int `json:"h"` Public bool `json:"-"` diff --git a/internal/thumb/sizes.go b/internal/thumb/sizes.go index 33456bb94..2e337510d 100644 --- a/internal/thumb/sizes.go +++ b/internal/thumb/sizes.go @@ -6,7 +6,7 @@ var ( Filter = ResampleLanczos ) -// MaxSize returns the max supported thumb size in pixels. +// MaxSize returns the max supported size in pixels. func MaxSize() int { if SizePrecached > SizeUncached { return SizePrecached @@ -15,29 +15,43 @@ func MaxSize() int { return SizeUncached } -// InvalidSize tests if the thumb size in pixels is invalid. +// InvalidSize tests if the size in pixels is invalid. func InvalidSize(size int) bool { return size < 0 || size > MaxSize() } +// SizeList represents a list of sizes. +type SizeList []Size + // SizeMap maps size names to sizes. type SizeMap map[Name]Size +// All returns a slice containing all sizes. +func (m SizeMap) All() SizeList { + result := make(SizeList, 0, len(m)) + + for _, s := range m { + result = append(result, s) + } + + return result +} + // Sizes contains the properties of all thumbnail sizes. var Sizes = SizeMap{ - Tile50: {Tile50, Tile500, "Lists", 50, 50, false, false, []ResampleOption{ResampleFillCenter, ResampleDefault}}, - Tile100: {Tile100, Tile500, "Maps", 100, 100, false, false, []ResampleOption{ResampleFillCenter, ResampleDefault}}, - Tile224: {Tile224, Tile500, "TensorFlow, Mosaic", 224, 224, false, false, []ResampleOption{ResampleFillCenter, ResampleDefault}}, - Tile500: {Tile500, "", "Tiles", 500, 500, false, false, []ResampleOption{ResampleFillCenter, ResampleDefault}}, + Tile50: {Tile50, Tile500, "List View", 50, 50, false, false, []ResampleOption{ResampleFillCenter, ResampleDefault}}, + Tile100: {Tile100, Tile500, "Places View", 100, 100, false, false, []ResampleOption{ResampleFillCenter, ResampleDefault}}, + Tile224: {Tile224, Tile500, "TensorFlow, Mosaic View", 224, 224, false, false, []ResampleOption{ResampleFillCenter, ResampleDefault}}, + Tile500: {Tile500, "", "Cards View", 500, 500, false, false, []ResampleOption{ResampleFillCenter, ResampleDefault}}, Colors: {Colors, Fit720, "Color Detection", 3, 3, false, false, []ResampleOption{ResampleResize, ResampleNearestNeighbor, ResamplePng}}, Left224: {Left224, Fit720, "TensorFlow", 224, 224, false, false, []ResampleOption{ResampleFillTopLeft, ResampleDefault}}, Right224: {Right224, Fit720, "TensorFlow", 224, 224, false, false, []ResampleOption{ResampleFillBottomRight, ResampleDefault}}, - Fit720: {Fit720, "", "Mobile, TV", 720, 720, true, true, []ResampleOption{ResampleFit, ResampleDefault}}, - Fit1280: {Fit1280, Fit2048, "Mobile, HD Ready TV", 1280, 1024, true, true, []ResampleOption{ResampleFit, ResampleDefault}}, - Fit1920: {Fit1920, Fit2048, "Mobile, Full HD TV", 1920, 1200, true, true, []ResampleOption{ResampleFit, ResampleDefault}}, - Fit2048: {Fit2048, "", "Tablets, Cinema 2K", 2048, 2048, true, true, []ResampleOption{ResampleFit, ResampleDefault}}, - Fit2560: {Fit2560, "", "Quad HD, Retina Display", 2560, 1600, true, true, []ResampleOption{ResampleFit, ResampleDefault}}, - Fit3840: {Fit3840, "", "Ultra HD", 3840, 2400, true, true, []ResampleOption{ResampleFit, ResampleDefault}}, // Deprecated in favor of fit_4096 - Fit4096: {Fit4096, "", "Ultra HD, Retina 4K", 4096, 4096, true, true, []ResampleOption{ResampleFit, ResampleDefault}}, - Fit7680: {Fit7680, "", "8K Ultra HD 2, Retina 6K", 7680, 4320, true, true, []ResampleOption{ResampleFit, ResampleDefault}}, + Fit720: {Fit720, "", "SD TV, Mobile", 720, 720, true, true, []ResampleOption{ResampleFit, ResampleDefault}}, + Fit1280: {Fit1280, Fit2048, "HD TV, SXGA", 1280, 1024, true, true, []ResampleOption{ResampleFit, ResampleDefault}}, + Fit1920: {Fit1920, Fit2048, "Full HD", 1920, 1200, true, true, []ResampleOption{ResampleFit, ResampleDefault}}, + Fit2048: {Fit2048, "", "DCI 2K, Tablets", 2048, 2048, true, true, []ResampleOption{ResampleFit, ResampleDefault}}, + Fit2560: {Fit2560, "", "Quad HD, Notebooks", 2560, 1600, true, true, []ResampleOption{ResampleFit, ResampleDefault}}, + Fit3840: {Fit3840, "", "4K Ultra HD", 3840, 2400, true, true, []ResampleOption{ResampleFit, ResampleDefault}}, // Deprecated in favor of fit_4096 + Fit4096: {Fit4096, "", "DCI 4K, Retina 4K", 4096, 4096, true, true, []ResampleOption{ResampleFit, ResampleDefault}}, + Fit7680: {Fit7680, "", "8K Ultra HD 2", 7680, 4320, true, true, []ResampleOption{ResampleFit, ResampleDefault}}, } diff --git a/internal/thumb/video.go b/internal/thumb/video.go new file mode 100644 index 000000000..3043f3fd9 --- /dev/null +++ b/internal/thumb/video.go @@ -0,0 +1,37 @@ +package thumb + +// VideoSizes contains all valid video output sizes sorted by size. +var VideoSizes = SizeList{ + Sizes[Fit7680], + Sizes[Fit4096], + Sizes[Fit3840], + Sizes[Fit2560], + Sizes[Fit2048], + Sizes[Fit1920], + Sizes[Fit1280], + Sizes[Fit720], +} + +// VideoSize returns the largest video size type for the given width limit. +func VideoSize(limit int) Size { + if limit < 0 { + // Return maximum size. + return Sizes[Fit7680] + } else if limit == 0 { + // Return default size. + return Sizes[Fit3840] + } else if limit <= 720 { + // Return minimum size. + return Sizes[Fit720] + } + + // Find match. + for _, t := range VideoSizes { + if t.Width <= limit { + return t + } + } + + // Return maximum size. + return Sizes[Fit7680] +} diff --git a/internal/thumb/video_test.go b/internal/thumb/video_test.go new file mode 100644 index 000000000..b97f108bf --- /dev/null +++ b/internal/thumb/video_test.go @@ -0,0 +1,22 @@ +package thumb + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestVideoSize(t *testing.T) { + assert.Equal(t, Sizes[Fit720], VideoSize(720)) + assert.Equal(t, Sizes[Fit720], VideoSize(1279)) + assert.Equal(t, Sizes[Fit1280], VideoSize(1280)) + assert.Equal(t, Sizes[Fit1280], VideoSize(1281)) + assert.Equal(t, Sizes[Fit1920], VideoSize(1920)) + assert.Equal(t, Sizes[Fit1920], VideoSize(2000)) + assert.Equal(t, Sizes[Fit2048], VideoSize(2048)) + assert.Equal(t, Sizes[Fit2560], VideoSize(3000)) + assert.Equal(t, Sizes[Fit3840], VideoSize(0)) + assert.Equal(t, Sizes[Fit3840], VideoSize(4000)) + assert.Equal(t, Sizes[Fit7680], VideoSize(8000)) + assert.Equal(t, Sizes[Fit7680], VideoSize(-1)) +}